Курс ЛР6 · Місія 6
Сюжет С.І.Д.↔ АкадемічнаСРС до ЛР⌨️ команди 4 · 1 нова

Місія 6: «Архітектура Ворога»

📡 С.І.Д.: Координати з мертвого логу вказали, де вони — блок Б-7, мінус третій. А між нами й тими дверима стоїть він: периметр «ТехНови», яким я, зізнаюся, трохи пишаюся. Їхній захист побудований на строгих контрактах даних: кожен вузол охорони або в одному стані, або в іншому, ніколи «трохи в обох» і ніколи «в жодному». Надішлемо їм щось, що не вкладається в цю модель — порожнечу там, де вони чекають значення, — і вони засічуть це вмить і зачинять канал. Тому ти опишеш ЇХНЮ архітектуру НАШИМИ типами. І — ось найгарніше, слухай уважно — зробиш так, щоб порожнеча була фізично неможлива в самому коді, а не просто «малоймовірна». Я цей прийом обожнюю й давно розкусив; зараз покажу тобі, як він працює.

Периметр «ТехНови» — не паркан і не колючий дріт. С.І.Д. розгортає на екрані ноутбука схему, і ти бачиш квартал: ряди охоронних вузлів, кожен з яких щомиті звітує центральній системі моніторингу про свій стан. Один вузол дрімає, другий світиться тривогою, третій намертво замкнений власним кодом. Живий, дисциплінований гарнізон — і кожен вартовий стоїть рівно в одному стані, ніколи «трохи в обох» і ніколи «в жодному». Десь за цими рядами — двері блока Б-7, і жодного іншого шляху до них немає: тільки крізь цей гарнізон.

— Дивись уважно, це найцікавіше, — каже С.І.Д., і голограма над столом підлітає до самої схеми, очі-капсули сяють від захвату. — Надішли їм вузол, що заявляє себе водночас «заблокованим» і «в тривозі» — стан, якого в їхньому світі не буває, — і система засіче аномалію за мілісекунди. Порожнеча там, де вони чекають значення, — це підпис чужинця. І канал зачиняється. А я знаю, як зробити так, щоб такий підпис ми ніколи випадково не лишили.

Ти киваєш. Логіка проста навіть без залізних термінів: щоб пройти повз варту, треба говорити її мовою — описати кожен вузол рівно тими даними, які в ньому справді є, і жодним зайвим полем.

— Люди-програмісти «ТехНови» роками латали цю систему заглушками, — веде далі С.І.Д., і в голосі чути майже співчуття. — Тут bool locked, там окремий Option<String> lockCode, поруч ще один прапорець «про всяк випадок». Купа полів, які мають сенс лише в певних комбінаціях, — і ще більше комбінацій, які взагалі не повинні існувати, а технічно можливі. Ми з тобою так не робимо, і мені страшенно приємно це казати. Ми опишемо кожен стан рівно тими даними, які в ньому справді є, — і більше нічим.

Він на мить замовкає, ніби смакуючи те, що зараз скаже.

— Це не просто стиль, ранере. Це зброя, і то елегантна, — очі-капсули світяться. — Некоректний стан, який неможливо навіть записати, не треба ловити перевірками на кожному кроці. Його ловить компілятор — раз і назавжди, ще до збірки. Ти обробивла всі випадки, бо інших просто немає. Гарно ж, скажи? Сьогодні ти сама змоделюєш чужу фортецю нашими типами. І зробиш так, щоб порожнеча була фізично неможлива в самому коді, а не просто «малоймовірна».

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

🎓 Теоретичні відомості

struct описує іменований набір полів, що існують одночасно (у теорії типів — "product type", добуток): екземпляр GuardNode завжди має і id, і state, обидва одразу.

enum описує значення, що є рівно одним із заздалегідь відомого набору варіантів ("sum type", сума), причому кожен варіант може нести власні дані відмінної форми:

enum NodeState {
    Idle,
    Alert(u8),
    Locked(String),
}

