Хабрахабр

[Перевод] Проектирование типами: Как сделать некорректные состояния невыразимыми

Представляю вашему вниманию перевод статьи Scott Wlaschin "Designing with types: Making illegal states unrepresentable".

В этой статье мы рассмотрим ключевое преимущество F# — возможность "сделать некорректные состояния невыразимыми" при помощи системы типов (фраза заимствована у Yaron Minsky).

В результате проведённого рефакторинга он сильно упростился: Рассмотрим тип Contact.

type Contact =

Соответствует ли наш тип этому правилу? Теперь предположим, что существует простое бизнес-правило: "Контакт должен содержать адрес электронной почты или почтовый адрес".

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

Кажется, ответ очевиден — сделать адреса необязательными, например, так:

type Contact = { Name: PersonalName; EmailContactInfo: EmailContactInfo option; PostalContactInfo: PostalContactInfo option; }

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

Как же решить эту задачу?

Обдумав правило бизнес-логики можно прийти к выводу, что возможны три случая:

  • указан только адрес электронной почты;
  • указан только почтовый адрес;
  • указан и адрес электронной почты, и почтовый адрес.

В такой формулировке решение становится очевидным — сделать тип-сумму с конструктором для каждого возможного случая.

type ContactInfo = | EmailOnly of EmailContactInfo | PostOnly of PostalContactInfo | EmailAndPost of EmailContactInfo * PostalContactInfo type Contact = { Name: Name; ContactInfo: ContactInfo; }

Все три случая выражены явно, при этом четвёртый случай (без какого-либо адреса) не допускается. Эта реализация полностью соответствует требованиям.

Пока что я просто использовал кортеж. Обратите внимание на случай "адрес электронной почты и почтовый адрес". В данном случае этого достаточно.

Для начала созданим новый контакт: Теперь давайте посмотрим, как использовать эту реализацию на примере.

let contactFromEmail name emailStr = let emailOpt = EmailAddress.create emailStr // обработка случаев с корректным и некорректным адресом электронной почты match emailOpt with | Some email -> let emailContactInfo = {EmailAddress=email; IsEmailVerified=false} let contactInfo = EmailOnly emailContactInfo Some {Name=name; ContactInfo=contactInfo} | None -> None let name = {FirstName = "A"; MiddleInitial=None; LastName="Smith"}
let contactOpt = contactFromEmail name "abc@example.com"

Однако адрес может быть некорректным, и функция должна обрабатывать оба этих случая. В этом примере мы создаём простую вспомогательную функцию contactFromEmail, чтобы создать новый контакт, передав имя и адрес электронной почты. Функция не может создать контакт с некоректным адресом, поэтому она возвращает значени типа Contact option, а не Contact.

Если надо добавить почтовый адрес к существующему ContactInfo, то придётся обработать три возможных случая:

  • если у контакта был только адрес электронной почты, то теперь у него указаны оба адреса, поэтому надо вернуть контакт с конструктором EmailAndPost;
  • если у контакта был только почтовый адрес, надо вернуть контакт с конструктором PostOnly, заменив почтовый адрес на новый;
  • если у контакта были оба адрес, надо вернуть контакт с конструктором EmailAndPost, заменив почтовый адрес на новый.

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

let updatePostalAddress contact newPostalAddress = let {Name=name; ContactInfo=contactInfo} = contact let newContactInfo = match contactInfo with | EmailOnly email -> EmailAndPost (email,newPostalAddress) | PostOnly _ -> // существующий почтовый адрес игнорируется PostOnly newPostalAddress | EmailAndPost (email,_) -> // существующий почтовый адрес игнорируется EmailAndPost (email,newPostalAddress) // создать новый контакт {Name=name; ContactInfo=newContactInfo}

А вот так выглядит использование этого кода:

let contact = contactOpt.Value // обратите внимание на предупреждение касательно option.Value ниже
let newPostalAddress = let state = StateCode.create "CA" let zip = ZipCode.create "97210" { Address = { Address1= "123 Main"; Address2=""; City="Beverly Hills"; State=state.Value; // обратите внимание на предупреждение касательно option.Value ниже Zip=zip.Value; // обратите внимание на предупреждение касательно option.Value ниже }; IsAddressValid=false }
let newContact = updatePostalAddress contact newPostalAddress

Value, чтобы получить содержимое option. ПРЕДУПРЕЖДЕНИЕ: В этом примере я использовал option. Надо всегда использовать сопоставление с образцом и обрабатывать оба конструктора option. Это допустимо, когда вы экспериментируете в интерактивной консоли, но это ужасное решение для рабочего кода!

Отвечу тремя тезисами. К этому времени вы могли решить, что мы всё слишком усложнили.

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

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

type ContactInfo = | EmailOnly of EmailContactInfo | PostOnly of PostalContactInfo | EmailAndPost of EmailContactInfo * PostalContactInfo

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

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

Показать больше

Похожие публикации

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»