Размещение аргументов

— 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::pushVec::pushVec::push_without
::std::vec::Vec::push_withVec::push_withVec::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) за ревью этого поста.

Примечания

  1. И нет, к сожалению, эксперимент с «эргономикой Pin» ничего не сделает для решения этой проблемы. Мне жаль, что мы не относимся к этой проблеме серьёзнее, потому что такая несовместимость влияет на язык способами, выходящими далеко за рамки синтаксиса и удобства. ←

  2. И Vec::len, и Vec::push нуждаются в доступе к «длине» вектора. Виртуализация полей / частичные заимствования (partial borrows) этого не изменят. ←

  3. Хотя и не совсем то же самое, эту же идею «способ по умолчанию должен быть правильным» можно увидеть в практике безопасности «poka-yoke». Или в различии Холнагеля (Hollnagel) между Safety 1 и Safety 2. ←