TDD

Links:

Статьи в секции:

  • Parse

    Rust Type-Driven Development

Суть

Представьте игру в LEGO: Без типоориентированной разработки: мы начинаем строить случайные стены и крышу из того, что под руку попало, и надеемся, что в конечном итоге соберём дом.

С типоориентированной разработкой: мы сначала проектируем и определяем конкретные кирпичики LEGO, которые понадобятся: их формы, размеры и точки соединения. Ход мышления: «Мне нужен кирпичик, который является дверью, кирпичик, который гарантирует, что он может держать крышу, и кирпичик, который обязательно должен иметь окно». Как только у нас определены нужные кирпичики, сборка становится простой, а компилятор выступает в роли помощника, гарантирует, что каждый выбранный кирпичик идеально встанет на свое место.

В Rust типы — это не просто данные для хранения. Это инструмент для обеспечения корректности, безопасности и бизнес-логики во время компиляции.

❗Философия такова: «Сделайте недействительные состояния непредставимыми».

Если логика программы гласит, что пользователь не может существовать без адреса электронной почты, то тип Rust для User даже не должен позволять вам создать его без электронной почты.

Как начать

На примере системы учёта сдачи в аренду книг в библиотеке:

Шаг 1: Понять предметную область и определить «существительные»:

Прежде чем писать какой-либо код, подумайте о реальных концепциях в вашем проекте. Каковы основные «вещи»? Примеры:

Книга: Вещь с названием, автором и уникальным идентификатором. 
Пользователь: Человек, который может брать книги. 
Выдача: Действие, связывающее Пользователя и Книгу на определенный период.

Шаг 2: Моделирование «существительных» как структур

Начните с определения основных структур данных. На этом этапе важно определить данные, которые они содержат, а не поведение.

// Простые типы для начала:
struct Book {
    id: u32,
    title: String,
    author: String,
}

struct User {
    id: u32,
    name: String,
    // Активен ли пользователь? Определим позднее.
}

struct Checkout {
    book_id: u32,
    user_id: u32,
    checkout_date: String, // Слишком размыто, нужно уточнение
}

Шаг 3: Кодирование ограничений с помощью новых типов

Первая проблема: id: u32 слишком гибок. Может ли Book быть создан с user_id по ошибке? - Да. Можете ли вы случайно передать ID Book там, где ожидается ID User? - Да. На помощь идиома New Type: мы оборачиваем примитивные типы, чтобы придать им семантическое значение.

// Создать обёртки для различия ID между собой
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] // типовые traits для IDs
struct BookId(u32);

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u32);

// намного более чёткие структуры
struct Book {
    id: BookId,      // чёткий тип BookId
    title: String,
    author: String,
}

struct User {
    id: UserId,      // говорящий тип UserId
    name: String,
    is_active: bool, // добавляем теперь активность пользователя
}

struct Checkout {
    book_id: BookId, // Компилятор теперь не даст перепутать IDs
    user_id: UserId,
    checkout_date: String, // Всё ещё проблема
}

Компилятор теперь не позволяет писать checkout.user_id = BookId(5). Вы закодировали ограничение “ID пользователя должен быть UserId” непосредственно в систему типов.

Шаг 4: Используйте enum для моделирования состояний и альтернатив

Наш Checkout имеет String для даты. Это рискованно: что, если она пустая? Что, если она недействительная? Кроме того, книги имеют состояния. Книга может быть Available или CheckedOut. Ставим enum-типы: они позволяют определить набор возможных состояний.

// Тип для обозначения даты 
use chrono::NaiveDate;

// моделируем состояния книги
enum BookStatus {
    Available,
    CheckedOut {
        checkout: Checkout, // у взятой книги ЕСТЬ запись в реестре
    },
    Lost,
}

// Book включает теперь status
struct Book {
    id: BookId,
    title: String,
    author: String,
    status: BookStatus, // статус как чать типа
}

