Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Философия: почему типы лучше тестов 🟢

Что вы узнаете: три уровня корректности на этапе компиляции (значения, состояния, протоколы), то, как обобщённые сигнатуры функций работают как гарантии, которые проверяет компилятор, и то, когда паттерны корректности по построению оправданы, а когда — нет.

Перекрёстные ссылки: гл. 02 (типизированные команды), гл. 05 (typestate), гл. 13 (шпаргалка)

Цена проверок во время выполнения

Рассмотрим типичную проверку во время выполнения в кодовой базе диагностики:

fn read_sensor(sensor_type: &str, raw: &[u8]) -> f64 {
    match sensor_type {
        "temperature" => raw[0] as i8 as f64,          // знаковый байт
        "fan_speed"   => u16::from_le_bytes([raw[0], raw[1]]) as f64,
        "voltage"     => u16::from_le_bytes([raw[0], raw[1]]) as f64 / 1000.0,
        _             => panic!("неизвестный тип датчика: {sensor_type}"),
    }
}

У этой функции есть четыре режима отказа, которые компилятор не может поймать:

  1. Опечатка: "temperture" → panic во время выполнения
  2. Неверная длина raw: fan_speed с одним байтом → panic во время выполнения
  3. Вызывающий код использует возвращённое f64 как обороты (RPM), хотя это градусы Цельсия → логическая ошибка, которая никак не проявляется
  4. Добавлен новый тип датчика, но этот match не обновлён → panic во время выполнения

Каждый из этих режимов обнаруживается уже после развёртывания. Тесты помогают, но они покрывают только те случаи, о которых кто-то подумал. Система типов охватывает все случаи, включая те, о которых никто не подумал.

Три уровня корректности

Уровень 1 — Корректность значений

Сделайте недопустимые значения непредставимыми.

// ❌ Любой u16 может быть «портом» — 0 недопустим, но код компилируется
fn connect(port: u16) { /* ... */ }

// ✅ Существуют только проверенные порты
pub struct Port(u16);  // приватное поле

impl TryFrom<u16> for Port {
    type Error = &'static str;
    fn try_from(v: u16) -> Result<Self, Self::Error> {
        if v > 0 { Ok(Port(v)) } else { Err("порт должен быть больше 0") }
    }
}

fn connect(port: Port) { /* ... */ }
// Port(0) никогда не может быть создан — инвариант выполняется везде

Пример из аппаратного обеспечения: SensorId(u8) — оборачивает номер датчика сырым значением и проверяет, что он находится в диапазоне SDR.

Уровень 2 — Корректность состояний

Сделайте недопустимые переходы непредставимыми.

use std::marker::PhantomData;

struct Disconnected;
struct Connected;

struct Socket<State> {
    fd: i32,
    _state: PhantomData<State>,
}

impl Socket<Disconnected> {
    fn connect(self, addr: &str) -> Socket<Connected> {
        // ... логика подключения ...
        Socket { fd: self.fd, _state: PhantomData }
    }
}

impl Socket<Connected> {
    fn send(&mut self, data: &[u8]) { /* ... */ }
    fn disconnect(self) -> Socket<Disconnected> {
        Socket { fd: self.fd, _state: PhantomData }
    }
}

// У Socket<Disconnected> нет метода send() — если его вызвать, будет ошибка компиляции

Пример из аппаратного обеспечения: режимы пинов GPIO — у Pin<Input> есть read(), но нет write().

Уровень 3 — Корректность протоколов

Сделайте недопустимые взаимодействия непредставимыми.

use std::io;

trait IpmiCmd {
    type Response;
    fn parse_response(&self, raw: &[u8]) -> io::Result<Self::Response>;
}

// Упрощено для наглядности — полный трейт с net_fn(), cmd_byte(), payload() и
// parse_response() см. в гл. 02.

struct ReadTemp { sensor_id: u8 }
impl IpmiCmd for ReadTemp {
    type Response = Celsius;
    fn parse_response(&self, raw: &[u8]) -> io::Result<Celsius> {
        Ok(Celsius(raw[0] as i8 as f64))
    }
}

#[derive(Debug)] struct Celsius(f64);

fn execute<C: IpmiCmd>(cmd: &C, raw: &[u8]) -> io::Result<C::Response> {
    cmd.parse_response(raw)
}
// ReadTemp всегда возвращает Celsius — нельзя случайно получить Rpm

Пример из аппаратного обеспечения: IPMI, Redfish, административные команды NVMe — тип запроса определяет тип ответа.

Типы как гарантии, которые проверяет компилятор

Когда вы пишете:

fn execute<C: IpmiCmd>(cmd: &C) -> io::Result<C::Response>

вы не просто пишете функцию, а формулируете гарантию: «для любого типа команды C, реализующего IpmiCmd, выполнение даёт ровно C::Response». Компилятор проверяет эту гарантию при каждой сборке вашего кода. Если типы не сходятся, программа не скомпилируется.

Именно поэтому система типов Rust так мощна: она не просто ловит ошибки, а обеспечивает корректность на этапе компиляции.

Когда НЕ стоит использовать эти паттерны

Корректность по построению не всегда оправдана:

СитуацияРекомендация
Критичная к безопасности граница (управление питанием, криптография)✅ Всегда: ошибка здесь может расплавить железо или утечь секреты
Публичный API между модулями✅ Обычно: неправильное использование должно приводить к ошибке компиляции
Автомат состояний с тремя и более состояниями✅ Обычно: typestate предотвращает неверные переходы
Внутренний помощник в одной функции на 50 строк❌ Избыточно: достаточно простого assert!
Прототипирование и изучение неизвестного оборудования❌ Сначала сырые типы; уточняйте их, когда поведение станет понятно
Разбор пользовательского CLI⚠️ clap + TryFrom на границе, внутри допустимы сырые типы

Ключевой вопрос: «Если эта ошибка случится в продакшене, насколько она опасна?»

  • Вентилятор остановился → GPU выходит из строя → используйте типы
  • Неверная запись DER → клиент получает некорректные данные → используйте типы
  • Немного неверное debug-сообщение в логе → используйте assert!

Ключевые выводы

  1. Три уровня корректности — значения (newtype), состояния (typestate), протоколы (ассоциированные типы) — каждый уровень устраняет более широкий класс ошибок.
  2. Типы как гарантии — каждая сигнатура обобщённой функции — это контракт, который компилятор проверяет при каждой сборке.
  3. Вопрос о цене — «если эта ошибка уйдёт в продакшен, насколько она опасна?» — определяет, что уместнее: типы или тесты.
  4. Типы дополняют тесты — типы устраняют целые категории ошибок, а тесты проверяют конкретные значения и граничные случаи.
  5. Знайте, когда остановиться — внутренние помощники и одноразовые прототипы редко нуждаются в принудительной проверке на уровне типов.