Философия: почему типы лучше тестов 🟢
Что вы узнаете: три уровня корректности на этапе компиляции (значения, состояния, протоколы), то, как обобщённые сигнатуры функций работают как гарантии, которые проверяет компилятор, и то, когда паттерны корректности по построению оправданы, а когда — нет.
Перекрёстные ссылки: гл. 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}"),
}
}
У этой функции есть четыре режима отказа, которые компилятор не может поймать:
- Опечатка:
"temperture"→ panic во время выполнения - Неверная длина
raw:fan_speedс одним байтом → panic во время выполнения - Вызывающий код использует возвращённое
f64как обороты (RPM), хотя это градусы Цельсия → логическая ошибка, которая никак не проявляется - Добавлен новый тип датчика, но этот
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!
Ключевые выводы
- Три уровня корректности — значения (newtype), состояния (typestate), протоколы (ассоциированные типы) — каждый уровень устраняет более широкий класс ошибок.
- Типы как гарантии — каждая сигнатура обобщённой функции — это контракт, который компилятор проверяет при каждой сборке.
- Вопрос о цене — «если эта ошибка уйдёт в продакшен, насколько она опасна?» — определяет, что уместнее: типы или тесты.
- Типы дополняют тесты — типы устраняют целые категории ошибок, а тесты проверяют конкретные значения и граничные случаи.
- Знайте, когда остановиться — внутренние помощники и одноразовые прототипы редко нуждаются в принудительной проверке на уровне типов.