Как стать автором
Обновить

Вычислительные выражения: Оставшиеся стандартные методы

Уровень сложностиСложный
Время на прочтение8 мин
Количество просмотров488
Автор оригинала: Scott Wlaschin

Мы выходим на финишную прямую.
Осталось несколько методов класса-построителя, с которыми надо разобраться, и вы готовы к самостоятельному плаванию!

Вот эти методы:

  • While для повторения.

  • TryWith и TryFinally для обработки исключений.

  • Use для управления освобождаемыми ресурсами.

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

Одно важное замечание, прежде чем мы начнём: все обсуждаемые здесь методы, основаны на использовании отложенных функций.
Если вы не используете отложенные функции, ни один из этих методов не даст ожидаемых результатов.

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

Реализуем While

Все мы знаем, что означает `while`` в обычном коде, но что он означает в контексте вычислительных выражений?
Чтобы разобраться, нам потребуется вернуться к концепции продолжений.

В предыдущих постах мы узнали, что последовательность выражений можно превратить в цепочку продолжений, например, так:

Bind(1,fun x ->
   Bind(2,fun y ->
     Bind(x + y,fun z ->
        Return(z)  // или Yield

Это ключ к пониманию цикла while — его можно развернуть похожим образом.

Для начала немного терминологии.
Цикл while состоит из двух частей:

  • В начале цикла while есть проверка, которая вычисляется перед каждой итерацией, чтобы определить, надо ли выполнять тело цикла. Если результат вычисления равен false, «выходим» из цикла. В вычислительных выражениях проверка известна как охранное выражение. У проверяющей функции нет параметров и она возвращает булево значение, так что её сигнатура — unit -> bool.

  • Также у цикла while есть тело, которое выполняется, пока проверка успешно проходит. В вычислительных выражениях это отложенная функция, которая вычисляет завёрнутое значение. Поскольку тело цикла while всегда одно и то же, каждый раз вызывается одна и та же функция. Функция, реализующая тело, не имеет параметров и ничего не возвращает, поэтому её сигнатура unit -> wrapped unit. (Она не должна ничего возвращать и в то же время должна возвращать завёрнутое значение, поэтому её результат — завёрнутое ничего — прим. переводчика).

На этом этапе, мы уже можем реализовать цикл while, опираясь на функции-продолжения. Пока вместо настоящего F# используем псевдо-код:

// вызываем тестовую функцию
let bool = guard()
if not bool
then
    // выходим из цикла
    return what??
else
    // выполняем тело цикла
    body()

    // возвращаемся к началу цикла

    // вызываем тестовую функцию снова
    let bool' = guard()
    if not bool'
    then
        // выходим из цикла
        return what??
    else
        // выполняем тело цикла снова
        body()

        // возвращаемся к началу цикла

        // вызываем тестовую функцию в третий раз
        let bool'' = guard()
        if not bool''
        then
            // выходим из цикла
            return what??
        else
            // выполняем тело цикла в третий раз
            body()

            // и т.д.

Сразу возникает вопрос: что нужно вернуть, если проверка в цикле не сработала?
Что ж, мы встречали подобное, когда обсуждали if..then.. и ответ, естественно — использовать значение Zero.

Затем мы должны избавиться от результата body().
Да, это функция с типом возврата unit, так что возвращать ничего не нужно, но и в этом случае мы хотим каким-то образом встроить в неё собственный код, потому что нам нужны побочные эффекты.
И, конечно, её надо вызывать с помощью Bind.

Вот версия псевдо-кода с методами Zero и Bind:

// вызываем тестовую функцию
let bool = guard()
if not bool
then
    // выходим из цикла
    return Zero
else
    // выполняем тело цикла
    Bind( body(), fun () ->

        // вызываем тестовую функцию снова
        let bool' = guard()
        if not bool'
        then
            // выходим из цикла
            return Zero
        else
            // выполняем тело цикла снова
            Bind( body(), fun () ->

                // вызываем тестовую функцию в третий раз
                let bool'' = guard()
                if not bool''
                then
                    // выходим из цикла
                    return Zero
                else
                    // выполняем тело цикла в третий раз
                    Bind( body(), fun () ->

                    // и т.д.

В нашем случае, функция-продолжение, передаваемая в Bind, имеет параметр типа unit, поскольку функция body не возвращает значения.

В конечном итоге, мы можем упростить псевдо-код, путём сворачивания в рекурсивную функцию, как здесь:

member this.While(guard, body) =
    // вызываем тестовую функцию
    if not (guard())
    then
        // выходим из цикла
        this.Zero()
    else
        // выполняем тело цикла
        this.Bind( body(), fun () ->
            // вызываем рекурсивно
            this.While(guard, body))

В действительности, это стандартная «шаблонная» реализация While почти для всех классов-построителей.

Тонкий, но важный момент заключается в том, что мы должны правильно выбрать значение Zero.
В предыдущих постах мы видели, что можем использовать для Zero и значение None и значение Some (), в зависимости от процесса.
Однако, чтобы While работал корректно, мы должны в качестве Zero использовать Some (), а не None, потому что передача None в Bind приведёт к преждевременному завершению цикла.

Также обратите внимание, что мы не используем ключевое слово rec, не смотря на то, что имеем дело с рекурсивной функцией.
Оно требуется только для рекурсивных функций F#, а не для методов классов.

While: инструкция по применению

Давайте посмотрим, как цикл работает в построителе build.
Вот класс-построитель целиком, с методом While:

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) =
        Some x

    member this.ReturnFrom(x) =
        x

    member this.Zero() =
        printfn "Zero"
        this.Return ()

    member this.Delay(f) =
        printfn "Delay"
        f

    member this.Run(f) =
        f()

    member this.While(guard, body) =
        printfn "While: проверка"
        if not (guard())
        then
            printfn "While: Zero"
            this.Zero()
        else
            printfn "While: цикл"
            this.Bind( body(), fun () ->
                this.While(guard, body))

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

Взглянув на сигнатуру While, мы видимо, что параметр body имеет тип unit -> unit option, то есть это отложенная функция.
Как я писал выше, если вы должным образом не реализуете Delay, то получите неопределённое поведение и загадочные ошибки компилятора.

type TraceBuilder =
    // прочие методы
    member
      While : guard:(unit -> bool) * body:(unit -> unit option) -> unit option

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

let mutable i = 1
let test() = i < 5
let inc() = i <- i + 1

let m = trace {
    while test() do
        printfn "i = %i" i
        inc()
    }

Обработка исключений с помощью try..with

Обработка исключений реализуется похожим образом.

Исследуя выражение try..with, мы видим, что оно состоит из двух частей:

  • У него есть тело try, которое выполняется один раз. В вычислительных выражениях оно превратится в отложенную функцию, которая возвращает завёрнутое значение. У функции нет параметров, так что её сигнатура — это unit -> wrapped type.

  • Часть with обрабатываем исключения. В качестве параметра она принимает исключение и возвращает тот же тип, что и часть try, так что её сигнатура — это exception -> wrapped type.

Мы можем создать псевдо-код для обработчика исключений, с учётом этих данных:

try
    let wrapped = delayedBody()
    wrapped  // возвращаем завёрнутое значение
with
| e -> handlerPart e

И это в точности соответствует стандартной реализации:

member this.TryWith(body, handler) =
    try
        printfn "TryWith Тело"
        this.ReturnFrom(body())
    with
        e ->
            printfn "TryWith Обработка исключения"
            handler e

Как видите, общей практикой для возврата завёрнутого значения является вызов ReturnFrom, так что оно будет обработано также, как и другие завёрнутые значения.

Вот фрагмент примера, чтобы разобраться, как действует обработчик:

trace {
    try
        failwith "бах!"
    with
    | e -> printfn "Исключение! %s" e.Message
    } |> printfn "Результат %A"

Реализуем try..finally

Конструкция try..finally очень похожа на try..with.

  • У него есть тело try, которое выполняется однократно. Тело не имеет параметров и его сигнатура — это unit -> wrapped type.

  • Часть finally вызывается всегда. У неё нет параметров и она возвращает unit, так что её сигнатура — это unit -> unit.

Как и в случае с try..with, стандартная реализация очевидна.

member this.TryFinally(body, compensation) =
    try
        printfn "TryFinally Цикл"
        this.ReturnFrom(body())
    finally
        printfn "TryFinally восстановление"
        compensation()

Ещё один фрагментик:

trace {
    try
        failwith "бах!"
    finally
        printfn "ок"
    } |> printfn "Результат %A"

Реализуем using

Последний метод для реализации — это Using.
Это метод построителя для реализации ключевого слова use!.

Вот что документация MSDN говорит об use!:

{| use! value = expr in cexpr |}

транслируется в:

builder.Bind(expr, (fun value -> builder.Using(value, (fun value -> {| cexpr |} ))))

Иными словами, ключевое слово use! запускает как Bind, так и Using.
Сначала Bind распаковывает завёрнутое значение, и затем незавёрнутый освобождаемый объект передаётся в Using, для последующего освобождения, вместе с функцией-продолжением в качестве второго параметра.

Это реализуется довольно просто.
Как и в других методах, у нас есть тело, или часть-продолжение выражения Using, которое выполняется один раз.
У этой функции есть параметр disposable, так что её сигнатура — это #IDisposable -> wrapped type.

Конечно мы хотим быть уверены, что освобождаемое значение освобождается в любом случае, так что нам надо завернуть вызов функции-тела в TryFinally.

Вот стандартная реализация:

member this.Using(disposable:#System.IDisposable, body) =
    let body' = fun () -> body disposable
    this.TryFinally(body', fun () ->
        match disposable with
            | null -> ()
            | disp -> disp.Dispose())

Замечания:

  • Параметр для TryFinally — это unit -> wrapped, с unit в качестве первого параметра, так что мы создаём отложенную функцию body', и передаём именно её.

  • Освобождаемое значение — это класс, так что он может быть null, и мы должны отдельно обрабатывать этот случай. В противном случае мы просто освобождаем его в продолжении finally.

Вот демонстрация Using в действии.
Обратите внимание, что makeResource создаёт завёрнутый освобождаемый объект.
Если он не заворачивается, нам не нужна специальная версия use! и мы можем использовать нормальный оператор use.

let makeResource name =
    Some {
    new System.IDisposable with
    member this.Dispose() = printfn "Освобождаем %s" name
    }

trace {
    use! x = makeResource "привет"
    printfn "Освобождаем в use!"
    return 1
    } |> printfn "Результат: %A"

Пересмотрим работу For

Напоследок вернёмся к реализации оператора For.
В предыдущих примерах For принимал простой параметр-список.
Но, имея в запасе Using и While, мы можем переписать его так, чтобы он принимал любую реализацию IEnumerable<_> или seq.

Вот стандартная реализация для For:

member this.For(sequence:seq<_>, body) =
       this.Using(sequence.GetEnumerator(),fun enum ->
            this.While(enum.MoveNext,
                this.Delay(fun () -> body enum.Current)))

Как видите, этот код отличается от предыдущих реализаций обработкой обобщённого параметра IEnumerable<_>.

  • Мы явно перебираем элементы коллекции, используя свойства и методы интерфейсаIEnumerator<_>.

  • IEnumerator<_> реализует IDisposable, так что мы заворачиваем итератор в Using,

  • Мы используем While .. MoveNext для итерации.

  • Далее, мы передаём enum.Current в функцию-тело.

  • Наконец, мы откладываем вызов функции-тела, используя Delay.

Полный код без трассировки

До сих пор наш код был сложнее, чем надо, из-за операторов трассировки и печати.
Трассировка полезна для понимания происходящего, но она убивает простоту методов.

Так что в качестве финального шага бросим взгляд на полный код класса-построителя для trace, но на этот раз без всякого постороннего кода.
Несмотря на то, что код достаточно сложный, назначение и реализация каждого метода должны быть вам понятны.

type TraceBuilder() =

    member this.Bind(m, f) =
        Option.bind f m

    member this.Return(x) = Some x

    member this.ReturnFrom(x) = x

    member this.Yield(x) = Some x

    member this.YieldFrom(x) = x

    member this.Zero() = this.Return ()

    member this.Delay(f) = f

    member this.Run(f) = f()

    member this.While(guard, body) =
        if not (guard())
        then this.Zero()
        else this.Bind( body(), fun () ->
            this.While(guard, body))

    member this.TryWith(body, handler) =
        try this.ReturnFrom(body())
        with e -> handler e

    member this.TryFinally(body, compensation) =
        try this.ReturnFrom(body())
        finally compensation()

    member this.Using(disposable:#System.IDisposable, body) =
        let body' = fun () -> body disposable
        this.TryFinally(body', fun () ->
            match disposable with
                | null -> ()
                | disp -> disp.Dispose())

    member this.For(sequence:seq<_>, body) =
        this.Using(sequence.GetEnumerator(),fun enum ->
            this.While(enum.MoveNext,
                this.Delay(fun () -> body enum.Current)))

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

Теги:
Хабы:
Всего голосов 1: ↑1 и ↓0+3
Комментарии0

Публикации

Истории

Работа

.NET разработчик
49 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань