Мы завершили предыдущий пост, описав набор полей для электронных адресов, почтовых индексов и прочих компонентов типа Contact.
EmailAddress: string; State: string; Zip: string;
Все они определены как обычные строки. Но на самом ли деле они — просто строки? Можно ли заменить электронный адрес почтовым индексом или названием штата?
В предметно-ориентированном проектировании это не просто строки, а совершенно разные значения. Чтобы их не перепутать, нам бы не помешали бы разные типы для этих значений.
Эта практика давно считается хорошей, однако, в таких языках, как C# и Java, создание сотен крошечных типов — муторное дело. Ей следуют немногие программисты, что приводит к коду «с душком», известным как «одержимость примитивами».
Но в F# для «одержимости примитивами» нет оправданий! Здесь создание простых типов-обёрток — тривиальное дело.
Заворачиваем примитивные типы
Простейший способ создать отдельный тип — поместить строку внутрь одновариантного объединения:
type EmailAddress = EmailAddress of string type ZipCode = ZipCode of string type StateCode = StateCode of string
Другой несложный способ — создать запись с одним строковым полем:
type EmailAddress = { EmailAddress: string } type ZipCode = { ZipCode: string } type StateCode = { StateCode: string}
Оба подхода можно использовать для создания обёрток над примитивными типами. Возникает вопрос — какой из них лучше?
Правильный ответ: одновариантное размеченное объединение. Он гораздо проще для «заворачивания» и «разворачивания», поскольку единственный размеченный вариант одновременно является и заворачивающей функцией. А разворачивание выполняется с помощью встраиваемого сопоставления с образцом.
Вот несколько примеров того, как завернуть строку в EmailAddress, а затем извлечь обратно:
type EmailAddress = EmailAddress of string // используем название варианта как функцию "a" |> EmailAddress ["a"; "b"; "c"] |> List.map EmailAddress // встраиваемое сопоставление с образцом let a' = "a" |> EmailAddress let (EmailAddress a'') = a' // в a'' теперь строка "a" let addresses = ["a"; "b"; "c"] |> List.map EmailAddress let addresses' = addresses |> List.map (fun (EmailAddress e) -> e)
Сделать что-то подобное с помощью записей не выйдет.
Добавим типы-обёртки в наш код. Теперь он выглядит так:
type PersonalName = { FirstName: string; MiddleInitial: string option; LastName: string; } type EmailAddress = EmailAddress of string type EmailContactInfo = { EmailAddress: EmailAddress; IsEmailVerified: bool; } type ZipCode = ZipCode of string type StateCode = StateCode of string type PostalAddress = { Address1: string; Address2: string; City: string; State: StateCode; Zip: ZipCode; } type PostalContactInfo = { Address: PostalAddress; IsAddressValid: bool; } type Contact = { Name: PersonalName; EmailContactInfo: EmailContactInfo; PostalContactInfo: PostalContactInfo; }
Приятное дополнение: реализация функций может быть инкапсулирована с помощью сигнатур модуля. Что это такое и как работает, обсудим чуть позже.
Наименование «варианта» одновариантного объединения
В примерах выше мы использовали одно и то же имя и для типа, и для его единственного варианта:
type EmailAddress = EmailAddress of string type ZipCode = ZipCode of string type StateCode = StateCode of string
Это немного путает, но на самом деле эти имена находятся в разных зонах видимости и не мешают друг другу. Одно из них — это тип, а другое — функция-конструктор.
Если вы видите сигнатуру функции наподобие этой:
val f: string -> EmailAddress
знайте, что она относится к миру типов, поскольку EmailAddress — это имя типа.
С другой стороны, если вы видите такой код:
let x = EmailAddress y
знайте, что он относится к миру значений, поскольку EmailAddress — имя функции-конструктора.
Конструируем одновариантные объединения
Часто для величин, имеющих особый смысл — электронных адресов, почтовых индексов — доступны далеко не все значения. Не всякая строка может быть электронным адресом или почтовым индексом.
Это значит, что в какой-то момент нам потребуется валидация. А какой момент может быть лучше, чем время создания переменной? Помимо прочего, после создания переменную нельзя изменить, значит можно не опасаться, что кто-то модифицирует её в будущем.
Вот как расширить наш модуль с помщью валидирующих функций-конструкторов:
... типы как выше ... let CreateEmailAddress (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then Some (EmailAddress s) else None let CreateStateCode (s:string) = let s' = s.ToUpper() let stateCodes = ["AZ";"CA";"NY"] // и т. д. if stateCodes |> List.exists ((=) s') then Some (StateCode s') else None
Протестируем конструкторы:
CreateStateCode "CA" CreateStateCode "XX" CreateEmailAddress "a@example.com" CreateEmailAddress "example.com"
Обрабатываем недопустимые входные данные в конструкторе
Когда у нас есть валидирующие функции-конструкторы, мы можем обрабатывать недопустимые входные данные. Но как? Например, как быть, если я вызову конструктор электронного адреса с параметром "abc"?
На этот вопрос есть несколько ответов.
Во-первых, можно выбросить исключение. В функциональном мире так не делают и мы не будем.
Во-вторых, можно вернуть опциональное значение, где None означает, что входные данные не прошли валидацию. Это именно то, что делают функции-конструкторы в примере, который мы только что привели.
Как правило, это простейший способ. Он обладает преимуществом — вызывающая сторона должна явно обработать недопустимое значение.
Скажем, вызывающий код может выглядеть так:
match (CreateEmailAddress "a@example.com") with | Some email -> ... // что-то делаем с email | None -> ... // игнорируем?
Недостаток способа в том, что при сложных проверках непонятно, что пошло не так. Был ли электронный адрес слишком длинным, или мы забыли символ '@', или дело в неправильном домене? Этого узнать нельзя.
Если вам нужны подробности, возвращайте тип с детальным объяснением ошибки.
Следующий пример возвращает тип CreationResult, чтобы сигнализировать об ошибке.
type EmailAddress = EmailAddress of string type CreationResult<'T> = Success of 'T | Error of string let CreateEmailAddress2 (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then Success (EmailAddress s) else Error "Электронный адрес должен содержать символ @" // тест CreateEmailAddress2 "example.com"
Вы передаёте в конструктор две функции. Одна из них вызывается в случае успеха (получая, как параметр, сконструированный электронный адрес), а другая — в случае неудачи (получая, как параметр, описание ошибки).
type EmailAddress = EmailAddress of string let CreateEmailAddressWithContinuations success failure (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then success (EmailAddress s) else failure "Электронный адрес должен содержать символ @"
Успешная функция принимает на вход электронный адрес, а ошибочная — строку с ошибкой. Обе функции должны вернуть один и тот же тип. Его выбор остаётся за вами.
Простой пример — обе функции вызывают printf и не возвращают ничего (т.е. возвращают значение типа unit):
let success (EmailAddress s) = printfn "успешное создание электронного адреса %s" s let failure msg = printfn "ошибка при создании электронного адреса: %s" msg CreateEmailAddressWithContinuations success failure "example.com" CreateEmailAddressWithContinuations success failure "x@example.com"
С помощью функций-продолжений можно имитировать другие способы. Например, можно получить опциональное значение. В этом случае обе функции должны вернуть значение типа EmailAddress option:
let success e = Some e let failure _ = None CreateEmailAddressWithContinuations success failure "example.com" CreateEmailAddressWithContinuations success failure "x@example.com"
Можно выбросить исключение в случае ошибки:
let success e = e let failure _ = failwith "неверный электронный адрес" CreateEmailAddressWithContinuations success failure "example.com" CreateEmailAddressWithContinuations success failure "x@example.com"
Этот код кажется несколько громоздким. На практике вместо длинной функции создают локальную функцию без последнего параметра. Такие функции называют частично-применёнными.
// создаём частично-применённую функцию let success e = Some e let failure _ = None let createEmail = CreateEmailAddressWithContinuations success failure // используем частично-применённую функцию createEmail "x@example.com" createEmail "example.com"
Создаём модули для типов-обёрток
Наши простые типы-обёртки стали сложнее, как только мы добавили валидацию. Возможно, это не единственное усложнение, которое нам потребуется.
Тип и обслуживающие его функции, можно инкапсулировать внутри модуля:
module EmailAddress = type T = EmailAddress of string // заворачиваем let create (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then Some (EmailAddress s) else None // разворачиваем let value (EmailAddress e) = e
Теперь пользователи вызывают функции модуля, чтобы завернуть или развернуть значение:
// создаём электронные адреса let address1 = EmailAddress.create "x@example.com" let address2 = EmailAddress.create "example.com" // разворачиваем электронные адреса match address1 with | Some e -> EmailAddress.value e |> printfn "the value is %s" | None -> ()
Заставляем вызывать конструктор
Осталась одна проблема: мы не можем заставить пользователя вызывать функцию-конструктор. Если захочет, он может пропустить валидацию и создать тип напрямую.
На практике, это не так страшно. Можно использовать соглашение об именовании, обозначив тип «якобы закрытым» и предоставив функции для «заворачивания» и «разворачивания». Клиентам никогда не придётся взаимодействовать с типом напрямую.
Пример:
module EmailAddress = // закрытый тип type _T = EmailAddress of string // заворачиваем let create (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then Some (EmailAddress s) else None // разворачиваем let value (EmailAddress e) = e
Конечно, тип в модуле не закрыт по настоящему. Но вы обозначаете вызывающей стороне своё намерение.
Если вы действительно хотите инкапсулировать тип и заставить вызывающую сторону использовать функции доступа, используйте сигнатуры модуля.
Пример файла сигнатур для электронного адреса:
// ФАЙЛ: EmailAddress.fsi module EmailAddress // инкапсулированный тип type T // заворачиваем val create : string -> T option // разворачиваем val value : T -> string
(Обратите внимание, что сигнатура модуля работают только в компилируемых проектах, а не в интерактивных скриптах. Для тестирования вам понадобиться создать три файла в проекте F# с именами, показанными здесь.)
Файл реализации:
// FILE: EmailAddress.fs // ФАЙЛ: EmailAddress.fs module EmailAddress // инкапсулированный тип type T = EmailAddress of string // заворачиваем let create (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then Some (EmailAddress s) else None // разворачиваем let value (EmailAddress e) = e
Клиентский код:
// ФАЙЛ: EmailAddressClient.fs module EmailAddressClient open EmailAddress // код работает при использовании публичных функций let address1 = EmailAddress.create "x@example.com" let address2 = EmailAddress.create "example.com" // код, который использует детали реализации, не компилируется let address3 = T.EmailAddress "bad email"
Тип EmailAddress.T, экспортируемый модулем сигнатур, не раскрывает детали реализации, поэтому клиенты не имеют доступа к его внутренностям.
Как видите, этот способ заставляет использовать конструктор. Попытка создать тип напрямую (T.EmailAddress "bad email") приведёт к ошибке компиляции.
Когда следует «заворачивать» одновариантные объединения
Разобравшись с типами-обёртками, перейдём к следующему вопросу. Когда заворачивать значения?
Обычно это делают на границах сервиса (например, на границах гексагональной архитектуры).
При таком подходе заворачивание выполняется на уровне UI или при загрузке данных из хранилища. Созданный тип-обёртка передаётся на уровень предметной области, где обрабатывается как единое целое. В рамках предметной области на удивление редко приходится извлекать завёрнутые значения, обрабатывать и заворачивать обратно.
В рамках конструирования критически важно, чтобы вызывающая сторона использовала функцию-конструктор, а не собственную логику проверки. Это гарантирует, что «плохие» значения не проникнут в предметную область.
Вот, например, код, где UI выполняет собственную проверку:
let processFormSubmit () = let s = uiTextBox.Text if (s.Length < 50) then // присвоить электронный адрес объекту предметной области else // показать сообщение об ошибке
Гораздо лучше, если проверку сделает конструктор:
let processFormSubmit () = let emailOpt = uiTextBox.Text |> EmailAddress.create match emailOpt with | Some email -> // присвоить электронный адрес объекту предметной области | None -> // показать сообщение об ошибке
Когда следует «разворачивать» одновариантные объединения
Хорошо, а когда требуется разворачивать значения? И снова — в целом — на границах сервиса.
Например, когда вы сохраняете электронный адрес в базу данных, или связываете его с элементом UI или моделью представления.
Чтобы избежать явного разворачивания, можно использовать продолжения, передавая функцию, которая будет вызывана с развёрнутым значением.
Вместо явного вызова функции «разворачивания»:
address |> EmailAddress.value |> printfn "значение %s"
передаём функцию, которая применяется к завёрнутому значению:
address |> EmailAddress.apply (printfn "значение %s")
Теперь модуль EmailAddress выглядит так:
module EmailAddress = type _T = EmailAddress of string // создаём с помощью продолжения let createWithCont success failure (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then success (EmailAddress s) else failure "Email address must contain an @ sign" // создаём напрямую let create s = let success e = Some e let failure _ = None createWithCont success failure s // разворачиваем с помощью продолжения let apply f (EmailAddress e) = f e // разворачиваем напрямую let value e = apply id e
Функции create и value не являются обязательными, они добавлены для удобства вызывающей стороны.
Код «в целом»
Проведём рефакторинг кода Contract, добавив типы-обёртки и модули.
module EmailAddress = type T = EmailAddress of string // создаём с помощью продолжения let createWithCont success failure (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\S+@\S+\.\S+$") then success (EmailAddress s) else failure "Email address must contain an @ sign" // создаём напрямую let create s = let success e = Some e let failure _ = None createWithCont success failure s // разворачиваем с помощью продолжения let apply f (EmailAddress e) = f e // разворачиваем напрямую let value e = apply id e module ZipCode = type T = ZipCode of string // создаём с помощью продолжения let createWithCont success failure (s:string) = if System.Text.RegularExpressions.Regex.IsMatch(s,@"^\d{5}$") then success (ZipCode s) else failure "Zip code must be 5 digits" // создаём напрямую let create s = let success e = Some e let failure _ = None createWithCont success failure s // разворачиваем с помощью продолжения let apply f (ZipCode e) = f e // разворачиваем напрямую let value e = apply id e module StateCode = type T = StateCode of string // создаём с помощью продолжения let createWithCont success failure (s:string) = let s' = s.ToUpper() let stateCodes = ["AZ";"CA";"NY"] //etc if stateCodes |> List.exists ((=) s') then success (StateCode s') else failure "State is not in list" // создаём напрямую let create s = let success e = Some e let failure _ = None createWithCont success failure s // разворачиваем с помощью продолжения let apply f (StateCode e) = f e // разворачиваем напрямую let value e = apply id e type PersonalName = { FirstName: string; MiddleInitial: string option; LastName: string; } type EmailContactInfo = { EmailAddress: EmailAddress.T; IsEmailVerified: bool; } type PostalAddress = { Address1: string; Address2: string; City: string; State: StateCode.T; Zip: ZipCode.T; } type PostalContactInfo = { Address: PostalAddress; IsAddressValid: bool; } type Contact = { Name: PersonalName; EmailContactInfo: EmailContactInfo; PostalContactInfo: PostalContactInfo; }
Обратите внимание, что в нашем примере много похожего кода в модулях с типами-обёртками. Как думаете, можно ли избавиться от дублей или, по крайней мере, сделать код чище?
Заключение
Подведём итоги:
Используйте одновариантные размеченные объединения для создания типов, точно описывающих предметную область.
Если завёрнутое значение требует проверки, предоставьте валидируцющие конструкторы и заставьте их вызывать.
Ясно показывайте, что будет, если проверка завершится неудачей. В простых случаях возвращайте опциональные типы. В сложных случаях используйте функции-продолжения.
Если у завёрнутого значения много связанных функций, поместите их в отдельный модуль.
Если вам нужна инкапсуляция, используйте файлы сигнатур.
Однако, мы не закончили с рефакторингом. В следующей части займёмся тем, чтобы сделать недопустимые состояния непредставимыми.
Обновление
Многие читатели просят подробнее рассказать о том, как гарантировать создание типов навроде EmailAddress, только через конструктор с проверкой. Для них я сделал небольшой git-фрагмент с несколькими примерами.
lazarus_net
F# в продакшене реально где-то используется?
Смотрел на него лет восемь назад, тогда был очевидный хайп вокруг функционального программирования. Потом переключился на C#. Сейчас вижу что C# методично забирает всё что было интересного в F# — records, pattern matching, nullable reference types.
Похоже MS потерял к нему интерес.
У меня основной язык Erlang/OTP но он достаточно нишевый, F# интересен как функиональный язык для работы со всем .Net стеком,
Идея лёгких типов-обёрток это классно, но учить "мертвый язык" не охота...
markshevchenko Автор
Ответ двоякий. С одной стороны, в индустрии язык не задержался. Есть проекты в мире и у нас, но их немного. С другой: есть большое дружное сообщество. Которое, кстати, считает, что основная причина нишевости языка это недоработка MS. Сейчас над компилятором, как говорят, работают полтора человека.
С деньгами MS можно было посадить и пять человек, и двадцать. Так что это политическая, не очень понятная с точки зрения программистов, ситуация.
Впрочем, сам материал вполне полезен. Не в F#, так в OCaml, да даже в Rust вполне можно использовать алгебраические типы.
boldape
В расте есть идиамотический new type для таких задач и он не одновариантный энум, а одноместный тупл. Я зашёл посмотреть почему именно одноваритный энум т.к. у меня была претензия к такому подходу в расте и вижу что в ф шарпе это имеет прекрасное объяснение, а в расте все совсем не так приятно и удобно. + тот случай для чего у нас он используется совсем иной - версионирование на очень светлое будущее.
markshevchenko Автор
Видимо, мне надо уточнить, что когда я писал “полезность”, я имел в виду не только этот пост, но и весь цикл. Наверное, к Rust можно применить 60% или даже 80% из написанного. Make invalid data unpresentable, Parse, don’t validate — всё это подходит и к Rust.
kleidemos
C# методично забирает, но криво и, так сказать, с элементами не самой удачной отсебятины. Из-за этого исчерпывающе F# исходники на C# перекладывать не получается. Особенно в тех ситуациях, когда разработка проекта достигла стадии конвейера с распрыгами на гранатомёте. Однако до непосредственной попытки переноса это может быть незаметно. В результате межлагерная коммуникация местами даже ухудшилась, так как до этого C#-еры не знали, с чем имеют дело, и поэтому прилагали усилия к изучению, а сейчас лишь думают, что знают, и поэтому ничего не делают.
Есть фичи, которые C# уже не перенесёт (либо перенесёт с большими костылями). Например, computation expressions, так как они очевидно входят в конфликт с существующими IEnumerable и Task. Это важно, так как у нас до четверти кода на проекте может находиться в рамках кастомных CE.
Наконец, по поводу “мёртвости” языка. F# не бросили посреди крупной миграции, так что он находится в очень стабильном состоянии (за что мс таки можно сказать спасибо). И всё, что в него можно было быстро добавить, добавили почти в самом начале, а не через 15 лет. Почти всё, что хотят добавить теперь, потребует высадки космодесанта от мира программирования (он есть, но занят). В данный момент наибольшее влияние на код F# оказывают либы, которые пилят сами F#-исты (публично или приватно). Скажем, замена
Hopac.Streamна что-то очень похожее, но чуть более сложное и совершенное на базе того жеHopac, сказалась на моём коде куда радикальнее, чем всё, что пережили C#-коллеги за последний десяток лет. Кейс, конечно, не рядовой, но с опытом в Erlang (у меня его нет) на F# можно и не так разгуляться.