// Checkout создаётся только при выписывании книги.
// Дата checkout_date теперь корректного типа, НЕ String.
struct Checkout {
    user_id: UserId,
    checkout_date: NaiveDate,
    due_date: NaiveDate,
}

С BookStatus невозможно иметь книгу, которая одновременно CheckedOut и Available. Система типов гарантирует, что книга находится в одном из трех определенных состояний.

Шаг 5: Используйте enum для результатов (тип Result)

Ваши функции должны использовать тип Result для кодирования возможности сбоя. Это заставляет вызывающую сторону обрабатывать ошибки.

// Определяем конкретные виды ошибок
#[derive(Debug, PartialEq)]
enum CheckoutError {
    UserInactive,
    BookNotAvailable,
    BookLost,
}

// Возвращаемые типы кодируют success (Checkout) или fail (CheckoutError)
fn checkout_book(book: &mut Book, user: &User) -> Result<Checkout, CheckoutError> {
    if !user.is_active {
        return Err(CheckoutError::UserInactive);
    }

    match &book.status {
        BookStatus::Available => {
            let checkout = Checkout {
                user_id: user.id,
                checkout_date: chrono::Utc::now().date_naive(),
                due_date: chrono::Utc::now().date_naive() + chrono::Duration::days(14),
            };
            book.status = BookStatus::CheckedOut { checkout: checkout.clone() }; // упрощение
            Ok(checkout)
        }
        BookStatus::CheckedOut { .. } => Err(CheckoutError::BookNotAvailable),
        BookStatus::Lost => Err(CheckoutError::BookLost),
    }
}

Возвращаемый тип Result<Checkout, CheckoutError> говорит любому, кто использует эту функцию: “Эта операция может завершиться неудачей, и вот точные способы, которыми она может завершиться неудачей”. Компилятор заставляет их обрабатывать как случаи Ok, так и Err.

Шаг 6: Используйте трейты для общего поведения

Трейты определяют, что могут делать типы. Когда у вас есть свои типы, вы можете обнаружить, что они имеют общее поведение. Например, и Book, и User имеют идентификаторы. Вы могли бы определить трейт для этого:

// trait определяет общее поведение
trait HasId {
    fn id(&self) -> u32; // Но наши IDs = BookId/UserId, а не u32. Ещё лучше!
}

// Или, с нашими newtypes:
trait HasTypedId<T> {
    fn id(&self) -> &T;
}

impl HasTypedId<BookId> for Book {
    fn id(&self) -> &BookId {
        &self.id
    }
}

impl HasTypedId<UserId> for User {
    fn id(&self) -> &UserId {
        &self.id
    }
}

Необязательно планировать все характеристики заранее, но обдумывание общих моделей поведения помогает разрабатывать гибкие, повторно используемые компоненты.

Короткий чеклист

  1. Identify Nouns: List the core concepts (Book, User, Checkout).
  2. Start with structs: Define the data these concepts hold. Don’t worry about behavior yet.
  3. Replace Primitives with New Types: Wrap u32String, etc., in struct wrappers (BookIdEmailAddress) to make the compiler enforce distinctions and constraints.
  4. Model State with enums: If a concept can be in multiple, mutually exclusive states (Available vs. CheckedOut), use an enum. Aim to make invalid states unrepresentable.
  5. Define Fallible Functions with Result: Before writing a function’s body, decide what its success and error types will be. This forces you to consider edge cases upfront.
  6. Abstract with traits: Once types are stable, identify shared behaviors and define traits.

Subsections of TDD

Parse

Link:

Parse - don’t validate

Эта философия превращает подверженные ошибкам проверки в runtime в гарантии во время компиляции. То есть, входные данные в программе нельзя тащить дальше в код. Нужно их на входе проверять и отсеивать. Т.е. вопросы: “а вдруг там ничего нет void/none, а вдруг пользователь ввёл некорректные данные?” - надо решать сразу на входной функции чтения данных, и не тащить это по всей программе, везде делая реверанс в стиле “а если там в начале ничего не было, то… "

