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


  1. develop7
    03.10.2018 10:48

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

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


    1. MaxKot Автор
      03.10.2018 10:56

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


  1. MaximChistov
    03.10.2018 14:50

    Спасибо за пример F# в реальной жизни :)


  1. Neftedollar
    03.10.2018 19:25

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


  1. yarric
    03.10.2018 21:00

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


    1. MaxKot Автор
      03.10.2018 21:07
      +1

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


      1. yarric
        03.10.2018 21:27

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


        1. MaxKot Автор
          03.10.2018 22:28

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


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


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

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


        1. kagetoki
          04.10.2018 06:05

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


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


          1. a-tk
            06.10.2018 13:16

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


  1. Sirikid
    04.10.2018 02:48

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


  1. Sirion
    04.10.2018 08:21

    Почему-то у меня чувство, что при усложнении модели сложность такого кода будет расти экспоненциально.


    1. fillpackart
      04.10.2018 08:31

      Не будет на самом деле. Если у меня скажем усложниться модель EmailContactInfo, то усложниться только этот тип и код, который работает с деталями этого типа. Типы, которые содержат этот тип, будут работать как и прежде.


      1. mayorovp
        04.10.2018 09:14
        +2

        Если в ООП-подходе у класса ContactInfo N опциональных полей, то при использовании приведенного в статье подхода у типа ContactInfo будет до 2^N конструкторов.

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


        1. kagetoki
          04.10.2018 12:59
          +1

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


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


        1. Neftedollar
          04.10.2018 13:28

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


          1. Sirion
            04.10.2018 13:48

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


            1. Yuuri
              04.10.2018 17:18

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


  1. a-tk
    06.10.2018 13:13
    -1

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