Курс › Лекція 6
▶ СлайдиСРС 11+12⌨️ команди 3
Конспект · Лекція 6

ЗРІЗИ, ЧАСИ ЖИТТЯ І ТИПИ БЕЗ NULL

Дивитися в дані, не володіючи ними — і описувати їхню відсутність явно

Ключова ідея. Точний тип замість копії й замість null: зріз — це вікно в чужі дані без володіння, `Option<T>` — це явно описана можливість, що значення немає.
Демо. match над Option<T>: компілятор вимагає обробити обидва варіанти, Some і None — забути None неможливо.
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття

Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР6. Матеріали публікуються в Google Classroom разом із лекцією.

📚 ТЕОРЕТИЧНІ ВІДОМОСТІ

Вступ

Попередня лекція дала два інструменти: володіння (у значення рівно один власник) і запозичення (&, &mut — скористатися, не забираючи). Цього достатньо, щоб код компілювався, але ще не достатньо, щоб описувати дані точно.

Ця лекція про точність опису. Два питання, на які вона відповідає. Перше: як дати функції доступ до частини даних — рядка всередині рядка, ділянки масиву — не копіюючи їх і не віддаючи володіння? Відповідь — зріз (slice): вікно в чужий буфер. Друге: як описати те, що значення може бути відсутнім, — не вигадуючи «магічних» значень на кшталт -1, "" чи null? Відповідь — Option<T>: тип, у якому відсутність є одним із варіантів, і компілятор не дасть про неї забути.

Спільне в обох — той самий принцип: тип має описувати реальність, а не приховувати її. Зріз не бреше про володіння; Option не бреше про наявність значення.

Non-Lexical Lifetimes: як насправді працює сучасна перевірка запозичень

Історично перевірка запозичень аналізувала лексичну область видимості посилання — тобто вважала посилання живим аж до закриваючої дужки } блоку, навіть якщо останнє реальне використання посилання сталося набагато раніше. Із редакції Rust 2018 перевірка запозичень перейшла на аналіз не-лексичних часів життя (Non-Lexical Lifetimes, NLL): вона відстежує фактичне останнє використання посилання в потоці керування, а не текстову область видимості змінної.

let mut v = vec![1, 2, 3];

let first = &v[0];       // незмінне запозичення починається
println!("{}", first);   // останнє використання first — тут

v.push(4);                // ОК під NLL: компілятор бачить, що first більше не використовується

До NLL цей код не компілювався б, бо first текстово "жива" до кінця блоку. NLL дозволяє природніший, менш багатослівний код, зберігаючи всі гарантії безпеки — жодна помилка, яку ловила стара перевірка запозичень, не стала пропускатися, лише прибрано хибні спрацювання.

Зрізи (slices)

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

  • &str — зріз рядка (наприклад, літерал "..." або результат String::as_str()).
  • &[T] — зріз масиву чи Vec<T>, наприклад &v[1..3].
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &b) in bytes.iter().enumerate() {
        if b == b' ' { return &s[..i]; }
    }
    s
}

let v = vec![10, 20, 30, 40];
let mid = &v[1..3];   // зріз [20, 30], без копіювання даних

Практична перевага зрізів — функція, що приймає &[i32], працює однаково і з Vec<i32>, і з масивом [i32; N], без потреби копіювати дані чи писати перевантаження:

fn largest(list: &[i32]) -> i32 {
    let mut max = list[0];
    for &item in list {
        if item > max { max = item; }
    }
    max
}

let numbers = vec![34, 50, 25, 3101, 65];
println!("{}", largest(&numbers));   // 3101, без копіювання вектора

Часи життя (lifetimes)

Кожне посилання неявно має час життя — інтервал, протягом якого гарантовано, що дані, на які воно вказує, лишаються дійсними. У більшості коду компілятор виводить цей інтервал сам (це називається lifetime elision — "стирання/виведення часу життя") за трьома формальними правилами:

  1. Кожен параметр-посилання отримує власний, окремий параметр часу життя.
  2. Якщо параметр часу життя рівно один — він автоматично присвоюється всім вихідним посиланням.
  3. Якщо серед параметрів є &self чи &mut self — час життя self присвоюється всім вихідним посиланням (типово для методів).

