Раздел 23 · Rust

Владение: move, Copy, drop и RAII

junior~35 мин

открытый урокЭтот раздел читается без входа. Войди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Владение: move, Copy, drop и RAII

Центральный урок блока. Владение это не правило компилятора, которое надо перетерпеть, а модель ресурсов: у каждого значения один владелец, владелец умер, ресурс освобождён. Память, файл, сокет, блокировка живут по одному закону, и этот закон проверяется до запуска программы.

Идея

В первом уроке мы пообещали безопасность памяти без сборщика мусора и показали ошибку E0382, не объясняя её до конца. Пора платить по счетам.

Начнём с вопроса, на который каждый язык отвечает по-своему: кто и когда освобождает память? В C отвечает программист: на каждый malloc он обязан вспомнить про free, ровно один раз и не раньше последнего использования. Забыл, получил утечку. Освободил дважды, получил порчу памяти. Освободил рано, получил use after free. В языках со сборщиком отвечает рантайм: он периодически ищет объекты, до которых больше не добраться, и убирает их, когда сочтёт нужным. Удобно, но за удобство платят все: процессорным временем, лишней памятью, паузами в непредсказуемые моменты.

Rust отвечает иначе: освобождает компилятор, и решение он принимает не в рантайме, а во время сборки. Чтобы это было возможно, язык вводит три правила:

  1. У каждого значения ровно один владелец: переменная, поле структуры, элемент коллекции.
  2. Владение можно передать: после передачи прежний владелец теряет доступ к значению.
  3. Когда владелец уходит из области видимости, значение уничтожается: срабатывает 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, charVec<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 и срезы: способ пользоваться значением, не забирая его себе.