We implement Zero and Yield

In the previous articles of the series, we dealt with binding, continuation functions and wrapper types. Now we are finally ready to get acquainted with the methods of builder classes.

Note that “builder” in the context of computational expressions is not the same as the object-oriented “builder” pattern, which is used to construct and validate objects.

If you look into MSDN documentationthen you will see there not only those familiar to us Bind And Returnbut also other methods with strange names, like Delay And Zero. In this and subsequent articles we will find out why They needed.

Action plan

To demonstrate how to create a builder class, we will describe a custom process that uses all of the possible builder methods.

But instead of explaining what these methods mean without any context, we'll start with a simple process, and add new methods only when we need to deal with some problem or bug. In other words, let's study the builder's methods, moving from bottom to top, rather than from top to bottom. As you read the article, you will understand how F# handles computational expressions In fact.

General process diagram:

  • Part 1: Here we will learn what methods are needed for the basic process. Let's get to know Zero, Yield, Combine And For.

  • Part 2: Let's learn how to delay calculations so that they are performed only when needed. Let's introduce the methods Delay And Runand consider lazy evaluation.

  • Part 3: Let's look at the remaining methods While And Usingand exception handling.

Before you start

Before we dive into creating the process, there are a few important points to discuss.

Computational Expressions Documentation

First, as you may have noticed, the MSDN documentation on computational expressions is not very detailed. In addition, it can be misleading, although there are no obvious errors in it. Let's say the builder method signatures more flexiblethan it might seem at first glance. This flexibility can be used to create strong, non-trivial solutions, but you won’t know this from the documentation. A little later we will look at one such example.

If you want to delve deeper into the topic, here are two resources I can recommend. Excellent material that examines in detail the concepts underlying computational expressions – article “The Zoo of F# Expressions” by Thomas Petrick and Don Syme.
In addition, the most accurate and up-to-date technical documentation is F# language specificationwhere there is a section about computational expressions.

Wrapped and unwrapped types

When looking at method signatures in the documentation, remember that by “unwrapped” types I mean something similar to 'Tand “wrapped” is something similar to M<'T>. For example, the method Return the signature looks like 'T -> M<'T>and this means that Return gets the unwrapped type and returns the wrapped one.

As before, I use the words “unwrapped” and “wrapped” to describe the relationships between types. As you get more familiar with the material, we'll move to common terminology, so at some point I'll start writing “computation type” instead of “wrapped type.” I hope that at this stage you will already understand what this means.

I'll try to keep the examples simple by using commentary code:

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

There is an oversimplification here. There is to be absolutely precise, x can be not just a value, but any sample. Also, a “wrapped type value” can of course be an expression. MSDN takes a precise approach, particularly in the documentation when defining let! pattern = expr in cexpr you will find both “pattern” and “expression”.

Here are examples of using patterns and expressions in a computational expression maybeWhere Option is a wrapper type. On the right side there are values ​​wrapped in this type:

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

However, I will continue to use simplified code so as not to increase the complexity of a topic that is complex in itself!

Implementation of special methods in the builder class (or not)

The MSDN documentation states that every special operation (say for..in or yield) is translated into a method call from the builder class.

In fact, it's not always a one-to-one correspondence, but in general, to support the special operation syntax, you must implement the appropriate method in the builder class, otherwise the compiler will give you an error.

On the other hand, you don't not obliged implement a method if you don't need the corresponding syntax. Let's say we described the process maybeby defining just two methods – Bind And Return. Doesn't have to be implemented Delay, Use and other methods if we don't want to use them.

What happens if we don't implement a method? Let's explore this issue using the example of syntax for..in..do in our process maybe:

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

We get a compilation error:

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

Until you understand what's going on behind the scenes of computational expressions, some of the errors may seem strange to you. For example, if you forgot to insert return into your process, like here:

maybe { 1 }

you will get an error:

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

You may ask: where did the method come from? Zero? Why is it needed in this expression? We will soon find out the answer to this question.

Operations with '!' and without '!'

It is striking that many special operations have two versions: with and without an exclamation mark. Examples: let And let! (pronounced let-bang) return And return!, yield And yield!and so on.

The difference is that the operations without “!” always have on the right side unwrapped type, while operations With “!” — wrapped.

Let's compare different syntaxes in the process maybeWhere Option is a wrapped type:

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 это "завёрнутый" тип

Versions with “!” are especially important for composition, since a wrapper type can be the result another computational expression of the same type.

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 ...
    }

Let's dive deeper – create a minimal implementation of the process

Well, let's begin! Let's create a minimal version of the “maybe” process. Let's rename it “trace” because each method will print a debug message so we can figure out what's happening during the calculation.

Code of the first version of the process 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()

While there is nothing new in this code, we have looked at these methods before.

Now let's see how this process works.

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"

Everything should work as we expect. In particular, the use None in the fourth example causes the next two lines (let y = ... return x+y) are skipped and the result of the entire expression becomes None.