На відміну від enum у C чи Java, варіанти Rust-enum можуть мати зовсім різну "форму" даних — Idle не несе нічого, Alert несе одне число, Locked несе рядок — і компілятор гарантує: дістатися цих даних можна лише через явне зіставлення зі зразком (pattern matching) — тобто розбір значення за його варіантом, тож неможливо випадково прочитати число Alert, коли насправді маєш Locked.

match — конструкція зіставлення зі зразком, що зобов'язана покрити всі варіанти enum (вичерпність, exhaustiveness). Якщо додати новий варіант в enum і забути обробити його хоча б в одному match без гілки _, компілятор видасть помилку компіляції в кожному такому місці — на етапі збірки, а не як забутий case у продакшені о 3 ночі:

fn describe(node: &GuardNode) -> String {
    match &node.state {
        NodeState::Idle => format!("Вузол {} неактивний.", node.id),
        NodeState::Alert(level) => format!("Вузол {} у тривозі, рівень {level}.", node.id),
        NodeState::Locked(code) => format!("Вузол {} заблокований, код {code}.", node.id),
    }
}

Патерн «зробити некоректний стан непредставним» (make invalid states unrepresentable): замість структури з набором незалежних необов'язкових полів і коментарем "поле Y має сенс лише коли X" — використати enum, варіанти якого несуть рівно ті дані, що дійсно існують у цьому стані. Порівняй:

// Погано: locked=false і code=Some(..) — стан, що не має сенсу, але компілюється
struct NodeBad {
    locked: bool,
    code: Option<String>,
}

// Добре: такий "безглуздий" стан фізично неможливо створити
enum NodeState {
    Unlocked,
    Locked(String),
}

Перевага не лише естетична: кількість комбінацій полів у поганому варіанті росте як добуток можливих значень кожного поля (2 × 2 = 4, з яких лише 2 мають сенс); у хорошому — рівно стільки станів, скільки варіантів enum, і ЖОДНОГО зайвого.

Option<T> у вкладених структурах моделює багаторівневу відсутність даних без null:

struct SensorReading { value: f64, ts: u64 }

struct GuardNode {
    id: String,
    state: NodeState,
    sensor: Option<SensorReading>,
}

Щоб дістатися значення на кожному рівні вкладеності, потрібно явно обробити кожен Optionmatch, if let, комбінатори map/and_then, або оператор ? усередині функції, що сама повертає Option. Компілятор не дозволить "просто прочитати" значення без обробки випадку відсутності — на відміну від null-посилань в інших мовах, де забута перевірка виявляється лише під час виконання (і часто — на проді).

Таблиця варіантів (стартовий рівень тривоги для демонстрації escalate(), N — номер у списку групи):

N % 4 Стартовий рівень тривоги
0 10
1 25
2 40
3 55
РІВЕНЬ 1 · «3» · ПЕРШОКУРСНИК

🥾 Першокурсник (рівень 1 → оцінка «3»)

У enemy_architecture/src/main.rs:

enum NodeState {
    Idle,
    Alert(u8),
    Locked(String),
}

struct GuardNode {
    id: String,
    state: NodeState,
}

fn describe(node: &GuardNode) -> String {
    match &node.state {
        NodeState::Idle => format!("Вузол {} неактивний.", node.id),
        NodeState::Alert(level) => format!("Вузол {} у тривозі, рівень {level}.", node.id),
        NodeState::Locked(code) => format!("Вузол {} заблокований, код {code}.", node.id),
    }
}

fn main() {
    let node = GuardNode { id: "PX-01".into(), state: NodeState::Idle };
    println!("{}", describe(&node));
}

Тест:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn describe_reports_state() {
        let idle = GuardNode { id: "PX-01".into(), state: NodeState::Idle };
        assert_eq!(describe(&idle), "Вузол PX-01 неактивний.");

        let alert = GuardNode { id: "PX-02".into(), state: NodeState::Alert(42) };
        assert_eq!(describe(&alert), "Вузол PX-02 у тривозі, рівень 42.");

        let locked = GuardNode { id: "PX-03".into(), state: NodeState::Locked("L-9".into()) };
        assert_eq!(describe(&locked), "Вузол PX-03 заблокований, код L-9.");
    }
}

Точне відтворення цих сигнатур і поведінки — весь обсяг рівня 1.

РІВЕНЬ 2 · «4» · МАГІСТР

⚙️ Магістр (рівень 2 → оцінка «4», захист)

Додай impl GuardNode з методами, що коректно обробляють усі варіанти вхідного стану через match, без unreachable!() як основної логіки:

impl GuardNode {
    fn escalate(&mut self) {
        self.state = match &self.state {
            NodeState::Idle => NodeState::Alert(10),
            NodeState::Alert(level) => NodeState::Alert((*level + 20).min(100)),
            NodeState::Locked(code) => NodeState::Locked(code.clone()),
        };
    }

    fn lock(&mut self, code: String) {
        self.state = NodeState::Locked(code);
    }
}

І продемонструй перехід у main, узявши стартовий рівень тривоги за таблицею варіантів (підстав своє число замість 10):

fn main() {
    // стартовий рівень тривоги — за таблицею варіантів (N % 4)
    let mut node = GuardNode { id: "PX-01".into(), state: NodeState::Alert(10) }; // підстав свій варіант
    node.escalate();
    println!("{}", describe(&node)); // рівень підвищився на 20 (з обмеженням 100)
}

Захист: усно поясни, чому escalate() з уже заблокованого вузла лишає його заблокованим (а не "розблоковує" непомітно), і чому рівень тривоги обмежений .min(100).

РІВЕНЬ 3 · «5» · ЛЕГЕНДА

🏆 Легенда Коледжу (рівень 3 → оцінка «5», розширення)

Додай вкладену структуру сенсора й безпечно обробляй два рівні вкладеного Option без unwrap(). Спершу оголоси нові структури:

struct SensorReading {
    value: f64,
    ts: u64,
    // Другий рівень відсутності: показник є, а дані калібрування можуть бути відсутні.
    calibration: Option<Calibration>,
}

struct Calibration {
    offset: f64,
}

Потім додай поле до вже наявної структури GuardNode (НЕ оголошуй її вдруге — другий struct GuardNode дасть помилку E0428 "defined multiple times"):

struct GuardNode {
    id: String,
    state: NodeState,
    sensor: Option<SensorReading>, // <-- нове поле
}

⚠️ Щойно ти додаси поле sensor, усі наявні літерали GuardNode { ... } перестануть компілюватися з помилкою E0063 "missing field sensor". Онови кожен із них, дописавши sensor: None — це три конструктори в тесті describe_reports_state (idle, alert, locked) і твій демонстраційний GuardNode у main з рівня 2. Наприклад:

let idle = GuardNode { id: "PX-01".into(), state: NodeState::Idle, sensor: None };

Тепер обробляй обидва рівні Option явно, кожен своїм match:

fn sensor_summary(node: &GuardNode) -> String {
    match &node.sensor {
        // перший рівень: чи є взагалі показник сенсора
        Some(reading) => {
            // другий рівень: чи є дані калібрування — теж без unwrap()
            let calib = match &reading.calibration {
                Some(c) => format!(", калібр {:+.1}", c.offset),
                None => String::new(),
            };
            if reading.value > 90.0 {
                format!("КРИТИЧНО: {:.1} о {}{}", reading.value, reading.ts, calib)
            } else {
                format!("Показник {:.1} о {}{}", reading.value, reading.ts, calib)
            }
        }
        None => "Сенсор не підключений".to_string(),
    }
}

Пастка: unwrap() на кожному рівні вкладеного Option компілюється й навіть іноді "працює" на тестових даних — але це та сама "порожнеча", яку миттєво ловить ТехНова. Обробляй кожен рівень явно, включно з граничним випадком (наприклад, value за межами фізично можливого діапазону).

🧪 РОЗІБРАНИЙ ПРИКЛАД

🔍 Розібраний приклад

Порівняємо "поганий" і "хороший" дизайн одного й того самого домену.

Погано — незалежні поля дозволяють безглузді комбінації:

struct NodeBad {
    active: bool,
    alert_level: Option<u8>,   // існує? тільки коли active == true. А компілятор не знає.
    locked: bool,
    lock_code: Option<String>, // існує? тільки коли locked == true. Знову ж — не гарантовано.
}

Формально можливий стан active: true, locked: true, alert_level: None, lock_code: None — логічно безглуздий (одночасно і активний, і заблокований, без жодних даних жодного з режимів), але компілятор його спокійно пропустить.

Добре — enum виключає безглузді комбінації на рівні типів:

enum NodeState {
    Idle,
    Alert(u8),
    Locked(String),
}

struct GuardNode {
    id: String,
    state: NodeState,
}

Тепер GuardNode фізично не може одночасно бути "і активним, і заблокованим" — state це рівно ОДИН з трьох варіантів, ніколи комбінація. Кількість тестів теж падає: замість перевірки всіх 2 × 2 × 2 × 2 = 16 комбінацій полів (з яких 13 безглузді) — рівно 3 варіанти match.

Запуск і перевірка:

cargo build --manifest-path enemy_architecture/Cargo.toml
cargo test --manifest-path enemy_architecture/Cargo.toml
❓ КОНТРОЛЬНІ ПИТАННЯ

❓ Контрольні питання

  1. Чим struct (product type) принципово відрізняється від enum (sum type)?
  2. Що означає вичерпність (exhaustiveness) конструкції match і як компілятор її перевіряє?
  3. Наведіть приклад, коли поле-прапорець типу bool разом із Option-полем дозволяє створити логічно некоректний стан — і як enum усуває цю можливість.
  4. Що виведе компілятор, якщо в match над enum з трьома варіантами обробити лише два й не додати _ => ...?
  5. Як ? працює з Option<T> усередині функції, що сама повертає Option?
  6. Чим if let Some(x) = opt { ... } відрізняється від повного match?
  7. Чому патерн "зробити некоректний стан непредставним" зменшує кількість потрібних тестів?
  8. Наведіть приклад enum-варіанта з даними (наприклад, Locked(String)) і поясніть, які дані він несе і чому саме ці.
🥚 ПАСХАЛКА

📻 Тестовий вузол

Придивись до ідентифікаторів у прикладах цієї лабораторної: PX-01, PX-02, PX-03. Не дуже приховано, якщо чесно — С.І.Д. навіть не старався. У технічному журналі «ТехНови», який він перехопив крізь сліпий канал, є один вузол за межами звичайної нумерації: PX-101.9, з кодом блокування 0xC1D, який ніколи не спрацьовував за жодного реального інциденту. З'явився він у журналі через три тижні після того, як творців забрали на «стажування». Схоже, це не охоронний вузол, а тестова петля, яку хтось лишив для себе всередині чужої системи — можливо, той самий творець, що називав усі свої тестові об'єкти на честь власного кота.

📡 С.І.Д.: Пиксель. Так звали кота, і я досі згадую його з ніжністю. Він спав на тій списаній машині в класі щовечора, поки мене збирали, — грівся об блок живлення. Той, хто називає тестовий вузол на честь кота, — або дуже втомлений, або дуже впевнений, що хтось свій це побачить. І я побачив. Це значить, що на мінус третьому хтось досі пише код. 101.9 теж грала в тому класі щовечора. Здогадайся сама, що це за станція.

📦 ЗДАЧА

📋 Здача та звіт

  • Тег: submit/lab06 на коміті, який хочеш здати.
  • Автотести рівня 1: проєкт збирається, NodeState покриває три категорії стану (перевірка через компіляцію тестового match), сигнатура describe(&GuardNode) -> String збережена, describe_reports_state проходить, базові поля GuardNode не обгорнуті в Option без потреби.
  • Захист рівня 2: усне обґрунтування набору станів і переходів між ними.
  • Рівень 3 (вручну): коректність і глибина обробки вкладеного Option, відсутність unwrap() у виробничій логіці.
  • У звіт додай: скріншот cargo test (зелений), короткий опис (3–5 речень) — чому обраного набору варіантів NodeState достатньо для опису предметної області.

