Контекст: я работаю над очень быстрым алгоритмом для решения задачи Advent of Code 2023, день 8, часть 2, без подхода наименьших общих множественных (который считается невозможным, отсюда и оптимизация).
Одно из моих решений работает с HashMap
внутри компании. В качестве ключей и значений они содержат строки или ссылки на строки, присутствующие во входных данных (представьте, что это просто большая строка).
По сути, я разбираю входные данные и распределяю их по множеству производных структур данных.
Теперь я могу либо скопировать эти биты, используя .to_owned()
, либо просто использовать ссылки на разделы исходного ввода, что сокращает время выполнения с 2 минут 12 секунд до 1 минуты 38 секунд.
Моя проблема в том, что я не могу использовать подход быстрой ссылки, не привязывая время жизни моих структур данных к времени жизни ссылки на входные данные, а это означает, что с этим приходится иметь дело вызывающему абоненту.
Я бы предпочел использовать входные данные, чтобы вызывающий объект мог забыть об этом и при этом продолжать работать со ссылками внутри. Но это просто не срабатывает.
Пусть какой-нибудь фиктивный код высветит проблему:
use std::fs;
/**
* Works fine, but needs to duplicate data, which makes it a bit slower
*/
pub fn read_file_and_analyze_little_slow(file_name: &str) -> (String, Vec<String>) {
let content = fs::read_to_string(file_name).expect("Could not read the file you told me to analyze");
let lines: Vec<String> = content.split("\n").map(|x| { x.to_owned() }).collect();
(content, lines)
}
/**
* I wish I could do this, but the read content of the file is dropped
* at the end of the file (or so the borrow checker thinks), invalidating
* the references
*/
pub fn read_file_and_analyze_fast(file_name: &str) -> (String, Vec<&str>) {
let content = fs::read_to_string(file_name).expect("Could not read the file you told me to analyze");
let lines: Vec<&str> = content.split("\n").collect();
// Would need to make content live on somehow, so the references remain valid
(content, lines) // Error: cannot move out of `content` because it is borrowed
}
/**
* This works super well, but we've just transferred the issue to the calling function,
* because now our result is tied to the lifetime of input string, meaning the parent
* function can't pass it as _its_ result, unless it also works with lifetimes
*/
pub fn read_file_and_analyze_unwieldy<'a>(already_read_file: &'a str) -> Vec<&'a str> {
already_read_file.split("\n").collect()
}
Конечно, есть и другие варианты, например, если входные данные передаются как String
, что лучше соответствует заголовку вопроса, но, на самом деле, не имеет реального значения для проблемы.
Я ищу решения, позволяющие API read_file_and_analyze_fast
работать. Я подозреваю, что это возможно, но может потребоваться unsafe
, с которым я раньше не работал.
Это очень интересная проблема, поэтому мы также будем признательны за некоторые общие рекомендации по решению подобных проблем.
Это все не так важно. Что я чувствовал важным, так это то, что я получаю большую строку в качестве входных данных и действую на ее основе на протяжении всей жизни моей программы. Но я хочу решить это без 'static
. Если вы хотите узнать больше, вот сам код: github.com/MatthiasvB/advent-of-code-2023
По сути, дубликат Почему я не могу хранить значение и ссылку на это значение в одной структуре?
Вы можете использовать подсчет ссылок (Rc
).
@Jmb да, но мне кажется, что ответов там немного не хватает. @Хаим Фридман, возможно, в простых случаях. Однако я не знаю, как будут взаимодействовать Rc
и функция split
, не говоря уже о моем примере из «реального мира».
Это один из случаев, когда String::leak может быть уместным. Это потребляет String
(не освобождая принадлежащее ему распределение кучи) и возвращает ссылку на фрагмент всей строки, но со временем жизни 'static
.
pub fn read_file_and_analyze_fast(file_name: &str) -> Vec<&'static str> {
let content = std::fs::read_to_string(file_name)
.expect("Could not read the file you told me to analyze")
.leak();
let lines: Vec<&str> = content.split("\n").collect();
lines
}
Обратите внимание, что я изменил тип возвращаемого значения на Vec<&'static str>
, чтобы было понятно, что фрагменты строк, содержащиеся в Vec
, имеют время жизни 'static
. Если вы не измените это, они будут ограничены тем же временем жизни, что и ссылка на входной фрагмент file_name
(что также может быть 'static
, но здесь это не имеет особого значения).
Помните, что утекшую строку невозможно безопасно восстановить, поэтому было бы плохой идеей использовать ее с любой строкой, которая, как вы не ожидаете, будет использоваться все время работы программы. Особенно будьте осторожны при использовании его в циклах!
Другой подход, поскольку входной файл каждый раз будет одним и тем же, — использовать include_str!, который представляет собой макрос, который расширяется до &'static str
, полученного путем чтения содержимого указанного имени файла во время компиляции. (Это то, что я делал, когда работал над Advent of Code.)
Если вы одержимы самореферентной структурой данных, со строками это безопасно, поскольку строковые данные хранятся в отдельном выделении, которое не перемещается, когда это делает сам String
. Чтобы гарантировать, что это правильно, String
должен существовать как минимум столько же, сколько структура данных, которая ссылается на его содержимое, и String
не может быть изменен в течение этого времени.
Чтобы сделать эту работу более эргономичной, когда нам не нужен отдельный тип для каждого типа контейнера, мы можем создать признак с общим ассоциированным типом на протяжении всего срока службы («lifetime GAT»).
pub trait StringSliceContainer {
type Container<'a>;
}
Теперь давайте реализуем для Vec<&str>
.
impl StringSliceContainer for Vec<&str> {
type Container<'a> = Vec<&'a str>;
}
Пока достаточно просто. Обратите внимание, что реализованный тип StringSliceContainer
не обязательно должен совпадать с типом Container
. Вместо этого мы могли бы создать свой собственный struct VecStr;
и реализовать его, по-прежнему используя type Container<'a> = Vec<&'a str>;
.
Есть несколько вещей, которые нам нужно иметь в виду, когда мы создаем свой собственный тип. Идея состоит в том, что для некоторой String
реализации T::Container
он будет владеть как T
, так и StringSliceContainer
. Есть две важные вещи, которые будут определять определение этого типа, и одна является следствием другой.
Во-первых, единственное допустимое время жизни, которое мы можем использовать, не вводя именованное время жизни, — это 'static
. Это означает, что мы должны определить наш тип примерно так:
pub struct SelfOwnedStringSlices<T: StringSliceContainer> {
_string: String,
container: T::Container<'static>,
}
Однако это кажется неправильным... и это действительно так. T::Container<'static>
это не так? Что ж, это лучшее, что мы можем здесь сделать. Важно то, что мы никогда не используем это поле как T::Container<'static>
! Сначала мы всегда должны сокращать параметр времени жизни до значения, не превышающего время жизни нашей собственной строки.
Во-вторых, из-за этого мы не можем полагаться на сгенерированный компилятором склеивающий элемент, потому что он будет отбрасывать T::Container<'static>
, что будет недопустимо. Поэтому мы должны настроить определение этой структуры, чтобы обернуть контейнер в ManuallyDrop . Это гарантирует, что сгенерированный компилятором склеивающий элемент фактически не отбросит это поле. Вместо этого мы сможем удалить его самостоятельно после того, как соответствующим образом скорректируем время жизни.
Итак, теперь у нас есть это:
pub struct SelfOwnedStringSlices<T: StringSliceContainer> {
_string: String,
container: ManuallyDrop<T::Container<'static>>,
}
Хорошо. Как нам сделать одну из этих вещей? Что ж, все, что нам нужно, это строка и способ создания T::Container<'static>
из &'a str
. За исключением того, что на самом деле мы не хотим, чтобы потребитель этого типа создавал T::Container<'static>
, потому что тогда он не сможет ссылаться ни на что в строке! Поэтому им нужно дать нам возможность производить T::Container<'a>
из &'a str
. Это достаточно легко выразить замыканием.
impl<T: StringSliceContainer> SelfOwnedStringSlices<T> {
pub fn new<F>(string: String, container_factory: F) -> Self
where
F: for<'a> FnOnce(&'a str) -> T::Container<'a>,
{
todo!()
}
}
Тело этой функции должно вызвать замыкание, а затем создать новый Self
, используя возвращенный контейнер и строку. Но мы получаем T::Container<'a>
, а не T::Container<'static>
! Что ж, к счастью, это один из случаев, когда трансмутация может нам помочь. Расположение типа не зависит от каких-либо параметров времени жизни, поэтому такая трансмутация безопасна. Однако, как упоминалось ранее, мы должны быть осторожны и никогда не использовать контейнер с этим сроком службы. Нам всегда нужно преобразовать время жизни обратно в то, которое не может превышать время жизни строки, которой мы владеем.
impl<T: StringSliceContainer> SelfOwnedStringSlices<T> {
pub fn new<F>(string: String, container_factory: F) -> Self
where
F: for<'a> FnOnce(&'a str) -> T::Container<'a>,
{
let container = container_factory(&string);
// SAFETY: We are only changing the lifetime parameter to be 'static,
// and we will never actually use this container with the 'static
// lifetime.
let container =
unsafe { std::mem::transmute::<T::Container<'_>, T::Container<'static>>(container) };
Self {
container: ManuallyDrop::new(container),
_string: string,
}
}
}
Как нам теперь использовать контейнер? К счастью, по сути, это та же самая трансмутация в другом направлении (замена 'static
на время жизни ссылки &self
), только для ссылки на контейнер.
impl<T: StringSliceContainer> SelfOwnedStringSlices<T> {
pub fn get<'a>(&'a self) -> &'a T::Container<'a> {
// SAFETY: We are only changing the lifetime parameter, and we are
// shortening it to the lifetime of the self reference, which ensures
// that the caller cannot retain any (non-static) reference stored in
// the container for longer than self exists.
unsafe {
std::mem::transmute::<&'a T::Container<'static>, &'a T::Container<'a>>(&self.container)
}
}
}
Не плохо.
Обратите внимание, что мы, к сожалению, не можем реализовать Deref
, потому что при определении Deref::Target
нам нужно будет использовать T::Container<'something>
, но в этом контексте у нас нет ссылки, из которой мы могли бы получить время жизни.
Последняя часть головоломки — как мы справляемся с падением контейнера. Интересно, что нам нужно очистить время жизни в Drop::drop
, чтобы у нас было именованное время жизни, в которое мы можем преобразовать параметр времени жизни контейнера. Как только у нас есть это именованное время жизни, трансмутация здесь происходит примерно так же, как в методе get
выше, только с эксклюзивной ссылкой и сохранением слоя ManuallyDrop
.
impl<T: StringSliceContainer> Drop for SelfOwnedStringSlices<T> {
fn drop<'a>(&'a mut self) {
// SAFETY: We change the lifetime of the container so it's safe to drop,
// then we drop it.
unsafe {
let container = std::mem::transmute::<
&'a mut ManuallyDrop<T::Container<'static>>,
&'a mut ManuallyDrop<T::Container<'a>>,
>(&mut self.container);
ManuallyDrop::drop(container);
}
}
}
И мы закончили!
Использовать этот тип теперь очень просто:
let string = "this is a test".to_owned();
let vec = SelfOwnedStringSlices::<Vec<&str>>::new(
string,
|s| s.split_ascii_whitespace().collect()
);
println!("{:?}", vec.get());
Обратите внимание, что здесь выполняется довольно много unsafe
гимнастики. Насколько мне известно, здесь все сделано добротно. Однако решения, использующие String::leak
и include_str!
, не предполагают написания небезопасного кода, поэтому они тривиально доказуемы. Они также намного короче!
Другими словами, у вас есть одно умное и необычное решение (которые являются синонимами слова «сложный» в разработке программного обеспечения) и два более простых альтернативных решения. В этом контексте (пришествие кода) все три решения должны иметь одинаковые характеристики производительности.
На мой взгляд, более простые решения победят. Тем не менее, такие задачи программирования, как «Пришествие кода», часто являются идеальным местом для изучения и экспериментирования с новыми вещами, поэтому использование более сложного подхода для изучения небезопасного Rust и того, как предоставить безопасный интерфейс поверх небезопасного кода, также было бы идеальным вариантом. действительное решение.
Приятно это знать, спасибо! Однако я хочу избежать жизни 'static
по причинам, которые вы указали, поскольку я сомневаюсь, что появление кода будет последним местом, где я столкнусь с этой проблемой;). Я думаю, что у меня было промежуточное решение, просто используя 'static
без leak
, но я не помню, как именно оно работало, и не использовал его.
@NoBullsh1t Вы не сможете использовать 'static
со строками, у которых нет статического времени жизни. Вы получите ошибку времени компиляции.
@NoBullsh1t Я обновил свой ответ примером самореферентной структуры, которая является правильной (насколько я могу судить). Если unsafe
гимнастика, необходимая для того, чтобы эта работа вас напугала… хорошо. :)
Большое спасибо за чрезвычайно подробный ответ. Как и следовало ожидать, мне потребуется некоторое время, чтобы переварить это. Я, вероятно, приму правильный ответ, как только сделаю :)
Я также взгляну на ящик auroboros
, он кажется многообещающим. Однако и этот ответ, и auroboros
работают с этими замыканиями, что немного неудобно и определенно не то, что мне бы хотелось в моем общедоступном API. Но я знаю, как создавать эти ссылки, а не мои «пользователи», поэтому думаю, что смогу скрыть эту деталь.
@NoBullsh1t Если замыкания кажутся вам неудобными, то некоторые из более мощных вещей в Rust вас сильно обеспокоят. Посмотрите на черту Iterator
; он полон функций, которые принимают замыкания! Вместо этого я бы посоветовал привыкнуть к замыканиям. Они очень сильны, и нет причин стараться их избегать.
Нет, это не замыкания в целом. Я люблю итераторы. Именно здесь я нахожу их... неожиданными. Но это очень неосведомленное заявление, так что неважно :D
Я проверил ваше решение, и оно работает. Или, по крайней мере, он не ломается сразу ;). В любом случае, я думаю, что теперь я понимаю большую часть этого. Я адаптировал его так, чтобы он не был привязан к String
: адаптации. Буду рад, если вы добавите несколько пояснений к своему ответу. (Смотрите следующий комментарий)
а) признак StringSliceContainer
существует только для того, чтобы мы могли ссылаться на тип со временем жизни без необходимости указывать время жизни, верно? Символ impl
для случайного типа существует только для того, чтобы этот тип мог нести информацию об этом типе, верно? б) Меня смущает transmute
. Документы, похоже, предполагают, что это действительно перемещает память. Это правильно? Нельзя ли этого избежать? в) Зачем нам нужно трансмутировать жизнь в drop
? Обычно пишут, что жизни не меняют программу, а «только документируют» ее. Разве это уже не так на этом уровне? Вы можете объяснить?
Кстати, я посмотрел ouroboros
, но он не работает. Он основан на макросе, и этот макрос задыхается (начиная с версии 0.18
) из-за своего особого 'this
времени жизни в позиции общего типа.
Большинство из нас понятия не имеют, о чем идет речь в задании Advent of Code 2023, день 8, часть 2. Может быть, вы скажете об этом пару слов?