В этом посте мы познакомимся с ключевым преимуществом F#, который использует систему типов, чтобы «сделать недопустимые состояния непредставимыми» (фраза позаимствована у Ярона Мински).
В прошлом посте мы упростили тип 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 * PostalContactInfotype 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 -> Nonelet name = {FirstName = "A"; MiddleInitial=None; LastName="Smith"}let contactOpt = contactFromEmail name "abc@example.com"
Здесь мы написали простую вспомогательную функцию conctactFromEmail, чтобы создать новый контакт из имени и электронного адреса. Однако, электронный адрес может быть неправильным, так функция умеет обрабатывать ошибку. Поэтому возвращаемый тип — это 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, чтобы извлечь содержимое опционального типа. Так можно делать в учебном коде, но категорически нельзя в продуктовом! Работая с опциональным типом всегда применяйте сопоставление с образцом.
Зачем вообще создавать такие сложные типы?
Возможно, сейчас вы думаете, что я всё неоправданно усложнил. Отвечу вам так:
Во-первых: бизнес-логика сложна сама по себе. Её нельзя упростить. Если ваш код проще логики, значит, в нём чего-то не хватает.
Во-вторых, логика, представленная в типах, сама себя документирует. Взглянув на варианты объединения, вы сразу понимаете, в чём заключается бизнес-правило. Вам не надо читать какой-то другой код.
type ContactInfo = | EmailOnly of EmailContactInfo | PostOnly of PostalContactInfo | EmailAndPost of EmailContactInfo * PostalContactInfo
Наконец, если логика представлена типом, любые изменения в бизнес-правиле немедленно сломают код, что, как это ни странно, хорошо.
Последний пункт мы обсудим в следующем посте. Возможно, что, пытаясь представить бизнес-логику с помощью типов, вы внезапно узнаете что-то совершенно новое о предметной области.
ссылка на оригинал статьи https://habr.com/ru/articles/1026858/