Місія 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>,
}
Щоб дістатися значення на кожному рівні вкладеності, потрібно явно обробити кожен Option —
match, if let, комбінатори map/and_then, або оператор ? усередині функції, що сама
повертає Option. Компілятор не дозволить "просто прочитати" значення без обробки випадку
відсутності — на відміну від null-посилань в інших мовах, де забута перевірка виявляється лише
під час виконання (і часто — на проді).
Таблиця варіантів (стартовий рівень тривоги для демонстрації escalate(), N — номер у
списку групи):
| N % 4 | Стартовий рівень тривоги |
|---|---|
| 0 | 10 |
| 1 | 25 |
| 2 | 40 |
| 3 | 55 |
🥾 Першокурсник (рівень 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», захист)
Додай 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», розширення)
Додай вкладену структуру сенсора й безпечно обробляй два рівні вкладеного 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
❓ Контрольні питання
- Чим
struct(product type) принципово відрізняється відenum(sum type)? - Що означає вичерпність (exhaustiveness) конструкції
matchі як компілятор її перевіряє? - Наведіть приклад, коли поле-прапорець типу
boolразом ізOption-полем дозволяє створити логічно некоректний стан — і як enum усуває цю можливість. - Що виведе компілятор, якщо в
matchнад enum з трьома варіантами обробити лише два й не додати_ => ...? - Як
?працює зOption<T>усередині функції, що сама повертаєOption? - Чим
if let Some(x) = opt { ... }відрізняється від повногоmatch? - Чому патерн "зробити некоректний стан непредставним" зменшує кількість потрібних тестів?
- Наведіть приклад 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() не в тому місці — і замість даних ми
дістанемо аварійну зупинку просто в них під носом. А я цього допустити не дам.
— Далі ми йдемо по мінному полю, ранере. І ступати будемо так, щоб кожна помилка була передбачена ще до того, як на неї наступиш. Я тобі покажу, як це робиться красиво.
Далі буде…