В Rust это можно и нужно зашивать в типы данных, которые гарантируют наличие контента.

Пример: непустой массив

Допустим, я создаю тип для дома, в котором будет массив комнат. Массив комнат в доме априори не может быть пустым - хотя бы одна комната должны быть!

Вариант 1 - валидация в конструкторе

pub struct House {
    rooms: Vec<Room>,
}
impl House {
    /// Конструктор, возвращающий Result - не может создать пустой дом
    pub fn new(rooms: Vec<Room>) -> Result<Self, &'static str> {
        if rooms.is_empty() {
            Err("House must have at least one room")
        } else {
            Ok(House { rooms })
        }
    }

Вариант 2 - NonEmpty

Добавляем зависимость cargo add nonempty спец-тип:

use nonempty::NonEmpty;

pub struct House {
    rooms: NonEmpty<Room>,  // Гарантированно не пустой список
}
impl House {
    /// Конструктор, принимающий как минимум одну комнату
    pub fn new(first_room: Room, rest_rooms: Vec<Room>) -> Self {
        let mut rooms = NonEmpty::new(first_room);
        rooms.extend(rest_rooms);
        House { rooms }
    }

Вариант 3 - собственный тип

Создаём собственный тип данных, аналог NonEmpty:

use std::ops::{Index, IndexMut};

/// Вектор, который гарантированно содержит хотя бы один элемент
#[derive(Debug, Clone)]
pub struct NonEmptyVec<T> {
    first: T,
    rest: Vec<T>,
}

impl<T> NonEmptyVec<T> {
    /// Создает новый NonEmptyVec с одним элементом
    pub fn new(first: T) -> Self {
        NonEmptyVec {
            first,
            rest: Vec::new(),
        }
    }
    
    /// Создает NonEmptyVec из Vec, возвращая None если вектор пуст
    pub fn from_vec(mut vec: Vec<T>) -> Option<Self> {
        if vec.is_empty() {
            None
        } else {
            let first = vec.remove(0);
            Some(NonEmptyVec {
                first,
                rest: vec,
            })
        }
    }
    
    /// Создает NonEmptyVec из Vec, паникуя если вектор пуст
    pub fn from_vec_unchecked(vec: Vec<T>) -> Self {
        assert!(!vec.is_empty(), "Cannot create NonEmptyVec from empty vector");
        let mut vec = vec;
        let first = vec.remove(0);
        NonEmptyVec {
            first,
            rest: vec,
        }
    }
    
    /// Создает NonEmptyVec из итератора, возвращая None если итератор пуст
    pub fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Option<Self> {
        let mut iter = iter.into_iter();
        let first = iter.next()?;
        let rest: Vec<T> = iter.collect();
        Some(NonEmptyVec { first, rest })
    }
    
    /// Возвращает количество элементов
    pub fn len(&self) -> usize {
        1 + self.rest.len()
    }
    
    /// Всегда возвращает false, так как NonEmptyVec никогда не пуст
    pub fn is_empty(&self) -> bool {
        false
    }
    
    /// Получает ссылку на элемент по индексу
    pub fn get(&self, index: usize) -> Option<&T> {
        if index == 0 {
            Some(&self.first)
        } else {
            self.rest.get(index - 1)
        }
    }
    
    /// Получает мутабельную ссылку на элемент по индексу
    pub fn get_mut(&mut self, index: usize) -> Option<&mut T> {
        if index == 0 {
            Some(&mut self.first)
        } else {
            self.rest.get_mut(index - 1)
        }
    }
    
    /// Возвращает ссылку на первый элемент
    pub fn first(&self) -> &T {
        &self.first
    }
    
    /// Возвращает мутабельную ссылку на первый элемент
    pub fn first_mut(&mut self) -> &mut T {
        &mut self.first
    }
    
