Представляю вашему вниманию перевод статьи Scott Wlaschin "Designing with types: Making illegal states unrepresentable".
В этой статье мы рассмотрим ключевое преимущество F# — возможность "сделать некорректные состояния невыразимыми" при помощи системы типов (фраза заимствована у Yaron Minsky).
Рассмотрим тип Contact
. В результате проведённого рефакторинга он сильно упростился:
type Contact =
{
Name: Name;
EmailContactInfo: EmailContactInfo;
PostalContactInfo: PostalContactInfo;
}
Теперь предположим, что существует простое бизнес-правило: "Контакт должен содержать адрес электронной почты или почтовый адрес". Соответствует ли наш тип этому правилу?
Нет. Из правила следут, что контакт может содержать адрес электронной почты, но не иметь почтового адреса, или наоборот. Однако в текущем виде тип требует, чтобы были заполнены оба поля.
Кажется, ответ очевиден — сделать адреса необязательными, например, так:
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;
}
Эта реализация полностью соответствует требованиям. Все три случая выражены явно, при этом четвёртый случай (без какого-либо адреса) не допускается.
Обратите внимание на случай "адрес электронной почты и почтовый адрес". Пока что я просто использовал кортеж. В данном случае этого достаточно.
Создание 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
Если надо добавить почтовый адрес к существующему 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
ПРЕДУПРЕЖДЕНИЕ: В этом примере я использовал option.Value
, чтобы получить содержимое option. Это допустимо, когда вы экспериментируете в интерактивной консоли, но это ужасное решение для рабочего кода! Надо всегда использовать сопоставление с образцом и обрабатывать оба конструктора option
.
Зачем заморачиваться этими сложными типами?
К этому времени вы могли решить, что мы всё слишком усложнили. Отвечу тремя тезисами.
Во-первых, бизнес-логика сложна сама по себе. Простого способа этого избежать нет. Если ваш код проще бизнес-логики, вы не обрабатываете все случаи, как надо.
Во-вторых, если логика выражена типами, то она самодокументируется. Можно посмотреть на конструкторы типа-суммы ниже и сразу понять бизнес-правило. Вам не придётся тратить время на анализ какого-либо другого кода.
type ContactInfo =
| EmailOnly of EmailContactInfo
| PostOnly of PostalContactInfo
| EmailAndPost of EmailContactInfo * PostalContactInfo
Наконец, если логика выражена типом, то любые изменения правил бизнес-логики сломают код, не учитывающий эти изменения, а это, как правило, хорошо.
Последний пункт раскрывается в следующей статье. Пытаясь выразить правила бизнес-логики через типы, вы можете прийти к углублённому пониманию предметной области.
Комментарии (19)
Neftedollar
03.10.2018 19:25Полезная статья, один из больших бонусов F# именно в возможности сделать ненужные состояния невозможными, и проверить это на этапе компиляции.
Спасибо!
yarric
03.10.2018 21:00Не проще ли обработать это в конструкторе класса?
MaxKot Автор
03.10.2018 21:07+1Возможно, и проще, но тогда ошибка будет проявляться во время исполнения, а здесь — во время компиляции. Вероятно, обработка ошибки в конструкторе потребует также дополнительных тестов, которые тоже надо учитывать при оценке сложности того или иного решения.
yarric
03.10.2018 21:27Так можно просто конструкторы сделать типа contactWithEmail и contactWithPostal — и тогда физически не получится создать Contact с обоими заполненными полями.
MaxKot Автор
03.10.2018 22:28Всё равно тогда придётся учитывать случай с некорректным состоянием, по крайней мере, при использовании сопоставления с образцом. Объявление
Contact
будет проще, но использование сложнее.
К тому же, если правила поменяются и контакт без адресов станет возможен, то старый код не сломается. Конечно, напрямую такую эволюцию представить сложно, а как серию изменений — вполне:
- Приходит изменение: теперь у всех конатктов должен быть телефон и хотя бы один адрес для писем. Везде добавляется обработка телефона. При этом "невозможный" обработчик, где есть телефон, но нет ни электронной, ни обычной почты, остаётся.
- Новое изменение — теперь контакт с одним только телефоном возможен. Теперь "невозможные" обработчики могут отработать и их надо искать и обновлять.
При явном выделении состояний такой проблемы не возникнет, никаких "невозможных" обработчиков не будет, просто добавится новое состояние PhoneOnly и компилятор подскажет, где надо добавить обработчики.
kagetoki
04.10.2018 06:05Можно, только зачем? Один из весомых бонусов подхода из статьи — код строго соответствует бизнес логике, вы читаете код и понимаете правило. Когда же вы видите класс, у которого есть оба поля, и они оба необязательные, а консистентность обеспечивается наличием двух конструкторов — новый разработчик добавит конструктор по умолчанию, и ваша защита на этом кончилась.
Причем это не будет выглядеть ошибкой или ломающим изменением — структура объекта предполагает одновременное отсутствие обоих полей, конструктор по умолчанию лишь позволяет это состояние создать.
И напротив, когда дизайн как в статье, на
Discriminated Unions
(они же типы-суммы и размеченные объединения), он говорит разработчику, не знакомому с кодом, что одно из полей обязательное. И добавление нового кейса без обязательных полей уже в явном виде говорит, что мы меняем бизнес логикуa-tk
06.10.2018 13:16Делаем пустой конструктор по умолчанию приватным, тем самым явно указывая на недопустимость этого действия.
Злонамеренные действия возможны при любом дизайне — это обычно костылём называют.
Sirikid
04.10.2018 02:48И это ещё один перевод статьи начального уровня про то, как же в мейнстримных языках не хватает типов-сумм.
Sirion
04.10.2018 08:21Почему-то у меня чувство, что при усложнении модели сложность такого кода будет расти экспоненциально.
fillpackart
04.10.2018 08:31Не будет на самом деле. Если у меня скажем усложниться модель EmailContactInfo, то усложниться только этот тип и код, который работает с деталями этого типа. Типы, которые содержат этот тип, будут работать как и прежде.
mayorovp
04.10.2018 09:14+2Если в ООП-подходе у класса ContactInfo N опциональных полей, то при использовании приведенного в статье подхода у типа ContactInfo будет до 2^N конструкторов.
Другое дело, что такой подход в ситуации с независимыми полями просто не нужен. А вот если они почти независимы, но из 10 полей должно быть заполнено хотя бы одно — привет комбинаторный взрыв и 1023 конструктора у типа-суммы :-)kagetoki
04.10.2018 12:59+1Кейс редкий, но допустим.
Во-первых, коль скоро мы говорим про F#, тут можно без каких либо проблем применять ООП подход: вам доступны и традиционные мутабельные ссылочные типы, иNullable
, и куда более удобныйOption<T>
(который, кстати, тоже тип-сумма). И вы можете задизайнить в привычной манере.
Во-вторых, если концепция плохо работает на каком-то радикальном случае, это не значит, что концепция плоха в принципе. Любой инструмент имеет смысл в рамках решения задачи, и если конкретная задача решается с его помощью плохо — всегда можно выбрать другой.
В-третьих, кейс с большим количеством полей можно вообще спроектировать принципиально по-другому, например, как массив объектов нашего типа-суммы (в котором 10 кейсов), и валидировать, что длина больше 0. А лучше вообще завернуть это в еще один тип-сумму
type MyInfo = | Invalid | Info of MyDU list
Короче говоря, если у вас нет миллионов опциональных полей, из которых только сотня обязательна, то такой дизайн поможет перетащить некоторые проблемы из рантайма в компайл тайм, а это огромный бонус.
Neftedollar
04.10.2018 13:28Кроме комментария kagetoki, который безусловно логично оспаривает ваш кейс, хотелось бы понять как вы с большим набором полей будете поддерживать логику логических состояний (да еще и тесты писать)?
Например, если у вас 5 опциональных полей, но некоторые связаны особой логикой (если заполнено это, то должно быть заполнно и то и это)
то разве не напишите вы тонну кода или фабрик? Не будт ли и у вас тут комбинаторный взрыв?
Ну и тесты, ими надо покрыть каждый кейс, а тут за вас «тесты» пишет и прогоняет компилятор.
a-tk
06.10.2018 13:13-1А как насчёт комбинаторного взрыва в случае, скажем, если из 5 доступных вариантов контактов (с возможностью расширения в будущем) необходимо заполнить как минимум 2?
Мне кажется в этой ситуации будет более уместным ОО-подход: полиморфный базовый тип, хранимый в списке, и набор конкретных реализаций контактов. В этом случае инвариантом будет наличие как минимум 2 элементов в списке в любой момент времени.
develop7
А вообще за перевод отдельное спасибо.
MaxKot Автор
Спасибо, исправил.