Computational Expressions: Overloading

In this post, we'll take a detour and introduce a couple of tricks that will help you add variety to the methods in the expression builder.

Ultimately, our explorations will lead us to dead ends, but I hope that the journey will provide insights into how to properly design computational expressions.

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.

Insight: Builder methods can be overloaded

At some point you may have an epiphany:

  • Builder methods are normal class methods, and unlike standalone functions, methods support overloading with different parameter types. This means that we can do different implementationsif the types of their parameters are different.

Perhaps this idea inspires you and you are enthusiastically thinking about how you can use it.
However, it may not be as useful as you think.
Let's look at some examples.

Overloading “Return”

Let's say you have a union type.
You decide to overload Return or Yield with different implementations for each combination option.

Here is a very simple example where Return has two overloads:

type SuccessOrError =
| Success of int
| Error of string

type SuccessOrErrorBuilder() =

    member this.Bind(m, f) =
        match m with
        | Success s -> f s
        | Error _ -> m

    /// перегрузка для int
    member this.Return(x:int) =
        printfn "Return a success %i" x
        Success x

    /// перегрузка для strings
    member this.Return(x:string) =
        printfn "Return an error %s" x
        Error x

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

Here's how you can use it:

successOrError {
    return 42
    } |> printfn "Результат в случае успеха: %A"
// Результат в случае успеха: Success 42

successOrError {
    return "error for step 1"
    } |> printfn "Результат в случае ошибки: %A"
// Результат в случае ошибки: Error "error for step 1"

What do you think is wrong here?

Well, first of all, if we go back to the discussion of wrapper types, we find that wrapper types are better done generalized.
We want to reuse our code as much as possible, including processes. Why tie the implementation to a specific primitive type?

In our case this means that the union type must be generalized:

type SuccessOrError<'a,'b> =
| Success of 'a
| Error of 'b

