Помилка як значення, а не як виняток
Після презентації теми — самостійна робота в аудиторії над двома темами. Вони ж готують до захисту та розширення ЛР7. Матеріали публікуються в Google Classroom разом із лекцією.
Три попередні лекції розібрали, як Rust гарантує безпеку пам'яті на етапі компіляції:
володіння, запозичення, зрізи. Минулого разу до цього додався Option<T> — тип, який
чесно описує відсутність значення.
Ця лекція завершує модуль темою того самого класу, але про інше: не «значення немає», а
операція не вдалася. Файл не відкрився, рядок не розпарсився, мережа не відповіла. У
більшості мов така подія — виняток: невидимий у сигнатурі, він летить крізь стек, доки
хтось його не зловить, а якщо не зловить — програма падає. У Rust винятків немає. Провал —
це значення типу Result<T, E>, яке функція повертає явно, і компілятор не дасть
проігнорувати варіант Err.
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 дозволяють моделювати стан програми так, щоб недійсні комбінації даних
узагалі не мали синтаксису: компілятор перевіряє повноту обробки статично, так само, як він
перевіряв правила володіння й запозичення в попередніх двох лекціях.
Фокус: компілятор не дає забути про порожній чи хибний випадок
Очікувано: Без гілки None компіляція падає з точною вказівкою відсутнього варіанта; з гілкою — успішна збірка
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.