Перейти к содержанию

Как перечисления в Rust помогают вам в DDD

Posted on:28 марта 2023 г. at 08:44

Example Dynamic OG Image link Rust - это язык, который появился в мире недавно и сразу же стал самым любимым языком. Но почему? Почему она стала такой популярной и любимой?

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

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

Перечисления

Перечисление обычно определяется как набор предопределенных элементов, называемых вариантами. Во многих языках перечисления представлены в виде целого числа: первый элемент обозначается 0, второй - 1 и так далее…

// C example
enum Direction {
    UP, // = 0
    LEFT, // = 1
    RIGTH, // = 2
    DOWN// = 3
};

В Rust вы также можете указать простое перечисление следующим образом:

enum Direction {
    Up,
    Left,
    Rigth,
    Down,
};

Комментарии удалены, потому что Rust абстрагирует перечисления, позволяя нам ссылаться на вариант только через его название, не используя индекс.

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

Этот функционал открывает моему сознанию новый мир.

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

struct GameState { /* ... */ }
struct GameOverReason { /* ... */ }

enum GameStatus {
    NotStarted,
    Playing(GameState),
    Paused(GameState),
    GameOver(GameOverReason)
};

let not_started = GameStatus::NotStarted;
let playing = GameStatus::Playing(GameState { /* ... */ } );
let paused = GameStatus::Paused(GameState { /* ... */ } );
let game_over = GameStatus::GameOver(GameOverReason { /* ... */ });

// The following line throws a compile error !!
let not_possible = GameStatus::GameOver(GameState{ /* ... */ });

В приведенном выше примере GameStatus - это перечисление, которое содержит различные типы в зависимости от того, какой вариант предполагала переменная.

Используйте перечисления в DDD

Как было сказано ранее, перечисление описывает исключительное условие для собственных вариантов: экземпляр enum не может идентифицировать 2 различных варианта одновременно.

Это свойство в сочетании с возможностью хранения данных позволяет вашему коду быть более понятным, избегая невозможных состояний.

Давайте приведем несколько примеров

Система тикетов

Мы хотим создать систему продажи билетов для поддержки обслуживания клиентов: клиент хочет знать, почему что-то пошло не так с продуктом. Для этого клиент создает билет, вставляя некоторые данные (тексты, изображения и так далее). Наш бизнес требует, чтобы билет был работоспособен только в том случае, если служба поддержки клиентов примет его. В противном случае заявка отклоняется. В конце клиент закрывает билет.

В этой ситуации в заявке следует рассмотреть:

Учитывая эти ограничения, возможным решением могло бы быть следующее:

enum TicketState {
    Open,
    Taken,
    Declined,
    Closed,
}
struct CustomerInitialInformation { /* ... */ }
struct CustomerCareTakenInformation { /* ... */ }
struct DeclineReason { /* ... */ }
struct CloseInformation { /* ... */ }

struct Ticket {
    // Отслеживание состояния билета
    state: TicketState,
    // Отслеживать при открытии билета
    open_date: Date,
    // Хранит первоначальный запрос клиента
    customer_intial_information: CustomerInitialInformation,
    // Отслеживать, когда билет забирается группой по работе с клиентами
    taken_date: Option<Date>,
    // Хранит ответ от группы по работе с клиентами
    customer_care_taken_information: Option<CustomerCareTakenInformation>,
    // Отслеживать при отклонении билета
    declined_date: Option<Date>,
    // Хранит причину разграничения
    decline_reason: Option<DeclineReason>,
    // Отслеживать дату закрытия
    close_date: Option<Date>,
    // Хранит закрытую информацию
    close_information: Option<CloseInformation>,
}

// Мы можем написать что-то вроде:
let ticket1 = {
    taken_date: Some(Date::now()),
    customer_care_taken_information: None,
    // ...
};
let ticket2 = {
    close_date: Some(Date::now()),
    declined_date: Some(Date::now()),
    // ...
};

ПРИМЕЧАНИЕ: В Rust нет null. Опция позволяет нам сохранить значение None как null, а Some - для хранения ненулевых данных.

Используя приведенную выше структуру, мы могли бы столкнуться с некоторыми незаконными состояниями: что произойдет, если у нас будет taken_date без customer_care_taken_information? И является ли допустимым состояние, если установлено close_date, а также declined_date? Наша структура допускает незаконные состояния.

Как мы можем это улучшить? Использование перечислений!

struct CustomerInitialInformation { /* ... */ }
struct CustomerCareTakenInformation { /* ... */ }
struct DeclineReason { /* ... */ }
struct CloseInformation { /* ... */ }

enum Ticket {
    Open(CustomerInitialInformation),
    Taken(CustomerInitialInformation, CustomerCareTakenInformation),
    Declined(CustomerInitialInformation, DeclineReason),
    Closed(CustomerInitialInformation, CustomerCareTakenInformation, CloseInformation),
}

Таким образом, код больше не содержит опции. И незаконные состояния невозможны: ваш домен безопаснее.

Корзина для покупок

Распространенной проблемой является корзина для покупок. Предположим, что электронной коммерции необходимо реализовать эту функцию в соответствии со следующими требованиями:

В этой ситуации ваше приложение должно быть разработано таким образом, чтобы избежать несогласованных состояний.

Возможным дизайном может быть следующий код:

struct Item { /* ... */ }

enum ShoppingCartState {
    Empty,
    Full,
    Paid,
}
struct PaymentInformation { /* ... */ }

struct ShoppingCart {
    // Отслеживание состояния корзины
    state: ShoppingCartState,
    // Хранит позиции
    items: Vec<Item>,
    // Информация о платеже
    payment_information: Option<PaymentInformation>,
}

// Но мы можем написать что-то вроде:
let cart1 = ShoppingCart {
    items: Vec::new(),
    payment_information: Some(PaymentInformation { /* ... */ }),
    // ...
};
let cart2 = ShoppingCart {
    state: ShoppingCartState::Full,
    payment_information: Some(PaymentInformation { /* ... */ }),
    // ...
};

В приведенном выше коде тип данных не обеспечивает несогласованное состояние: на самом деле, кто гарантирует, что массив items не будет пустым при выполнении payment_information? payment_information может быть установлена также при заполнении состояния? Опять же, мы можем сделать это лучше.

Давайте вместо этого посмотрим на следующий код:

struct Item { /* ... */ }

struct PaymentInformation { /* ... */ }
struct FullCart { items: Vec<Item> }
struct PaidCart {
    paid_items: Vec<Item>,
    payment_information: PaymentInformation,
}

enum ShoppingCart {
    Empty,
    Full(FullCart),
    Paid(PaidCart),
}

Теперь мы не можем держать корзину в нелегальном состоянии: наш домен безопасен, намного лучше!

Вывод

Rust имеет сильную систему типов. Возможности перечислений поразительны, позволяя вашему приложению учитывать только допустимое состояние. Вы пробовали это? Каков ваш опыт использования перечислений?