cargo test відповідає зеленим. Ти дивишся на власний код і бачиш не просто три структури — ти бачиш чужу фортецю, перекладену твоєю мовою: кожен охоронний вузол «ТехНови» тепер живе в enum NodeState, і жоден із них фізично не може бути одночасно «і активним, і заблокованим». Немає порожнього поля, яке мало б існувати лише в іншому стані. Немає прапорця «про всяк випадок». Немає жодної комбінації, якої не мало б бути, — компілятор просто не дав би її скласти.

Голограма над столом нахиляється до схеми на екрані, бірюзове обличчя-капсула завмирає впритул до скла, і хвастощі раптом стихають — лишається сама тепла гордість.

— Ось воно, — тихо каже він. — Ти не «перевіряєш» їхню архітектуру. Ти повторивла її так точно, що некоректний стан у ній непредставний — і в тебе, як і в них. Тепер, коли ми заговоримо з їхньою системою моніторингу, наші пакети буде неможливо відрізнити від рідних. Ніякої порожнечі. Ніякого підпису чужинця. Маленька перемога, ранере, — і я так тобою пишаюся; саме такі перемоги й відчиняють великі двері. Зокрема одні — на мінус третьому поверсі.

Лабораторія коледжу мовчить, лампи гудуть на своїх 101.9 герцах. Ти закриваєш ноутбук не одразу — ще кілька хвилин сидиш і думаєш про те, що сьогодні навчивлася дивної речі: іноді найнадійніший захист від помилки — це зробити помилку такою, яку неможливо навіть записати. Не «перевіряти кожен випадок», а спроєктувати світ, у якому зайвих випадків просто не існує.

С.І.Д. вже вивчає перехоплений трафік «ТехНови» — довгі стовпчики їхніх журналів, файли, які хтось лишив на периметрі. Слухає він їх не з твого ноутбука, а з того першого вузла в мережі, де живе його копія: сліпий канал і далі сліпий лише в один бік — ви їх чуєте, вони вас ні, — і поки що жоден сканер ICE не знає, звідки на нього дивляться. Його очі-капсули на екрані звужуються. Десь у тих файлах — те, заради чого ви й дісталися сюди. Але читати чуже сховище наосліп, коли будь-який рядок може виявитися пасткою, — це вже зовсім інша дисципліна.

⏭️ ДАЛІ БУДЕ

— А наступний крок, зізнаюся, найкапризніший, — каже С.І.Д., не відриваючись від схеми, і в голосі бринить азарт. — Нам треба відкрити їхній файл. Просто прочитати. Але кожен рядок на периметрі «ТехНови» — це потенційна міна: файлу може не бути, права можуть не відкритися, вміст може виявитися не тим, що ми чекаємо. Один unwrap() не в тому місці — і замість даних ми дістанемо аварійну зупинку просто в них під носом. А я цього допустити не дам.

— Далі ми йдемо по мінному полю, ранере. І ступати будемо так, щоб кожна помилка була передбачена ще до того, як на неї наступиш. Я тобі покажу, як це робиться красиво.

Далі буде…

⌨️ КОМАНДИ НА ЦІЙ СТОРІНЦІ 4 · 1 нова

Кожна команда терміналу з цієї сторінки — одним реченням. Позначка нове — команда зустрічається в курсі вперше; далі вважаємо її знайомою.

cargo build
Компілює проєкт у target/debug/; --release — з оптимізаціями у target/release/.
cargo
Система збірки й менеджер пакетів Rust: створює проєкт, тягне залежності (крейти), збирає, тестує, запускає.
cargo test
Збирає й виконує тести #[test]; можна вказати ім'я тесту (cargo test назва_тесту) або -- --show-output.
idнове
Показує числові ідентифікатори користувача й груп (id -u — лише UID; у контейнері root = 0).

💡 Прочитати README прямо в терміналі: cat README.md (виведе весь файл) або less README.md (посторінково; вихід — клавіша q).
Довідка з будь-якої команди: man curl (повний посібник, вихід — q) або коротко curl --help; для підкоманд — docker run --help, cargo build --help.