В предыдущем посте мы познакомились с процессом "maybe", благодаря которому можно значительно упросить код, работающий с Option<T>
.
Типичное использование "maybe" выглядит так:
let result =
maybe
{
let! anInt = expression of Option<int>
let! anInt2 = expression of Option<int>
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<T>
и DbResult<T>
соответственно).
И это не особые случаи. На самом деле, у каждого вычислительного выражения должен быть связанный с ним тип-обёртка.
И часто этот тип-обёртка разрабатывается с оглядкой на процесс, которым мы хотим управлять.
Пример выше ясно это демонстрирует.
Тип DbResult
, который мы создали — больше чем просто тип, возвращающий какие-то значения; в действительности он критически важен для процесса, "сохраняя" его состояние, независимо от того, удачным или ошибочным был очередной шаг.
Процесс dbresult
может незаметно переходить из состояния в состояние, пряча от нас проверки и позволяя нам сконцентрироваться на основной задаче.
Позже в этой серии мы научимся проектировать хорошие типы-обёртки, а пока узнаем, как их использовать.
Bind, Return и типы-обёртки
Ещё раз взглянем на определение методов Bind
и Return
в вычислительных выражениях.
Начнём с простого — с Return
.
Сигнатура Return
, как написано в MSDN, выглядит так:
member Return : 'T -> M<'T>
Иными словами, для какого-то типа T
, метод Return
просто заворачивает его в тип-обёртку.
Обратите внимание: В сигнатурах тип-обёртка обычно называется M
, так что M<int>
— это тип-обёртка с параметром int
, а M<string>
— тип-обёртка с параметром 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>
, или принимать string
, а возвращать Option<bool>
.
Единственное ограничение заключается в том, что она всегда должна возвращать 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<CustomerId>
илиDbResult<OrderId>
, а результат должен быть типаDbResult<ProductId>
. Не смотря на то, что оба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<CustomerId>
,DbResult<OrderId>
, ...), но, поскольку все они используют один и тот же тип-обёртку, связывание работает так, как мы и ожидаем.
И, для сравнения — процесс с ошибкой.
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
, что даёт нам важную возможность:
выход метода
Return
можно подать на вход методаBind
Иными словами, поскольку процесс возвращает тип-обёртку, и поскольку 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
— основными методами любого класса-строителя.
В следующем посте мы продолжим изучать типы-обёртки, в том числе рассмотрим списки в качестве таких типов.