История вопроса
Система типов Rust классифицирует параметры времени жизни на "ранние" и "поздние". Ранние параметры времени жизни определяются в точке определения или инстанциирования, становятся конкретными и фиксированными на протяжении существования элемента. Поздние параметры времени жизни, введенные с помощью синтаксиса for<'a> в HRTB, остаются полиморфными до фактической точки использования, позволяя функции или ограничению свойства работать единообразно с любым возможным временем жизни. Это различие возникло из-за необходимости поддерживать настоящие функции высшего порядка — те, которые принимают обратные вызовы или замыкания, которые сами манипулируют заимствованными данными — без принуждения вызывающего к привязке ко времени жизни для всех вызовов.
Проблема
Когда функция высшего порядка объявляет явный параметр времени жизни в своем сигнатуре, такой как fn process<'a, F: Fn(&'a Data)>(f: F), время жизни 'a становится ранним. Это означает, что компилятор выбирает конкретное время жизни 'a на месте вызова в зависимости от контекста, и тип замыкания F должен удовлетворять Fn(&'a Data) только для этого конкретного 'a. Следовательно, замыкание не может быть повторно использовано с данными различной продолжительности времени жизни в последующих вызовах, и попытка передать его в контекст, где срок займа короче или длиннее, приводит к ошибке несоответствия времени жизни. Это ограничение фактически предотвращает создание гибких, повторно используемых абстракций, таких как пулы потоков или диспетчеры событий, которые должны обрабатывать временные заимствования.
Решение
HRTB решает это, перемещая параметр времени жизни в само ограничение свойства: fn process<F: for<'a> Fn(&'a Data)>(f: F). Здесь for<'a> утверждает, что тип F реализует свойство для каждого возможного времени жизни 'a, а не только одного. Это делает время жизни поздним; компилятор проверяет, что замыкание универсально полиморфно, позволяя ему принимать ссылки с любым временем жизни на каждом отдельном месте вызова в теле функции. Этот механизм отделяет хранение обратного вызова от продолжительности жизни данных, позволяя нулевые затраты абстракциям безопасно обрабатывать заимствованные данные в различных контекстах выполнения.
// Раннее: 'a зафиксировано на месте вызова, ограничивая гибкость fn bad_process<'a, F>(f: F) где F: Fn(&'a str) -> usize, { let local = String::from("temp"); // ОШИБКА: local не живет так долго, как ранний 'a // f(&local); } // Позднее: HRTB позволяет 'a быть любым временем жизни при каждом вызове fn good_process<F>(f: F) где F: for<'a> Fn(&'a str) -> usize, { let local = String::from("temp"); // ОК: 'a инициализируется как время жизни &local только для этого вызова println!("{}", f(&local)); } fn main() { let count_fn = |s: &str| s.len(); good_process(count_fn); }
Описание проблемы
При проектировании системы диспетчеризации событий без копирования для движка высокочастотной торговли команде нужен был реестр обработчиков стратегий. Эти обработчики были замыканиями, которые проверяли данные рыночных пакетов без взятия на себя права собственности, позволяя обрабатывать данные на уровне микросекунд. Центральный диспетчер должен был хранить эти обработчики в HashMap<String, Box<dyn Handler>> и вызывать их с временными представлениями входящих сетевых буферов. Сложность заключалась в том, что сетевые буферы имели крайне короткие, ограниченные временем жизни, в то время как сам диспетчер был долгоживущим синглтоном. Если бы трейтовый обработчик был привязан к конкретному времени жизни, диспетчеру требовался бы этот параметр времени жизни, что делало бы невозможным хранение в глобальном состоянии или выживание на разных торговых сессиях.
Решение A: Статическая диспетчеризация с параметризацией времени жизни
Один из подходов состоял в том, чтобы сделать диспетчер обобщенным по отношению к 'a, храня Box<dyn Handler<'a>>. Это потребовало бы, чтобы вся структура диспетчера носила время жизни 'a, что эффективно сделало бы его короткоживущим объектом, связанным с областью видимости сетевого буфера. Плюсы включали нулевые затраты на абстракции и отсутствие дополнительных затрат времени выполнения. Однако минусы были архитектурными: диспетчер нельзя было бы хранить в lazy_static! или отправлять в другие потоки с независимыми сроками жизни, что обязывало бы полное переработку логики управления сессиями.
Решение B: Стертые сроки при помощи ограничений 'static
Еще одной опцией было бы требование, чтобы все данные, передаваемые в обработчики, были 'static или чтобы заставить обработчики брать данные в собственность (например, Vec<u8>). Это позволяло бы хранить обработчики как Box<dyn Handler + 'static>. Плюсы заключались в простоте и легкости хранения. Минусы включали серьезные потери производительности: каждый сетевой пакет требовал бы распределения памяти и копирования данных, чтобы продвинуть его до состояния 'static или в собственности, что уничтожало требования к задержке в микросекундах и увеличивало нагрузку на память при высокой пропускной способности.
Решение C: Более высокие ограничения для свойств (HRTB)
Выбранное решение определяло обработчик-трейт с использованием HRTB: trait Handler { fn handle(&self, data: &Packet); }, реализованный для F: for<'a> Fn(&'a Packet). Это позволило хранить Box<dyn Handler> (по умолчанию 'static, поскольку он обещает работать для любого времени жизни) при передаче эфемерных заимствований сетевых буферов во время вызова handle. Преимущества заключались в сохранении нулевой производительности копирования и способности хранить обработчики в долгосрочном, глобальном состоянии. Недостатком были увеличенная сложность в ограничениях свойств и необходимость удостовериться, что обработчики случайно не захватывают ссылки из своей окружающей среды, которые нарушают контракт for<'a>.
Результат
Движок торговли успешно обрабатывал миллионы событий в секунду без распределения памяти для данных пакетов. Архитектура на основе HRTB позволила команде смешивать и сочетать обработчики из различных модулей — некоторые заимствовали из стека, другие из локальных арен потоков — в то время как компилятор гарантировал, что ни один обработчик не мог пережить эфемерные данные, к которым он получил доступ, предотвращая гонки данных и использование после освобождения в высококонкурентной среде.
Почему Box<dyn Fn(&'a T)> накладывает параметр времени жизни на содержащую структуру, а Box<dyn for<'a> Fn(&'a T)> — нет?
В первом случае время жизни 'a является конкретным параметром типа самого объектного трейта. Тип dyn Fn(&'a T) неявно несет ограничение 'a, что означает, что объект трейта действителен только для этого конкретного времени жизни. Следовательно, любая структура, содержащая его, должна объявить <'a>, чтобы доказать, что структура не переживет ссылки, которые замыкание может захватывать или принимать. С for<'a> объект трейта утверждает, что замыкание работает для всех времени жизни, эффективно стирая конкретную зависимость от 'a из сигнатуры типа контейнера. Это позволяет структуре быть 'static, так как она содержит обещание универсальной применимости вместо ссылки на конкретное заимствование.
Как HRTB взаимодействуют с замыканиями, которые пытаются вернуть ссылки на заимствованный ввод?
Кандидаты часто пытаются написать F: for<'a> Fn(&'a T) -> &'a U, ожидая, что срок выхода совпадет с входным. Однако стандартный ассоциированный тип трейта Fn Output не является обобщенным по отношению к 'a; он фиксирован для типа замыкания. Следовательно, HRTB сами по себе не могут выразить тип возвращаемого значения, срок которого связан с входным аргументом внутри семейства треитов Fn. Чтобы достичь этого, необходимо использовать обобщенные ассоциированные типы (GAT) вместе с HRTB, определяя пользовательский трейт, такой как trait Processor { type Output<'a>; fn process<'a>(&self, input: &'a T) -> Self::Output<'a>; }. Без понимания этого ограничения кандидаты часто сталкиваются с ошибками компилятора, утверждающими, что возвращаемый тип "не живет достаточно долго", ошибочно полагая, что HRTB может решить проблему времени жизни возврата в стандартных замыканиях.
В чем фундаментальное различие между ранним временем жизни на функции и поздним временем жизни в ограничении свойства относительно мономорфизации?
Когда функция объявляет свое собственное время жизни, как в fn foo<'a, F: Fn(&'a T)>, время жизни 'a является ранним. Во время мономорфизации или проверки типов на месте вызова компилятор выбирает одно конкретное 'a, которое удовлетворяет всем требованиям для этого конкретного вызова. Тип F затем проверяется относительно этого конкретного 'a. В отличие от этого, с fn foo<F: for<'a> Fn(&'a T)> компилятор проверяет, что F удовлетворяет ограничению для всех возможных времен жизни универсально. Это означает, что внутри foo вы можете несколько раз вызывать замыкание с аргументами разных времен жизни, в то время как с ранним временем жизни все вызовы внутри foo будут ограничены единым 'a, выбранным при вызове foo. Кандидаты часто упускают, что ранние времена жизни на функциях действуют как "константы времени компиляции" для этого вызова, в то время как поздние времена жизни в HRTB действуют как "универсально квантифицированные переменные", действительные для любой инстанциации.