Курс Лекція 5
▶ СлайдиСРС 09+10⌨️ команди 2
Конспект · Лекція 5

ВОЛОДІННЯ (OWNERSHIP)

Хто власник значення

Ключова ідея. У кожного значення в Rust рівно один власник; коли власник виходить з області видимості, значення знищується автоматично.
Демо. Move: після переміщення значення в нову змінну спроба використати стару змінну — помилка компіляції error[E0382].
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття
📚 ТЕОРЕТИЧНІ ВІДОМОСТІ

Вступ

Попередня лекція показала мотивацію: помилки керування пам'яттю коштують дорого, а Rust обіцяє виявляти їх на етапі компіляції. Ця лекція розкриває механізм, який робить це можливим — володіння (ownership). Це не бібліотечна фіча і не опційна практика — це фундаментальна властивість типової системи Rust, з якої компілятор виводить, коли саме звільнити кожну ділянку пам'яті, без збирача сміття і без ручного free(). Зрозуміти володіння — означає зрозуміти, чому Rust-код виглядає так, як виглядає, і чому компілятор іноді "не пускає" код, що здається цілком розумним.

Три правила володіння

Уся модель зводиться до трьох тверджень, які компілятор перевіряє статично:

  1. У кожного значення в Rust є змінна, яку називають його власником (owner).
  2. У кожен момент часу власник може бути лише один.
  3. Коли власник виходить за межі області видимості — значення знищується (викликається drop).

Ці правила виглядають простими, але з них випливає практично вся дисципліна роботи з пам'яттю в мові: немає потреби вручну відстежувати, хто і коли звільнить ресурс — компілятор виводить це з області видимості змінної-власника.

{
    let s = String::from("hello");   // s стає власником рядка
    // тут можна користуватись s
}   // область видимості s закінчилась → компілятор вставляє виклик drop(s)

Важливо: drop викликається автоматично й детерміновано, у зворотному порядку до створення змінних, у точці виходу з блоку — на відміну від збирача сміття, де момент звільнення непередбачуваний.

Стек, купа і типи з Copy-семантикою

Не всі присвоєння в Rust поводяться однаково — це залежить від того, де фізично лежать дані значення.

Прості типи фіксованого розміру, які повністю вміщуються на стеку (i32, f64, bool, char, кортежі з таких типів), реалізують спеціальний маркерний трейт (trait) Copy. Присвоєння такого значення іншій змінній створює побітову копію — обидві змінні лишаються незалежно валідними:

let x = 3101;                 // 0xC1D
let y = x;                    // побітова копія
println!("{} {}", x, y);      // працює: i32 реалізує Copy

Копіювання тут дешеве (кілька байтів), тому робити його типовою поведінкою безпечно й передбачувано. Типи, для яких копіювання можна дозволити за замовчуванням, — це ті, для яких воно не приховує жодної дорогої операції: копія i32 не виділяє пам'ять і нічого не звільняє.

Що таке move

Для типів, дані яких живуть на купі (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 буде викликаний рівно один раз.

Чому move, а не копіювання за замовчуванням

Якби String, Vec та подібні типи копіювалися неявно при кожному присвоєнні (як у деяких мовах з семантикою значень), це означало б непередбачувану вартість продуктивності: рядок у мільйон символів копіювався б повністю щоразу, коли його передають далі за кодом, і читач коду не бачив би цієї вартості в жодному синтаксисі. Rust свідомо робить дешеву операцію (move — копія трьох машинних слів) типовою поведінкою, а дорогу (глибоке копіювання) — явною дією, яку треба викликати самому.

Clone — явна глибока копія

Коли справді потрібні дві незалежні копії даних на купі, викликають .clone(). Це виділяє нову ділянку пам'яті і копіює вміст туди — обидві змінні після цього лишаються повноцінними, незалежними власниками своїх даних.

let s1 = String::from("hello");
let s2 = s1.clone();       // глибока копія: нова ділянка пам'яті

println!("{} {}", s1, s2); // ОК: обидві змінні валідні й незалежні

Головна методологічна ідея: дорога операція завжди видна в коді явно. Читач одразу бачить .clone() і розуміє: тут сталася алокація й копіювання. Немає прихованих дорогих операцій за непомітним синтаксисом присвоєння =.

Copy vs 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) дозволяє функції скористатися значенням, не забираючи власність узагалі.

Drop і RAII

Коли власник виходить з області видимості, 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() і завжди видна в коді. Передача значення у функцію теж переміщує власність, що часто незручно — і саме ця незручність мотивує наступну тему курсу: запозичення, яке дозволяє користуватися значенням без передачі володіння.

Розбір за слайдами

План лекції

Фокус: одне значення — один власник, завжди

Три правила володіння

  1. 1
    У кожного значення є власникзмінна, якій значення належить (owner)
  2. 2
    Власник у кожен момент — одиндвох власників одного значення не буває
  3. 3
    Вихід з області видимості — dropкомпілятор вставляє звільнення сам, детерміновано

Ці три правила компілятор перевіряє статично, без збирача сміття в рантаймі

Стек, копіювання і типи з Copy

rust
let x = 3101;       // 0xC1D, i32 — живе на стеку
let y = x;          // копія: обидва x і y валідні
println!("{} {}", x, y);   // працює: i32 реалізує трейт Copy

// прості типи фіксованого розміру на стеку (i32, bool, char, f64, кортежі з них)
// копіюються автоматично — тут немає move

Copy-типи — виняток із загального правила move

Що таке move

