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

Автор оригинала: Scott Wlaschin
  • Перевод

Представляю вашему вниманию перевод статьи 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

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


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

Поделиться публикацией

Комментарии 27

    +1
    Надо всегда использовать сопоставление шаблонов и обрабатывать оба конструктора option.
    Классический перевод термина «pattern matching» — «cопоставление с образцом».

    А вообще за перевод отдельное спасибо.
      +1

      Спасибо, исправил.

      +1
      Спасибо за пример F# в реальной жизни :)
        +1
        Полезная статья, один из больших бонусов F# именно в возможности сделать ненужные состояния невозможными, и проверить это на этапе компиляции.
        Спасибо!
          0

          Не проще ли обработать это в конструкторе класса?

            +4

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

              0

              Так можно просто конструкторы сделать типа contactWithEmail и contactWithPostal — и тогда физически не получится создать Contact с обоими заполненными полями.

                +4

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


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


                1. Приходит изменение: теперь у всех конатктов должен быть телефон и хотя бы один адрес для писем. Везде добавляется обработка телефона. При этом "невозможный" обработчик, где есть телефон, но нет ни электронной, ни обычной почты, остаётся.
                2. Новое изменение — теперь контакт с одним только телефоном возможен. Теперь "невозможные" обработчики могут отработать и их надо искать и обновлять.

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

                  +2

                  Можно, только зачем? Один из весомых бонусов подхода из статьи — код строго соответствует бизнес логике, вы читаете код и понимаете правило. Когда же вы видите класс, у которого есть оба поля, и они оба необязательные, а консистентность обеспечивается наличием двух конструкторов — новый разработчик добавит конструктор по умолчанию, и ваша защита на этом кончилась.
                  Причем это не будет выглядеть ошибкой или ломающим изменением — структура объекта предполагает одновременное отсутствие обоих полей, конструктор по умолчанию лишь позволяет это состояние создать.


                  И напротив, когда дизайн как в статье, на Discriminated Unions (они же типы-суммы и размеченные объединения), он говорит разработчику, не знакомому с кодом, что одно из полей обязательное. И добавление нового кейса без обязательных полей уже в явном виде говорит, что мы меняем бизнес логику

                    –1
                    Делаем пустой конструктор по умолчанию приватным, тем самым явно указывая на недопустимость этого действия.
                    Злонамеренные действия возможны при любом дизайне — это обычно костылём называют.
                      0
                      А аргументы, почему то, что я предложил — плохо?
            • НЛО прилетело и опубликовало эту надпись здесь
                +2
                Почему-то у меня чувство, что при усложнении модели сложность такого кода будет расти экспоненциально.
                  0
                  Не будет на самом деле. Если у меня скажем усложниться модель EmailContactInfo, то усложниться только этот тип и код, который работает с деталями этого типа. Типы, которые содержат этот тип, будут работать как и прежде.
                    +2
                    Если в ООП-подходе у класса ContactInfo N опциональных полей, то при использовании приведенного в статье подхода у типа ContactInfo будет до 2^N конструкторов.

                    Другое дело, что такой подход в ситуации с независимыми полями просто не нужен. А вот если они почти независимы, но из 10 полей должно быть заполнено хотя бы одно — привет комбинаторный взрыв и 1023 конструктора у типа-суммы :-)
                      +1

                      Кейс редкий, но допустим.
                      Во-первых, коль скоро мы говорим про F#, тут можно без каких либо проблем применять ООП подход: вам доступны и традиционные мутабельные ссылочные типы, и Nullable, и куда более удобный Option<T> (который, кстати, тоже тип-сумма). И вы можете задизайнить в привычной манере.
                      Во-вторых, если концепция плохо работает на каком-то радикальном случае, это не значит, что концепция плоха в принципе. Любой инструмент имеет смысл в рамках решения задачи, и если конкретная задача решается с его помощью плохо — всегда можно выбрать другой.
                      В-третьих, кейс с большим количеством полей можно вообще спроектировать принципиально по-другому, например, как массив объектов нашего типа-суммы (в котором 10 кейсов), и валидировать, что длина больше 0. А лучше вообще завернуть это в еще один тип-сумму
                      type MyInfo = | Invalid | Info of MyDU list


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

                        0
                        Кроме комментария kagetoki, который безусловно логично оспаривает ваш кейс, хотелось бы понять как вы с большим набором полей будете поддерживать логику логических состояний (да еще и тесты писать)?
                        Например, если у вас 5 опциональных полей, но некоторые связаны особой логикой (если заполнено это, то должно быть заполнно и то и это)
                        то разве не напишите вы тонну кода или фабрик? Не будт ли и у вас тут комбинаторный взрыв?
                        Ну и тесты, ими надо покрыть каждый кейс, а тут за вас «тесты» пишет и прогоняет компилятор.
                          0
                          Выразительные возможности языка в рантайме, как правило, сильно больше выразительных возможностей языка в компайл тайме.
                            0

                            Очень зависит от языка и развитости его системы типов.

                    –1
                    А как насчёт комбинаторного взрыва в случае, скажем, если из 5 доступных вариантов контактов (с возможностью расширения в будущем) необходимо заполнить как минимум 2?
                    Мне кажется в этой ситуации будет более уместным ОО-подход: полиморфный базовый тип, хранимый в списке, и набор конкретных реализаций контактов. В этом случае инвариантом будет наличие как минимум 2 элементов в списке в любой момент времени.
                      0
                      Господа минусующие, а аргументы можно?
                      Истина рождается в дискуссии.
                      • НЛО прилетело и опубликовало эту надпись здесь
                          0
                          А как подобное сделать в F#? Вот есть, допустим, такая логика верификации пользователя, два-из-многих. Как описать систему типов для F# для её представления?
                          • НЛО прилетело и опубликовало эту надпись здесь
                              0

                              В F# же есть ООП, совместимое со всем прочим дотнетом.

                                +1

                                Извините, скорее всего я вас не так понял. Вы имели в виду "система типов F# слишком простая, чтобы решить это как-то помимо ООП"?

                                • НЛО прилетело и опубликовало эту надпись здесь

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое