Почему владение/заимствование в Rust такое сложное?
Оригинал статьи написан живущим на вашингтонщине Иваном Сагалаевым,
мужем небезызвестной Алёны 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.
Хотя в целом статья любопытна, автор находится в некотором заблуждении насчёт совершенно нового класса.
Коментарии статьи на Habrahabr
Mingun Хотя в целом статья любопытна, автор находится в некотором заблуждении насчёт совершенно нового класса.
Да даже не столько в заблуждении, сколько можно охарактеризовать это словом "зажрались".
Вам дают базовые принципы, очень простые и легко формализуемые (это важно для компилятора), показывают, как всем этим пользоваться и какие плюсы вы получаете.
А в ответ брюзжание "гамно, многабукв, непанятна". Да вашу ж за ногу!
Да, сложность обучения немного возросла.Не получится теперь всюду пихать указатели на указатели,
не имея представления, какой указатель какие полномочия над объектом имеет. Ну так это не недостаток языка, а лень его изучения.
Проще говоря, компилятор Rust говорит нам, что до тех пор, пока lexeme доступна в этом блоке кода,
он не позволит нам изменить self.state — другую часть парсера. Но это вообще бессмысленно!
По этой фразе сразу видно, что автор не разобрался с новой концепцией.
Да, неочевидно (опять же, смотря для кого. Уверен, поработав с rust-ом достаточно времени такие вещи станут очевидными),
но раз компилятору не нравится, значит, смысл есть. И ведь потом автор его находит.
Так что пост о непривычных для программиста на традиционных императивных языках особенностях, а никак не о "побочных эффектах".
ttim
Вы привели интересное сравнение в конце — а можете привести примеры не императивных языков в которых нужно следить за памятью а-ля rust?
Все программирование построено на том, что мы строим абстракции в терминах в которых легче рассуждать.
Иногда при этом что-то теряется.
В сравнении с C++, rust, конечно, намного лучше — проблема управления памяти есть в обоих языках, но rust её намного лучше решает.
Но в сравнении с альтернативным решением проблемы управления памятью — gc, подход rust-а, будь он хоть прекрасно контролируемым, создает значительно большую нагрузку на мозг.
Так что "традиционных императивных языках особенностях" стоит заменить на "языки с gc", к которым, например, относятся многие функциональные языки.
naething
GC не решает проблему управления ресурсами в общем случае, а только управление памятью.
В той же Java слежение за открытыми файлами или, например, соединениями с БД создает огромную нагрузку на мозг,
потому что нет никаких инструментов для гарантированного автоматического освобождения кроме finally.
Да и "утечку памяти" в языке с GC устроить не так сложно, например в стандартной библиотеке Java для этого есть замечательная функция File.deleteOnExit :)
Mingun
Что-то я совсем не понимаю вашего комментария. Какое сравнение? Чего с чем? И где я что-то говорил про управление памятью?
Ну про память можно только из фразы
Не получится теперь всюду пихать указатели на указатели, не имея представления, какой указатель какие полномочия над объектом имеет.
хоть как-то вывести, хотя я не имел ввиду именно память.
С тем же успехом эту фразу можно применить файлу, сокету, соединению. к БД,
да вообще к чему угодно, что можно закрыть, разрушить или провести другие необратимые действия, после которых пользоваться объектом нельзя.
Понимание, через какую переменную (она же указатель в общем смысле — как указывающая на объект) мы можем делать такие опасные вещи здорово облегчает написание и сопровождение системы.
И rust предлагает для этого отличную концепцию! Один владелец.
Четко обозначенные границы полномочий — когда что можно делать, чтобы всем было безопасно.
Настройка иерархии владения через время жизни — ссылаемся ли мы на объект, как холоп, или полностью подчиняем его своей злой воле?
Но в сравнении с альтернативным решением проблемы управления памятью — gc, подход rust-а, будь он хоть прекрасно контролируемым, создает значительно большую нагрузку на мозг.
Наоборот, мне показалось, что многое делается намного легче, когда четко знаешь, кто за что отвечает.
Другое дело, что разбирать синтаксис структур и методов порой тяжело. Особенно, если смотреть на уже готовый результат, а не идти к нему по шагам.
Но что поделать. Такова цена zero-const абстракций и гибкости в одном лице.
GC тут совершенно не причем. В C/C++/Pascal проблем, описанных автором, не возникнет.
Там даже в голову не придет, что метод consume_lexeme чем-то может быть опасен.
А в Rust это реальность. Конечно, в данном случае компилятор ошибается.
Но компилятор не ИИ, он не может знать, что здесь все безопасно. У него жесткий набор правил.
В этом случае было ложное срабатывание. Но знаете, в математической статистике существуют такие понятия, как ошибки первого и второго рада.
Минимизируя одну неизбежно увеличиваем другую. Также и здесь — борясь с одним, вылезло другое, не совсем очевидное и желательное свойство, но это меньшее зло.
К тому же, автор быстро нашел, как его обойти. А это и показывает живучесть концепции. В целом, сумма зол уменьшилась.
Тут можно ещё провести аналогию с неизменяемыми переменными. Думаю, сейчас уже большинство понимает, что неизменяемость по умолчанию лучше, чем изменяемость.
Все новые языки следуют этой концепции. Она банально проще. И для человека, и для компилятора.
GC, кстати, тоже всего лишь перераспределяет эту сумму зол, и я даже думаю, что также уменьшает её. Писать проще, да, но это иллюзия.
Когда встают проблемы с производительносью, то уже часто приходится всячески бороться с GC, чтобы он не начинал непредсказуемо влиять
на программу — делать тонкий тюнинг, вставлять в код костыли и хаки, которые, к тому же, будут ещё и на конкретный GC завязаны, скорее всего. Приятного мало.
Почему я сказал "традиционных императивных языках особенностях"? Потому что знаю только императивные,
а у меня нет привычки рассуждать о том, о чем я не знаю. В большинстве случаев это выглядит глупо.
potan
Избавится или снизить потребность в GC полезна. Такие попытки делались в Mercury. Ещё была версия ML.
zim32
Как по мне оф документация слегка хромает. После прочтения остается какая-то каша в голове.
С одной стороны там пытаются объяснить все чуть ли не на пальцах, с другой стороны остается ощущение неполноты изложения.
После прочтения Lifetimes, захотелось закрыть доку и больше не открывать.
ozkriff
Бедный Клабник эту главу уже нцатый раз переписывает. Просто «время жизни» в ржавчине как монады в хаскелях — штука простая, но хрен её просто так объяснишь)
Опубликовано: 2018.02.21, последняя правка: 2019.01.28 20:52
Добавить свой отзыв
Написать автору можно на электронную почту mail(аt)compiler.su
|