Коли компілятор не може вивести час життя однозначно — типово, коли функція повертає посилання, яке могло походити з кількох різних вхідних посилань, — його треба вказати явно синтаксисом 'a:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

let s1 = String::from("довгий рядок");
let s2 = String::from("короткий");
println!("{}", longest(&s1, &s2));

Анотація 'a тут не змінює фактичний час життя жодного значення — вона лише повідомляє компілятору контракт: "результат живе не довше за коротше з двох вхідних посилань". Компілятор потім перевіряє, чи це справді так у всіх точках виклику, і відмовляється компілювати код, де результат longest використовується довше, ніж дозволяють обидва вхідні посилання.

Iterator invalidation неможлива

У мовах без перевірки запозичень модифікація колекції під час ітерації по ній — класичне джерело недетермінованих багів (invalidated iterator): внутрішній буфер Vec може бути перевиділений при push, і всі наявні посилання/ітератори на старий буфер стають dangling.

let mut v = vec![1, 2, 3];

for x in &v {
    v.push(*x);   // error[E0502]: cannot borrow `v` as mutable
                   //   because it is also borrowed as immutable
}

for x in &v тримає незмінне запозичення v протягом усього циклу; спроба v.push(...) всередині циклу вимагає &mut v — конфлікт із правилом "& багато або &mut одне" ловиться компілятором до запуску програми, а не проявляється як важковідтворюваний баг у продакшн-логах.

Запозичення в методах: &self і &mut self

Той самий механізм лежить в основі методів структур. Перший параметр методу — self — теж може бути запозиченням, і вибір форми визначає, що метод має право робити з даними:

struct Counter {
    value: i32,
}

impl Counter {
    fn get(&self) -> i32 {          // читає, не змінює
        self.value
    }

    fn increment(&mut self) {       // змінює — потребує &mut
        self.value += 1;
    }

    fn into_value(self) -> i32 {    // забирає власність (типово для "фінальних" перетворень)
        self.value
    }
}

let mut c = Counter { value: 0 };
c.increment();          // тут потрібен `mut c`, бо increment просить &mut self
println!("{}", c.get());

Правило третього пункту lifetime elision (час життя self присвоюється всім вихідним посиланням методу) саме тому дозволяє писати fn get(&self) -> &str без явної анотації 'a — компілятор знає, що повернене посилання не може пережити self.

Навіщо все це

Правила запозичень виключають цілий клас помилок ще до запуску: dangling pointer, data race, iterator invalidation — і роблять це, не додаючи жодних накладних витрат у рантаймі: немає блокувань (lock), немає збирача сміття, немає підрахунку посилань за замовчуванням — усе вирішується статично компілятором один раз, під час збірки. Ціна цього — потрібно явно продумувати, хто й коли позичає дані, і саме це часто ставлять Rust у докір як "суворість" мови. На практиці ця суворість — перенесений на етап компіляції той аналіз, який досвідчений C++-розробник і так тримає в голові вручну, тільки тепер його перевіряє машина, а не рев'ю коду постфактум.

Проблема null

