Размещение аргументов
— 2025-08-13 Yoshua Wuyts источник
- введение
- размещающие функции
- замыкания
- разрешение путей, зависящее от редакции
Введение
В моём предыдущем посте я представил размещающие функции (placing functions) — функции, которые могут «возвращать» значения без их копирования. Это полезно не только из-за эффективности (меньше копий), но и потому, что это гарантирует стабильность адресов — что необходимо для типов с внутренними заимствованиями (самоссылающимися типами). Напоминание, как выглядят размещающие функции:
#![allow(unused)] fn main() { #[placing] // ← 1. Помечает функцию как "размещающую". fn new_cat(age: u8) -> Cat { // ← 2. Имеет логический возвращаемый тип `Cat`. Cat { age } // ← 3. Конструирует `Cat` во фрейме вызывающего кода. }
Как видите, это обычный код на Rust, с единственным отличием — добавленной аннотацией #[placing]. Основная идея размещающих функций — обратная совместимость. Мы должны иметь возможность постепенно добавлять аннотации #[placing] во всю стандартную библиотеку, подобно тому, как мы делаем это с const. Это важно, потому что мы не хотим добавлять ещё одну ось в язык, которая бы блокировала использование типов, чувствительных к адресу, с существующими трейтами и функциями. Мы уже страдаем от раскола, который ввёл Pin¹, и я не думаю, что нам следует повторять это для самоссылающихся типов.
Размещающие функции
Круто иметь идеалы и видение того, как всё должно быть, но нам всегда нужно сверять их с реальностью. И хотя кажется, что размещающие функции будут работать без проблем (пост о размещающих функциях, которые могут завершиться ошибкой, будет позже), именно размещение аргументов выглядит проблематичным. Напомню, что такое размещающие аргументы: идея в том, что мы могли бы написать следующее определение, и всё бы просто работало без каких-либо копий:
#![allow(unused)] fn main() { impl<T> Box<T> { // `Box::new` здесь принимает тип `T` и // конструирует его на месте в куче fn new(x: #[placing] T) -> Self { ... } } }
В том посте я сделал следующее утверждение (выделенное жирным, потому что оно важно):
Как ключевое ограничение дизайна: вызов функции, принимающей размещающие аргументы, не должен отличаться от вызова обычной функции. Это необходимо для сохранения обратной совместимости API.
Отличная идея, но есть одна маленькая загвоздка: мы не можем этого сделать. И всё потому, что размещающие аргументы по своей сути кодируют семантику обратного вызова. Оливье Фор (Olivier Faure) привёл этот пример, чтобы доказать почему:
#![allow(unused)] fn main() { let x = Box::new({ return 0; 12 }); }
Чтобы сохранить совместимость, мы должны сохранить порядок выполнения. В Rust на сегодня, если вы вызываете это, выражение внутри Box::new будет вычислено до вызова Box::new. В этом случае это означает, что функция вернётся до того, как у Box::new появится шанс выполнить аллокацию.
Если мы применим семантику размещения, это фундаментально изменит порядок. Прежде чем разместить что-либо, нужно создать место. В данном случае это место — в куче, что означает взаимодействие с аллокатором, которое в Rust может вызвать панику. Это значит, что код, который, казалось бы, должен вернуть управление до вызова функции, на самом деле запаникует. И это плохо!
Чтобы проиллюстрировать это ещё лучше, Тейлор Крамер (Taylor Cramer) недавно привёл пример, показывающий, как порядок выполнения также создаёт проблемы и для borrow checker. В этом примере нам нужно удерживать &vec как часть выражения для vec.len, одновременно удерживая &mut vec для vec.push. И это не может работать; даже с view types и абстрактными полями²:
#![allow(unused)] fn main() { vec.push(make_big_thing(vec.len())); // ^^^^^^^^ ^^^^^^^ // | &mut vec | &vec }
Замыкания
Единственный способ, как я вижу, разобраться с этим — это фактически требовать от пользователей использовать здесь замыкания. Это сделает порядок намного понятнее и предотвратит любые сюрпризы с порядком выполнения или заимствованиями. Ральф Юнг (Ralf Jung) заметил, что FnOnce для подобных сценариев идеально кодирует нужную нам семантику, и я согласен. Рассмотрим этот пример:
#![allow(unused)] fn main() { vec.push(|| make_big_thing(vec.len())); }
Это даст следующую ошибку:
#![allow(unused)] fn main() { error[E0502]: cannot borrow `vec` as mutable because it is also borrowed as immutable --> src/main.rs:50:9 | 47 | vec.push(|| make_big_thing(vec.len())); | --- immutable borrow occurs here ... 47 | vec.push(|| make_big_thing(vec.len())); | ^^^ mutable borrow occurs here }
В этой ошибке нет ничего специфичного для размещающих функций, равно как и в её решении:
#![allow(unused)] fn main() { let len = vec.len(); vec.push(|| make_big_thing(len)); }
Но мы не можем просто изменить сигнатуру Vec::push, чтобы она принимала замыкания. Вместо этого нам нужно будет ввести новый метод, например, Vec::push_with, который может принимать замыкание и размещать его результат:
#![allow(unused)] fn main() { impl<T> Vec<T> { pub fn push_with<F>(&mut self, f: F) where F: #[placing] FnOnce() -> T; } }
И это указывает на довольно серьёзную проблему с такими API, потому что, по сути, это прокладывает путь к устареванию Vec::push. Метод Vec::push_with эффективнее, чем Vec::push, и, кроме причин совместимости, не будет причин продолжать использовать Vec::push. Так что люди естественным образом начнут считать Vec::push устаревшим, даже если мы явно не пометим его таковым.
Разрешение путей, зависящее от редакции
Я твёрдо верю, что очевидный выбор должен быть правильным³. Ощущается неправильным ругать людей за использование Box::new, говоря им, что им следует использовать Box::new_with. Или что вместо вызова Hashmap::insert они должны вызывать Hashmap::insert_with. И так далее.
Мой предпочтительный способ решить это — иметь возможность разрешения путей, зависящего от редакции: импорты и символы, которые разрешаются по-разному в зависимости от используемой редакции. Например, на редакции 2024 и ниже люди будут видеть и Vec::push, и Vec::push_with:
#![allow(unused)] fn main() { impl<T> Vec<T> { pub fn push(&mut self, item: T); pub fn push_with(&mut self, f: impl #[placing] FnOnce() -> T); } }
Но на редакции 2024 + 1 мы сможем устарешить Vec::push, переименовать его во что-то другое (например, push_without), а на его место поставить Vec::push_with:
#![allow(unused)] fn main() { impl<T> Vec<T> { // Называется `push_with` на редакциях 2024 и ниже pub fn push(&mut self, f: impl #[placing] FnOnce() -> T); // Называется `push` на редакциях 2024 и ниже #[deprecated(note = "please use `push` instead")] pub fn push_without(&mut self, item: T); } }
Под капотом эти функции всё равно будут разрешаться в одни и те же символы после учёта редакций. Концептуально для этого нужен стабильный, не зависящий от редакции идентификатор, на который библиотеки могут ссылаться в редакционно-специфичной манере:
| Стабильный идентификатор | Редакция 2024 | Редакция 2024 + 1 |
|---|---|---|
::std::vec::Vec::push | Vec::push | Vec::push_without † |
::std::vec::Vec::push_with | Vec::push_with † | Vec::push |
†: Эти имена приведены только для иллюстрации; это не конкретные предложения.
Мы могли бы пойти ещё дальше: в гипотетической редакции 2024 + 2 мы могли бы полностью удалить старый API, так что вы сможете использовать только новый:
#![allow(unused)] fn main() { impl<T> Vec<T> { // Называется `push_with` на редакциях 2024 и ниже; // `push_without` больше недоступен. pub fn push(&mut self, f: impl #[placing] FnOnce() -> T); } }
Основная причина, по которой у нас до сих пор нет такой системы, — это большой объём работы и гарантированное наличие крайних случаев, которые делают это сложнее, чем кажется. Базовый дизайн, который я описываю, уже приходил в голову людям раньше, и не в этом причина, почему его ещё не реализовали.
Однако, насколько полезным ни было бы разрешение путей, зависящее от редакции, в долгосрочной перспективе, оно не нужно с самого начала. Мы можем начать с добавления размещающих вариантов существующих методов в стандартную библиотеку. Этого, вероятно, достаточно для продолжения работы, с пониманием, что разрешение путей, зависящее от редакции, может стать возможным в будущем.
Спасибо Алисе Рихл (Alice Ryhl) за ревью этого поста.
Примечания
-
И нет, к сожалению, эксперимент с «эргономикой Pin» ничего не сделает для решения этой проблемы. Мне жаль, что мы не относимся к этой проблеме серьёзнее, потому что такая несовместимость влияет на язык способами, выходящими далеко за рамки синтаксиса и удобства. ←
-
И
Vec::len, иVec::pushнуждаются в доступе к «длине» вектора. Виртуализация полей / частичные заимствования (partial borrows) этого не изменят. ← -
Хотя и не совсем то же самое, эту же идею «способ по умолчанию должен быть правильным» можно увидеть в практике безопасности «poka-yoke». Или в различии Холнагеля (Hollnagel) между Safety 1 и Safety 2. ←