[Перевод] Вычислительные выражения: Типы-обёртки
В предыдущем посте мы познакомились с процессом «maybe», благодаря которому можно значительно упросить код, работающий с Option
.
Типичное использование «maybe» выглядит так:
let result =
maybe
{
let! anInt = expression of Option
let! anInt2 = expression of Option
return anInt + anInt2
}
Раньше мы отмечали, что здесь есть один странный момент:
В строках, где встречается
let!
, выражение справа от знака равенства имеет типint option
, при этом значение слева имеет типint
.let!
«разворачивает» опциональный тип перед тем, как связать его со значением.В строке с оператором
return
всё наоборот. Возвращаемое выражение имеет типint
, но значение всего вычислительного выражения (result
) имеет типint option
.return
«заворачивает» обычное значение обратно в опциональный тип.
В этом посте мы не раз столкнёмся с подобными наблюдениями. Неявное «разворачивание» и «заворачивание» значений, хранящихся в каком-то типе-обёртке — один из основных способов применения вычислительных выражений.
Ещё один пример
Предположим, мы обращаемся к базе данных, и хотим получать результат в виде типа-объединения Успех/Ошибка:
type DbResult<'a> =
| Success of 'a
| Error of string
После объявления можно использовать этот тип в методах доступа к базе данных.
Вот несколько простых заглушек, которые дадут вам представление об использовании типа DbResult
:
let getCustomerId name =
if (name = "")
then Error "ошибка в getCustomerId"
else Success "Cust42"
let getLastOrderForCustomer custId =
if (custId = "")
then Error "ошибка в getLastOrderForCustomer"
else Success "Order123"
let getLastProductForOrder orderId =
if (orderId = "")
then Error "ошибка в getLastProductForOrder"
else Success "Product456"
Предположим, эти вызовы надо выполнить последовательно.
Сначала по имени покупателя получаем его идентификатор, затем по идентификатору покупателя — заказ, и, наконец, по идентификатору заказа — товар.
Вот очевидный способ решения задачи.
Здесь на каждом шаге мы вынуждены использовать сопоставление с образцом.
let product =
let r1 = getCustomerId "Алиса"
match r1 with
| Error _ -> r1
| Success custId ->
let r2 = getLastOrderForCustomer custId
match r2 with
| Error _ -> r2
| Success orderId ->
let r3 = getLastProductForOrder orderId
match r3 with
| Error _ -> r3
| Success productId ->
printfn "Товар %s" productId
r3
Код поистине ужасен.
Кроме того, здесь логика основного процесса смешалась с логикой обработки ошибок.
Вычислительные выражения спешат на помощь!
Мы можем реализовать процесс, за фасадом которого осуществляется обработка ветвления Успех/Ошибка:
type DbResultBuilder() =
member this.Bind(m, f) =
match m with
| Error _ -> m
| Success a ->
printfn "\tУдачно: %s" a
f a
member this.Return(x) =
Success x
let dbresult = new DbResultBuilder()
Ещё раз обратите внимание, что «построитель» («builder») в контексте вычислительных выражений — это не то же самое, что объектно-ориентированный паттерн «строитель», который применяется для конструирования и валидации объектов.
Имея такой процесс, мы можем сконцентрироваться на основной задаче и писать гораздо чище:
let product' =
dbresult {
let! custId = getCustomerId "Алиса"
let! orderId = getLastOrderForCustomer custId
let! productId = getLastProductForOrder orderId
printfn "Товар %s" productId
return productId
}
printfn "%A" product'
Если возникнут ошибки, процесс ловко их перехватит и сообщит нам о причине:
let product'' =
dbresult {
let! custId = getCustomerId "Алиса"
let! orderId = getLastOrderForCustomer "" // провоцируем ошибку!
let! productId = getLastProductForOrder orderId
printfn "Товар %s" productId
return productId
}
printfn "%A" product''
Роль типов-обёрток при работе с процессами
Сейчас мы познакомились с двумя процессами (maybe
и dbresult
), у каждого из которых есть собственный тип-обёртка (Option
и DbResult
соответственно).
И это не особые случаи. На самом деле, у каждого вычислительного выражения должен быть связанный с ним тип-обёртка.
И часто этот тип-обёртка разрабатывается с оглядкой на процесс, которым мы хотим управлять.
Пример выше ясно это демонстрирует.
Тип DbResult
, который мы создали — больше чем просто тип, возвращающий какие-то значения; в действительности он критически важен для процесса, «сохраняя» его состояние, независимо от того, удачным или ошибочным был очередной шаг.
Процесс dbresult
может незаметно переходить из состояния в состояние, пряча от нас проверки и позволяя нам сконцентрироваться на основной задаче.
Позже в этой серии мы научимся проектировать хорошие типы-обёртки, а пока узнаем, как их использовать.
Bind, Return и типы-обёртки
Ещё раз взглянем на определение методов Bind
и Resturn
в вычислительных выражениях.
Начнём с простого — с Return
.
Сигнатура Return
, как написано в MSDN, выглядит так:
member Return : 'T -> M<'T>
Иными словами, для какого-то типа T
, метод Return
просто заворачивает его в тип-обёртку.
Обратите внимание: В сигнатурах тип-обёртка обычно называется M
, так что M
— это тип-обёртка с параметром int
, а M
— тип-обёртка с параметром string
, и так далее.
Мы видели два примера такого использования.
Процесс maybe
возвращает Some
, который является одним из вариантов опционального типа, а процесс dbresult
возвращает Success
, который также является одним из вариантов типа DbResult
.
// Return для процесса maybe
member this.Return(x) =
Some x
// Return для процесса dbresult
member this.Return(x) =
Success x
Теперь посмотрим на Bind
.
Сигнатура Bind
:
member Bind : M<'T> * ('T -> M<'U>) -> M<'U>
Она довольно сложная, так что давайте разбираться.
Функция получает на вход кортеж M<'T> * ('T -> M<'U>)
и возвращает M<'U>
, где M<'U>
— это тип-обёртка для типа-параметра U
.
Кортеж состоит из двух частей:
M<'T>
— тип-обёртка с типом параметромT
, и'T -> M<'U>
— функция которая получает «развёрнутое» значениеT
и возвращает «завёрнутое» значениеU
.
Другими словами, вот что делает Bind
:
Берёт «завёрнутое» значение.
Разворачивает его в соответствии с уникальной «закулисной» логикой процесса.
Затем, возможно, применяет функцию к «развёрнутому» значению, чтобы получить новое «завёрнутое» значение.
Даже если функция не применяется,
Bind
всё равно должен вернуть «завёрнутое» значениеU
.
Учитывая всё это, ещё раз взглянем на методы Bind
, которые мы написали ранее:
// Bind для процесса maybe
member this.Bind(m,f) =
match m with
| None -> None
| Some x -> f x
// Bind для процесса dbresult
member this.Bind(m, f) =
match m with
| Error _ -> m
| Success x ->
printfn "\tУдачно: %s" x
f x
Посмотрите на этот код и убедитесь, что вы понимаете, почему эти методы на самом деле следуют шаблону, описанному выше.
На всякий случай вот вам картинка.
Здесь нарисована диаграмма различных типов и функций:
диаграмма связывания
Для
Bind
мы начинаем с завёрнутого значения (на картинкеm
), разворачиваем его в простое значение типаT
и затем (может быть) применяем к нему функциюf
, чтобы получить завёрнутое значение типаU
.Для
Return
мы начинаем с обычного значения (на картинкеx
) и просто заворачиваем его.
Тип-обёртка — обобщённый тип
Обратите внимание, что все функции используют обобщённые типы (T
и U
), за исключением самого типа-обёртки, который везде одинаковый.
В частности, ничто не мешает функции связывания maybe
принимать на вход int
и возвращать Option
, или принимать string
, а возвращать Option
.
Единственное ограничение заключается в том, что она всегда должна возвращать Option<что-то>
.
Чтобы убедиться в этом, вернёмся к примеру выше, но вместо использования строк в качестве параметров, создадим особые типы для идентификаторов покупателя, заказа и продукта.
Снова начнём с типов, определив CustomerId
и все прочие:
type DbResult<'a> =
| Success of 'a
| Error of string
type CustomerId = CustomerId of string
type OrderId = OrderId of int
type ProductId = ProductId of string
Код почти не изменится, за исключением того, что в ветках Success
появятся новые типы.
let getCustomerId name =
if (name = "")
then Error "ошибка в getCustomerId"
else Success (CustomerId "Cust42")
let getLastOrderForCustomer (CustomerId custId) =
if (custId = "")
then Error "ошибка в getLastOrderForCustomer"
else Success (OrderId 123)
let getLastProductForOrder (OrderId orderId) =
if (orderId = 0)
then Error "ошибка в getLastProductForOrder"
else Success (ProductId "Product456")
Снова «длинная» версия кода.
let product =
let r1 = getCustomerId "Алиса"
match r1 with
| Error e -> Error e
| Success custId ->
let r2 = getLastOrderForCustomer custId
match r2 with
| Error e -> Error e
| Success orderId ->
let r3 = getLastProductForOrder orderId
match r3 with
| Error e -> Error e
| Success productId ->
printfn "Товар %A" productId
r3
Здесь есть пара моментов, достойных обсуждения:
Во-первых, функция
printfn
в конце программы использует формат »%A» вместо »%s». Это нужно, поскольку типProductId
— не строка, а объединение.Второй, более тонкий момент заключается в том, что код в ошибочных ветках кажется избыточным. Зачем писать
| Error e -> Error e
? Причина в том, что входящая ошибка имеет типDbResult
илиDbResult
, а результат должен быть типаDbResult
. Не смотря на то, что обаError
выглядят одинаково, в действительности они имеют разные типы.
И, наконец, класс-построитель, код которого не меняется, за исключением строки | Error e -> Error e
.
type DbResultBuilder() =
member this.Bind(m, f) =
match m with
| Error e -> Error e
| Success a ->
printfn "\tУдача: %A" a
f a
member this.Return(x) =
Success x
let dbresult = new DbResultBuilder()
Код самого процесса остался неизменным.
let product' =
dbresult {
let! custId = getCustomerId "Алиса"
let! orderId = getLastOrderForCustomer custId
let! productId = getLastProductForOrder orderId
printfn "Товар %A" productId
return productId
}
printfn "%A" product'
В каждой строке, возвращаемое значение имеет свой собственный тип (DbResult
, DbResult
, …), но, поскольку все они используют один и тот же тип-обёртку, связывание работает так, как мы и ожидаем.
И, для сравнения — процесс с ошибкой.
let product'' =
dbresult {
let! custId = getCustomerId "Алиса"
let! orderId = getLastOrderForCustomer (CustomerId "") // провоцируем ошибку!
let! productId = getLastProductForOrder orderId
printfn "Product is %A" productId
return productId
}
printfn "%A" product''
Композиция вычислительных выражений
Мы узнали, что каждое вычислительное выражение обязано иметь связанный с ним тип-обёртку.
Этот тип-обёртка используется и в методе Bind
и в методе Return
, что даёт нам важную возможность:
Иными словами, поскольку процесс возвращает тип-обёртку, и поскольку let!
получает тип-обёртку, вы можете поместить «дочерний» процесс в правую часть выражения let!
.
Например, у вас есть процесс myworkflow
.
Тогда вы можете написать что-то подобное:
let subworkflow1 = myworkflow { return 42 }
let subworkflow2 = myworkflow { return 43 }
let aWrappedValue =
myworkflow {
let! unwrappedValue1 = subworkflow1
let! unwrappedValue2 = subworkflow2
return unwrappedValue1 + unwrappedValue2
}
Вы даже можете «встроить» вызовы непосредственно во внешний процесс:
let aWrappedValue =
myworkflow {
let! unwrappedValue1 = myworkflow {
let! x = myworkflow { return 1 }
return x
}
let! unwrappedValue2 = myworkflow {
let! y = myworkflow { return 2 }
return y
}
return unwrappedValue1 + unwrappedValue2
}
Если вы использовали процесс async
, то, скорее всего, сталкивались с подобным подходом, поскольку асинхронные вычисления обычно содержат другие асинхронные вычисления:
let a =
async {
let! x = doAsyncThing // вложенный процесс
let! y = doNextAsyncThing x // вложенный процесс
return x + y
}
Введение в «ReturnFrom»
Мы используем return
, чтобы завернуть результат вычислительного выражения.
Но иногда у нас есть функция, которая уже возвращает завёрнутое значение, которое нам надо передать дальше.return
для этого не подходит, поскольку он требует сначала развернуть значение.
Решением является вариация return
которая называется return!
.
Этот оператор получает на вход завёрнутый тип и возвращает его же.
Соответствующий метод в классе «построителе» называется ReturnFrom
.
Как правило, он просто возвращает завёрнутое значение «как есть» (хотя, конечно, вы всегда можете добавить какую-то дополнительную логику).
Новай вариант процесса «maybe»:
type MaybeBuilder() =
member this.Bind(m, f) = Option.bind f m
member this.Return(x) =
printfn "Оборачивает значение в опциональный тип"
Some x
member this.ReturnFrom(m) =
printfn "Возвращает опциональное значение напрямую"
m
let maybe = new MaybeBuilder()
Вот как его можно использовать:
// возвращаем int
maybe { return 1 }
// возвращаем Option
maybe { return! (Some 2) }
Если вам нужен более реалистичный пример, взгляните, как return!
используется совместно с divideBy
:
// используем return
maybe
{
let! x = 12 |> divideBy 3
let! y = x |> divideBy 2
return y // возвращаем int
}
// используем return!
maybe
{
let! x = 12 |> divideBy 3
return! x |> divideBy 2 // возвращаем Option
}
Заключение
Этот пост рассказал о типах-обёртках и о том, как они связаны с методами Bind
, Return
и ReturnFrom
— основными методами любого класса-строителя.
В следующем посте мы продолжим изучать типы-обёртки, в том числе рассмотрим списки в качестве таких типов.