Тоні Хоар, автор конструкції null-посилання в мові ALGOL W (1965), публічно назвав це рішення своєю "помилкою на мільярд доларів" (2009). null — значення, яке формально має тип T, але насправді не містить жодних даних типу T. Проблема не в самій ідеї "значення може бути відсутнім" — вона цілком легітимна (порожній результат пошуку, необов'язкове поле). Проблема в тому, що компілятор мов з null не змушує перевіряти його перед розіменуванням: String name у Java виглядає так само, як завжди-непорожній рядок, і компілятор не заважає забути перевірку — і саме забуті перевірки дають NullPointerException у Java чи segfault у C.

Rust вирішує це радикально: значення типу T завжди гарантовано містить дійсні дані типу T. У безпечному коді Rust взагалі немає null як такого — відсутність значення моделюється типом Option<T>, окремим від T.

enum Option

Option<T> — звичайний перелічуваний тип (enum) із стандартної бібліотеки, доступний завжди через prelude без імпорту:

enum Option<T> {
    Some(T),
    None,
}

let some_number: Option<i32> = Some(3101);   // 0xC1D
let absent_number: Option<i32> = None;

Ключова відмінність від null: Option<i32> і i32 — це різні типи. Компілятор не дозволить використати Option<i32> там, де очікується i32, без явної обробки обох варіантів. Помилку "забув перевірити на відсутність значення" переносить із рантайму (де вона проявляється несподівано, на випадковому вході) у сферу типів, де компілятор ловить її статично.

match над Option — вимога повноти

Основний спосіб безпечно дістати значення з Option<T> — зіставлення зі зразком, match:

fn describe(x: Option<i32>) -> String {
    match x {
        Some(n) if n < 0 => format!("від'ємне: {}", n),
        Some(n) => format!("значення: {}", n),
        None => String::from("значення відсутнє"),
    }
}

match у Rust — це вираз, і компілятор вимагає, щоб гілки покривали всі можливі варіанти типу (exhaustiveness checking). Прибрати гілку None — і код узагалі не скомпілюється:

error[E0004]: non-exhaustive patterns: `None` not covered

Це найпряміший практичний наслідок принципу "невалідні стани непредставні": неможливо забути обробити відсутність значення так, як це трапляється з if (x != null), який просто не написали.

Enum як спосіб моделювати стан точно

Option — окремий випадок загальнішого прийому: enum дозволяє описати точно ті варіанти стану, які реально можливі, і жодного зайвого. Порівняймо дві моделі одного й того самого стану з'єднання:

// Погано: булеві прапорці дозволяють суперечливі комбінації
struct ConnectionBad {
    is_connected: bool,
    is_connecting: bool,
    error_message: Option<String>,   // а що як is_connected=true і error_message.is_some()?
}

// Добре: неможливо виразити суперечливий стан
enum Connection {
    Disconnected,
    Connecting,
    Connected { session_id: u64 },
    Failed { reason: String },
}

У другому варіанті просто немає синтаксису, яким можна одночасно сказати "з'єднано" і "є помилка" — набір полів для кожного варіанта існує лише тоді, коли цей варіант активний. Це і є "make invalid states unrepresentable" на практиці: чим точніше типи описують домен, тим менше класів багів фізично можливо написати.

Підводні камені

Пастка Чому це проблема Як уникнути
Тримати &mut і & до тієї самої змінної в одній логічній ділянці error[E0502] Розвести запозичення в часі; NLL часто вирішує це автоматично при коректному порядку коду
Плутати &str (запозичений зріз) з String (власник) Спроба повернути &str на локальну String дає dangling reference Повертати String (з переданням власності) або приймати вхідне посилання з достатнім часом життя
Забувати, що for x in &v тримає запозичення на весь цикл Неможливо модифікувати v всередині циклу Збирати індекси/значення для зміни окремо, застосувати після циклу
Вважати 'a "часом життя, який компілятор створює" 'a — лише опис наявного контракту, не механізм подовження життя Час життя визначається реальною областю видимості даних, анотація лише документує це для компілятора

Підсумок

Запозичення дозволяє коду користуватися значенням, не забираючи власність: &T для читання, &mut T для зміни, і в будь-який момент — або кілька &T, або рівно один &mut T. Ці правила компілятор перевіряє статично; сучасна перевірка запозичень (NLL) аналізує фактичне останнє використання посилання, а не лексичну область видимості. Зрізи (&str, &[T]) дають доступ до частини колекції без володіння й без копіювання. Часи життя ('a) — явний спосіб описати компілятору контракт, коли він не може вивести його сам. Результат — цілий клас рантайм-помилок (dangling pointer, data race, iterator invalidation) стає структурно неможливим, без жодних накладних витрат під час виконання програми. Наступна лекція (Л7) бере другу половину тієї самої ідеї: не «значення може не бути», а «операція могла не вдатися» — і робить помилку значенням у сигнатурі функції, так само явно, як Option робить явною відсутність, і так само замість null чи винятків.

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

План лекції

Фокус: позичити — не означає володіти

Живе демо

  1. Пишемо функцію, яка одночасно тримає &Vec<i32> (читає елемент) і &mut Vec<i32> (додає елемент) у тій самій області видимості
  2. Запускаємо `cargo build`, читаємо повідомлення error[E0502] з поясненням, яке саме запозичення конфліктує
  3. Розводимо запозичення по різних, не перетинних областях видимості (закриваємо {} блок)
  4. Переконуємось, що код збирається і поведінка передбачувана

Очікувано: Перша версія: помилка компіляції з точним рядком конфлікту. Друга: успішна збірка

Власник проти зрізу

ВласникЗрізЩо містить зрізПриклад
String&strвказівник на початок + довжина"літерал", s.as_str(), &s[..i]
Vec<T>&[T]вказівник + довжина&v[1..3]
[T; N]&[T]вказівник + довжина&arr[..]

Зріз — посилання на неперервну ділянку колекції: не володіє даними, нічого не копіює і не звільняє

Зріз — вікно в буфер, а не володіння

ЗРІЗ — ЦЕ ВІКНО, А НЕ ВОЛОДІННЯ VEC<I32> · 6 ЕЛЕМЕНТІВ У КУПІ &v[2..5] ptr → v[2] · len = 3 10 0 20 1 30 2 40 3 50 4 60 5 зріз не копіює дані — це вказівник + довжина ЧОМУ &MUT ЗАБОРОНЕНИЙ, ПОКИ ЗРІЗ ЖИВИЙ let s = &v[2..5] позика жива v.push(60) потрібне &mut v push перевиділить буфер — і зріз вказував би в нікуди NLL: жива до останнього вжитку error[E0502] зріз не володіє даними — він лише вікно, тому й правила позик такі суворі

`&v[2..5]` — це вказівник на v[2] плюс довжина 3. Поки цей зріз живий, `v.push(60)` потребує `&mut v` і не компілюється: error[E0502]

&str і &[T] на практиці

rust
fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();
    for (i, &b) in bytes.iter().enumerate() {
        if b == b' ' { return &s[..i]; }
    }
    s
}

let v = vec![10, 20, 30, 40];
let mid = &v[1..3];   // зріз [20, 30], без копіювання даних

Зрізи дозволяють працювати з частиною даних без володіння й без копіювання

largest: типова функція із зрізом

rust
fn largest(list: &[i32]) -> i32 {
    let mut max = list[0];
    for &item in list {
        if item > max { max = item; }
    }
    max
}

let numbers = vec![34, 50, 25, 3101, 65];
println!("{}", largest(&numbers));   // 3101, без копіювання вектора

Функція приймає зріз — працює і з Vec<i32>, і з масивом [i32; N]

Що виключають правила запозичень

🧷
Висяче посиланняпосилання на вже знищені дані — не компілюється
⚡
Гонка за даними&mut виключає будь-який інший доступ до тих самих даних
🔁
Інвалідація ітератораpush у Vec під час for по ньому — error[E0502]
🆓
Без рантайм-витратжодних lock, GC чи підрахунку посилань — усе статично

Ціна — явно продумувати, хто й коли позичає дані; це і є «суворість», яку ставлять Rust у докір

Лексична перевірка проти NLL

Rust 2015: лексичні області
  • Посилання живе до закриваючої дужки блоку
  • Потрібні штучні блоки {} для завершення запозичення
  • Хибні спрацювання на коректному коді
→
Rust 2018+: Non-Lexical Lifetimes
  • Посилання живе до останнього фактичного використання
  • Природний код без зайвих блоків
  • Ті самі гарантії безпеки — жодна помилка не пропускається

NLL аналізує потік керування, а не текстову область видимості змінної

NLL: запозичення завершується там, де закінчилось реальне використання

rust
let mut v = vec![1, 2, 3];

let first = &v[0];       // незмінне запозичення починається
println!("{}", first);   // останнє використання first — тут

v.push(4);                // ОК під NLL: first більше не використовується

До появи NLL цей код відмовлявся компілюватись — first текстово "жила" до кінця блоку

Часи життя (lifetimes) і синтаксис 'a

Детально — на ЛР5 і в парному СРС: там це інструмент, а не теорія заради теорії

Функція з явним часом життя

rust
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

let s1 = String::from("довгий рядок");
let s2 = String::from("короткий");
println!("{}", longest(&s1, &s2));

Без 'a компілятор не знав би, з якого саме аргументу походить повернене посилання

Як компілятор виводить час життя (elision)

  1. 1
    Правило 1кожен параметр-посилання отримує власний час життя
  2. 2
    Правило 2вхідний час життя рівно один → він і у вихідних посилань
  3. 3
    Правило 3є &self / &mut self → його час життя у вихідних посилань
  4. 4
    Інакшеоднозначної відповіді немає — потрібна явна анотація 'a

Саме правило 3 дозволяє писати fn get(&self) -> &str без 'a

Спроба модифікувати Vec під час ітерації

rust
let mut v = vec![1, 2, 3];

for x in &v {
    v.push(*x);   // error[E0502]: cannot borrow `v` as mutable
                  //   because it is also borrowed as immutable
}

Iterator invalidation — класичний рантайм-баг в інших мовах — тут помилка компіляції

Підводні камені

ПасткаЩо станетьсяЯк уникнути
&mut і & до однієї змінної в одній ділянціerror[E0502]розвести запозичення в часі; часто рятує NLL
Повернути &str на локальну Stringвисяче посилання — не компілюєтьсяповертати String або приймати вхідне посилання
v.push() усередині for x in &verror[E0502]: інвалідація ітераторазібрати зміни окремо, застосувати після циклу
Вважати, що 'a «створює» час життяконтракт не збігається з реальністю'a лише описує наявну область життя даних

Проблема null

enum Option<T> — ЗАМІСТЬ null Option<T> два стани Some(value) є значення T None значення немає match дає гілку на кожен стан → компілятор змушує обробити None некоректний стан (null) фізично непредставний

null проти Option<T>

null (Java, C, JS …)
  • Має тип T, але даних типу T не містить
  • Перевірку if (x != null) легко забути
  • Помилка виявляється в рантаймі: NPE, segfault
  • На випадковому вході, у проді
vs
Option<T> (Rust)
  • Окремий тип: Option<i32> ≠ i32
  • Компілятор вимагає обробити Some і None
  • Помилка виявляється при компіляції: error[E0004]
  • До першого запуску програми

Дістати значення безпечно: match або комбінатори (map, unwrap_or, and_then)

enum Option<T>

rust
enum Option<T> {
    Some(T),
    None,
}

let some_number: Option<i32> = Some(3101);   // 0xC1D
let absent_number: Option<i32> = None;

Option<T> — частина стандартної бібліотеки, є завжди в prelude

match над Option

rust
fn describe(x: Option<i32>) -> String {
    match x {
        Some(n) if n < 0 => format!("від'ємне: {}", n),
        Some(n) => format!("значення: {}", n),
        None => String::from("значення відсутнє"),
    }
}

Прибрати гілку None — і код взагалі не скомпілюється: error[E0004] non-exhaustive

Enum робить невалідний стан непредставним

⚪
Disconnectedбез полів — жодних зайвих даних
🟡
Connectingбез полів
🟢
Connected { session_id }ідентифікатор існує лише тут
🔴
Failed { reason }причина існує лише тут

Альтернатива — прапорці is_connected + is_connecting + error: дозволяє «з'єднано» і «помилка» водночас. Option — окремий випадок цього прийому (СРС 12)

У будь-який момент часу можна мати або одне змінне посилання, або будь-яку кількість незмінних — і посилання завжди мають бути дійсними. — The Rust Programming Language, розд. 4.2 «References and Borrowing»

Підсумок

Джерела

🔗 ДЖЕРЕЛА
🥚 ПАСХАЛКА
Найбільше число у прикладі largest — 3101, тобто 0xC1D. Функція повертає його копією, не забираючи володіння вектором; сама константа супроводжує курс від першого модуля.
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 3▾

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

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

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