В прошлых статьях цикла мы разобрались со связыванием, функциями-продолжениями и типами-обёртками. Теперь мы, наконец, готовы к знакомству с методами классов-построителей.

Обратите внимание, что "построитель" в контексте вычислительных выражений — это не то же самое, что объектно-ориентированный паттерн "строитель", который используется для конструирования и валидации объектов.

Если вы заглянете в документацию MSDN, то увидите там не только знакомые нам Bind и Return, но и другие методы со странными названиями, навроде Delay и Zero. В этой и последующих статьях мы выясним, для чего они нужны.

План действий

Чтобы продемонстрировать, как создавать класс-построитель, мы опишем собственный процесс, который использует все возможные методы строителя.

Но вместо того, чтобы без всякого контекста объяснять, что означают эти методы, мы начнём с простого процесса, и будем добавлять новые методы, только когда нам нужно будет справиться с какой-то проблемой или ошибкой. Иными словами, изучим методы построителя, двигаясь снизу вверх, а не сверху вниз. По мере чтения статьей, вы разберётесь, как F# обрабатывает вычислительные выражения на самом деле.

Общая схема процесса:

  • Часть 1: Здесь мы узнаем, какие методы нужны для базового процесса. Познакомимся с Zero, Yield, Combine и For.

  • Часть 2: Познакомимся с тем, как откладывать вычисления, чтобы они выполнялись только тогда, когда нужно. Представим методы Delay и Run, и рассмотрим ленивые вычисления.

  • Часть 3: Рассмотрим оставшиеся методы While и Using, и обработку исключений.

Перед тем, как начать

Прежде, чем погрузиться в создание процесса, обсудим несколько важных моментов.

Документация по вычислительным выражениям

Во-первых, как вы, возможно, заметили, документация MSDN по вычислительным выражениям не очень подробна. Кроме того, она может вводить в заблуждение, хотя явных ошибок в ней нет. Скажем, сигнатуры методов построителя гибче, чем может показаться на первый взгляд. Эту гибкость можно использовать для создания сильных нетривиальных решений, но из документации вы про это не узнаете. Чуть позже мы разберём один такой пример.

Если вы хотите глубже познакомиться с темой, вот два источника, которые я могу порекомендовать. Великолепный материал, детально разбирающий концепции, положенных в основу вычислительных выражений — статья "Зоопарк выражений F#" Томаса Петрика и Дона Сайма.
Кроме того, в качестве самой точной и актуальной технической документации, выступает спецификация языка F#, где есть раздел про вычислительные выражения.

Завёрнутые и незавёрнутые типы

Разбираясь с сигнатурами методов в документации, помните, что "незавёрнутыми" типами я называю что-то похожее на 'T, а "завёрнутыми" — что-то похожее на M<'T>. Например, у метода Return сигнатура имеет вид 'T -> M<'T>, и это означает, что Return получает незавёрнутый тип и возвращает завёрнутый.

Как и раньше, я использую слова "незавёрнутый" и "завёрнутый", чтобы описать взаимоотношения между типами. По мере того, как вы будете осваиваться в материале, мы будем переходить на общепринятую терминологию, так что в какой-то момент я начну писать "тип вычисления" вместо "завёрнутый тип". Я надеюсь, что на этом этапе вы уже будете понимать, что это значит.

Я попытаюсь сохранять простоту примеров, используя код-комментарий:

let! x = ...значение завёрнутого типа...

Здесь налицо чрезмерное упрощение. Есть быть совсем точным, x может быть не просто значением, но и любым образцом. Кроме того, "значение завёрнутого типа" может, конечно, быть выражением. В MSDN используется точный подход, в частности, в документации при определении let! pattern = expr in cexpr вы найдёте и "образец", и "выражение".

Вот примеры использования образцов и выражений в вычислительном выражении maybe, где Option — это тип-обёртка. В правой части находятся завёрнутые в этот тип значения:

// let! pattern = expr in cexpr
maybe {
    let! x,y = Some(1,2)
    let! head::tail = Some( [1;2;3] )
    // и т.д.
    }

Тем не менее, я продолжу использовать переупрощённый код, чтобы не увеличивать сложность темы, которая сложная сама по себе!

Имплементация особых методов в классе-строителе (или нет)

Документация MSDN утверждает, что каждая особая операция (скажем, for..in или yield) транслируется в вызов метода из класса-построителя.

На самом деле, речь не всегда идёт о соответствии один-к-одному, но в целом, чтобы поддерживать синтаксис особой операции, вы должны реализовать соответствующий метод в классе-построителе, иначе компилятор выдаст вам ошибку.

С другой стороны, вы не не обязаны реализовывать метод, если вам не нужен соответствующий синтаксис. Скажем, мы описали процесс maybe, определив всего лишь два метода — Bind и Return. Не обязательно реализовывать Delay, Use и другие методы, если мы не хотим их использовать.

Что случиться, если мы не реализуем какой-то метод? Исследуем этот вопрос на примере синтаксиса for..in..do в нашем процессе maybe:

maybe { for i in [1;2;3] do i }

Получим ошибку компиляции:

Конструкция данного элемента управления может использоваться только в том случае, если построитель вычислительного выражения определяет метод "For"

Пока вы не понимаете, что происходит за кулисами вычислительных выражений, часть ошибок может показаться вам странной. Например, если вы забыли вставить return в свой процесс, как здесь:

maybe { 1 }

вы получите ошибку:

Конструкция данного элемента управления может использоваться только в том случае, если построитель вычислительного выражения определяет метод "Zero"

Вы можете спросить: откуда взялся метод Zero? Почему он нужен в этом выражении? Ответ на этот вопрос мы скоро узнаем.

Операции с '!' и без '!'

Бросается в глаза, что многие особые операции имеют две версии: с восклицательным знаком и без него. Примеры: let и let! (произносится "лет-бэнг"), return и return!, yield и yield!, и так далее.

Различие в том, что операции без "!" всегда имеют в правой части незавёрнутый тип, в то время как операции с "!" — завёрнутый.

Сравним различные синтаксисы в процессе maybe, где Option — это завёрнутый тип:

let x = 1           // 1 это "незавёрнутый" тип
let! x = (Some 1)   // Some 1 это "завёрнутый" тип
return 1            // 1 это "незавёрнутый" тип
return! (Some 1)    // Some 1 это "завёрнутый" тип
yield 1             // 1 это "незавёрнутый" тип
yield! (Some 1)     // Some 1 это "завёрнутый" тип

Версии с "!" особенно важны для композиции, поскольку тип-обёртка может быть результатом другого вычислительного выражения того же типа.

let! x = maybe {...)       // "maybe" возвращает "завёрнутый" тип

// связываем с другими процессом такого же типа, используя let!
let! aMaybe = maybe {...)  // создаём "завёрнутый" тип
return! aMaybe             // возвращаем его

// связываем два дочерних асинка в родительском асинке, используя let!
let processUri uri = async {
    let! html = webClient.AsyncDownloadString(uri)
    let! links = extractLinks html
    ... etc ...
    }

Погружаемся глубже — создаём минимальную реализацию процесса

Что ж, начнём! Создадим минимальную версию процесса "maybe". Переименуем его в "trace", потому что каждый метод будет выводить отладочное (трассирующее) сообщение, чтобы мы могли разобраться, что происходит во время вычислений.

Код первой версии процесса trace:

type TraceBuilder() =
    member this.Bind(m, f) =
        match m with
        | None ->
            printfn "Bind с None. Выход."
        | Some a ->
            printfn "Bind с Some(%A). Продолжение" a
        Option.bind f m

    member this.Return(x) =
        printfn "Return незавёрнутого значения %A." x
        Some x

    member this.ReturnFrom(m) =
        printfn "Return завёрнутого значения (%A)." m
        m

// создаём экземпляр процесса
let trace = new TraceBuilder()

Пока в этом коде нет ничего нового, мы рассматривали эти методы раньше.

Теперь давайте посмотрим, как работает этот процесс.

trace {
    return 1
    } |> printfn "Результат 1: %A"

trace {
    return! Some 2
    } |> printfn "Результат 2: %A"

trace {
    let! x = Some 1
    let! y = Some 2
    return x + y
    } |> printfn "Результат 3: %A"

trace {
    let! x = None
    let! y = Some 1
    return x + y
    } |> printfn "Результат 4: %A"

Всё должно работать так, как мы ожидаем. В частности, использование None в четвёртом примере приводит к тому, что следующие две строки (let y = ... return x+y) пропускаются и результатом всего выражения становится None.

Введение в "do!"

Пока наше выражение поддерживает let!. А что насчёт do!?

В нормальном F# do — это аналог let, за исключением того, что выражение не возвращает ничего полезного (буквально, возвращает значение типа unit).

Смысл do! в вычислительных выражениях очень похож. Оператор let! передаёт завёрнутое значение в метод Bind, то же самое делает и do!. Очевидно, в случае с do! в метод Bind передаётся "завёрнутая" версия unit.

Простая демонстрация на базе процесса trace:

trace {
    do! Some (printfn "...выражение типа unit")
    do! Some (printfn "...ещё одно выражение типа unit")
    let! x = Some (1)
    return x
    } |> printfn "Результ do: %A"

Вывод:

> ...выражение типа unit
> Bind с Some(<null>). Продолжение
> ...ещё одно выражение типа unit
> Bind с Some(<null>). Продолжение
> Bind с Some(1). Продолжение
> Return незавёрнутого значения 1
> Результат do: Some 1

Вы можете самостоятельно убедиться, что unit option передаётся в Bind, как результат каждого do!.

Введение в "Zero"

Какое наименьшее вычислительное выражение в принципе может заработать? Пустое?

trace {
    } |> printfn "Результат пустого процесса: %A"

Мгновенно получаем ошибку:

Это значение не является функцией, и применить его невозможно.

Достаточно справедливо. Если подумать, в пустом вычислительном выражении нет никакого смысла. В конце концов, его цель — объединять цепочку выражений.

А что насчёт простого выражения без let! или return?

trace {
    printfn "привет мир"
    } |> printfn "Результат простого выражения: %A"

Теперь у нас другая ошибка:

Конструкция данного элемента управления может использоваться только в том случае, если построитель вычислительного выражения определяет метод "Zero"

Почему метод Zero вдруг потребовался, хотя раньше он был не нужен? В этом конкретном случае ответ заключается в том, что мы ничего не вернули, хотя вычислительное выражение просто обязано вернуть какое-то завёрнутое значение.

На самом деле, эта ошибка будет появляться каждый раз, когда у вычислительного выражения не будет какого-то оператора, возвращающего значение. Похожая штука происходит, если у вас есть выражение if..then без клаузы else.

trace {
    if false then return 1
    } |> printfn "Результат if без else: %A"

В нормальном коде F# if..then без else должен возвращать unit, но в вычислительном выражении конкретное возвращаемое значение должно быть завёрнутым. Кажется, что можно вернуть завёрнутое значение unit, но на самом деле этот вариант подходит далеко не всегда.

Чтобы избавиться от ошибки, надо сказать компилятору, какое конкретно значение использовать по умолчанию. В этом и есть цель метода Zero.

Какое значение использовать для Zero?

Так какое значение надо использовать для Zero? Ответ зависит от того, какого рода процесс вы создаёте.

Вот несколько рекомендаций:

  • Есть ли в процессе концепции "успех" и "ошибка"? Если да, используйте для Zero значение "ошибка". Например, в процессе trace, мы используем None, чтобы сообщить о неудаче, так что можно использовать None как значение для Zero.

  • Есть ли в процессе концепция "пошагового выполнения"? Идёт ли речь о том, что вы выполняете вычисления шаг за шагом? В обычном коде F# выражение, которое не возвращает ничего, имеет значение unit. Результатом такого же вычислительного выражения должно быть завёрнутое значение unit. В частности, в опциональном процессе, можно возвращать Some () при вызове Zero. Кстати, это было бы то же самое, что и вызов Return ().

  • Связан ли процесс в первую очередь с какой-то структурой данных? Если да, Zero должен возвращать "пустой" экземпляр этой структуры. Например, при реализации "построителя списков" в качестве значения Zero можно использовать пустой список.

Zero также играет важную роль при комбинировании завёрнутых типов. Оставайтесь на связи: обсудим эту особенность Zero в следующем посте.

Реализация Zero

Добавим метод Zero в наш испытательный класс. Он будет возвращать None.

type TraceBuilder() =
    // другие методы, как раньше
    member this.Zero() =
        printfn "Zero"
        None

// создаём новый экземпляр
let trace = new TraceBuilder()

// проверяем
trace {
    printfn "привет мир"
    } |> printfn "Результат простого выражения: %A"

trace {
    if false then return 1
    } |> printfn "Результат if без else: %A"

Этот тестовый код показывает, что за кулисами вычислительного выражения вызывается метод Zero. В качестве результата в обоих случаях мы получим None. Примечание: None может быть напечатано как <null>. Не обращайте внимания.

Всегда ли вам нужен Zero?

Имейте в виду, что вы не обязаны добавлять метод Zero, если он не имеет смысла в контексте процесса. Скажем, у seq такого смысла нет, а у async есть:

let s = seq {printfn "zero" }    // Ошибка
let a = async {printfn "zero" }  // Работает

Введение в "Yield"

В C# есть оператор "yield", который нужен для раннего возврата значений из итератора. После возврата управления обратно в итератор, он возобновляет работу с того места, где она была прервана.

