В прошлом посте мы говорили, что об операторе let
можно думать, как о приятном синтаксисе для продолжений с какой-то дополнительной закулисной работой.
Теперь мы готовы к тому, чтобы разобраться с методом Bind
класса-строителя, который формализует этот подход. Bind
— это сердце любого вычислительного выражения.
Обратите внимание, что "класс-построитель" в контексте вычислительных выражений — это не то же самое, что "паттерн строитель", который применяется для конструирования и валидации объектов.
Введение в "Bind"
Страница MSDN о вычислительных выражениях описывает let!
как синтаксический сахар для метода Bind
. Сравним их ещё раз:
Документация по оператору let!
вместе с примером использования:
// документация
{| let! pattern = expr in cexpr |}
// пример
let! x = 43 in some expression
Документация по методу Bind
, также с примером использования:
// документация
builder.Bind(expr, (fun pattern -> {| cexpr |}))
// пример
builder.Bind(43, (fun x -> some expression))
Обратим внимание на несколько важных моментов:
Bind
принимает два параметра: выражение (43
) и лямбду.Параметр лямбды (
x
) связывается с выражением, переданным в качестве первого параметра. (По крайней мере, в этом примере. Подробности позже.)Параметры
Bind
записываются в порядке, обратном к порядку вlet!
.
Иными словами, если мы запишем подряд несколько операторов let!
вот так:
let! x = 1
let! y = 2
let! z = x + y
компилятор превратит их в вызовы Bind
вот так:
Bind(1, fun x ->
Bind(2, fun y ->
Bind(x + y, fun z ->
// и т.д.
Думаю, вы уже поняли, к чему я веду.
Действительно, наша функция pipeInfo
(из прошлого поста) — то же самое, что и метод Bind
.
Ключевая мысль: вычислительное выражение — это упрощённая запись вещей, которые мы и так можем делать.
Функция bind под микроскопом
Рассмотренная нами функция bind
— это, на самом деле стандартный функциональный паттерн, который вообще связан с вычислительными выражениями.
Во-первых, почему она называется "bind" (привязать, связывать)? Ну, как мы видели, функцию или метод "bind" можно рассматривать, как передачу входного значения в функцию. Этот процесс известен, как "связывание" значения с параметром функции (помним, что в функциональных языках все функции можно привести к виду, когда они получают только — эта штука называется каррирование).
Если смотреть на связывание с этой точки зрения, оно напоминает конвейер или композицию функций.
Вы и правда можете превратить его в инфиксный оператор:
let (>>=) m f = pipeInto(m,f)
Кстати, символ ">>=" — стандартная запись связывания в виде инфиксного оператора. Если вы когда-нибудь видели её в F#-коде, скорее всего, вы видели именно связывание.
Вернёмся к примеру с безопасным делением, и перепишем логику в одну строку:
let divideByWorkflow x y w z =
x |> divideBy y >>= divideBy w >>= divideBy z
Вам, возможно, интересно, чем именно связывание отличается от обычного конвейера или композиции? Это не так очевидно.
Ответ здесь двойной:
Во-первых, функция
bind
делает дополнительную работу, разную в разных ситуациях. Это не универсальная функция, как конвейер или композиция.Во-вторых, тип входного параметра (
m
выше) не обязательно совпадает с типом результата функции (f
выше), так что одна из вещей, которую делаетbind
— это элегантная обработка несоответствия типов, благодаря которой вызовыbind
можно объединять в цепочку.
Как мы увидим в следующем посте, связывание в целом работает на базе какого-то типа-обёртки. Типом параметра может быть WrapperType<TypeA>
, а сигнатурой функционального параметра функции bind
будет TypeA -> WrapperType<TypeB>
.
В случае bind
для безопасного деления, типом-обёрткой является Option
. Тип входного параметра (выше m
) — Option<int>
, а сигнатура функционального параметре (выше f
) — int -> Option<int>
.
Чтобы увидеть связывание в разных контекстах, приведём пример логирования, работающий посредством инфиксной функции bind
:
let (>>=) m f =
printfn "expression is %A" m
f m
let loggingWorkflow =
1 >>= (+) 2 >>= (*) 42 >>= id
Здесь нет даже типа-обёртки, используется только int
. Но и здесь у bind
есть специальное поведение — логирование — которое выполняется под капотом (или за кулисами, как вам больше нравится).
Option.bind: ещё раз про обработку опциональных значений
В библиотеке F# вы не раз встретите функции или методы Bind
. Теперь вы знаете, зачем они нужны!
Особенно полезна функция Option.bind
, которая делает в точности то, что мы написали выше, а именно
Если входной параметр имеет значение
None
, она не вызывает функцию-продолжение.Если входной параметр имеет значение
Some
, она вызывает функцию-продолжение, передавая ей содержимоеSome
.
Так выглядела функция, которую мы написали сами:
let pipeInto (m,f) =
match m with
| None ->
None
| Some x ->
x |> f
А так выглядит реализация Option.bind
:
module Option =
let bind f m =
match m with
| None ->
None
| Some x ->
x |> f
Вот и мораль — не торопитесь писать свои функции. Может оказаться, что они давно есть в библиотеке!
Вот методы класса-построителя опционального типа, реализованные через Option.bind
:
type MaybeBuilder() =
member this.Bind(m, f) = Option.bind f m
member this.Return(x) = Some x
Сравнение подходов
На данный момент мы использовали четыре различных подхода в примере с "безопасным делением". Давайте ещё раз сравним их строка за строкой.
Примечание: я переименовал оригинальную функцию pipeInfo
в bind
и использовал Option.bind
вместо оригинальной самописной реализации.
Для начала взглянем на оригинальную версию, явно описывающую весь процесс:
module DivideByExplicit =
let divideBy bottom top =
if bottom = 0
then None
else Some(top/bottom)
let divideByWorkflow x y w z =
let a = x |> divideBy y
match a with
| None -> None // прерываем
| Some a' -> // продолжаем
let b = a' |> divideBy w
match b with
| None -> None // прерываем
| Some b' -> // продолжаем
let c = b' |> divideBy z
match c with
| None -> None // прерываем
| Some c' -> // продолжаем
// возврат
Some c'
// проверяем
let good = divideByWorkflow 12 3 2 1
let bad = divideByWorkflow 12 3 0 1
Теперь — на версию с самописной функцией bind
(которую мы называли pipeInfo
):
module DivideByWithBindFunction =
let divideBy bottom top =
if bottom = 0
then None
else Some(top/bottom)
let bind (m,f) =
Option.bind f m
let return' x = Some x
let divideByWorkflow x y w z =
bind (x |> divideBy y, fun a ->
bind (a |> divideBy w, fun b ->
bind (b |> divideBy z, fun c ->
return' c
)))
// проверяем
let good = divideByWorkflow 12 3 2 1
let bad = divideByWorkflow 12 3 0 1
Далее на версию с вычислительным выражением:
module DivideByWithCompExpr =
let divideBy bottom top =
if bottom = 0
then None
else Some(top/bottom)
type MaybeBuilder() =
member this.Bind(m, f) = Option.bind f m
member this.Return(x) = Some x
let maybe = new MaybeBuilder()
let divideByWorkflow x y w z =
maybe
{
let! a = x |> divideBy y
let! b = a |> divideBy w
let! c = b |> divideBy z
return c
}
// проверяем
let good = divideByWorkflow 12 3 2 1
let bad = divideByWorkflow 12 3 0 1
И, наконец, на версию с bind
в качестве инфиксного оператора:
module DivideByWithBindOperator =
let divideBy bottom top =
if bottom = 0
then None
else Some(top/bottom)
let (>>=) m f = Option.bind f m
let divideByWorkflow x y w z =
x |> divideBy y
>>= divideBy w
>>= divideBy z
// test
let good = divideByWorkflow 12 3 2 1
let bad = divideByWorkflow 12 3 0 1
Функции связывания оказываются очень мощными. В следующем посте мы увидим, как комбинирование bind
с типом-обёрткой позволяет элегантно и при этом неявно передавать дополнительную информацию.
Упражнение: Насколько вы разобрались в материале?
Перед тем, как двинуться дальше, почему бы вам не проверить, насколько хорошо вы поняли всё, что мы обсудили к этому моменту?
Вот для вас небольшое упражнение.
Часть 1 — реализуйте процесс
Для начала напишите функцию, которая преобразует строку в целое число:
let strToInt str = ???
и затем — класс-построитель вычислительного выражения, такой, чтобы его можно было использовать в программе, показанной ниже.
let stringAddWorkflow x y z =
yourWorkflow
{
let! a = strToInt x
let! b = strToInt y
let! c = strToInt z
return a + b + c
}
// проверяем
let good = stringAddWorkflow "12" "3" "2"
let bad = stringAddWorkflow "12" "xyz" "2"
Часть 2 — напишите функцию bind
Как только ваш код заработает, расширьте его, добавив две новых функции:
let strAdd str i = ???
let (>>=) m f = ???
Теперь, с помощью этих функций вам будет нетрудно переписать код в таком стиле:
let good = strToInt "1" >>= strAdd "2" >>= strAdd "3"
let bad = strToInt "1" >>= strAdd "xyz" >>= strAdd "3"
Заключение
Вот о чём, в двух словах, мы говорили в этом посте:
Вычислительные выражения — это красивый синтаксис для программирования через передачу продолжений, скрывающий от нас вложенность когда.
bind
— ключевая функция которая связывает выход, полученный на текущем шаге с входом следующего шага.Символ ">>=" — стандартная запись
bind
в виде инфиксного оператора.