But now, because of generic types, the method Return can no longer be overloaded! (In case 'a And 'b coincide, ambiguity will arise – translator's note.)

Secondly, it is not a good idea to expose implementation details of a type inside an expression.
The concept of “success” and “failure” options is useful, but a better way would be to hide the “failure” option and handle it automatically internally Bind:

type SuccessOrError<'a,'b> =
| Success of 'a
| Error of 'b

type SuccessOrErrorBuilder() =

    member this.Bind(m, f) =
        match m with
        | Success s ->
            try
                f s
            with
            | e -> Error e.Message
        | Error _ -> m

    member this.Return(x) =
        Success x

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

Here Return is used only in case of “success”, and “failure” is hidden.

successOrError {
    return 42
    } |> printfn "Результат в случае успеха: %A"

successOrError {
    let! x = Success 1
    return x/0
    } |> printfn "Результат в случае ошибки: %A"

We will see more examples of this technique in the next post.

Many implementations of “Combine”

Another situation where you might be tempted to overload a method is Combine.

For example, let's rewrite the method Combine for the process trace.
If you remember, in the previous implementation we simply added numbers.

But what if we change our requirements, say like this:

First attempt at rewriting Combine will look like this:

member this.Combine (a,b) =
    match a,b with
    | Some a', Some b' ->
        printfn "комбинируем %A с %A" a' b'
        Some [a';b']
    | Some a', None ->
        printfn "комбинируем %A с None" a'
        Some [a']
    | None, Some b' ->
        printfn "комбинируем None с %A" b'
        Some [b']
    | None, None ->
        printfn "комбинируем None с None"
        None

In the method Combine we unwrap the values ​​from the passed optional type and combine them into a wrapper list in Some (those. Some [a';b']).

For two operators yield everything will work as we expected:

trace {
    yield 1
    yield 2
    } |> printfn "Результат yield и последющий yield: %A"

// Результат yield и последющий yield: Some [1; 2]

And upon return Noneeverything will also work as it should:

trace {
    yield 1
    yield! None
    } |> printfn "Результат yield и последующий None: %A"

// Результат yield и последующий None: Some [1]

But what happens if we try to combine three values? For example, like this:

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

Suddenly we get a compilation error:

Error FS0193 : Несоответствие ограничений типов. Тип 
    "int list option"    
несовместим с типом
    "int option"

What's the problem?

The answer is that after combining the 2nd and 3rd values ​​(yield 2; yield 3), we get an optional value containing a list of integers or int list option.
The error occurs when we try to combine the first value (Some 1) with a combined value (Some [2;3]).
That is, we transmit int list option as the second parameter Combinebut the first parameter is still normal int option.
The compiler is telling us that it wants the second parameter to be of the same type as the first.

We can use the overload trick again.
Let's write two implementations Combine with different types of the second parameter – one that takes int option and the second one which accepts int list option.

Here are two methods with different parameter types:

/// комбинируем с опциональным списком
member this.Combine (a, listOption) =
    match a,listOption with
    | Some a', Some list ->
        printfn "комбинируем %A с %A" a' list
        Some ([a'] @ list)
    | Some a', None ->
        printfn "комбинируем %A с None" a'
        Some [a']
    | None, Some list ->
        printfn "комбинируем None с %A" list
        Some list
    | None, None ->
        printfn "комбинируем None с None"
        None

/// комбинируем с опциональным одиночным значением
member this.Combine (a,b) =
    match a,b with
    | Some a', Some b' ->
        printfn "комбинируем %A с %A" a' b'
        Some [a';b']
    | Some a', None ->
        printfn "комбинируем %A с None" a'
        Some [a']
    | None, Some b' ->
        printfn "комбинируем None с %A" b'
        Some [b']
    | None, None ->
        printfn "комбинируем None с None"
        None

Now we can combine the three values ​​and get exactly what we wanted.

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

// Результат yield x 3: Some [1; 2; 3]

Unfortunately, this trick broke some of the previous code!
If you try to return now Noneyou will get a compilation error.

trace {
    yield 1
    yield! None
    } |> printfn "Результа yield с последующим None: %A"

Error:

Невозможно определить уникальную перегрузку метода "Combine" на основе сведений о типе, заданных до данной точки программы. Возможно, требуется аннотация типа.

Before you get frustrated, try thinking like a compiler.
If you were a compiler and you got Nonewhichever method was called You?

There is no correct answer because None can be passed as the second parameter in both method.
The compiler doesn't know if this applies None to type int list option (first method) or to type int option (second method).

As the compiler says, a type annotation would help us, so let's provide one. None be int option.

trace {
    yield 1
    let x:int option = None
    yield! x
    } |> printfn "Результат yield с последующим None: %A"

Of course, it's ugly, but in practice it doesn't happen very often.

More importantly, such ugliness is a sign of bad design.
Sometimes a computation expression returns 'a optionand sometimes – 'a list option.
We must be consistent in our design, so that the computational expression must always have one and the same type, regardless of how many operators it contains yield.

If we really we want to allow as many operators as we want yieldthen we should use as a wrapper type from the very beginning 'a list option.
In this case the method Yield will create list optionand the method Combine will again receive only one implementation.

Here is the third version of our code.

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.Zero() =
        printfn "Zero"
        None

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

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

    member this.Combine (a, b) =
        match a,b with
        | Some a', Some b' ->
            printfn "комбинируем %A с %A" a' b'
            Some (a' @ b')
        | Some a', None ->
            printfn "комбинируем %A с None" a'
            Some a'
        | None, Some b' ->
            printfn "комбинируем None с %A" b'
            Some b'
        | None, None ->
            printfn "комбинируем None с None"
            None

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

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

Now the example works as expected, without any special tricks:

trace {
    yield 1
    yield 2
    } |> printfn "Результат yield с последующим yield: %A"

// Результат yield с последующим yield: Some [1; 2]

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

// Результат yield x 3: Some [1; 2; 3]

trace {
    yield 1
    yield! None
    } |> printfn "Результат yield с последующим None: %A"

// Результат yield с последующим None: Some [1]

This code is not only cleaner, but more versatile because instead of a specific type int option we use a generic type 'a option. We managed to do the same thing earlier with the method Return.

Overloading “For”

A smart option where overloading can be useful is the method For.
Possible reasons:

  • You want to support different types of collections (i.e. list And IEnumerable).

  • You want to implement efficient traversal for certain types of collections.

  • You want to “wrap” a list into another type (say, make LazyList) and you are going to support both versions – wrapped and unwrapped.

Here's an example of a list builder that supports not only lists but also sequences:

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

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

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

    member this.For(m:_ seq,f) =
        printfn "For %A используя seq" m
        let m2 = List.ofSeq m
        this.Bind(m2,f)

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

Here's how to use it:

listbuilder {
    let list = [1..10]
    for i in list do yield i
    } |> printfn "Результат для list: %A"

listbuilder {
    let s = seq {1..10}
    for i in s do yield i
    } |> printfn "Результат для seq : %A"

If you comment out the second method Forthe example with the sequence will start to lead to a compilation error.
So in this case, overloading is necessary.

Conclusion

Well, we've learned that methods can be overloaded if needed, but we need to be careful before jumping into this kind of decision head-on, because the need for such code can be a sign of weak design.

In the next post we will return to the main topic and learn how to fine-tune the expression evaluation process using lazy evaluation. out builder.

Similar Posts

Leave a Reply

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