Если верить документации, "yield" есть и в вычислительных выражениях F#. Что он делает? Проведём пару экспериментов и узнаем.

trace {
    yield 1
    } |> printfn "Результат yield: %A"

Получаем ошибку:

Конструкция данного элемента управления может использоваться только в том случае, если построитель вычислительного выражения определяет метод "Yield"

Пока что никаких сюрпризов. Так на что должна быть похожа реализация метода "yield"? Документация MSDN говорит, что он имеет сигнатуру 'T -> M<'T>, которая в точности совпадает с сигнатурой метода Return. Он получает незавёрнутое значение и возвращает завёрнутое.

Сделаем такую же реализацию, как и у метода Return, и повторим эксперимент.

type TraceBuilder() =
    // другие методы, как раньше

    member this.Yield(x) =
        printfn "Yield незавёрнутого значения %A" x
        Some x

// создаём новый экземпляр
let trace = new TraceBuilder()

// проверяем
trace {
    yield 1
    } |> printfn "Результат для yield: %A"

Теперь всё работает, и выглядит, как полная замена return.

Есть также метод YieldFrom похожий на ReturnFrom. Он ведёт себя точно также, позволяя вернуть завёрнутое значение вместо незавёрнутого.

Добавим его в наш класс-построитель:

type TraceBuilder() =
    // другие методы, как раньше

    member this.YieldFrom(m) =
        printfn "Yield завёрнутого значения (%A)" m
        m

// создаём новый экземпляр
let trace = new TraceBuilder()

// проверяем
trace {
    yield! Some 1
    } |> printfn "Результат yield!: %A"

Возможно, сейчас вам стало интересно: если return и yield делают одно и то же, почему для них предусмотрены разные ключевые слова? Ответ в том, что вы можете обеспечить нужный вам синтаксис, реализуя или первый метод, или второй. Например, выражение seq разрешает yield, но не разрешает return, в то время, как async разрешает return, но не разрешает yield:

let s = seq {yield 1}    // Работает
let s = seq {return 1}   // Не работает

let a = async {return 1} // Работает
let a = async {yield 1}  // Не работает

На самом деле, вы можете сделать эти методы разными. Например, вызов return мог бы останавливать вычисления, в то время, как вызов yield продолжал бы их дальше.

Конечно, в целом yield используют для семантики последовательности/перебора, в то время как return обычно пишут один раз в конце выражения. (В следующем посте мы увидим пример с несколькими операторами yield).

Возвращаясь к "For"

В предыдущем посте мы говорили о синтаксисе for..in..do. Давайте вернёмся к "построителю списков", который мы тогда обсуждали, и добавим в него новые методы. С тем, как определить Bind и Return для списка, мы уже знакомы.

  • Метод Zero просто возвращает пустой список.

  • Метод Yield может быть реализован также, как и Return.

  • Метод For может быть реализован также, как и Bind.

type ListBuilder() =
    member this.Bind(m, f) =
        m |> List.collect f

    member this.Zero() =
        printfn "Zero"
        []

    member this.Return(x) =
        printfn "Return незавёрнутого значения %A" x
        [x]

    member this.Yield(x) =
        printfn "Yield незавёрнутого значения %A" x
        [x]

    member this.For(m,f) =
        printfn "For %A" m
        this.Bind(m,f)

// создаём экземпляр процесса
let listbuilder = new ListBuilder()

Вот код с оператором let!:

listbuilder {
    let! x = [1..3]
    let! y = [10;20;30]
    return x + y
    } |> printfn "Результат: %A"

А вот эквивалент с оператором for:

listbuilder {
    for x in [1..3] do
    for y in [10;20;30] do
    return x + y
    } |> printfn "Результат: %A"

Оба подхода дают один и тот же результат.

Заключение

В этом посте мы увидели, как реализовать основные методы простого вычислительного выражения.

Некоторые моменты для закрепления:

  • Для простых выражений не обязательно реализовывать все методы.

  • Методы с восклицательным знаком принимают завёрнутые типы в правой части выражения.

  • Методы без восклицательного знакак принимают незавёрнутые типы в правой части выражения.

  • Реализуйте метод Zero, если вам нужен процесс, который возвращает значение по-умолчанию.

  • Yield в общем и целом является эквивалентом Return, но Yield нужно использовать для семантики последовательности/перебора.

  • For в общем и целом является эквивалентом Bind.

В следующем посте мы узнаем, как вычислительные выражения позволяют комбинировать несколько значений.

Комментарии (0)