Introduction to “do!”

As long as our expression supports let!. And what's about do!?

In normal F# do – this is an analogue letexcept that the expression does not return anything useful (literally, it returns a value like unit).

Meaning do! in computational terms it is very similar. Operator let! passes the wrapped value to the method Binddoes the same thing do!. Obviously, in the case of do! to method Bind a “wrapped” version is transmitted unit.

Simple Process Based Demonstration trace:

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

Conclusion:

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

You can verify for yourself that unit option transmitted to Bindas a result of each do!.

Introduction to “Zero”

What is the smallest computational expression that can, in principle, work? Empty?

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

We instantly get the error:

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

Fair enough. If you think about it, an empty computational expression makes no sense. After all, its purpose is to combine a chain of expressions.

What about a simple expression without let! or return?

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

Now we have another error:

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

Why method Zero suddenly needed, although it was not needed before? In this particular case, the answer is that we returned nothing, even though the computation expression is simply obliged return some wrapped value.

In fact, this error will appear whenever a computational expression does not have some operator that returns a value. A similar thing happens if you have the expression if..then without clause else.

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

In normal F# code if..then without else must return unit, but in a computational expression the specific return value must be wrapped. It seems that it is possible to return the wrapped value unitbut in reality this option is not always suitable.

To get rid of the error, you need to tell the compiler which specific value to use as the default. This is the purpose of the method Zero.

What value should I use for Zero?

So what's the significance necessary use for Zero? The answer depends on what kind of process you are creating.

Here are some recommendations:

  • Are there concepts of “success” and “failure” in the process? If yes, use for Zero meaning “error”. For example, in the process tracewe use Noneto report failure, so you can use None as a value for Zero.

  • Is there a “step-by-step” concept in the process? Are you talking about doing the calculations step by step? In normal F# code, an expression that returns nothing has a value unit. The result of the same computational expression should be wrapped meaning unit. In particular, in an optional process, one can return Some () when calling Zero. By the way, this would be the same as calling Return ().

  • Is the process primarily associated with some data structure? If yes, Zero should return an “empty” instance of this structure. For example, when implementing “list builder” as the value Zero you can use an empty list.

Zero also plays an important role when combining wrapped types. Stay in touch: let's discuss this feature Zero in the next post.

Implementation Zero

Let's add a method Zero to our test class. He will return 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"

This test code shows that behind the scenes of a computation expression the method is called Zero. As a result in both cases we get None. Note: None can be printed as <null>. Never mind.

Do you always need Zero?

Keep in mind that you not obliged add method Zero, if it does not make sense in the context of the process. Let's say seq there is no such meaning, but async There is:

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

Introduction to Yield

C# has a “yield” operator that is used to return values ​​early from an iterator. After returning control back to the iterator, it resumes work from where it was interrupted.

According to the documentation, “yield” is also present in F# computational expressions. What is he doing? Let's do a couple of experiments and find out.

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

We get an error:

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

So far, no surprises. So what should an implementation of the “yield” method look like? The MSDN documentation says it has the signature 'T -> M<'T>which exactly matches the method signature Return. It receives the unwrapped value and returns the wrapped one.

Let's make the same implementation as the method Returnand repeat the experiment.

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

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

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

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

Now everything works and looks like a complete replacement return.

There is also a method YieldFrom Looks like ReturnFrom. It behaves exactly the same, allowing you to return a wrapped value instead of an unwrapped one.

Let's add it to our builder class:

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

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

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

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

Now you might be wondering: if return And yield do the same thing, why are there different keywords for them? The answer is that you can provide the syntax you need by implementing either the first method or the second. For example, the expression seq allows yieldBut does not allow returnwhile async allows returnbut doesn't allow yield:

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

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

In fact, you can make these methods different. For example, call return could stop calculations while calling yield I would continue them further.

Of course, in general yield are used for sequence/brute force semantics, while return usually written once at the end of the expression. (In the next post we will see an example with multiple operators yield).

Returning to “For”

In the previous post we talked about syntax for..in..do. Let's go back to the “list builder” we discussed back then and add new methods to it. With how to determine Bind And Return for the list, we are already familiar.

  • Method Zero just returns an empty list.

  • Method Yield can be implemented in the same way as Return.

  • Method For can be implemented in the same way as 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()

Here is the code with the operator let!:

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

And here is the equivalent with the operator for:

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

Both approaches give the same result.

Conclusion

In this post, we saw how to implement the basic methods of a simple computational expression.

Some points to reinforce:

  • For simple expressions, it is not necessary to implement all methods.

  • Methods with an exclamation point accept wrapped types on the right side of the expression.

  • Methods without an exclamation point accept unwrapped types on the right side of the expression.

  • Implement the method Zeroif you need a process that returns a default value.

  • Yield is generally equivalent ReturnBut Yield should be used for sequence/brute force semantics.

  • For is generally equivalent Bind.

In the next post, we'll learn how computational expressions allow you to combine multiple values.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *