☰ Конспект ← Курс
ЛЕКЦІЯ 7

OPTION І RESULT

Відсутність і помилка як тип

VTFK · 2026

План лекції

  • Проблема null: "помилка на мільярд доларів"
  • enum Option<T>: Some(T) або None
  • match над Option — компілятор вимагає повноти
  • enum Result<T, E>: Ok(T) або Err(E)
  • Оператор ? — елегантне поширення помилки
  • Живе демо: match над Option

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

Проблема null

  • Тоні Гоар, автор null-посилання в ALGOL W (1965): «помилка на мільярд доларів»
  • null має тип T, але нічого не містить: NullPointerException, segfault
  • Компілятор не змушує перевіряти null перед використанням — легко забути
  • Rust радикально: null як значення в безпечному коді взагалі не існує
enum Option<T> — ЗАМІСТЬ null Option<T> два стани Some(value) є значення T None значення немає match дає гілку на кожен стан → компілятор змушує обробити None некоректний стан (null) фізично непредставний

null проти Option<T>

null (Java, C, JS …)
  • Має тип T, але даних типу T не містить
  • Перевірку if (x != null) легко забути
  • Помилка виявляється в рантаймі: NPE, segfault
  • На випадковому вході, у проді
vs
Option<T> (Rust)
  • Окремий тип: Option<i32> ≠ i32
  • Компілятор вимагає обробити Some і None
  • Помилка виявляється при компіляції: error[E0004]
  • До першого запуску програми

Дістати значення безпечно: match або комбінатори (map, unwrap_or, and_then)

enum Option<T>

rust
enum Option<T> {
    Some(T),
    None,
}

let some_number: Option<i32> = Some(3101);   // 0xC1D
let absent_number: Option<i32> = None;

Option<T> — частина стандартної бібліотеки, є завжди в prelude

match над Option

rust
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

Живе демо

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

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

Enum робить невалідний стан непредставним

Disconnectedбез полів — жодних зайвих даних
🟡
Connectingбез полів
🟢
Connected { session_id }ідентифікатор існує лише тут
🔴
Failed { reason }причина існує лише тут

Альтернатива — прапорці is_connected + is_connecting + error: дозволяє «з'єднано» і «помилка» водночас. Option — окремий випадок цього прийому (СРС 12)

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

  • Result описує операцію, що завершується успіхом (Ok) або провалом (Err)
  • На відміну від винятків (exceptions), тип помилки видно в сигнатурі функції
  • Проігнорований Result — попередження компілятора (unused_must_use)
  • Читач і компілятор бачать, які функції можуть провалитись і як саме
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<T> — не лише Result
  • Якщо вираз зліва від ? дає None, функція одразу повертає None
  • Змішувати ? для Option і Result в одній функції без конвертації не можна
  • Зручно для коротких ланцюжків пошуку/парсингу, де кожен крок може дати None

Ланцюжок 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)

Підсумок

  • У Rust немає null: відсутність значення — це Option<T>, а не невалідне T
  • match над Option/Result вимагає обробити всі варіанти — не дасть забути None чи Err
  • Result<T, E> робить можливість провалу видимою в сигнатурі функції, а не прихованою у винятках
  • Оператор ? прибирає ручні match-ланцюжки при поширенні помилок нагору по стеку викликів

🥚 У прикладі Option значення дорівнює Some(3101) — 3101 це 0xC1D, константа, що супроводжує курс від першого модуля. None, як і належить, значення не має.

VTFK · TERMLINK · L07← → · space · f — на весь екран