Відсутність і помилка як тип
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР7. Матеріали публікуються в Google Classroom разом із лекцією.
Дві попередні лекції розібрали, як Rust гарантує безпеку пам'яті на етапі компіляції через володіння й запозичення. Ця лекція завершує модуль темою іншого класу помилок — тих, що виникають не через невалідну пам'ять, а через невалідний стан програми: значення, якого може не бути, і операції, які можуть провалитися. Ідея наскрізна: якщо стан можна описати типом так, щоб недійсна комбінація даних узагалі не мала синтаксису, — компілятор зробить цю помилку неможливою так само, як він робить неможливим use-after-free. Це і є принцип, відомий як "зроби невалідні стани непредставними" (make invalid states unrepresentable).
Тоні Хоар, автор конструкції 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" на практиці: чим точніше типи описують домен, тим менше класів багів фізично можливо написати.
Result описує операцію, яка може завершитися успіхом (Ok) або провалом (Err) — на відміну
від винятків (exceptions), можливість провалу видно прямо в сигнатурі функції, а не прихована в
документації чи взагалі ніде:
enum Result<T, E> {
Ok(T),
Err(E),
}
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("ділення на нуль"))
} else {
Ok(a / b)
}
}
Тип помилки E — звичайний тип, часто власний enum або String для простих випадків.
Компілятор попереджає атрибутом #[must_use] (стоїть на самому Result), якщо значення
проігноровано і ніяк не оброблено — типова помилка новачків "забув перевірити результат
операції" стає видимим попередженням компілятора, а не тихим багом.
Якщо функція сама повертає Result, оператор ? розгортає Ok(value) у value, а у випадку
Err — одразу повертає цю помилку з поточної функції:
use std::fs::File;
use std::io::{self, Read};
fn read_username() -> Result<String, io::Error> {
let mut f = File::open("user.txt")?; // якщо Err — функція одразу повертає його
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}
Без ? довелося б писати ручний match на кожен виклик, що повертає Result:
fn read_username_verbose() -> Result<String, io::Error> {
let mut f = match File::open("user.txt") {
Ok(file) => file,
Err(e) => return Err(e),
};
// ... той самий шаблон для кожного наступного виклику
Ok(String::new())
}
? прибирає цей шаблон і робить happy path лінійним і читабельним. Той самий оператор працює й
у функціях, що повертають Option<T> — тоді None одразу повертається з функції замість Err.
Rust розрізняє дві категорії помилок за принципово різним ставленням.
Невідновні помилки (panic!) — сигналізують про порушення інваріанта програми, з якого
безпечно продовжити неможливо: вихід за межі масиву, розіменування None через .unwrap(),
явний виклик panic!("..."). За замовчуванням паніка розкручує стек (unwind), викликаючи Drop
для кожного значення на шляху, і завершує потік; профіль panic = "abort" у Cargo.toml замінює
розкрутку на негайне завершення процесу — менший бінарник, але без коректного Drop.
Відновні помилки (Result) — очікувана частина нормальної роботи програми: файл може не
існувати, мережа може бути недоступна, ввід користувача може бути некоректним. Такі ситуації
не мають призводити до аварійного завершення — виклик коду вирішує, як реагувати.
panic! |
Result<T, E> |
|
|---|---|---|
| Коли використовувати | Порушено інваріант, продовження безглузде | Очікуваний, оброблюваний провал |
| Видимість у сигнатурі | Ні, прихована | Так, тип E видно в сигнатурі функції |
| Типовий контекст | Баг у самій програмі, assert! у тестах |
Файли, мережа, парсинг, ввід користувача |
| Наслідок | Завершення потоку (unwind/abort) | Виклик коду вирішує сам |
.unwrap() і .expect("повідомлення") дістають значення з Some/Ok або панікують із None/
Err. Вони доречні в прототипах, тестах і ситуаціях, де провал справді неможливий за контрактом
програми (наприклад, парсинг рядкового літералу, чия коректність очевидна на місці). У
виробничому коді, що обробляє зовнішні дані — файли, мережу, ввід користувача — майже завжди
краще явний match або ?, бо паніка в такому коді означає аварійне завершення на реальних,
непередбачуваних вхідних даних.
Коли функція може провалитися з кількох різних причин, зручно описати їх одним enum, а не
пласким String:
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
InvalidInput { field: String, reason: String },
Io(std::io::Error),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
AppError::NotFound(name) => write!(f, "не знайдено: {}", name),
AppError::InvalidInput { field, reason } =>
write!(f, "невалідне поле {}: {}", field, reason),
AppError::Io(e) => write!(f, "помилка вводу-виводу: {}", e),
}
}
}
impl std::error::Error for AppError {}
Щоб ? міг автоматично перетворювати помилку з бібліотечного типу (наприклад,
std::io::Error) у власний AppError, потрібна реалізація From:
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e)
}
}
fn read_config() -> Result<String, AppError> {
let content = std::fs::read_to_string("config.toml")?; // io::Error → AppError автоматично
Ok(content)
}
Оператор ? шукає саме реалізацію From<E1> for E2, де E1 — тип помилки викликаної функції, а
E2 — тип помилки, який повертає поточна функція; якщо реалізація є, конвертація стається
неявно в момент застосування ?.
Ручне написання impl Display та impl From для кожного типу помилки швидко стає багатослівним.
Спільнота виробила два взаємодоповнювальні крейти під різні сценарії.
thiserror — для бібліотек, де споживач коду має розрізняти варіанти помилки програмно
(через match). Макрос #[derive(Error)] генерує Display і std::error::Error з атрибутів:
use thiserror::Error;
#[derive(Error, Debug)]
enum AppError {
#[error("не знайдено: {0}")]
NotFound(String),
#[error("невалідне поле {field}: {reason}")]
InvalidInput { field: String, reason: String },
#[error("помилка вводу-виводу")]
Io(#[from] std::io::Error), // #[from] генерує impl From автоматично
}
anyhow — для застосунків (бінарників), де кінцевому коду не важливо розрізняти
конкретний варіант помилки — важливо лише повідомити користувача й, можливо, залогувати. Тип
anyhow::Error — це універсальна "коробка" для будь-якої помилки, що реалізує std::error::Error,
з можливістю додавати контекст по дорозі:
use anyhow::{Context, Result};
fn load_and_parse() -> Result<Config> {
let text = std::fs::read_to_string("config.toml")
.context("не вдалося прочитати config.toml")?;
let cfg: Config = toml::from_str(&text)
.context("не вдалося розпарсити config.toml")?;
Ok(cfg)
}
thiserror |
anyhow |
|
|---|---|---|
| Типовий контекст | Бібліотеки (crate, який використовують інші) | Бінарники, main.rs, скрипти, застосунки |
| Тип помилки | Власний, типізований enum |
Універсальний anyhow::Error |
| Споживач розрізняє варіант? | Так, через match |
Зазвичай ні — лише повідомлення й контекст |
| Головна фіча | #[error("...")], #[from] |
.context("...") для ланцюжка причин |
Для простих перетворень повний match буває багатослівним. Стандартна бібліотека надає набір
комбінаторів (combinators), що дозволяють ланцюжок операцій без вкладеного match:
let x: Option<i32> = Some(5);
let doubled = x.map(|n| n * 2); // Some(10) — перетворити всередині Some
let value = x.unwrap_or(0); // 5 — дістати або підставити типове
let chained = x.and_then(|n| if n > 0 { Some(n) } else { None }); // ланцюжок Option-операцій
// ? працює і з Option:
fn first_char(s: &str) -> Option<char> {
let c = s.chars().next()?; // якщо None — функція одразу повертає None
Some(c.to_ascii_uppercase())
}
| Комбінатор | Що робить |
|---|---|
.map(f) |
Застосувати f до значення всередині Some/Ok, не займаючись None/Err |
.unwrap_or(default) |
Дістати значення або підставити типове |
.unwrap_or_else(f) |
Те саме, але типове значення обчислюється лінивою функцією |
.and_then(f) |
Ланцюжок операцій, кожна з яких сама повертає Option/Result |
.ok() |
Перетворити Result<T, E> у Option<T>, відкинувши помилку |
| Пастка | Чому це проблема | Як уникнути |
|---|---|---|
.unwrap() у коді, що обробляє зовнішній ввід |
Паніка на реальних, непередбачуваних даних у проді | match, ? або .unwrap_or(...) замість .unwrap() |
Плоский String як тип помилки для складної логіки |
Викликач не може програмно розрізнити причини провалу | Власний enum з thiserror |
Забути #[from]/impl From для конвертації помилок під ? |
? не компілюється: немає шляху E1 → E2 |
Додати impl From<E1> for E2 або #[from] |
Використовувати anyhow у бібліотеці, що інші крейти імпортують |
Споживачі не можуть match по конкретній причині |
anyhow — для бінарників; бібліотеки — типізовані помилки (thiserror) |
| Моделювати стан булевими прапорцями замість enum | Дозволяє логічно суперечливі комбінації полів | enum з варіантами, що несуть лише релевантні для себе дані |
Rust прибирає null як окрему конструкцію і замінює відсутність значення явним типом
Option<T>, який компілятор змушує обробити повністю через вимогу вичерпності match. Той самий
принцип поширюється на помилки: Result<T, E> робить можливість провалу видимою в сигнатурі
функції, а оператор ? прибирає ручні ланцюжки перевірок. Rust розрізняє невідновні помилки
(panic! — порушення інваріанта) і відновні (Result — очікувана частина нормальної роботи);
для складної логіки власний тип помилки, оформлений через thiserror (бібліотеки) чи anyhow
(застосунки), дає і типобезпеку, і зручність. Загальніший принцип, що стоїть за всім модулем —
enum і Option/Result дозволяють моделювати стан програми так, щоб недійсні комбінації даних
узагалі не мали синтаксису: компілятор перевіряє повноту обробки статично, так само, як він
перевіряв правила володіння й запозичення в попередніх двох лекціях.
Фокус: компілятор не дає забути про порожній чи хибний випадок
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
Очікувано: Без гілки None компіляція падає з точною вказівкою відсутнього варіанта; з гілкою — успішна збірка
Альтернатива — прапорці is_connected + is_connecting + error: дозволяє «з'єднано» і «помилка» водночас. Option — окремий випадок цього прийому (СРС 12)
enum Result<T, E> {
Ok(T),
Err(E),
}
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("ділення на нуль"))
} else {
Ok(a / b)
}
}Тип помилки E — це звичайний тип, часто власний enum або String
Result<T, E> або Option<T>Замінює ланцюжки match { Ok(v) => v, Err(e) => return Err(e) } на кожен виклик
Ok(v) — значення підставляється й виконання йде далі; Err(e) — From::from(e) конвертує тип помилки і функція негайно повертає Err. Тому потрібен impl From<E> for E2
use std::fs::File;
use std::io::{self, Read};
fn read_username() -> Result<String, io::Error> {
let mut f = File::open("user.txt")?; // якщо Err — функція одразу повертає його
let mut s = String::new();
f.read_to_string(&mut s)?;
Ok(s)
}Без ? довелося б писати два ручні match — по одному на кожен виклик
| Спосіб | Що робить | Коли доречно |
|---|---|---|
match | усі варіанти явно, вимога повноти | складна логіка, різні дії на варіант |
? | поширює Err/None нагору | функція сама повертає Result/Option |
.map(f) | перетворити всередині Some/Ok | не займаючись None/Err |
.unwrap_or(d) | значення або типове d | є розумне значення за замовчуванням |
.and_then(f) | ланцюжок, кожен крок дає Option/Result | послідовні операції, що можуть провалитись |
.unwrap() / .expect("…") | значення або panic! | тести, прототипи, провал неможливий за контрактом |
У коді, що обробляє зовнішні дані (файли, мережа, ввід), unwrap() майже завжди програє match або ?
panic = "abort" у Cargo.toml дає менший бінарник, але без коректного Drop
use std::fmt;
#[derive(Debug)]
enum AppError {
NotFound(String),
InvalidInput { field: String, reason: String },
Io(std::io::Error),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?}", self)
}
}
impl std::error::Error for AppError {}enum описує рівно ті варіанти провалу, які реально можливі — жодного зайвого
From<E1> for E2Без impl From<E1> for E2 код із ? між різними типами помилок не скомпілюється
impl From<std::io::Error> for AppError {
fn from(e: std::io::Error) -> Self {
AppError::Io(e)
}
}
fn read_config() -> Result<String, AppError> {
let content = std::fs::read_to_string("config.toml")?; // io::Error → AppError
Ok(content)
}? конвертує помилку неявно в момент застосування, якщо From реалізовано
| Ознака | thiserror | anyhow |
|---|---|---|
| Де застосовують | бібліотеки — крейти, які імпортують інші | бінарники, main.rs, застосунки |
| Тип помилки | власний типізований enum | універсальний anyhow::Error |
| Споживач розрізняє варіант? | так, через match | зазвичай ні — повідомлення й контекст |
| Головна можливість | #[error("…")], #[from] | .context("…") — ланцюжок причин |
| Що генерує | Display і std::error::Error | обгортку для будь-якої Error |
Один проєкт часто поєднує обидва: thiserror у внутрішніх крейтах, anyhow у main.rs
// бібліотека: thiserror
#[derive(thiserror::Error, Debug)]
enum AppError {
#[error("не знайдено: {0}")]
NotFound(String),
#[error("помилка вводу-виводу")]
Io(#[from] std::io::Error),
}
// застосунок: anyhow
fn main() -> anyhow::Result<()> {
let text = std::fs::read_to_string("config.toml")
.context("не вдалося прочитати config.toml")?;
Ok(())
}Один і той самий проєкт часто поєднує обидва: thiserror у внутрішніх крейтах (crate), anyhow у main.rs
fn first_char_upper(s: &str) -> Option<char> {
let c = s.chars().next()?; // якщо None — функція одразу повертає None
c.to_uppercase().next() // Option<char>: перший символ верхнього регістру
}
assert_eq!(first_char_upper("привіт"), Some('П'));
assert_eq!(first_char_upper(""), None);Без ? довелося б писати match Some/None вручну на кожному кроці
Я називаю це своєю помилкою на мільярд доларів — винаходом null-посилання в 1965 році. — Тоні Гоар, «Null References: The Billion Dollar Mistake» (QCon, 2009)
Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.
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.