Pull to refresh

Почему владение/заимствование в Rust такое сложное?

Reading time 5 min
Views 12K
Оригинал статьи написан живущим на вашингтонщине Иваном Сагалаевым, мужем небезызвестной Алёны C++.

Сама статья.

Работать с чистыми функциями просто: вы передаете аргументы и получаете результат, при этом нет никаких побочных эффектов. С другой стороны, если функция производит побочные эффекты, такие, как изменение собственных аргументов или же глобальных объектов, то найти причины этого трудно. Мы привыкли также, что если видим что-то вроде player.set_speed(5), то можно быть уверенным, что тут собираются изменить объект player предсказуемым способом (и, возможно, посылают некоторые сигналы куда-нибудь).

Система владения/заимствования языка Rust сложна и она создает совершенно новый класс побочных эффектов.

Простой пример
Рассмотрим этот код:
let point = Point {x: 0, y: 0};
let result = is_origin(point);
println!("{}: {}", point, result);

Опыт большинства программистов не подготовит к тому, что объект point вдруг становится недоступным после вызова функции is_origin()! Компилятор не позволит вам использовать его в следующей строке. Это – побочный эффект, что-то произошло с аргументом, но это совсем не то, что вы видели в других языках.

Это происходит, потому что объект point перемещен (вместо того, чтобы быть скопированным) в функцию, и, таким образом, функция становится ответственной за его уничтожение. Компилятор же препятствует использованию объекта после смены собственника. Есть способ исправить это: нужно либо передать аргумент по ссылке, либо научить его копировать себя. Это имеет смысл, если вы знаете о «перемещение по умолчанию». Но эти вещи имеют тенденцию вылезать случайным образом во время какого-нибудь невинного рефакторинга или, к примеру, при добавлении логирования.

Пример посложнее
Рассмотрите парсер, который берет некоторые данные из lexer и сохраняет некоторое состояние:
struct Parser {
    lexer: Lexer,
    state: State,
}
impl Parser {
    fn consume_lexeme(&mut self) -> Lexeme {
        self.lexer.next()
    }
    pub fn next(&mut self) -> Event {
        let lexeme = self.consume_lexeme(); // читать следующую лексему
        if lexeme == SPECIAL_VALUE {
            self.state = State::Closed      // изменить состояние парсера
        }
    }
}

Ненужная на первый взгляд consume_lexeme() является просто удобной оберткой вокруг длинной последовательности вызовов, которые я делаю в приведённом коде. lexer.next() возвращает самодостаточную лексему путем копирования данных из внутреннего буфера lexer. Но теперь мы хотим это оптимизировать, чтобы лексемы содержали только ссылки на эти данные во избежание копирования. Меняем объявление метода на следующее:
pub fn next<'a>(&'a mut self) -> Lexeme<'a>

Пометка 'a явно нам говорит, что время жизни лексемы теперь связано с временем жизни ссылки на lexer, с которой мы вызываем метод .next(). Т.е. не может жить само по себе, а зависит от данных в буфере lexer. И теперь Parser::next() перестает работать:
error: cannot assign to `self.state` because it is borrowed [E0506]
Ошибка: self.state не может присвоено значение, потому что оно заимствовано
       self.state = State::Closed
       ^~~~~~~~~~~~~~~~~~~~~~~~~~

note: borrow of `self.state` occurs here
Примечание: здесь происходит заимствование `self.state`
       let lexeme = self.consume_lexeme();
       ^~~~

Проще говоря, компилятор Rust говорит нам, что до тех пор, пока lexeme доступна в этом блоке кода, он не позволит нам изменить self.state – другую часть парсера. Но это вообще бессмысленно! Виновником здесь является consume_lexeme(). Хотя на самом деле нам нужен только self.lexer, мы говорим компилятору, что ссылаемся на весь парсер (обратите внимание на self). И поскольку эта ссылка может быть изменена, компилятор никому не позволит касаться любой части парсера для изменения данных, зависящих теперь от lexeme. Таким образом, мы имеем побочный эффект снова: хотя мы не меняли фактические типы в сигнатуре функции и код по-прежнему правилен и должен работать корректно, смена собственника неожиданно не позволяет ему далее компилироваться.

Даже при том, что я понял проблему в целом, мне потребовались не менее чем два дня, чтобы до меня дошло и исправление стало очевидным.

