Владение: move, Copy, drop и RAII
Ферапонт · кладовщик, хранитель Книги выдачи
Вот, полюбуйся. Два слитка, клеймо одно и то же: №7. По Книге слиток №7 выдан один раз, одному хозяину. Как вещь может оказаться у двоих? Не отвечай сразу. Сначала разберись, что значит «отдал».
Ферапонт прав: в Окалине «отдал» значит «больше не твоё», и в Rust ровно так же. Этот урок про то, кто владеет значением, что происходит, когда владение переходит, и кто убирает за значением, когда хозяин уходит. Двойной слиток в мире Rust называется двойным освобождением, и безопасный Rust его не собирает.
Центральный урок блока. Владение это не правило компилятора, которое надо перетерпеть, а модель ресурсов: у каждого значения один владелец, владелец умер, ресурс освобождён. Память, файл, сокет, блокировка живут по одному закону, и этот закон проверяется до запуска программы.
Идея
В первом уроке мы пообещали безопасность памяти без сборщика мусора и показали ошибку E0382, не объясняя её до конца. Пора платить по счетам.
Начнём с вопроса, на который каждый язык отвечает по-своему: кто и когда освобождает память? В C отвечает программист: на каждый malloc он обязан вспомнить про free, ровно один раз и не раньше последнего использования. Забыл, получил утечку. Освободил дважды, получил порчу памяти. Освободил рано, получил use after free. В языках со сборщиком отвечает рантайм: он периодически ищет объекты, до которых больше не добраться, и убирает их, когда сочтёт нужным. Удобно, но за удобство платят все: процессорным временем, лишней памятью, паузами в непредсказуемые моменты.
Rust отвечает иначе: освобождает компилятор, и решение он принимает не в рантайме, а во время сборки. Чтобы это было возможно, язык вводит три правила:
- У каждого значения ровно один владелец: переменная, поле структуры, элемент коллекции.
- Владение можно передать: после передачи прежний владелец теряет доступ к значению.
- Когда владелец уходит из области видимости, значение уничтожается: срабатывает drop.
Из трёх правил математически следует то, ради чего всё затевалось. Владелец один, значит освобождение случится ровно один раз: двойное освобождение невозможно. Владелец известен на этапе компиляции, значит точка освобождения известна заранее: компилятор вставляет нужный код на выходе из области видимости, и в рантайме никто ничего не ищет и не считает. Доступ после передачи владения запрещён, значит use after free не существует как класс багов.
Дёшево, детерминированно и проверено до запуска. Цена: модель надо выучить, и местами она будет казаться неудобной. Это нормально, неудобство снимут ссылки из следующего урока.
Стек против кучи
Чтобы правила владения перестали быть абстракцией, нужно вспомнить, где физически живут данные. Подробно ты разбирал это в уроке про машину и память, здесь короткая выжимка в терминах Rust.
Стек хранит локальные значения известного размера: все скаляры из урока про значения и типы, кортежи, массивы. Выделить и освободить память на стеке стоит почти ничего: это сдвиг указателя. Куча нужна, когда размер неизвестен заранее или данные должны пережить функцию: за участок кучи надо просить аллокатор, и это уже настоящая работа с накладными расходами.
Тип String живёт в обоих мирах сразу:
let s = String::from("привет");
На стеке лежит маленькая структура из трёх машинных слов: указатель на буфер, длина и ёмкость. Сами байты строки лежат в куче, там их может быть и двенадцать, и двенадцать миллионов. Это устройство стоит запомнить как картинку: ручка на стеке, груз в куче. Все правила владения управляют грузом: ручка копируется тривиально, а вот кучный буфер должен быть освобождён ровно один раз.
Move: передача владения
Теперь пример из первого урока перестанет быть фокусом:
let s = String::from("привет");
let t = s;
Что произошло в памяти при let t = s? Скопировались три слова со стека: указатель, длина, ёмкость. Байты в куче не копировались, это было бы дорого, и Rust никогда не делает дорогих вещей молча. Получились две ручки на один груз.
И вот здесь развилка, которая определяет язык. Если оставить обе ручки живыми, на выходе из области видимости каждая попытается освободить один и тот же буфер: double free, одна из классических катастроф C. Сборщик мусора решил бы вопрос в рантайме, но сборщика у нас нет по условию задачи. Rust решает на этапе компиляции: присваивание объявляется перемещением. Владение буфером переехало к t, а имя s помечено мёртвым, и любое обращение к нему отклоняется с уже знакомой ошибкой:
error[E0382]: borrow of moved value: `s`
Заметь, насколько это решение дешёвое: в рантайме move это копирование трёх слов, и всё. Никаких счётчиков ссылок, никакой магии. Вся работа по учёту, кто чем владеет, происходит в голове компилятора и исчезает из итогового бинаря.
Если тебе нужны две независимые строки, скажи это явно:
let s = String::from("привет");
let t = s.clone();
println!("{s} {t}"); // обе живы, у каждой свой буфер в куче
clone честно копирует буфер. Дорогая операция теперь видна в коде глазами: ревьюер, встретив .clone(), точно знает, что здесь аллокация и копирование. Сравни с языками, где копирование происходит неявно, и поди догадайся, во что компилируется невинное присваивание.
Copy: типы, которым можно
Прошлый урок закончился вопросом: почему строка переезжает, а число копируется без скандала?
let x = 42;
let y = x;
println!("{x} {y}"); // оба живы, компилятор спокоен
Ответ: у i32 нет груза в куче. Всё значение целиком лежит на стеке, копирование это те же четыре байта, что и оригинал, и после копирования не остаётся никакого общего ресурса, за который пришлось бы отвечать дважды. Для таких типов move-семантика не нужна, и Rust помечает их маркером Copy: присваивание копирует, оба имени живут дальше.
Copy-типы это все скаляры из урока про типы, а также кортежи и массивы, целиком собранные из Copy-типов:
| Copy: копируется | Не Copy: переезжает |
|---|---|
i32, u8 и все целые | String |
f32, f64, bool, char | Vec<T> и другие коллекции |
(i32, bool), [u8; 16] | файлы, сокеты, блокировки |
Правило для интуиции: Copy получают типы, чьё копирование это тривиальное побайтовое дублирование без последствий. Всё, что владеет ресурсом за пределами собственных байт, переезжает. А что было бы, если бы String всё-таки сделали Copy? Каждое присваивание строки молча копировало бы кучный буфер, и самый невинный код стал бы решетом из скрытых аллокаций. Rust выбирает другую сделку: дорогое всегда явно.
Drop и RAII
Третье правило обещает: владелец ушёл из области видимости, значение уничтожено. Посмотрим на механизм в упор. Любой тип может определить, что значит «уничтожить меня», реализовав Drop:
struct TempFile {
path: String,
}
impl Drop for TempFile {
fn drop(&mut self) {
println!("удаляю временный файл {}", self.path);
}
}
Синтаксис структур разберём через два урока, читать можно уже сейчас: тип TempFile с полем path и его деструктор. Теперь проследим, когда он срабатывает:
fn main() {
let report = TempFile { path: String::from("report.tmp") };
{
let cache = TempFile { path: String::from("cache.tmp") };
println!("работаем внутри блока");
} // здесь: удаляю временный файл cache.tmp
println!("работаем дальше");
} // здесь: удаляю временный файл report.tmp
Никто не вызывал drop руками. Компилятор вставил вызовы на закрывающих скобках, каждому значению на выходе из его области, в обратном порядке объявления. Точки освобождения видны прямо в тексте программы: вот скобка, вот здесь умрёт cache. Это и отличает Rust от языков со сборщиком, где «когда-нибудь потом» это лучший доступный ответ.
У приёма есть имя из C++: RAII. Захватил ресурс в конструкторе, освободил в деструкторе, и время жизни ресурса намертво привязано к времени жизни значения. В Rust на этой идиоме стоит вся стандартная библиотека:
StringиVecв drop освобождают свой буфер.Fileв drop закрывает файловый дескриптор. Конструкции вродеusingиtry/finallyне нужны: забыть закрыть файл невозможно, у тебя просто нет такой кнопки.MutexGuardв drop отпускает блокировку. Класс багов «взял лок и забыл вернуть» не существует.- Сетевой сокет в drop закрывает соединение.
Отсюда главная мысль урока, ради которой он называется уроком про модель ресурсов, а не про память. Память это лишь частный случай. Владение отвечает на вопрос «кто освобождает ресурс» для чего угодно: файла, сокета, транзакции, блокировки. В языках со сборщиком мусора GC решает только память, а файлы и локи всё равно закрывают руками через try/finally и линтеры. В Rust один механизм закрывает всё.
Владение и функции
Передача аргумента в функцию подчиняется тем же правилам, что присваивание: это move.
fn send(report: String) {
println!("отправляю: {report}");
} // drop(report): строка освобождена здесь
fn main() {
let report = String::from("итоги квартала");
send(report); // владение уехало в параметр
println!("{report}"); // ошибка компиляции: E0382
}
Функция send забрала владение строкой, попользовалась и уронила её на своей закрывающей скобке. В main строки больше нет, и компилятор это докажет. Возврат значения, симметрично, переезжает наружу:
fn shout(mut text: String) -> String {
text.push_str("!!!");
text // владение уезжает вызывающему
}
fn main() {
let line = String::from("вперёд");
let line = shout(line); // отдали, получили обратно, перепривязали
println!("{line}");
}
Работает, и shadowing из урока про типы пригодился. Но согласись, утомительно: каждой функции, которой нужно лишь взглянуть на значение, приходится отдавать владение целиком и просить вернуть. Представь сигнатуру из трёх таких параметров: туда кортеж, обратно кортеж. Это неудобство не баг, а указатель на недостающую деталь модели: нужен способ дать функции доступ к значению, не отдавая владение. Способ называется заимствованием, и ему посвящён весь следующий урок.
Демонстрация
Собери все случаи урока в одну картинку. В виджете пять сценариев: move, Copy, передача в функцию, вложенный блок с drop и тизер ссылок. Шагай по строкам и следи за полосами жизни значений: где владение переезжает, где копируется, где срабатывает drop и какая строка не пройдёт компиляцию. Перед каждым шагом старайся предсказать, что изменится.
Хорошая проверка понимания: в сценарии «функция» объясни, почему drop для строки срабатывает не в main, а внутри send. В сценарии «блок» назови порядок, в котором умрут значения, до того как дошагаешь до конца.
Практика
Теперь дай компилятору поспорить с тобой вживую: почини сигнатуры после move, выбери правильный способ передачи для каждого типа и поуправляй порядком drop руками.
Мастерская, этап 4
Горн в мастерской уже разогревается до оранжевого, пора плавить слитки. Слиток в этом этапе это структура без Copy: отдал его Вадиму, и у мастерской его больше нет, второй раз отдать нечего. Передачу ты напишешь функцией, которая забирает слиток по значению, и компилятор сам проследит, чтобы у слитка был один хозяин.
Доска заказов
На доске Гильдии висит заказ без подсказок: слитки-близнецы.
ДЗ
Дальше
Слиток в мастерской теперь честный: один хозяин, второй раз не отдашь. Тем страннее два слитка №7 у Ферапонта. Кто-то сделал то, чего правила не позволяют, и Ферапонт хочет понять, кто и как. А пока мастерской нужен инструмент, которым пользуются, не забирая его себе.
Модель на месте: один владелец, move передаёт, drop освобождает, и не только память. Осталось снять главное неудобство: отдавать владение всем подряд, чтобы просто показать значение. В уроке 05 появляются ссылки &T и &mut T, правила borrow checker и срезы: способ пользоваться значением, не забирая его себе.