СТЕК (швидко, LIFO) КУПА (heap) let s = String ptr,len,cap x: i32 = 5 на стеку "HELLO" дані рядка let s2 = s; // MOVE s — недійсний moved out s2 → "HELLO" один власник рівно один власник даних у купі → немає подвійного звільнення

Move: компілятор забороняє повторне використання

rust
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

Що відбувається при `let s2 = s1;`

MOVE: ПЕРЕЇЖДЖАЄ ЗАГОЛОВОК, А НЕ ДАНІ СТЕК · let s2 = s1; s1 ptr len cap moved out E0382 при вживанні s2 ptr → купа len 5 cap 5 єдиний власник він і викличе drop 24 Б КУПА · буфер String String::from("hello") h e l l o буфер не рухається 0 байтів даних скопійовано ВИНЯТОК · COPY-ТИПИ let x = 3101; let y = x; 3101 x 3101 y на купі нічого нема — копіюється все обидва лишаються валідними власник один; move — це передача відповідальності за drop, а не копія даних

Копіюється лише заголовок (ptr/len/cap = 24 байти); буфер "hello" на купі не рухається, а s1 стає недійсним. Copy-типи на кшталт i32 — виняток: у них на купі нічого нема

Move проти Clone

Move — типова поведінка
  • Копіює лише заголовок: вказівник, довжина, місткість
  • Буфер на купі не чіпається
  • Стара змінна стає недійсною
  • Дешево: не залежить від розміру даних
  • Синтаксис — звичайне присвоєння =
vs
Clone — явна дія
  • Виділяє нову ділянку на купі
  • Копіює вміст байт за байтом
  • Обидві змінні — незалежні власники
  • Дорого: пропорційно розміру даних
  • Синтаксис — видимий виклик .clone()

Rust робить дешеву операцію типовою, а дорогу — явною: читач бачить кожну алокацію

Що насправді переміщує move у String

24
байти копіюються при move
заголовок: ptr + len + capacity на 64-бітній системі
0
байтів буфера на купі
дані не рухаються — переходить лише право на них
1
власник у кожен момент
стара змінна недійсна після move
1
виклик drop на значення
double free структурно неможливий

Move не пропорційний розміру даних — саме тому він може бути типовою поведінкою

Clone на практиці

rust
let s1 = String::from("hello");
let s2 = s1.clone();       // глибока копія: нова ділянка пам'яті

println!("{} {}", s1, s2); // ОК: обидві змінні валідні й незалежні

Clone видно в коді — це свідомий вибір, а не прихована вартість

Власність у виклику функції

s1 у викликачаString::from("дані")
move у функціюtakes_ownership(s1) — s1 недійсний
s усерединіфункція — власник на час виклику
return sвласність повертається результатом
s1 знову власникlet s1 = takes_ownership(s1)

Передавати й повертати власність через кожну функцію незручно — тому в Л6 з'явиться запозичення

Володіння переходить у функцію і повертається

rust
fn takes_ownership(s: String) -> String {
    println!("отримав: {}", s);
    s   // повертаємо власність назад викликачу
}

let s1 = String::from("дані");
let s1 = takes_ownership(s1);   // власність пішла у функцію й повернулась
println!("{}", s1);            // s1 знову валідний

Постійно передавати й повертати власність незручно — саме тому в Л6 з'явиться запозичення (borrowing)

Живе демо

  1. Пишемо функцію, яка приймає String за значенням і нічого не повертає
  2. Викликаємо її з s1, потім намагаємось знову вивести s1 через println!
  3. Запускаємо `cargo build` і читаємо повідомлення компілятора error[E0382]
  4. Виправляємо, повертаючи власність з функції (як на попередньому слайді), переконуємось, що збирається

Очікувано: Перша версія не компілюється з чіткою вказівкою рядка й причини; виправлена версія збирається і виконується

Copy чи move? Залежить від типу

Тип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

ОзнакаCopyClone
Викликаєтьсянеявно, при присвоєнніявно, методом .clone()
Вартістьзавжди дешева: побітова копіябудь-яка: глибоке копіювання
Хто може реалізуватитипи без ресурсів на купі й без Dropпрактично будь-який тип
Прикладиi32, bool, (f64, f64)String, Vec<T>, більшість структур
Зв'язокCopy вимагає CloneClone не вимагає Copy

`#[derive(Clone, Copy)]` працює, лише якщо всі поля структури самі реалізують ці трейти

derive(Clone, Copy) для власної структури

rust
#[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

Часткове переміщення полів структури

Часткове переміщення на практиці

rust
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

Drop і RAII

🔚
Drop::drop автоматичновикликається, коли власник виходить з області видимості
🏗️
RAII як у C++але примусово перевірений компілятором
🔁
Зворотний порядокостаннє створене знищується першим — навіть під час паніки
🚫
x.drop() забороненораннє звільнення — лише std::mem::drop(x)
🔌
Ресурси, не тільки пам'ятьфайли, з'єднання, м'ютекси звільняються детерміновано
1️⃣
Рівно один викликне можна забути звільнити або звільнити двічі

Без збирача сміття і без ручного free()/close() у кожному місці виходу з функції

impl Drop приклад

rust
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»

Підсумок

Джерела

🥚 ПАСХАЛКА
У прикладі Copy змінна x дорівнює 3101 — 0xC1D. Побітова копія такого i32 коштує рівно 4 байти; сама константа в матеріалах курсу з'являється не вперше.
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 2

Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.

cargo build
Компілює проєкт у target/debug/; --release — з оптимізаціями у target/release/.
cargo
Система збірки й менеджер пакетів Rust: створює проєкт, тягне залежності (крейти), збирає, тестує, запускає.

💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.