Исправляем
Изменение consume_lexeme(), позволяющее ссылаться только на lexer, а не на весь парсер, устранило проблему, но код не выглядел идиоматически из-за замены нотации с точкой на вызов обычной функции:
let lexeme = consume_lexeme(self.lexer);
// вместо этого хотелось бы self.<что-то>

К счастью, Rust тоже позволяет пойти по правильному пути. Поскольку в Rust определение полей данных (struct) отличаются от определения методов (impl), я могу определить мои собственные методы для любой структуры, даже если он импортируется из другого пространства имен:
use lexer::Lexer;
// Мой метод Lexer. Не влияет на иные случаи использования Lexer
// где бы то ни было.
impl Lexer {
    pub fn consume(&mut self) -> Lexeme { .. }
}
// ...
let lexeme = self.lexer.consume(); // это работает!

Изящно!

Проверка заимствований в Rust – это замечательная вещь, которая заставляет вас писать более надежный код. Но это отличается от того, к чему вы привыкли, и потребуется время для развития навыков эффективной работы.

Отзывы читателей
Juarez: У меня сложилось впечатление, что Rust добавляет излишнюю сложность внедрением «перемещение по умолчанию» для элементарных типов. Программист везде имеет дополнительное бремя boxing-ссылок. На мой взгляд, кажется, естественно думать о:
а) «копирование по умолчанию» для элементарных типов
б) «ссылка по умолчанию» для составных типов (структуры, трейты и т.д.)
в) «перемещение по умолчанию» для составных типов в асинхронных методах – от случая к случаю.
Я что-то пропустил?
Ralf: Обратите внимание, однако, что то, что вы называете «эффект» здесь на самом деле очень, очень сильно отличается от тех «эффектов», которые люди обычно имеют в виду, когда они говорят о «побочных эффектах». Понятия владения и перемещения является понятиями только время компиляции, оно не меняет того, что делает ваш код. Следовательно, это не делает рассуждения о поведении вашего кода сложнее, так как поведение не изменяется.

На самом деле побочные эффекты теперь гораздо более управляемы. Это относится, в частности, к рассуждения о неограниченных эффектах, подобным тем, которые имеет C++, где почти везде можно получить доступ ко всем видам данных под псевдонимами.

Обязанность проверки заимствования и владения – не новый побочный эффект, речь идет об ограничении существующих побочных эффектов. Если вы владеете чем-то или имеете изменяемую ссылку (которая обязательно является уникальной), вы можете быть уверенными в отсутствии неожиданных (нелокальных) побочных эффектов этого объекта, потому что никто не может иметь его псевдоним. Под этим я имею в виду, что вызов некоторой функции для некоторых данных (которыми вы владеете) никогда не будет их изменять волшебным образом. Если у вас есть разделяемая ссылка, вы можете быть уверены в отсутствии побочных эффектов, потому что никто не может изменять данные. Когда компилятор говорит вам, что данные перемещаются и вы не можете их использовать, это не новый побочный эффект. Это «простое» понимание компилятором побочных эффектов нужно для того, чтобы он мог убедиться, что всё находятся под контролем.

В C++, если вы передаёте параметр Point перед некоторой функцией, компилятор делает неполную копию, и если Point содержит указатели, то это может привести к беспорядку. Здесь объект Point является безопасным для копирования в близлежащем контексте, но вы должны явно сказать компилятору, что вы хотите:
#[derive(Copy,Clone)] struct Point { ... }

Вы можете задаться вопросом, почему компилятор не может понять это автоматически. Было бы точно так же, как это делается для Send.
Проблемой здесь является стабильность интерфейса. Если вы пишете библиотеку, которая экспортирует тип, к которому применяется Copy, то библиотека обязана всегда сохранять этот тип Copy и в будущем. Он должен быть осознанным выбором автора библиотеки, чтобы гарантировать, что этот тип является и всегда будет являться Copy – вследствие явной аннотации.

Послесловие от переводчика: стимулом к переводу этой статьи было желание узнать, что же за «совершенно новый класс побочных эффектов» возник в Rust. Хотя в целом статья любопытна, автор находится в некотором заблуждении насчёт совершенно нового класса.
Tags:
Hubs:
+16
Comments 11
Comments Comments 11

Articles