Дивитися в дані, не володіючи ними — і описувати їхню відсутність явно
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР6. Матеріали публікуються в Google Classroom разом із лекцією.
Попередня лекція дала два інструменти: володіння (у значення рівно один власник) і
запозичення (&, &mut — скористатися, не забираючи). Цього достатньо, щоб код
компілювався, але ще не достатньо, щоб описувати дані точно.
Ця лекція про точність опису. Два питання, на які вона відповідає. Перше: як дати функції
доступ до частини даних — рядка всередині рядка, ділянки масиву — не копіюючи їх і не
віддаючи володіння? Відповідь — зріз (slice): вікно в чужий буфер. Друге: як описати
те, що значення може бути відсутнім, — не вигадуючи «магічних» значень на кшталт -1,
"" чи null? Відповідь — Option<T>: тип, у якому відсутність є одним із варіантів,
і компілятор не дасть про неї забути.
Спільне в обох — той самий принцип: тип має описувати реальність, а не приховувати її.
Зріз не бреше про володіння; Option не бреше про наявність значення.
Історично перевірка запозичень аналізувала лексичну область видимості посилання — тобто вважала
посилання живим аж до закриваючої дужки } блоку, навіть якщо останнє реальне використання
посилання сталося набагато раніше. Із редакції 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++-розробник і так тримає в голові вручну, тільки тепер його перевіряє машина, а не рев'ю коду
постфактум.
Тоні Хоар, автор конструкції null-посилання в мові ALGOL W (1965), публічно назвав це рішення
своєю "помилкою на мільярд доларів" (2009). null — значення, яке формально має тип T, але
насправді не містить жодних даних типу T. Проблема не в самій ідеї "значення може бути
відсутнім" — вона цілком легітимна (порожній результат пошуку, необов'язкове поле). Проблема в
тому, що компілятор мов з null не змушує перевіряти його перед розіменуванням: String name
у Java виглядає так само, як завжди-непорожній рядок, і компілятор не заважає забути перевірку —
і саме забуті перевірки дають NullPointerException у Java чи segfault у C.
Rust вирішує це радикально: значення типу T завжди гарантовано містить дійсні дані типу
T. У безпечному коді Rust взагалі немає null як такого — відсутність значення моделюється
типом Option<T>, окремим від T.
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, без явної обробки обох варіантів.
Помилку "забув перевірити на відсутність значення" переносить із рантайму (де вона проявляється
несподівано, на випадковому вході) у сферу типів, де компілятор ловить її статично.
Основний спосіб безпечно дістати значення з 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), який просто не написали.
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 чи винятків.
Фокус: позичити — не означає володіти
Очікувано: Перша версія: помилка компіляції з точним рядком конфлікту. Друга: успішна збірка
| Власник | Зріз | Що містить зріз | Приклад |
|---|---|---|---|
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 лише описує наявну область життя даних |
Option<T> (Rust)Дістати значення безпечно: match або комбінатори (map, unwrap_or, and_then)
enum Option<T> {
Some(T),
None,
}
let some_number: Option<i32> = Some(3101); // 0xC1D
let absent_number: Option<i32> = None;Option<T> — частина стандартної бібліотеки, є завжди в prelude
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
Альтернатива — прапорці is_connected + is_connecting + error: дозволяє «з'єднано» і «помилка» водночас. Option — окремий випадок цього прийому (СРС 12)
У будь-який момент часу можна мати або одне змінне посилання, або будь-яку кількість незмінних — і посилання завжди мають бути дійсними. — 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.