It's pretty simple: traverseChoiceAsync
isn't using foldBack
. Yes, with foldBack
the last item would be processed first, so that by the time you get to the first item and discover that its result is Error
you'd have triggered the side effects of every item. Which is, I think, precisely why whoever wrote traverseChoiceAsync
in FSharpx chose not to use foldBack
, because they wanted to ensure that side effects would be triggered in order, and stop at the first Error
(or, in the case of the Choice
version of the function, the first Choice2Of2
— but I'll pretend from this point on that that function was written to use the Result
type.)
Let's look at the traverseChoieAsync
function in the code you linked to, and read through it step-by-step. I'll also rewrite it to use Result
instead of Choice
, because the two types are basically identical in function but with different names in the DU, and it'll be a little easier to tell what's going on if the DU cases are called Ok
and Error
instead of Choice1Of2
and Choice2Of2
. Here's the original code:
let rec traverseChoiceAsync (f:'a -> Async<Choice<'b, 'e>>) (s:AsyncSeq<'a>) : Async<Choice<AsyncSeq<'b>, 'e>> = async {
let! s = s
match s with
| Nil -> return Choice1Of2 (Nil |> async.Return)
| Cons(a,tl) ->
let! b = f a
match b with
| Choice1Of2 b ->
return! traverseChoiceAsync f tl |> Async.map (Choice.mapl (fun tl -> Cons(b, tl) |> async.Return))
| Choice2Of2 e ->
return Choice2Of2 e }
And here's the original code rewritten to use Result
. Note that it's a simple rename, and none of the logic needs to be changed:
let rec traverseResultAsync (f:'a -> Async<Result<'b, 'e>>) (s:AsyncSeq<'a>) : Async<Result<AsyncSeq<'b>, 'e>> = async {
let! s = s
match s with
| Nil -> return Ok (Nil |> async.Return)
| Cons(a,tl) ->
let! b = f a
match b with
| Ok b ->
return! traverseChoiceAsync f tl |> Async.map (Result.map (fun tl -> Cons(b, tl) |> async.Return))
| Error e ->
return Error e }
Now let's step through it. The whole function is wrapped inside an async { }
block, so let!
inside this function means "unwrap" in an async context (essentially, "await").
let! s = s
This takes the s
parameter (of type AsyncSeq<'a>
) and unwraps it, binding the result to a local name s
that henceforth will shadow the original parameter. When you await the result of an AsyncSeq
, what you get is the first element only, while the rest is still wrapped in an async that needs to be further awaited. You can see this by looking at the result of the match
expression, or by looking at the definition of the AsyncSeq
type:
type AsyncSeq<'T> = Async<AsyncSeqInner<'T>>
and AsyncSeqInner<'T> =
| Nil
| Cons of 'T * AsyncSeq<'T>
So when you do let! x = s
when s
is of type AsyncSeq<'T>
, the value of x
will either be Nil
(when the sequence has run to its end) or it will be Cons(head, tail)
where head
is of type 'T
and tail
is of type AsyncSeq<'T>
.
So after this let! s = s
line, our local name s
now refers to an AsyncSeqInner
type, which contains the head item of the sequence (or Nil
if the sequence was empty), and the rest of the sequence is still wrapped in an AsyncSeq
so it has yet to be evaluated (and, crucially, its side effects have not yet happened).
match s with
| Nil -> return Ok (Nil |> async.Return)
There's a lot happening in this line, so it'll take a bit of unpacking, but the gist is that if the input sequence s
had Nil
as its head, i.e. had reached its end, then that's not an error, and we return an empty sequence.
Now to unpack. The outer return
is in an async
keyword, so it takes the Result
(whose value is Ok something
) and turns it into an Async<Result<something>>
. Remembering that the return type of the function is declared as Async<Result<AsyncSeq>>
, the inner something
is clearly an AsyncSeq
type. So what's going on with that Nil |> async.Return
? Well, async
isn't an F# keyword, it's the name of an instance of AsyncBuilder
. Inside a computation expression foo { ... }
, return x
is translated into foo.Return(x)
. So calling async.Return x
is just the same as writing async { return x }
, except that it avoids nesting a computation expression inside another computation expression, which would be a little nasty to try and parse mentally (and I'm not 100% sure the F# compiler allows it syntactically). So Nil |> async.Return
is async.Return Nil
which means it produces a value of Async<x>
where x
is the type of the value Nil
. And as we just saw, this Nil
is a value of type AsyncSeqInner
, so Nil |> async.Return
produces an Async<AsyncSeqInner>
. And another name for Async<AsyncSeqInner>
is AsyncSeq
. So this whole expression produces an Async<Result<AsyncSeq>>
that has the meaning of "We're done here, there are no more items in the sequence, and there was no error".
Phew. Now for the next line:
| Cons(a,tl) ->
Simple: if the next item in the AsyncSeq
named s
was a Cons
, we deconstruct it so that the actual item is now called a
, and the tail (another AsyncSeq
) is called tl
.
let! b = f a
This calls f
on the value we just got out of s
, and then unwraps the Async
part of f
's return value, so that b
is now a Result<'b, 'e>
.
match b with
| Ok b ->
More shadowed names. Inside this branch of the match
, b
now names a value of type 'b
rather than a Result<'b, 'e>
.
return! traverseResultAsync f tl |> Async.map (Result.map (fun tl -> Cons(b, tl) |> async.Return))
Hoo boy. That's too much to tackle at once. Let's write this as if the |>
operators were lined up on separate lines, and then we'll go through each step one at a time. (Note that I've wrapped an extra pair of parentheses around this, just to clarify that it's the final result of this whole expression that will be passed to the return!
keyword).
return! (
traverseResultAsync f tl
|> Async.map (
Result.map (
fun tl -> Cons(b, tl) |> async.Return)))
I'm going to tackle this expression from the inside out. The inner line is:
fun tl -> Cons(b, tl) |> async.Return
The async.Return
thing we've already seen. This is a function that takes a tail (we don't currently know, or care, what's inside that tail, except that by the necessity of the type signature of Cons
it must be an AsyncSeq
) and turns it into an AsyncSeq
that is b
followed by the tail. I.e., this is like b :: tl
in a list: it sticks b
onto the front of the AsyncSeq
.
One step out from that innermost expression is:
Result.map
Remember that the function map
can be thought of in two ways: one is "take a function and run it against whatever is "inside" this wrapper". The other is "take a function that operates on 'T
and make it into a function that operates on Wrapper<'T>
". (If you don't have both of those clear in your mind yet, https://sidburn.github.io/blog/2016/03/27/understanding-map is a pretty good article to help grok that concept). So what this is doing is taking a function of type AsyncSeq -> AsyncSeq
and turning it into a function of type Result<AsyncSeq> -> Result<AsyncSeq>
. Alternately, you could think of it as taking a Result<tail>
and calling fun tail -> ...
against that tail
result, then re-wrapping the result of that function in a new Result
. Important: Because this is using Result.map
(Choice.mapl
in the original) we know that if tail
is an Error
value (or if the Choice
was a Choice2Of2
in the original), the function will not be called. So if traverseResultAsync
produces a result that starts with an Error
value, it's going to produce an <Async<Result<foo>>>
where the value of Result<foo>
is an Error
, and so the value of the tail will be discarded. Keep that in mind for later.
Okay, next step out.
Async.map
Here, we have a Result<AsyncSeq> -> Result<AsyncSeq>
function produced by the inner expression, and this converts it to an Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
function. We've just talked about this, so we don't need to go over how map
works again. Just remember that the effect of this Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
function that we've built up will be the following:
- Await the outer
async
.
- If the result is
Error
, return that Error
.
- If the result is
Ok tail
, produce an Ok (Cons (b, tail))
.
Next line:
traverseResultAsync f tl
I probably should have started with this, because this will actually run first, and then its value will be passed into the Async<Result<AsyncSeq>> -> Async<Result<AsyncSeq>>
function that we've just analysed.
So what this whole thing will do is to say "Okay, we took the first part of the AsyncSeq
we were handed, and passed it to f
, and f
produced an Ok
result with a value we're calling b
. So now we need to process the rest of the sequence similarly, and then, if the rest of the sequence produces an Ok
result, we'll stick b
on the front of it and return an Ok
sequence with contents b :: tail
. BUT if the rest of the sequence produces an Error
, we'll throw away the value of b
and just return that Error
unchanged."
return!
This just takes the result we just got (either an Error
or an Ok (b :: tail)
, already wrapped in an Async
) and returns it unchanged. But note that the call to traverseResultAsync
is NOT tail-recursive, because its value had to be passed into the Async.map (...)
expression first.
And now we still have one more bit of traverseResultAsync
to look at. Remember when I said "Keep that in mind for later"? Well, that time has arrived.
| Error e ->
return Error e }
Here we're back in the match b with
expression. If b
was an Error
result, then no further recursive calls are made, and the whole traverseResultAsync
returns an Async<Result>
where the Result
value is Error
. And if we were currently nested deep inside a recursion (i.e., we're in the return! traverseResultAsync ...
expression), then our return value will be Error
, which means the result of the "outer" call, as we've kept in mind, will also be Error
, discarding any other Ok
results that might have happened "before".
Conclusion
And so the effect of all of that is:
- Step through the
AsyncSeq
, calling f
on each item in turn.
- The first time
f
returns Error
, stop stepping through, throw away any previous Ok
results, and return that Error
as the result of the whole thing.
- If
f
never returns Error
and instead returns Ok b
every time, return an Ok
result that contains an AsyncSeq
of all those b
values, in their original order.
Why are they in their original order? Because the logic in the Ok
case is:
- If sequence was empty, return an empty sequence.
- Split into head and tail.
- Get value
b
from f head
.
- Process the tail.
- Stick value
b
in front of the result of processing the tail.
So if we started with (conceptually) [a1; a2; a3]
, which actually looks like Cons (a1, Cons (a2, Cons (a3, Nil)))
we'll end up with Cons (b1, Cons (b2, Cons (b3, Nil)))
which translates to the conceptual sequence [b1; b2; b3]
.