Владение: move, Copy, drop и RAII
открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти
Владение: move, Copy, drop и RAII
Центральный урок блока. Владение это не правило компилятора, которое надо перетерпеть, а модель ресурсов: у каждого значения один владелец, владелец умер, ресурс освобождён. Память, файл, сокет, блокировка живут по одному закону, и этот закон проверяется до запуска программы.
Идея
В первом уроке мы пообещали безопасность памяти без сборщика мусора и показали ошибку 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 руками.
ДЗ
Дальше
Модель на месте: один владелец, move передаёт, drop освобождает, и не только память. Осталось снять главное неудобство: отдавать владение всем подряд, чтобы просто показать значение. В уроке 04 появляются ссылки &T и &mut T, правила borrow checker и срезы: способ пользоваться значением, не забирая его себе.