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

OPTION І RESULT

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

Ключова ідея. У Rust немає null: відсутність значення і помилка — явні типи, які компілятор змушує обробити.
Демо. match над Option<T>: компілятор вимагає обробити обидва варіанти, Some і None — забути None неможливо.
🧩 СРС ДО ЦІЄЇ ЛЕКЦІЇ · 2-га година заняття

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

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

Вступ

Дві попередні лекції розібрали, як Rust гарантує безпеку пам'яті на етапі компіляції через володіння й запозичення. Ця лекція завершує модуль темою іншого класу помилок — тих, що виникають не через невалідну пам'ять, а через невалідний стан програми: значення, якого може не бути, і операції, які можуть провалитися. Ідея наскрізна: якщо стан можна описати типом так, щоб недійсна комбінація даних узагалі не мала синтаксису, — компілятор зробить цю помилку неможливою так само, як він робить неможливим use-after-free. Це і є принцип, відомий як "зроби невалідні стани непредставними" (make invalid states unrepresentable).

Проблема null

Тоні Хоар, автор конструкції null-посилання в мові ALGOL W (1965), публічно назвав це рішення своєю "помилкою на мільярд доларів" (2009). null — значення, яке формально має тип T, але насправді не містить жодних даних типу T. Проблема не в самій ідеї "значення може бути відсутнім" — вона цілком легітимна (порожній результат пошуку, необов'язкове поле). Проблема в тому, що компілятор мов з null не змушує перевіряти його перед розіменуванням: String name у Java виглядає так само, як завжди-непорожній рядок, і компілятор не заважає забути перевірку — і саме забуті перевірки дають NullPointerException у Java чи segfault у C.

Rust вирішує це радикально: значення типу T завжди гарантовано містить дійсні дані типу T. У безпечному коді Rust взагалі немає null як такого — відсутність значення моделюється типом Option<T>, окремим від T.

enum Option

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, без явної обробки обох варіантів. Помилку "забув перевірити на відсутність значення" переносить із рантайму (де вона проявляється несподівано, на випадковому вході) у сферу типів, де компілятор ловить її статично.

match над Option — вимога повноти

Основний спосіб безпечно дістати значення з 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), який просто не написали.

Enum як спосіб моделювати стан точно

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

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

План лекції

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

Проблема 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<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)

Підсумок

Джерела

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