Размещающие функции (Placing Functions)
— 2025-07-08 Yoshua Wuyts Источник
- Что такое размещающие функции?
- Базовый десугаринг
- Мышление в терминах размещающих функций
- Предшествующие наработки в Rust
- Вопросы и ответы
- А как насчет размещающих аргументов?
- А как насчет заимствований / расширения локальных времен жизни?
- А как насчет закрепления (pinning)?
- Обязательны ли аннотации?
- А как насчет самоссылающихся типов?
- Почему бы не использовать напрямую
Init<T>или&out? - Могут ли размещающие функции быть вложенными?
- Заключение
Что такое размещающие функции?
Около года назад я заметил, что построение на месте (in-place construction) оказывается на удивление простым. Разделив создание места в памяти и запись значения в это место, нетрудно понять, как можно превратить это в языковую возможность. И вот около шести месяцев назад я взялся за это и создал крейт placing: прототип на основе proc-макросов для "размещающих функций".
Размещающие функции — это функции, тип возвращаемого значения которых конструируется в стековом фрейме вызывающей стороны, а не в стековом фрейме функции. Это означает, что адрес с момента начала конструирования стабилен. Это не только может привести к повышению производительности, но и служит основой для ряда полезных функций, таких как поддержка dyn AFIT (Асинхронных Функций в Traits).
В этом посте я объясню, как работает десугаринг размещающих функций, почему они являются правильным решением для размещения, и как они интегрируются в язык. Не так подробно, как в настоящем RFC, но скорее как общее введение в идею размещающих функций. И чтобы сразу перейти к делу, вот базовый пример того, как это будет работать:
struct Cat { age: u8, } impl Cat { #[placing] // ← Помечает функцию как "размещающую". fn new(age: u8) -> Self { Self { age } // ← Конструирует `Self` во фрейме вызывающей стороны. } fn age(&self) -> &u8 { &self.age } } fn main() { let cat = Cat::new(12); // ← `Cat` конструируется на месте. assert_eq!(cat.age(), &12); }
Базовый десугаринг
Цель крейта placing — доказать, что размещающие функции не должны быть сложными в реализации. Мне удалось реализовать рабочий прототип за несколько часов во время зимних каникул. В общей сложности я потратил на реализацию около четырех дней. Я, впрочем, не инженер по компиляторам, и я ожидаю, что замечательные ребята из T-Compiler, вероятно, смогут воссоздать это за доли времени, которое потратил я.
Поскольку я не знаком с фронтендом rustc, я реализовал крейт placing полностью с помощью proc-макросов. Плюс был в том, что я мог быстрее получить работающий прототип. Минус в том, что proc-макросы не имеют доступа к информации о типах, поэтому мне пришлось обходить это ограничение. Что привело к API, требующему множества атрибутов proc-макросов. Но для доказательства концепции это приемлемо.
Давайте пройдемся по базовому примеру, который я показал ранее, но на этот раз с использованием proc-макросов. Начнем с установки крейта placing:
$ cargo add placing
Затем мы можем импортировать placing и определить нашу структуру Cat. Нам нужно аннотировать её атрибутом #[placing], потому что нам нужно немного изменить внутреннее представление. Вот как это выглядит:
#![allow(unused)] fn main() { //! Исходный код use placing::placing; #[placing] pub struct Cat { age: u8, } }
Давайте посмотрим, во что раскрывается аннотация #[placing]. Как я сказал во введении: размещающие функции разделяют создание места в памяти и инициализацию значений в этом месте. Для нашего типа Cat местом должен быть тип MaybeUninit<Cat>. Но поскольку мы хотим сохранить тот же тип, даже если мы меняем внутреннее устройство, мы фактически хотим оставить Cat как внешнее имя типа и переместить поля во внутренний MaybeUninit:
#![allow(unused)] fn main() { //! Десугаринг use std::mem::MaybeUninit; /// Это сохраняет тот же внешний тип, /// но меняет внутреннее устройство, чтобы хранить поля /// в `MaybeUninit`. #[repr(transparent)] pub struct Cat(MaybeUninit<InnerCat>); /// Это поля исходного типа `Cat`. /// Выделены в отдельную структуру, чтобы их можно было /// обернуть в `MaybeUninit` внутри. struct InnerCat { age: u8, } }
Нашему десугарингу нужно добавить последний штрих к определению типа, чтобы обеспечить корректную работу. Поскольку мы теперь храним MaybeUninit, мы должны убедиться, что он вызывает свои деструкторы при дропе. Это означает, что наш десугаринг должен сгенерировать реализацию Drop, которая передает вызов через MaybeUninit.
#![allow(unused)] fn main() { //! Десугаринг impl Drop for Cat { fn drop(&mut self) { // Конструкторы гарантируют, что это никогда // не будет дропнуто до инициализации. unsafe { self.0.assume_init_drop() } } } }
Теперь, когда у нас есть определение типа, давайте покажем, как реализовать конструктор new. Поскольку у нас нет доступа к информации о типах, крейт placing требует аннотации как на блоке impl, так и на методе:
#![allow(unused)] fn main() { //! Исходный код #[placing] impl Cat { #[placing] fn new(age: u8) -> Self { Self { age } } } }
Это самая сложная часть десугаринга, потому что ей нужно разделить конструктор на две части. Одну — для создания места, другую — для инициализации значений на месте. Концептуально это означает переписывание возвращаемого типа последней строки в запись в изменяемый аргумент.
#![allow(unused)] fn main() { //! Десугаринг use std::mem::MaybeUninit; impl Cat { /// Вызов этой функции конструирует место для /// инициализации типа. Это часть двухэтапного /// конструктора, и за ней всегда должен /// следовать вызов `new_init`. unsafe fn new_uninit() -> Self { Self(MaybeUninit::uninit()) } /// Эта функция инициализирует значения типа на месте. /// `new_init` нельзя вызывать более одного раза, и /// он всегда должен следовать после вызова `new_uninit`. unsafe fn new_init(&mut self, age: u8) { let this = self.0.as_mut_ptr(); unsafe { (&raw mut (*this).age).write(age) }; } } }
Далее у нас также есть геттер. Все, что нам нужно с ним сделать, — это научить его "пробираться" через внешнюю структуру к внутренним полям. Вот определение:
#![allow(unused)] fn main() { //! Исходный код #[placing] impl Cat { fn age(&self) -> u8 { &self.age } } }
И вот во что он раскрывается:
#![allow(unused)] fn main() { //! Десугаринг impl Cat { fn age(&self) -> u8 { let this = unsafe { self.0.assume_init_ref() }; this.age } } }
Теперь мы готовы создать экземпляр Cat на месте и вызвать наш геттер. Это единственная часть, которую нельзя абстрагировать, поскольку правила видимости макросов Rust очень строги по сравнению с прямой реализацией в компиляторе. То, что нам действительно хотелось бы сделать, это:
#![allow(unused)] fn main() { let cat = placing!(Cat, new, 12); }
Но пока нам приходится вызывать это вручную, и вызов выглядит так:
//! Исходный код fn main() { let mut cat = unsafe { Cat::new_uninit() }; unsafe { cat.new_init(12) }; assert_eq!(cat.age(), &12); }
Кстати: в Rust теперь есть экспериментальная функция super_let, которая позволяет конструировать типы в охватывающей области видимости, но ссылаться на них потом. Это почти подходит для нашего случая использования, но может возвращать только ссылки, а не владеющие типы. Это означает, что лучшее, что мы можем сделать с этой функцией, это следующее (песочница):
#![allow(unused)] #![feature(super_let)] fn main() { macro_rules! new_cat { ($value:expr $(,)?) => { { super let mut cat = unsafe { Cat::new_uninit()) }; unsafe { Cat::new_init(&mut cat, $value) }; &mut cat // ← ❌ Возвращает по ссылке, а не по владению. } } } }
Если мы попытаемся вернуть владеемое значение, оно фактически скопируется — чего мы как раз и пытаемся избежать. Мы могли бы изменить это в реализации компилятора. И чтобы подвести итог: вот версия нашего исходного примера от крейта placing:
//! Исходный код use placing::placing; #[placing] struct Cat { age: u8, } #[placing] impl Cat { #[placing] fn new(age: u8) -> Self { Self { age } } fn age(&self) -> u8 { &self.age } } fn main() { // `Cat` конструируется на месте. let mut cat = unsafe { Cat::new_uninit() }; unsafe { cat.new_init() }; assert_eq!(cat.age(), &12); }
А вот тот же код с раскрытыми макросами:
//! Десугаринг use std::mem::MaybeUninit; #[repr(transparent)] struct Cat(MaybeUninit<InnerCat>); struct InnerCat { age: u8, } impl Cat { /// Создает неинициализированное место. unsafe fn new_uninit() -> Self { Self(MaybeUninit::uninit()) } /// Инициализирует поля на месте. fn new_init(&mut self, age: u8) { let this = self.0.as_mut_ptr(); unsafe { (&raw mut (*this).age).write(age) }; } fn age(&self) -> u8 { let this = unsafe { self.0.assume_init_ref() }; this.age } } impl Drop for Cat { fn drop(&mut self) { unsafe { self.0.assume_init_drop() } } } fn main() { let mut cat = unsafe { Cat::new_uninit() }; unsafe { cat.new_init() }; assert_eq!(cat.age(), &12); }
Крейт placing также поддерживает десугаринг типов, возвращающих Result, Box и Arc. А также включает поддержку вложенных конструкторов, где вы в конечном итоге вызываете #[placing] fn из другой #[placing] fn. Поэтому я вполне уверен, что это должно сработать.
Главное ограничение крейта в том, что он пока не поддерживает черты (traits). Я начал добавлять поддержку, но у меня закончилось время. Вот почему, если вы посмотрите на кодогенерацию в крейте, вы увидите, что везде вставлен константный дженерик PLACING: bool. Пока он не особо полезен, но теперь вы знаете, зачем он там.
Мышление в терминах размещающих функций
Размещающие функции черпают вдохновение из двух других языковых возможностей:
- Super Let (Rust Nightly): это экспериментальная функция, которая позволяет временно расширять время жизни. Это позволяет переменным создаваться в охватывающей области видимости.
- Гарантированное устранение копий (C++ 17): иногда называемое "отложенной материализацией временных объектов", гарантирует, что структуры всегда конструируются в области видимости вызывающей стороны.
Если super let работает с областями видимости блоков, то размещающие функции можно считать работающими через границы функций. И если гарантированное устранение копий — это автоматическая гарантия, применяемая ко всем функциям, то размещающие функции применяются только к функциям, которые явно включили эту возможность.
Дизайн размещающих функций пытается сбалансировать три основных ограничения:
- Контроль: в некоторых случаях размещение уже происходит через оптимизации (например, инлайнинг). Языковая функция размещения должна гарантировать размещение, иначе компиляция должна завершаться ошибкой.
- Интеграция: гарантированное устранение копий в C++ показывает, насколько широко применимо размещение. Это означает, что первоначальная верхняя граница для этой языковой возможности — это каждая функция с возвращаемым типом. Это невероятно широко, и нам нужно обеспечить ее тесную и простую интеграцию с остальным языком.
- Совместимость: стандартная библиотека Rust дает строгие гарантии обратной совместимости. Мы хотим, чтобы она могла использовать размещение без нарушения обратной совместимости. Это означает, что мы не можем создавать новые черты только для поддержки размещения или добавлять новые API specifically для размещения.
Было бы ошибкой считать размещение функцией с узкой применимостью; C++ доказывает, что размещение актуально почти для каждого конструктора. Правильный способ думать о размещении — предположить, что у него максимально широкая верхняя граница. Но затем начать проектировать и реализовывать минимальное подмножество. Хотя мы можем в конечном итоге найти ограничения или случаи, когда размещение невозможно; это будет что-то, что мы докажем через реализацию.
Вот почему я считаю, что нам следует моделировать "размещение" скорее как эффект, а не как другой вид языковой возможности. Я думаю о размещающих функциях как о чем-то среднем между const функциями и async функциями. Они меняют кодогенерацию функции, не совсем unlike преобразование генератора, которое мы используем для async и gen. Но оно никогда не понижается до другого типа, который мы можем наблюдать в системе типов, что делает его очень похожим на const.
Предшествующие наработки в Rust
Когда я уже почти готовился опубликовать этот пост, я немного поговорил с Sy Brand о размещающих функциях, C++ и ABI. Оказалось: Rust уже гарантирует размещение в ряде случаев. Рассмотрим, например, следующий код:
#![allow(unused)] fn main() { pub struct A { a: i64, b: i64, c: i64, d: i64, e: i64, } impl A { pub fn new() -> A { A { a: 42, b: 69, c: 4269, d: 6942, e: 696942, } } } }
Когда мы компилируем это для ABI SYSV на x86, выводится следующая ассемблерная вставка (godbolt):
example::A::new::hd00831bc57a4b613:
mov rax, rdi
mov qword ptr [rdi], 42
mov qword ptr [rdi + 8], 69
mov qword ptr [rdi + 16], 4269
mov qword ptr [rdi + 24], 6942
mov qword ptr [rdi + 32], 696942
ret
Эта ассемблерная вставка записывает данные напрямую по смещениям указателя, переданного в функцию. Другими словами: эта функция размещает. И она фактически гарантированно это делает, как определено в спецификации ABI x86 SYSV, раздел 3.2.3:
[!quote] Если тип имеет класс MEMORY, то вызывающая сторона предоставляет место для возвращаемого значения и передает адрес этого хранилища в
%rdi, как если бы это был первый аргумент функции. Фактически, этот адрес становится "скрытым" первым аргументом. Это хранилище не должно перекрывать любые данные, видимые вызываемой стороне через другие имена, кроме этого аргумента. При возврате%raxбудет содержать адрес, переданный вызывающей стороной в%rdi.
Скрытый первый аргумент, передаваемый функциям? Это очень похоже на то, как должен работать десугаринг для размещающих функций. Фактически, гарантированное устранение копий в C++ использует эту же самую возможность. В разделе 3.2.3 stated следующее:
Если объект C++ имеет нетривиальный конструктор копирования или нетривиальный деструктор, он передается по невидимой ссылке (объект заменяется в списке параметров указателем, который имеет класс INTEGER).
Это все невероятно похоже на то, что я предлагаю, но происходит автоматически на уровне ABI, а не прозрачно на уровне языка. Это также raises the question: насколько легко было бы изменить код понижения ABI в rustc для x64, чтобы просто сказать "если тип объявлен с атрибутом placing, он всегда классифицируется как MEMORY"?
Вопросы и ответы
А как насчет размещающих аргументов?
До сих пор этот пост обсуждал размещение только в отношении возвращаемых типов. Для нашей цели сохранения полной совместимости с существующей stdlib, размещения возвращаемых типов недостаточно. Еще в марте Eric Holk и Tyler Mandry argued что нам также понадобится какая-то форма размещающих аргументов (или, по крайней мере, возможность, которая позволяет нам это делать). Давайте используем Box::new в качестве примера, чтобы показать, почему. Без размещающих аргументов лучшее, что мы могли бы сделать, это определить какую-то форму Box::new_with, которая принимает размещающее замыкание:
#![allow(unused)] fn main() { impl<T> Box<T> { // Существующий конструктор по умолчанию. fn new(x: T) -> Self { ... } // Вновь введенный `placing` конструктор. fn new_with<F>(f: F) -> Self where F: #[placing] FnOnce() -> T, { ... } } }
Конструктор new_with всегда предпочтительнее конструктора new, потому что он гарантирует отсутствие промежуточных копий в стеке. Это приведет к эффективному устареванию Box::new. Если не полному, то, вероятно, сначала путем "лучших практик".
Способ решить это — позволить Box::new быть получателем значений, которые нужно разместить. Это будет сделано путем требования аннотаций не на уровне функции, а на уровне аргумента/возвращаемого типа. Продолжая использовать нашу заполнительную нотацию #[placing], мы можем представить, что это выглядит примерно так:
#![allow(unused)] fn main() { //! Исходный код impl<T> Box<T> { // `Box::new` здесь принимает тип `T` и // конструирует его на месте в куче. fn new(x: #[placing] T) -> Self { ... } } }
Я ожидаю, что десугаринг этого, вероятно, будет выглядеть somewhat similar to RFC инициализации на месте Alice Ryhl, десугарируясь в некоторую форму impl Emplace trait. Но ключевой момент: это будет наблюдаемо только внутри реализации, а не для любой из вызывающих сторон.
#![allow(unused)] fn main() { //! Десугаринг /// Запись значения в место. trait Emplace<T> { fn emplace(self, slot: *mut T); } impl<T> Box<T> { fn new(x: impl Emplace<T>) -> Self { let mut this = Box::<T>::new_uninit(); // 1. Создать место. x.emplace(this.as_mut_ptr()); // 2. Инициализировать значение. Ok(this.assume_init()) // 3. Все готово. } } }
Причина, по которой я поместил это в раздел "Вопросы и ответы", в том, что я еще не разобрался с более тонкими языковыми правилами, поскольку еще не реализовал это. Как основное ограничение дизайна: вызов функции, принимающей размещающие аргументы, не должен отличаться от обычной функции. Это необходимо для сохранения обратной совместимости API. Особенность должна заключаться в том, что функции с размещающими аргументами и размещающими возвращаемыми типами должны работать вместе для размещения.
А как насчет заимствований / расширения локальных времен жизни?
В своем посте о super let Mara приводит наглядный пример, когда полезно расширение времени жизни временных объектов. Здесь Writer::new принимает &'a File, и нам нужна такая возможность, как super let, чтобы создать экземпляр File, который переживет область видимости блока:
#![allow(unused)] fn main() { let writer = { println!("opening file..."); let filename = "hello.txt"; super let file = File::create(filename).unwrap(); Writer::new(&file) }; }
Возвращаемый тип Writer здесь должен иметь время жизни, чтобы иметь возможность ссылаться на super let file. Но это не может быть обычное время жизни, поскольку оно не подчиняется обычным правилам. Без указания конкретных правил это время жизни было названо 'super. С точки зрения блока оно ведет себя почти как 'static — хотя关键о оно не то же самое, что 'static.
Теперь вопрос: как мы можем представить этот блок как функцию? Потому что логично, что мы в конечном итоге захотим выносить функциональность из блоков в функции. Мы, вероятно, захотели бы сделать это с помощью времени жизни 'super, примерно так:
#![allow(unused)] fn main() { //! Исходный код fn create_writer(filename: &str) -> Writer<'super> { println!("opening file..."); super let file = File::create(filename).unwrap(); Writer::new(&file) } }
Обратите внимание, что сам Writer не требует аннотации #[placing]: нормально, что мы копируем его из функции. Единственная важная часть — это то, что file переживает текущую область видимости. Десугаринг для этого довольно забавен, даже если мы еще не можем представить его в системе типов. Что нам нужно сделать здесь, так это убедиться, что file сконструирован на месте в области видимости вызывающей стороны. И после инициализации мы можем ссылаться на него, используя пустое/небезопасное время жизни в нашем возвращаемом типе. Я не проверял этого, но я считаю, что это валидный десугаринг:
#![allow(unused)] fn main() { //! Десугаринг fn create_writer<'a>(filename: &str, file: &'a mut MaybeUninit<File>) -> Writer<'a> { println!("opening file..."); let file = unsafe { file.write(File::create(filename).unwrap()) }; Writer::new(file) } }
Однако это должно быть paired with прелюдией функции при вызове для создания места для file. Таким образом, мы можем представить, что вызов этой функции выглядит примерно так:
#![allow(unused)] fn main() { // С синтаксическим сахаром. let writer = create_writer("hello.text"); // Десугаринг. let mut file = MaybeUninit::uninit(); let writer = create_writer("hello.text", &mut file); }
А как насчет закрепления (pinning)?
Как только у нас появится размещение в языке, большинство причин, по которым Pin устроен именно так, отпадут. Но есть разрыв между наличием размещения и наличием !Move, так что нам нужно обеспечить некоторую форму совместимости с Pin. К счастью, тип Pin — это просто частный случай предыдущего примера с расширением времени жизни.
Что замечательно в размещающих функциях, так это то, что они позволят нам заменить макрос std::pin::pin! на свободную функцию pin, используя время жизни 'super:
#![allow(unused)] fn main() { //! Исходный код pub fn pin<T>(t: T) -> Pin<&'super mut T> { super let mut t = t; unsafe { Pin::new_uninit(&mut t) } } }
Все, что нужно сделать десугарингу, чтобы это работало, — это изменить функцию так, чтобы она принимала дополнительный слот MaybeUninit<T>, в который мы можем записать наше значение. Это позволяет нам расширить время жизни, после чего мы можем ссылаться на него в нашем возвращаемом типе, как показано ниже:
#![allow(unused)] fn main() { //! Десугаринг pub fn pin<T, 'a>(t: T, slot: &'a mut MaybeUninit<T>) -> Pin<&'a mut T> { let mut t = unsafe { slot.write(t) }; unsafe { Pin::new_uninit(&mut t) } } }
Обязательны ли аннотации?
RFC 2884: Placement by Return предложил ввести правила Гарантированного Устранения Копий C++ в Rust практически как есть. Я ценю этот RFC, потому что в нем есть правильное представление о масштабе изменений и делается это solely путем изменения смысла return. Но где он сталкивается с проблемами, так это в том, что он меняет только смысл return. И поэтому вместо добавления размещения, например, в Box::new, ему нужно добавить новый метод Box::new_with.
Фундаментально есть три вида размещения, которые нас интересуют:
- Размещение возвращаемых типов: когда мы хотим избежать копирования типа, возвращаемого из функции. Например, если нам нужен ссылочно-стабильный конструктор.
- Размещение аргументов функции: когда мы хотим избежать копирования аргумента, передаваемого в функцию. Например: при конструировании типа в куче.
- Расширение времени жизни: когда мы хотим ссылаться на локальную переменную из локального типа, который переживет текущую область видимости (расширение времени жизни). Например: при закреплении (pinning).
Даже если Гарантированное Устранение Копий C++ должно быть нашей конечной целью; аннотации позволяют нам достичь этого постепенно. Размещение возвращаемых типов довольно просто. Размещение аргументов функции немного сложнее. А расширение времени жизни будет еще сложнее. Возможность явно включать это с помощью аннотаций означает, что мы можем начать с малого и постепенно наращивать.
Для чего-то такого широкого, как "функции с аргументами или возвращаемыми типами", это кажется правильным way to start. И если по какой-то причине эта возможность окажется настолько успешной, что мы захотим аннотировать почти каждую функцию ей, изменение значений по умолчанию, похоже, можно будет сделать через редакцию (edition), если бы мы захотели.
А как насчет самоссылающихся типов?
Я уже много писал о самоссылающихся типах ранее. Как только у нас появятся размещающие функции, у нас будут три компонента для обобщенных самоссылающихся типов:
- Размещающие функции: чтобы мы могли конструировать тип в стабильном месте в памяти.
- Самовремена жизни (Self-lifetimes): чтобы вы могли объявить, что некоторое поле заимствует из некоторого другого поля.
- Частичные конструкторы: чтобы вы могли начать с инициализации владеемых данных first, а затем инициализировать ссылки на эти данные second.
Мы уже видели размещающие функции. Самовремена жизни позволят полям ссылаться на данные, содержащиеся в других полях, что, вероятно, будет выглядеть примерно так:
#![allow(unused)] fn main() { struct Cat { data: String, name: &'self.data str, // ← ссылается на `self.data` } }
А частичные конструкторы позволят вам конструировать типы в несколько этапов. Например, здесь мы сначала инициализируем поле data в Cat. А затем мы берем строку внутри него и выполняем crude parsing, чтобы интерпретировать все до первого пробела как имя кота:
#![allow(unused)] fn main() { fn new_cat(data: String) -> Cat { let mut cat = Cat { data, .. }; cat.name = cat.data.split(' ').next().unwrap(); cat } }
Как только у нас это появится, мы, конечно, можем combine это с примерами расширения времени жизни, которые мы показали ранее, чтобы гарантировать, что тип остается в стабильном месте памяти. Но ключевой момент: это также forward-совместимо с альтернативными механизмами ссылочной стабильности, такими как автотрейт Move.
Как примечание: частичные конструкторы — это basically та же возможность, что и типы представлений (view types) и типы шаблонов (pattern types). Это все та же общая возможность уточнения (refinement), но теперь с добавленным правилом, что мы можем перейти от уточненного типа обратно к исходному типу, заполнив его поля. Присваивание здесь занимает место обратного совпадения (inverse match), если хотите.
Что хорошо в этом дизайне, так это то, что эти возможности все ортогональны, но дополняют друг друга. Размещение полезно даже без частичной инициализации. И частичная инициализация (уточнение) полезна даже без самоссылающихся времен жизни. То, как возможности дополняют друг друга таким образом, для меня является признаком хорошего дизайна языка. Это означает, что он обобщается beyond просто нишевого случая использования. Но одновременно становится еще более полезным в сочетании с другими функциями.
Почему бы не использовать напрямую Init<T> или &out?
Как тип Init, так и функция параметров &out несовместимы с обратной совместимостью при добавлении к существующим типам и интерфейсам. Это проблема, потому что размещение широко применимо: мы знаем из C++ 17, что практически каждый конструктор хочет быть размещающим. И мы не можем разумно переписать каждую функцию, возвращающую -> T, чтобы вместо этого возвращать -> Init<T> или принимать &out T.
#![allow(unused)] fn main() { // 1. Исходная сигнатура. fn new_cat() -> Cat { ... } // 2. Использование `Init`, меняет сигнатуру. fn new_cat() -> Init<Cat> { ... } // 3. Использование `&out`, меняет сигнатуру. fn new_cat(cat: &out Cat) { ... } // 4. Использование `#[placing]`, сохраняет сигнатуру. #[placing] fn new_cat() -> Cat { ... } }
Это не означает, что эти проекты inherently сломаны или некорректны; совсем наоборот, actually. Но поскольку они, seem to assume другой объем дизайна, это естественно означает, что эти проекты работают с другим набором ограничений дизайна — что, в свою очередь, приводит к другим проектам.
Я считаю, что RFC 2884: Placement by Return от PoignardAzur была правильной идеей. Чтобы Rust был конкурентоспособен с C++, нам нужно гарантировать, что большинство конструкторов могут размещаться. И чтобы сделать это, мы не можем требовать от людей переписывать их код.
Однако в RFC Init есть некоторые great ideas о том, как размещать аргументы функции. На что у RFC 2884 не было хорошего ответа. Я считаю, что сила Rust заключается в его способности distilling различные идеи и синтезировать их в нечто новое. Я считаю, что мы можем прийти к чему-то truly великому, если объединим super let, placement-by-return, Init и обеспечим обратную совместимость.
Могут ли размещающие функции быть вложенными?
Да — размещающие функции, вызываемые в возвращаемой позиции, должны иметь возможность композироваться. Для языковой возможности это должно быть relatively straight-forward при вызове одной размещающей функции внутри другой в возвращаемой позиции:
#![allow(unused)] fn main() { struct Foo {} #[placing] fn inner() -> Foo { Foo {} // ← 1. Конструируется в области видимости вызывающей стороны. } #[placing] fn outer() -> Foo { inner() // ← 2. Передает размещение своей вызывающей стороне. } }
Это само по себе напоминает гарантии гарантированного устранения копий C++, которые композируются через функции. Это потому, что в C++ временные объекты материализуются в реальные объекты только в конце цепочки вызовов, что позволяет произвольно глубокой композиции. Для Rust важно, чтобы мы сохраняли это же свойство, включая случаи обертывания и композиции типов:
#![allow(unused)] fn main() { struct Foo {} #[placing] fn inner() -> Foo { Foo {} // ← 1. Конструируется в области видимости вызывающей стороны. } struct Bar(Foo) #[placing] fn outer() -> Bar { Bar(inner()) // ← 2. Размещает Bar в вызывающей стороне, и Foo внутри Bar. } }
В этом примере Bar конструируется в области видимости вызывающей стороны, и как часть инициализации он вызывает и размещает Foo внутри себя. Я прекратил работу над