Borrowing, slices
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР6. Матеріали публікуються в Google Classroom разом із лекцією.
Попередня лекція завершилась незручним спостереженням: щоб функція просто скористалась рядком — порахувала довжину, вивела вміст на екран — доводиться передавати їй повне володіння і потім вручну повертати його назад. Писати так увесь код було б нестерпно. Ця лекція вводить механізм, що розв'язує проблему: запозичення (borrowing) — здатність тимчасово скористатися значенням через посилання, не забираючи власність і не звільняючи пам'ять. Саме на запозиченні тримається "безкоштовна" (з погляду рантайму) безпека Rust: перевірка запозичень (borrow checker) контролює всі правила посилань статично, компілятором, без жодного механізму блокування чи підрахунку посилань під час виконання.
Посилання (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); // "> готово"
Компілятор перевіряє два інваріанти для кожної області видимості, у якій живе посилання:
&T), або рівно одне
змінне (&mut T) — але не обидва одночасно.Перше правило — це, по суті, класична проблема "читач-письменник" (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) — тему, яка виходить за межі цього курсу, але спирається саме на
перевірку запозичень.
Історично перевірка запозичень аналізувала лексичну область видимості посилання — тобто вважала
посилання живим аж до закриваючої дужки } блоку, навіть якщо останнє реальне використання
посилання сталося набагато раніше. Із редакції 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 дозволяє
природніший, менш багатослівний код, зберігаючи всі гарантії безпеки — жодна помилка, яку
ловила стара перевірка запозичень, не стала пропускатися, лише прибрано хибні спрацювання.
Зріз (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, без копіювання вектора
Кожне посилання неявно має час життя — інтервал, протягом якого гарантовано, що дані, на які воно вказує, лишаються дійсними. У більшості коду компілятор виводить цей інтервал сам (це називається lifetime elision — "стирання/виведення часу життя") за трьома формальними правилами:
&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 використовується довше, ніж дозволяють обидва вхідні посилання.
У мовах без перевірки запозичень модифікація колекції під час ітерації по ній — класичне джерело
недетермінованих багів (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 — теж може
бути запозиченням, і вибір форми визначає, що метод має право робити з даними:
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 чи винятків.
Фокус: позичити — не означає володіти
Позичити — не означає володіти: посилання нічого не звільняє при виході з області видимості
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 = два потоки звертаються до тих самих даних одночасно, і хоча б один запис
| Уже є | Додати ще &T | Додати &mut T |
|---|---|---|
| нічого | ✓ | ✓ |
&T (одне чи багато) | ✓ | ✗ error[E0502] |
&mut T | ✗ error[E0502] | ✗ error[E0499] |
Це класична задача «читачі–письменник», розв'язана не блокуванням у рантаймі, а відмовою компілювати
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]: конфлікт запозичень виявлено до запуску програми
Очікувано: Перша версія: помилка компіляції з точним рядком конфлікту. Друга: успішна збірка
| Власник | Зріз | Що містить зріз | Приклад |
|---|---|---|---|
String | &str | вказівник на початок + довжина | "літерал", s.as_str(), &s[..i] |
Vec<T> | &[T] | вказівник + довжина | &v[1..3] |
[T; N] | &[T] | вказівник + довжина | &arr[..] |
Зріз — посилання на неперервну ділянку колекції: не володіє даними, нічого не копіює і не звільняє
`&v[2..5]` — це вказівник на v[2] плюс довжина 3. Поки цей зріз живий, `v.push(60)` потребує `&mut v` і не компілюється: error[E0502]
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], без копіювання данихЗрізи дозволяють працювати з частиною даних без володіння й без копіювання
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]
Ціна — явно продумувати, хто й коли позичає дані; це і є «суворість», яку ставлять Rust у докір
NLL аналізує потік керування, а не текстову область видимості змінної
let mut v = vec![1, 2, 3];
let first = &v[0]; // незмінне запозичення починається
println!("{}", first); // останнє використання first — тут
v.push(4); // ОК під NLL: first більше не використовуєтьсяДо появи NLL цей код відмовлявся компілюватись — first текстово "жила" до кінця блоку
Детально — на ЛР5 і в парному СРС: там це інструмент, а не теорія заради теорії
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 компілятор не знав би, з якого саме аргументу походить повернене посилання
Саме правило 3 дозволяє писати fn get(&self) -> &str без 'a
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 &v | error[E0502]: інвалідація ітератора | зібрати зміни окремо, застосувати після циклу |
Вважати, що 'a «створює» час життя | контракт не збігається з реальністю | 'a лише описує наявну область життя даних |
У будь-який момент часу можна мати або одне змінне посилання, або будь-яку кількість незмінних — і посилання завжди мають бути дійсними. — The Rust Programming Language, розд. 4.2 «References and Borrowing»
Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.
cargo buildtarget/debug/; --release — з оптимізаціями у target/release/.cargocargo run-- йдуть програмі (cargo run -- staff.log).💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.