In the previous post, we introduced the concept of “wrapper types” and how they relate to computational expressions.
In this post we will look at what types can be used as wrappers.
What types can be wrappers?
If every evaluation expression must be associated with a wrapper type, what types can be used as wrappers?
Do they have any special restrictions?
There is one basic rule that says:
-
Any generic type with a parameter can be used as a wrapper type
For example, we saw that we can use Option<T>
, DbResult<T>
and similar types as wrappers.
What about other generic types such as List<T>
or IEnumerable<T>
?
These are collection types, so it seems strange that they can wrap some kind of one meaning.
In fact, they too can be wrappers, and a little later we will figure out how it works.
Are non-generic wrapper types okay?
Is it possible to create a wrapper type that No generalized parameter?
In one of the previous examples, we tried to implement string addition of the form "1" + "2"
.
Is it possible in this case to interpret string
as a wrapper type int
?
It would be great.
Let’s try.
We can use method signatures Bind
And Return
as a starting point.
-
Bind
receives a tuple. The first part of the tuple is the wrapper type (in our casestring
), and the second part of the tuple is a function that takes an unwrapped type and turns it into a wrapped type. In this case, its signature will beint -> string
. -
Return
gets the unwrapped type (in our caseint
) and turns it into a wrapped type. And in this case, the signatureReturn
willint -> string
.
Now, this is what we get:
-
Implementation of a “wrapping” function with a signature
int -> string
turns any number into a string. This is a normal “toString” type methodint
. -
The bind function must expand the value from
string
Vint
and then pass it to the function. For implementation we can useint.Parse
. -
What happens if the bind function can not extract a value from a string because it is not a valid number? In this case, the binding function is still must return wrapper type (
string
), so we can just return something like the string “error”.
Here is the implementation of the corresponding builder class:
type StringIntBuilder() =
member this.Bind(m, f) =
let b,i = System.Int32.TryParse(m)
match b,i with
| false,_ -> "ошибка"
| true,i -> f i
member this.Return(x) =
sprintf "%i" x
let stringint = new StringIntBuilder()
Tests:
let good =
stringint {
let! i = "42"
let! j = "43"
return i+j
}
printfn "хороший результат=%s" good
What happens if one of the lines is not a number?
let bad =
stringint {
let! i = "42"
let! j = "xxx"
return i+j
}
printfn "плохой результат=%s" bad
This is really cool – inside our process we can treat strings like numbers!
But wait, not everything is so rosy.
Let’s imagine that we pass a value to the process, unwrap it (using let!
) and then immediately wrap (using return
), without performing any other actions.
What will happen then?
let g1 = "99"
let g2 = stringint {
let! i = g1
return i
}
printfn "g1=%s g2=%s" g1 g2
No problem.
Input value g1
and output value g2
coincide, as we expected.
But what happens if there is an error?
let b1 = "xxx"
let b2 = stringint {
let! i = b1
return i
}
printfn "b1=%s b2=%s" b1 b2
What we got here was not what we expected.
Input value b1
and output value b2
Not match up.
We have a discrepancy.
Is this a problem in practice?
I don’t know.
But I would try to avoid such situations, and try something else, such as an optional type, which is consistent in all cases.
Rules for a process that uses a wrapper type
Here’s a quick question for you!
Are there any differences between these two pieces of code, and should the code behave differently?
// фрагмент до рефакторинга
myworkflow {
let wrapped = // какое-то завёрнутое значение
let! unwrapped = wrapped
return unwrapped
}
// фрагмет после рефакторинга
myworkflow {
let wrapped = // какое-то завёрнутое значение
return! wrapped
}
The answer is no, they should not behave differently.
The only difference in the second example is that the value unwrapped
was thrown away as a result of refactoring, and the value wrapped
– returned directly.
But, as we just saw in the previous section, you can end up with inconsistency if you’re not careful.
So any implementation you create should follow a few standard rules, namely:
Rule 1: If you start with an unwrapped value, then wrap it (using return
) and expand again (using bind
), you should get the original unwrapped value.
Both this rule and the next one are about the fact that you cannot lose information when wrapping and expanding values.
Obviously, this is a reasonable rule that must be followed for the highlight code refactoring to work correctly.
The first rule in code form:
myworkflow {
let originalUnwrapped = something
// заворачиваем
let wrapped = myworkflow { return originalUnwrapped }
// разворачиваем
let! newUnwrapped = wrapped
// убеждаемся, что значения совпадают
assertEqual newUnwrapped originalUnwrapped
}
Rule 2: If you start with a wrapped value, then unwrap it (using bind
) and wrap again (using return
), you should get the original wrapped value.
Process stringInt
described earlier, violates exactly this rule.
The second rule in code form:
myworkflow {
let originalWrapped = something
let newWrapped = myworkflow {
// разворачиваем
let! unwrapped = originalWrapped
// заворачиваем
return unwrapped
}
// убеждаемся, что значения совпадают
assertEqual newWrapped originalWrapped
}
Rule 3: A child process must return the same result as if it were “embedded” in the main process.
This rule is required for composition to behave as expected and for code highlight refactoring to continue to work.
If you follow some guidelines (which I’ll cover in the next post), your code will follow all the rules automatically.
And here is an example with a built-in process:
// встроенный
let result1 = myworkflow {
let! x = originalWrapped
let! y = f x // какая-то функция с x
return! g y // какая-то функция с y
}
// используя дочерний процесс ("выделение" рефакторинг)
let result2 = myworkflow {
let! y = myworkflow {
let! x = originalWrapped
return! f x // какая-то функция с x
}
return! g y // какая-то функция с y
}
// убеждаемся, что значения совпадают
assertEqual result1 result2
List as a wrapper type
I said earlier that types like List<T>
or IEnumerable<T>
can be used as wrappers.
But a list can store several values, so how can we “expand” it?
It turns out that there should not be a one-to-one correspondence between wrapped and unwrapped types.
In this case, the “wrapper” analogy is a little misleading.
Let’s return to the method bind
connecting the output of one expression to the input of another.
As we have seen, the function bind
“unfolds” the type and applies a continuation function to the unwrapped value.
But nothing in the definition says that there should only be one expanded meaning.
There’s no reason why we can’t apply a continuation function to each element of the list in turn.
We just have to write bind
so that it takes a list and a continuation function, and the continuation function processes one element at a time:
bind( [1;2;3], fun elem -> // выражение с одним элементом )
Following this concept, we can merge calls bind
in a chain:
let add =
bind( [1;2;3], fun elem1 ->
bind( [10;11;12], fun elem2 ->
elem1 + elem2
))
However, we missed something.
Continuation function passed to bind
must have a certain signature.
It accepts an expanded type, but returns wrapped type.
In other words, the continuation function as a result should always return a new list.
bind( [1;2;3], fun elem -> // выражение с одним элементом, возвращающее список )
Therefore, the call chain example needs to be rewritten so that the result elem1 + elem2
was placed on the list.
let add =
bind( [1;2;3], fun elem1 ->
bind( [10;11;12], fun elem2 ->
[elem1 + elem2] // список!
))
So the logic for our method is bind
now looks like this:
let bind(list,f) =
// 1) к каждому элементу списка применить f
// 2) f вернёт список (как того требует сигнатура)
// 3) результатом будет список списков
Now we have another task. Bind
must return a wrapper type, which means that a “list of lists” is not suitable for us as a result.
We need to turn it back into a flat “single-level” list.
It’s quite simple – in the module List
there is a function that does exactly this, it’s called concat
.
Putting everything together, we get:
let bind(list,f) =
list
|> List.map f
|> List.concat
let added =
bind( [1;2;3], fun elem1 ->
bind( [10;11;12], fun elem2 ->
// elem1 + elem2 // неправильно
[elem1 + elem2] // правильно: возвращаем список
))
Now that we understand how it works bind
we can create a “list process”.
-
Bind
applies a continuation function to each element of the passed list and then turns the resulting list of lists into a flat, single-level list. -
List.collect
– a library function that can be used instead of a linkList.map
AndList.concat
. -
Return
turns an expanded value into a wrapped value. In our case, it simply places a single element into the list.
type ListWorkflowBuilder() =
member this.Bind(list, f) =
list |> List.collect f
member this.Return(x) =
[x]
let listWorkflow = new ListWorkflowBuilder()
Here’s the process at work:
let added =
listWorkflow {
let! i = [1;2;3]
let! j = [10;11;12]
return i+j
}
printfn "суммы=%A" added
let multiplied =
listWorkflow {
let! i = [1;2;3]
let! j = [10;11;12]
return i*j
}
printfn "произведения=%A" multiplied
The results show that every element from the first collection is combined with every element from the second collection:
val added : int list = [11; 12; 13; 12; 13; 14; 13; 14; 15]
val multiplied : int list = [10; 11; 12; 20; 22; 24; 30; 33; 36]
This is really quite surprising.
We completely hid the logic of iterating over list elements, leaving only the process itself.
Syntactic sugar for “for”
By treating lists and sequences as a special case, we can add a little syntactic sugar to replace let!
something more natural.
For example, we can replace let!
to the expression for..in..do
:
// версия с let!
let! i = [1;2;3] in [some expression]
// версия с for..in..do
for i in [1;2;3] do [some expression]
Both options mean exactly the same thing, they just look different.
To allow the F# compiler to handle this, we must add a method For
to our builder class.
In general it does the same thing as Bind
but is traditionally used with types that store sequences.
type ListWorkflowBuilder() =
member this.Bind(list, f) =
list |> List.collect f
member this.Return(x) =
[x]
member this.For(list, f) =
this.Bind(list, f)
let listWorkflow = new ListWorkflowBuilder()
Here is an example of use:
let multiplied =
listWorkflow {
for i in [1;2;3] do
for j in [10;11;12] do
return i*j
}
printfn "произведения=%A" multiplied
LINQ and the “list-type process”
True, design for element in collection do
looks familiar?
The syntax is very similar to from element in collection...
which is used in LINQ.
LINQ is indeed based on the same technique of “behind the scenes” conversion from syntax like from element in collection ...
into real method calls.
In F#, as we have seen, bind
calls a function List.collect
.
Equivalent List.collect
in LINQ is an extension method SelectMany
.
Once you understand how it works SelectMany
you can implement this type of query yourself.
Jon Skeet wrote useful post on your blogwhich explains how to do this.
Identical “wrapper type”
So far, we have looked at several wrapper types, and we can say that any computational expression must have an associated wrapper type.
But what about logging from the previous post? It didn’t have any wrapper type.
There was let!
which somewhere inside did different things, but the input type was the same as the output type.
In other words, the wrapped type was identical to the unwrapped type.
The short answer to this question is that each type can be treated as its own “wrapper”.
But there is another, deeper explanation.
Let’s go back and understand what a wrapper type definition like List<T>
.
Actually, List<T>
is not a “real” type at all.
List<int>
– real type and List<string>
– real type.
But on my own List<T>
– incomplete.
It has a parameter and we must provide it for it to become a real type.
You can think about List<T>
not as a type, but as a function.
This is a function not from the concrete world of ordinary values, but from the abstract world of types, but like any other function it maps some values to others.
However, its input values are types (int
And string
), and the output values are also types (List<int>
And List<string>
).
Like any function, it has a parameter, and this is precisely the “type parameter”.
By the way, this is why what we call “generalized types” are called in scientific circles parametric polymorphism.
If you have grasped the concept of functions that generate one type from another (they are called “type constructors”), you understand that a “wrapper type” is just such a constructor.
But if a “wrapper type” is just a function that maps one type to another, then surely a function that maps a type to itself (traditionally it is called identity), also falls into this category?
In real code, we can define an “identical process” as the simplest possible implementation of the builder.
type IdentityBuilder() =
member this.Bind(m, f) = f m
member this.Return(x) = x
member this.ReturnFrom(x) = x
let identity = new IdentityBuilder()
let result = identity {
let! x = 1
let! y = 2
return x + y
}
Now, having understood everything, we can consider the example with logging to be a regular identical process with additional logging logic.
Results
Another long post.
We’ve covered a lot of topics, so I hope you now understand what wrapper types are.
Once we get to common processes, such as “writer process” or “stateful process”, we’ll look at how to use wrapper types in practice.
We will talk about this in one of the following posts.
A summary of the main topics we covered:
-
The main use of computation expressions is to expand and wrap values that are stored in a wrapper type.
-
Computational expressions are easy to compose, since the function output
Return
can be used as a function inputBind
. -
Each computational expression must be associated with a computation type.
-
Any type with a generic parameter, even a list, can be used as a wrapper type.
-
When creating a process, you must ensure that your implementation follows the three common sense rules of wrapping, unfolding, and composition.
Acknowledgement and Usage Notice
The editorial team at TechBurst Magazine acknowledges the invaluable contribution of Марк Шевченко the author of the original article that forms the foundation of our publication. We sincerely appreciate the author’s work. All images in this publication are sourced directly from the original article, where a reference to the author’s profile is provided as well. This publication respects the author’s rights and enhances the visibility of their original work. If there are any concerns or the author wishes to discuss this matter further, we welcome an open dialogue to address potential issues and find an amicable resolution. Feel free to contact us through the ‘Contact Us’ section; the link is available in the website footer.