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

ЗАПОЗИЧЕННЯ І ЗРІЗИ

Borrowing, slices

Ключова ідея. Позичити значення — скористатися ним, не забираючи володіння і не звільняючи пам'ять.
Демо. Перевірка запозичень (borrow checker) ловить гонку за даними (data race) ще на етапі збірки — коли код одночасно тримає змінне (mutable) і незмінне (immutable) запозичення.
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття

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

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

Вступ

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

Запозичення: & і &mut

Посилання (reference) — це значення, яке вказує на дані, якими володіє хтось інший, і не несе відповідальності за їх звільнення.

  • &T — незмінне (immutable) посилання: через нього можна читати дані, але не змінювати.
  • &mut T — змінне (mutable) посилання: через нього можна і читати, і змінювати дані.
fn calculate_length(s: &String) -> usize {
    s.len()
}   // s виходить з області видимості, але дані НЕ звільняються — s лише позичав

let s1 = String::from("привіт");
let len = calculate_length(&s1);
println!("{}: {}", s1, len);   // s1 і далі валідний — власність не передавалась

Функція, що приймає &T замість T, не забирає значення — оригінал і далі належить своєму власнику й лишається доступним після виклику. Це прямо усуває незручність попередньої лекції: більше не треба повертати значення з кожної функції, яка лише його читає.

Аналогічно працює &mut:

fn add_prefix(s: &mut String) {
    s.insert_str(0, "> ");
}

let mut msg = String::from("готово");
add_prefix(&mut msg);
println!("{}", msg);   // "> готово"

Правила запозичень

Компілятор перевіряє два інваріанти для кожної області видимості, у якій живе посилання:

  1. У будь-який момент можна мати або кілька незмінних посилань (&T), або рівно одне змінне (&mut T) — але не обидва одночасно.
  2. Посилання завжди мусить вказувати на дійсні дані: dangling reference (посилання на вже знищені дані) неможливе — компілятор це гарантує статично.

Перше правило — це, по суті, класична проблема "читач-письменник" (reader-writer), вирішена не в рантаймі блокуванням, а на етапі компіляції відмовою скомпілювати конфліктний код:

let mut s = String::from("привіт");

let r1 = &s;        // незмінне запозичення
let r2 = &mut s;    // error[E0502]: cannot borrow `s` as mutable
                     //   because it is also borrowed as immutable
println!("{} {}", r1, r2);

Data race (гонка за даними) виникає, коли два потоки звертаються до тих самих даних одночасно і хоча б один з них — запис. Правило "&T багато або &mut одне" — це саме те, що структурно унеможливлює цю ситуацію ще до того, як мова взагалі перейде до потоків: якщо в одному потоці лежить &mut, компілятор не дасть жодному іншому коду мати одночасний доступ до тих самих даних, навіть у однопотоковому коді. Для багатопотокового коду ці ж правила, поширені на межі потоків через трейти Send/Sync, і дають Rust гарантію "безстрашної конкурентності" (fearless concurrency) — тему, яка виходить за межі цього курсу, але спирається саме на перевірку запозичень.

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++-розробник і так тримає в голові вручну, тільки тепер його перевіряє машина, а не рев'ю коду постфактум.

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

Пастка Чому це проблема Як уникнути
Тримати &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) розглядає інше джерело помилок — відсутність значення й провал операції — і те, як Rust робить обидва явними типами замість null чи винятків.

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

План лекції

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

Два види запозичення

&T — незмінне посилання
  • Можна читати, не можна змінювати
  • Одночасно — скільки завгодно
  • Власність лишається у власника
  • fn len(s: &String) — s можна використати далі
|
&mut T — змінне посилання
  • Можна і читати, і змінювати
  • Одночасно — рівно одне
  • Власність теж не переходить
  • Потребує let mut у власника

Позичити — не означає володіти: посилання нічого не звільняє при виході з області видимості

Immutable borrow

rust
fn calculate_length(s: &String) -> usize {
    s.len()
}   // s виходить з області видимості, але дані НЕ звільняються — s лише позичав

let s1 = String::from("привіт");
let len = calculate_length(&s1);
println!("{}: {}", s1, len);   // s1 і далі валідний — власність не передавалась

& — читати, не володіти

Правила запозичень

Data race = два потоки звертаються до тих самих даних одночасно, і хоча б один запис

ПРАВИЛА ПОЗИК (BORROW CHECKER) &T &T &T багато читачів одночасно — OK &mut T рівно один письменник і жодного читача & XOR &mut перевіряється під час компіляції → немає гонок даних

Що можна тримати одночасно

Уже єДодати ще &TДодати &mut T
нічого
&T (одне чи багато)✗ error[E0502]
&mut T✗ error[E0502]✗ error[E0499]

Це класична задача «читачі–письменник», розв'язана не блокуванням у рантаймі, а відмовою компілювати

Порушення правил: компілятор ловить конфлікт

rust
let mut s = String::from("привіт");

let r1 = &s;        // незмінне запозичення
let r2 = &mut s;    // error[E0502]: cannot borrow `s` as mutable
                    //   because it is also borrowed as immutable
println!("{} {}", r1, r2);

error[E0502]: конфлікт запозичень виявлено до запуску програми

Живе демо

  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 лише описує наявну область життя даних
У будь-який момент часу можна мати або одне змінне посилання, або будь-яку кількість незмінних — і посилання завжди мають бути дійсними. — 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.