Хто власник значення
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР5. Матеріали публікуються в Google Classroom разом із лекцією.
Попередня лекція показала мотивацію: помилки керування пам'яттю коштують дорого, а Rust обіцяє
виявляти їх на етапі компіляції. Ця лекція розкриває механізм, який робить це можливим —
володіння (ownership). Це не бібліотечна фіча і не опційна практика — це фундаментальна
властивість типової системи Rust, з якої компілятор виводить, коли саме звільнити кожну ділянку
пам'яті, без збирача сміття і без ручного free(). Зрозуміти володіння — означає зрозуміти,
чому Rust-код виглядає так, як виглядає, і чому компілятор іноді "не пускає" код, що здається
цілком розумним.
Уся модель зводиться до трьох тверджень, які компілятор перевіряє статично:
drop).Ці правила виглядають простими, але з них випливає практично вся дисципліна роботи з пам'яттю в мові: немає потреби вручну відстежувати, хто і коли звільнить ресурс — компілятор виводить це з області видимості змінної-власника.
{
let s = String::from("hello"); // s стає власником рядка
// тут можна користуватись s
} // область видимості s закінчилась → компілятор вставляє виклик drop(s)
Важливо: drop викликається автоматично й детерміновано, у зворотному порядку до створення
змінних, у точці виходу з блоку — на відміну від збирача сміття, де момент звільнення
непередбачуваний.
Не всі присвоєння в Rust поводяться однаково — це залежить від того, де фізично лежать дані значення.
Прості типи фіксованого розміру, які повністю вміщуються на стеку (i32, f64, bool, char,
кортежі з таких типів), реалізують спеціальний маркерний трейт (trait) Copy. Присвоєння такого значення
іншій змінній створює побітову копію — обидві змінні лишаються незалежно валідними:
let x = 3101; // 0xC1D
let y = x; // побітова копія
println!("{} {}", x, y); // працює: i32 реалізує Copy
Копіювання тут дешеве (кілька байтів), тому робити його типовою поведінкою безпечно й
передбачувано. Типи, для яких копіювання можна дозволити за замовчуванням, — це ті, для яких воно
не приховує жодної дорогої операції: копія i32 не виділяє пам'ять і нічого не звільняє.
Для типів, дані яких живуть на купі (String, Vec<T>, Box<T>, більшість власних структур із
такими полями), присвоєння не копіює дані — воно переміщує власність (move). Стара змінна після
цього вважається недійсною, і компілятор більше не дозволить її використати.
let s1 = String::from("hello");
let s2 = s1; // власність переходить від s1 до s2
println!("{}", s1); // error[E0382]: value borrowed here after move
// ^ s1 більше не власник — компілятор це знає і не дає скомпілювати
Технічно String — це структура з трьох полів: вказівник на буфер на купі, довжина (len) і
місткість (capacity). Коли s1 "переміщується" в s2, копіюються лише ці три поля (24 байти на
64-бітній системі) — сам буфер на купі не чіпається, копіюється лише "заголовок". Це і є причина,
чому move дешевий: він не пропорційний розміру даних на купі.
Ключовий наслідок для безпеки пам'яті: якби s1 і s2 обидва лишались валідними власниками
одного й того самого буфера, вихід обох з області видимості спричинив би подвійне звільнення
(double free) — саме ту помилку з попередньої лекції. Модель "один власник" структурно унеможливлює
цей сценарій: після move власник рівно один, отже, і drop буде викликаний рівно один раз.
Якби String, Vec та подібні типи копіювалися неявно при кожному присвоєнні (як у деяких мовах
з семантикою значень), це означало б непередбачувану вартість продуктивності: рядок у мільйон
символів копіювався б повністю щоразу, коли його передають далі за кодом, і читач коду не бачив
би цієї вартості в жодному синтаксисі. Rust свідомо робить дешеву операцію (move — копія трьох
машинних слів) типовою поведінкою, а дорогу (глибоке копіювання) — явною дією, яку треба викликати
самому.
Коли справді потрібні дві незалежні копії даних на купі, викликають .clone(). Це виділяє нову
ділянку пам'яті і копіює вміст туди — обидві змінні після цього лишаються повноцінними,
незалежними власниками своїх даних.
let s1 = String::from("hello");
let s2 = s1.clone(); // глибока копія: нова ділянка пам'яті
println!("{} {}", s1, s2); // ОК: обидві змінні валідні й незалежні
Головна методологічна ідея: дорога операція завжди видна в коді явно. Читач одразу бачить
.clone() і розуміє: тут сталася алокація й копіювання. Немає прихованих дорогих операцій за
непомітним синтаксисом присвоєння =.
Для власних типів компілятор може автоматично згенерувати реалізацію обох трейтів через атрибут
#[derive(...)] — за умови, що всі поля типу самі реалізують ці трейти.
#[derive(Debug, Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // Copy: побітова копія, p1 і далі валідний
println!("{:?} {:?}", p1, p2);
Copy |
Clone |
|
|---|---|---|
| Викликається | Неявно, при присвоєнні | Явно, методом .clone() |
| Вартість | Завжди дешева (побітова копія) | Може бути будь-якою (глибоке копіювання) |
| Хто може реалізувати | Лише типи без ресурсів на купі й без Drop |
Практично будь-який тип |
| Приклад | i32, bool, (f64, f64) |
String, Vec<T>, більшість структур |
Важливе обмеження: тип не може одночасно реалізовувати Copy і мати власну реалізацію Drop.
Це логічно — якщо тип володіє ресурсом, який треба явно звільнити (Drop), побітове копіювання
призвело б до кількох "власників" одного ресурсу, а отже, і до подвійного звільнення. Компілятор
забороняє це поєднання на етапі компіляції.
Move працює й на рівні окремих полів структури. Якщо перемістити одне поле, решта структури лишається доступною (якщо не використовувати структуру цілком):
struct Runner {
name: String,
level: u32, // u32 реалізує Copy
}
let r = Runner { name: String::from("ранер"), level: 3 };
let name = r.name; // часткове переміщення: r.name переміщено
println!("{}", r.level); // ОК: level — Copy, і не було переміщене
// println!("{}", r.name); // помилка: r.name вже переміщено
// println!("{:?}", r); // помилка: структура частково переміщена, Debug вимагає всіх полів
Компілятор відстежує стан переміщення на рівні окремих полів — це називається "розщепленим запозиченням/переміщенням" (split borrows/moves) і є однією з тонкощів, які часто дивують новачків: структуру не можна використати цілком, якщо хоча б одне її поле вже переміщене, але окремі непереміщені поля лишаються доступними.
Передача значення у функцію за значенням (без &) — це move: власність переходить у функцію.
Після виклику функції змінна-аргумент у викликача більше не власник (якщо тип не Copy). Функція
може повернути власність назад через результат:
fn takes_ownership(s: String) -> String {
println!("отримав: {}", s);
s // повертаємо власність назад викликачу
}
let s1 = String::from("дані");
let s1 = takes_ownership(s1); // власність пішла у функцію й повернулась
println!("{}", s1); // s1 знову валідний
Це працює, але незручно: якщо функція мала лише прочитати рядок (наприклад, обчислити довжину),
навіщо забирати повне володіння і потім повертати його назад вручну через кожну функцію в
ланцюжку викликів? Саме ця незручність — природний місток до наступної лекції: запозичення
(&, &mut) дозволяє функції скористатися значенням, не забираючи власність узагалі.
Коли власник виходить з області видимості, Rust автоматично викликає метод трейту Drop::drop.
Це той самий принцип RAII (Resource Acquisition Is Initialization), знайомий з C++, але в
Rust він примусово перевірений компілятором: неможливо забути реалізувати звільнення ресурсу
чи випадково викликати його двічі — компілятор гарантує рівно один виклик drop на кожен
власник.
struct Guard {
name: String,
}
impl Drop for Guard {
fn drop(&mut self) {
println!("звільняю ресурс: {}", self.name);
}
}
fn main() {
let _g = Guard { name: String::from("з'єднання") };
println!("працюємо...");
} // тут автоматично викликається _g.drop() → "звільняю ресурс: з'єднання"
Файли, мережеві з'єднання, м'ютекси, дескриптори БД — усе це типово обгортають у структуру з
власною реалізацією Drop, і звільнення відбувається детерміновано, синхронно, у точці виходу з
області видимості, без збирача сміття і без ручного виклику "close"/"free" в кожному місці, де
могла статися помилка (включно з ранніми return чи панікою — drop викликається й під час
розкрутки стека при паніці).
Явно викликати x.drop() заборонено компілятором (це призвело б до подвійного виклику при виході
з області видимості) — для примусового раннього звільнення використовують вільну функцію
std::mem::drop(x), яка просто забирає значення за власністю й одразу дає йому вийти з області
видимості.
| Пастка | Чому це проблема | Як уникнути |
|---|---|---|
Очікувати, що s2 = s1 копіює String як у C++ за замовчуванням |
Це move, а не копія — s1 стає недійсним |
Викликати .clone(), якщо потрібні дві незалежні копії |
#[derive(Copy)] на структурі з String-полем |
Не скомпілюється: String не реалізує Copy |
Copy можна деривувати лише коли всі поля самі Copy |
Спроба викликати x.drop() вручну |
Заборонено компілятором | Використовуйте std::mem::drop(x) |
| Забути, що часткове переміщення блокує використання структури цілком | println!("{:?}", r) після let x = r.field не компілюється |
Перемістити поле в останню чергу або спершу зробити .clone() |
| Повертати власність з кожної функції, яка лише читає дані | Багатослівний, незручний код | Використовувати запозичення &T (тема Л6) |
Модель володіння Rust будується на трьох простих правилах: у кожного значення один власник, і
воно знищується автоматично, коли власник виходить з області видимості. Для типів на стеку
фіксованого розміру присвоєння — дешева побітова копія (Copy); для типів на купі — це
переміщення власності (move), яке робить стару змінну недійсною і структурно унеможливлює
подвійне звільнення пам'яті. Явна глибока копія доступна через .clone() і завжди видна в коді.
Передача значення у функцію теж переміщує власність, що часто незручно — і саме ця незручність
мотивує наступну тему курсу: запозичення, яке дозволяє користуватися значенням без передачі
володіння.
Фокус: одне значення — один власник, завжди
Ці три правила компілятор перевіряє статично, без збирача сміття в рантаймі
let x = 3101; // 0xC1D, i32 — живе на стеку
let y = x; // копія: обидва x і y валідні
println!("{} {}", x, y); // працює: i32 реалізує трейт Copy
// прості типи фіксованого розміру на стеку (i32, bool, char, f64, кортежі з них)
// копіюються автоматично — тут немає moveCopy-типи — виняток із загального правила move
let s1 = String::from("hello");
let s2 = s1; // власність переходить від s1 до s2
println!("{}", s1); // error[E0382]: value borrowed here after move
// ^ s1 більше не власник — компілятор це знає і не дає скомпілюватиerror[E0382]: use of moved value
Копіюється лише заголовок (ptr/len/cap = 24 байти); буфер "hello" на купі не рухається, а s1 стає недійсним. Copy-типи на кшталт i32 — виняток: у них на купі нічого нема
Rust робить дешеву операцію типовою, а дорогу — явною: читач бачить кожну алокацію
Move не пропорційний розміру даних — саме тому він може бути типовою поведінкою
let s1 = String::from("hello");
let s2 = s1.clone(); // глибока копія: нова ділянка пам'яті
println!("{} {}", s1, s2); // ОК: обидві змінні валідні й незалежніClone видно в коді — це свідомий вибір, а не прихована вартість
Передавати й повертати власність через кожну функцію незручно — тому в Л6 з'явиться запозичення
fn takes_ownership(s: String) -> String {
println!("отримав: {}", s);
s // повертаємо власність назад викликачу
}
let s1 = String::from("дані");
let s1 = takes_ownership(s1); // власність пішла у функцію й повернулась
println!("{}", s1); // s1 знову валіднийПостійно передавати й повертати власність незручно — саме тому в Л6 з'явиться запозичення (borrowing)
Очікувано: Перша версія не компілюється з чіткою вказівкою рядка й причини; виправлена версія збирається і виконується
| Тип | Copy? | Присвоєння let b = a | Чому |
|---|---|---|---|
i32, f64, bool, char | так | побітова копія, a валідне | фіксований розмір, повністю на стеку |
кортеж (i32, f64) | так | побітова копія | усі елементи — Copy |
масив [u8; 4] | так | побітова копія | усі елементи — Copy |
String, Vec<T>, Box<T> | ні | move, a недійсне | володіють буфером на купі |
struct з полем String | ні | move | хоч одне поле не Copy |
struct з #[derive(Copy)] | так | побітова копія | усі поля Copy і немає Drop |
Тип не може одночасно реалізовувати Copy і мати власну реалізацію Drop
| Ознака | Copy | Clone |
|---|---|---|
| Викликається | неявно, при присвоєнні | явно, методом .clone() |
| Вартість | завжди дешева: побітова копія | будь-яка: глибоке копіювання |
| Хто може реалізувати | типи без ресурсів на купі й без Drop | практично будь-який тип |
| Приклади | i32, bool, (f64, f64) | String, Vec<T>, більшість структур |
| Зв'язок | Copy вимагає Clone | Clone не вимагає Copy |
`#[derive(Clone, Copy)]` працює, лише якщо всі поля структури самі реалізують ці трейти
#[derive(Debug, Clone, Copy)]
struct Point {
x: i32,
y: i32,
}
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // Copy: побітова копія, p1 і далі валідний
println!("{:?} {:?}", p1, p2);derive працює, лише якщо всі поля структури самі реалізують Copy
struct Runner {
name: String,
level: u32, // u32 реалізує Copy
}
let r = Runner { name: String::from("ранер"), level: 3 };
let name = r.name; // часткове переміщення поля name
println!("{}", r.level); // ОК: level — Copy, не було переміщене
// println!("{}", r.name); // error: r.name вже переміщеноКомпілятор дозволяє доступ до непереміщених полів навіть після часткового move
std::mem::drop(x)Без збирача сміття і без ручного free()/close() у кожному місці виходу з функції
struct Guard {
name: String,
}
impl Drop for Guard {
fn drop(&mut self) {
println!("звільняю ресурс: {}", self.name);
}
}
fn main() {
let _g = Guard { name: String::from("з'єднання") };
println!("працюємо...");
} // тут автоматично: "звільняю ресурс: з'єднання"Звільнення ресурсу детерміноване й гарантоване компілятором, без ручного close()
| Пастка | Що станеться | Як уникнути |
|---|---|---|
Очікувати, що s2 = s1 копіює String | це move: s1 стає недійсним | .clone(), якщо потрібні дві копії |
#[derive(Copy)] на структурі з String | не скомпілюється | Copy — лише коли всі поля Copy |
Викликати x.drop() вручну | заборонено компілятором | std::mem::drop(x) |
Часткове переміщення, потім {:?} структури | error: use of partially moved value | перемістити поле в останню чергу або .clone() |
| Повертати власність з функції, що лише читає | багатослівний код | запозичення &T — тема Л6 |
Володіння — найунікальніша риса Rust, і вона має глибокі наслідки для всієї мови: саме воно дає гарантії безпеки пам'яті без збирача сміття. — The Rust Programming Language, розд. 4 «Understanding Ownership»
Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.
cargo buildtarget/debug/; --release — з оптимізаціями у target/release/.cargo💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.