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

RESULT, ? І ВЛАСНІ ТИПИ ПОМИЛОК

Помилка як значення, а не як виняток

Ключова ідея. У Rust помилка — це значення в сигнатурі функції, яке компілятор змушує обробити, а не невидимий виняток, що летить крізь стек.
Демо. Оператор ?: та сама функція з ручним match і з ? — поведінка ідентична, рядків утричі менше.
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття

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

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

Вступ

Три попередні лекції розібрали, як Rust гарантує безпеку пам'яті на етапі компіляції: володіння, запозичення, зрізи. Минулого разу до цього додався Option<T> — тип, який чесно описує відсутність значення.

Ця лекція завершує модуль темою того самого класу, але про інше: не «значення немає», а операція не вдалася. Файл не відкрився, рядок не розпарсився, мережа не відповіла. У більшості мов така подія — виняток: невидимий у сигнатурі, він летить крізь стек, доки хтось його не зловить, а якщо не зловить — програма падає. У Rust винятків немає. Провал — це значення типу Result<T, E>, яке функція повертає явно, і компілятор не дасть проігнорувати варіант Err.

Result<T, E> — помилка як значення

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.

panic! проти Result: коли що

Rust розрізняє дві категорії помилок за принципово різним ставленням.

Невідновні помилки (panic!) — сигналізують про порушення інваріанта програми, з якого безпечно продовжити неможливо: вихід за межі масиву, розіменування None через .unwrap(), явний виклик panic!("..."). За замовчуванням паніка розкручує стек (unwind), викликаючи Drop для кожного значення на шляху, і завершує потік; профіль panic = "abort" у Cargo.toml замінює розкрутку на негайне завершення процесу — менший бінарник, але без коректного Drop.

Відновні помилки (Result) — очікувана частина нормальної роботи програми: файл може не існувати, мережа може бути недоступна, ввід користувача може бути некоректним. Такі ситуації не мають призводити до аварійного завершення — виклик коду вирішує, як реагувати.

panic! Result<T, E>
Коли використовувати Порушено інваріант, продовження безглузде Очікуваний, оброблюваний провал
Видимість у сигнатурі Ні, прихована Так, тип E видно в сигнатурі функції
Типовий контекст Баг у самій програмі, assert! у тестах Файли, мережа, парсинг, ввід користувача
Наслідок Завершення потоку (unwind/abort) Виклик коду вирішує сам

unwrap/expect — коли доречно

.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 — тип помилки, який повертає поточна функція; якщо реалізація є, конвертація стається неявно в момент застосування ?.

thiserror і anyhow: коли який

Ручне написання 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("...") для ланцюжка причин

Комбінатори Option і Result

Для простих перетворень повний 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 дозволяють моделювати стан програми так, щоб недійсні комбінації даних узагалі не мали синтаксису: компілятор перевіряє повноту обробки статично, так само, як він перевіряв правила володіння й запозичення в попередніх двох лекціях.

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

План лекції

Фокус: компілятор не дає забути про порожній чи хибний випадок

Живе демо

  1. Пишемо функцію match над Option<i32> із гілками лише для Some(n)
  2. Запускаємо `cargo build`: компілятор видає error[E0004]: patterns `None` not covered
  3. Додаємо гілку None => ...
  4. Переконуємось, що тепер код збирається — усі випадки враховано

Очікувано: Без гілки None компіляція падає з точною вказівкою відсутнього варіанта; з гілкою — успішна збірка

Result<T, E> — помилка як значення

Result<T, E> ТА ОПЕРАТОР ? f()? Result Ok(T) → продовжити Err(E) → return Err розгортає значення поширює помилку вгору жодних прихованих винятків — помилка у типі, її видно

enum Result<T, E>

rust
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

Що робить оператор ? за один крок

  1. 1
    Обчислити виразрезультат — Result<T, E> або Option<T>
  2. 2
    Ok(v) / Some(v)підставити v і виконувати далі — лінійний happy path
  3. 3
    Err(e)перетворити через From::from(e) у тип помилки функції
  4. 4
    return Err(…) / Noneнегайний вихід із функції — без ручного match

Замінює ланцюжки match { Ok(v) => v, Err(e) => return Err(e) } на кожен виклик

Оператор ? у розрізі

ОПЕРАТОР ?: ДВІ ГІЛКИ ОДНОГО ВИРАЗУ File::open(..)? виклик з ? Result<T,E> два варіанти ГІЛКА OK Ok(v) v підставлено й іде далі happy path лишається лінійним ГІЛКА ERR From::from(e) E → тип помилки функції return Err(…) негайний вихід ? = ЦЕ САМЕ, ЛИШЕ КОРОТШЕ match File::open(..) { Ok(v) => v, Err(e) => return Err(From::from(e)), } вимога: impl From<E> for E2 інакше типи не зійдуться помилка — це значення; ? лише скорочує match, а не ховає його

Ok(v) — значення підставляється й виконання йде далі; Err(e) — From::from(e) конвертує тип помилки і функція негайно повертає Err. Тому потрібен impl From<E> for E2

? поширює помилку без ручного match

rust
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 — по одному на кожен виклик

Як дістати значення з Option / Result

СпосібЩо робитьКоли доречно
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! проти Result

panic! — невідновна помилка
  • Порушено інваріант, продовження безглузде
  • Вихід за межі масиву, unwrap() на None
  • Не видно в сигнатурі функції
  • unwind: розкрутка стека з Drop; abort: негайний вихід
  • Контекст: баг у програмі, assert! у тестах
vs
Result — відновна помилка
  • Очікувана частина нормальної роботи
  • Файл не існує, мережа недоступна, поганий ввід
  • Тип E видно в сигнатурі
  • Викликач сам вирішує, як реагувати
  • Контекст: файли, мережа, парсинг, ввід користувача

panic = "abort" у Cargo.toml дає менший бінарник, але без коректного Drop

Власний тип помилки: enum

rust
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 описує рівно ті варіанти провалу, які реально можливі — жодного зайвого

Як ? конвертує помилку між типами

io::Errorпомилка викликаної функції (E1)
→
?шукає From<E1> for E2
→
From::from(e)impl From або #[from] у thiserror
→
AppError::Io(e)тип помилки поточної функції (E2)
→
return Err(…)вихід із функції

Без impl From<E1> for E2 код із ? між різними типами помилок не скомпілюється

From<io::Error> for AppError

rust
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

Ознакаthiserroranyhow
Де застосовуютьбібліотеки — крейти, які імпортують іншібінарники, main.rs, застосунки
Тип помилкивласний типізований enumуніверсальний anyhow::Error
Споживач розрізняє варіант?так, через matchзазвичай ні — повідомлення й контекст
Головна можливість#[error("…")], #[from].context("…") — ланцюжок причин
Що генеруєDisplay і std::error::Errorобгортку для будь-якої Error

Один проєкт часто поєднує обидва: thiserror у внутрішніх крейтах, anyhow у main.rs

thiserror і anyhow на практиці

rust
// бібліотека: 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

? з Option<T>

Ланцюжок Option через ?

rust
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)

Підсумок

Джерела

🔗 ДЖЕРЕЛА
🥚 ПАСХАЛКА
У прикладі Result значення дорівнює Ok(3101) — 3101 це 0xC1D, константа, що супроводжує курс від першого модуля. А Err, як і належить, несе не значення, а причину: саме через це оператор ? уміє вийти з функції, не втративши її.
⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 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.