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.