    /// Возвращает ссылку на последний элемент
    pub fn last(&self) -> &T {
        self.rest.last().unwrap_or(&self.first)
    }
    
    /// Возвращает мутабельную ссылку на последний элемент
    pub fn last_mut(&mut self) -> &mut T {
        if self.rest.is_empty() {
            &mut self.first
        } else {
            self.rest.last_mut().unwrap()
        }
    }
    
    /// Добавляет элемент в конец
    pub fn push(&mut self, value: T) {
        self.rest.push(value);
    }
    
    /// Удаляет последний элемент, возвращая его
    /// Гарантированно возвращает Some, так как всегда есть хотя бы один элемент
    pub fn pop(&mut self) -> Option<T> {
        self.rest.pop().or_else(|| {
            // Не можем удалить последний элемент, так как это сделает коллекцию пустой
            // Вместо этого возвращаем None, сигнализируя, что удаление невозможно
            None
        })
    }
    
    /// Удаляет последний элемент, паникуя если это был последний элемент
    pub fn pop_unchecked(&mut self) -> T {
        self.rest.pop().expect("Cannot pop the last element of NonEmptyVec")
    }
    
    /// Вставляет элемент на указанную позицию
    pub fn insert(&mut self, index: usize, value: T) {
        if index == 0 {
            let old_first = std::mem::replace(&mut self.first, value);
            self.rest.insert(0, old_first);
        } else {
            self.rest.insert(index - 1, value);
        }
    }
    
    /// Удаляет элемент по индексу, возвращая его
    pub fn remove(&mut self, index: usize) -> Option<T> {
        if index == 0 {
            if self.rest.is_empty() {
                // Не можем удалить последний элемент
                None
            } else {
                let old_first = std::mem::replace(&mut self.first, self.rest.remove(0));
                Some(old_first)
            }
        } else {
            self.rest.remove(index - 1).into()
        }
    }
    
    /// Создает итератор
    pub fn iter(&self) -> impl Iterator<Item = &T> {
        std::iter::once(&self.first).chain(self.rest.iter())
    }
    
    /// Создает мутабельный итератор
    pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut T> {
        std::iter::once(&mut self.first).chain(self.rest.iter_mut())
    }
    
    /// Преобразует в Vec
    pub fn into_vec(mut self) -> Vec<T> {
        let mut vec = Vec::with_capacity(self.len());
        vec.push(self.first);
        vec.append(&mut self.rest);
        vec
    }
    
    /// Применяет функцию ко всем элементам
    pub fn map<U, F>(self, mut f: F) -> NonEmptyVec<U>
    where
        F: FnMut(T) -> U,
    {
        NonEmptyVec {
            first: f(self.first),
            rest: self.rest.into_iter().map(f).collect(),
        }
    }
}

// Реализация Index для удобного доступа по индексу
impl<T> Index<usize> for NonEmptyVec<T> {
    type Output = T;
    
    fn index(&self, index: usize) -> &Self::Output {
        self.get(index).expect("Index out of bounds")
    }
}

// Реализация IndexMut для мутабельного доступа по индексу
impl<T> IndexMut<usize> for NonEmptyVec<T> {
    fn index_mut(&mut self, index: usize) -> &mut Self::Output {
        self.get_mut(index).expect("Index out of bounds")
    }
}

// Реализация IntoIterator для использования в циклах
impl<T> IntoIterator for NonEmptyVec<T> {
    type Item = T;
    type IntoIter = std::vec::IntoIter<T>;
    
    fn into_iter(self) -> Self::IntoIter {
        self.into_vec().into_iter()
    }
}

// Реализация FromIterator для создания из итератора
impl<T> FromIterator<T> for NonEmptyVec<T> {
    fn from_iter<I: IntoIterator<Item = T>>(iter: I) -> Self {
        NonEmptyVec::from_iter(iter).expect("Cannot create NonEmptyVec from empty iterator")
    }
}