Async as surrogate IO by Mark Seemann
Haskell defines IO as an explicit context for handling effects. In F#, you can experiment with using Async as a surrogate.
As a reaction to my article on the relationship between Functional architecture and Ports and Adapters, Szymon Pobiega suggested on Twitter:
random idea: in c# “IO Int” translates to “Task<int>”. By consistently using this we can make type system help you even in c#If you know Haskell, you might object to that statement, but I don't think Szymon meant it literally. It's an idea worth exploring, I think.
Async as an IO surrogate #
Functions in Haskell are, by default, pure. Whenever you need to do something impure, like querying a database or sending an email, you need to explicitly do this in an impure context: IO
. Don't let the name IO
mislead you: it's not only for input and output, but for all impure actions. As an example, random number generation is impure as well, because a function that returns a new value every time isn't pure. (There are ways to model random number generation with pure functions, but let's forget about that for now.)
F#, on the other hand, doesn't make the distinction between pure and impure functions in its type system, so nothing 'translates' from IO
in Haskell into an equivalent type in F#. Still, it's worth looking into Szymon's suggestion.
In 2013, C# finally caught up with F#'s support for asynchronous work-flows, and since then, many IO-bound .NET APIs have gotten asynchronous versions. The underlying API for C#'s async
/await
support is the Task Parallel Library, which isn't particularly constrained to IO-bound operations. The use of async
/await
, however, makes most sense for IO-bound operations. It improves resource utilisation because it can free up threads while waiting for IO-bound operations to complete.
It seems to be a frequent observation that once you start using asynchronous operations, they tend to be 'infectious'. A common rule of thumb is async all the way. You can easily call synchronous methods from asynchronous methods, but it's harder the other way around.
This is similar to the distinction between pure and impure functions. You can call pure functions from impure functions, but not the other way around. If you call an impure function from a 'pure' function in F#, the caller also automatically becomes impure. In Haskell, it's not even possible; you can call a pure function from an IO
context, but not the other way around.
In F#, Async<'a> is more idiomatic than using Task<T>. While Async<'a> doesn't translate directly to Haskell's IO
either, it's worth experimenting with the analogy.
Async I/O #
In the previous Haskell example, communication with the database was implemented by functions returning IO
:
getReservedSeatsFromDB :: ConnectionString -> ZonedTime -> IO Int saveReservation :: ConnectionString -> Reservation -> IO ()
By applying the analogy of IO
to Async<'a>
, you can implement your F# data access module to return Async work-flows:
module SqlGateway = // string -> DateTimeOffset -> Async<int> let getReservedSeats connectionString (date : DateTimeOffset) = // ... // string -> Reservation -> Async<unit> let saveReservation connectionString (reservation : Reservation) = // ...
This makes it harder to compose the desired imp
function, because you need to deal with both asynchronous work-flows and the Either monad. In Haskell, the building blocks are already there in the shape of the EitherT
monad transformer. F# doesn't have monad transformers, but in the spirit of the previous article, you can define a computation expression that combines Either and Async:
type AsyncEitherBuilder () = // Async<Result<'a,'c>> * ('a -> Async<Result<'b,'c>>) // -> Async<Result<'b,'c>> member this.Bind(x, f) = async { let! x' = x match x' with | Success s -> return! f s | Failure f -> return Failure f } // 'a -> 'a member this.ReturnFrom x = x let asyncEither = AsyncEitherBuilder ()
This is the minimal implementation required for the following composition. A more complete implementation would also define a Return method, as well as other useful methods.
In the Haskell example, you saw how I had to use hoistEither
and liftIO
to 'extract' the values within the do
block. This is necessary because sometimes you need to pull a value out of an Either value, and sometimes you need to get the value inside an IO
context.
In an asyncEither
expression, you need similar functions:
// Async<'a> -> Async<Result<'a,'b>> let liftAsync x = async { let! x' = x return Success x' } // 'a -> Async<'a> let asyncReturn x = async { return x }
I chose to name the first one liftAsync
as a counterpart to Haskell's liftIO
, since I'm using Async<'a>
as a surrogate for IO
. The other function I named asyncReturn
because it returns a pure value wrapped in an asynchronous work-flow.
You can now compose the desired imp
function from the above building blocks:
let imp candidate = asyncEither { let! r = Validate.reservation candidate |> asyncReturn let! i = SqlGateway.getReservedSeats connectionString r.Date |> liftAsync let! r = Capacity.check 10 i r |> asyncReturn return! SqlGateway.saveReservation connectionString r |> liftAsync }
Notice how, in contrast with the previous example, this expression uses let!
and return!
throughout, but that you have to compose each line with asyncReturn
or liftAsync
in order to pull out the appropriate values.
As an example, the original, unmodified Validate.reservation
function has the type ReservationRendition -> Result<Reservation, Error>
. Inside an asyncEither
expression, however, all let!
-bound values must be of the type Async<Result<'a, 'b>>
, so you need to wrap the return value Result<Reservation, Error>
in Async. That's what asyncReturn
does. Since this expression is let!
-bound, the r
value has the type Reservation
. It's 'double-unwrapped', if you will.
Likewise, SqlGateway.getReservedSeats
returns a value of the type Async<int>
, so you need to pipe it into liftAsync
in order to turn it into an Async<Result<int, Error>>
. When that value is let!
-bound, then, i
has the type int
.
There's one change compared to the previous examples: the type of imp
changed. It's now ReservationRendition -> Async<Result<unit, Error>>
. While you could write an adapter function that executes this function synchronously, it's better to recall the mantra: async all the way!
Async Controller #
Instead of coercing the imp
function to be synchronous, you can change the ReservationsController that uses it to be asynchronous as well:
// ReservationRendition -> Task<IHttpActionResult> member this.Post(rendition : ReservationRendition) = async { let! res = imp rendition match res with | Failure (ValidationError msg) -> return this.BadRequest msg | Failure CapacityExceeded -> return this.StatusCode HttpStatusCode.Forbidden | Success () -> return this.Ok () } |> Async.StartAsTask
Notice that the method body uses an async
computation expression, instead of the new asyncEither
. This is because, when returning a result, you finally need to explicitly deal with the error cases as well as the success case. Using a standard async
expression enables you to let!
-bind res
to the result of invoking the asynchronous imp
function, and pattern match against it.
Since ASP.NET Web API support asynchronous controllers, this works when you convert the async work-flow to a Task with Async.StartAsTask
. Async all the way.
In order to get this to work, I had to overload the BadRequest, StatusCode, and Ok methods, because they are protected, and you can't use protected methods from within a closure. Here's the entire code for the Controller, including the above Post method for completeness sake:
type ReservationsController(imp) = inherit ApiController() member private this.BadRequest (msg : string) = base.BadRequest msg :> IHttpActionResult member private this.StatusCode statusCode = base.StatusCode statusCode :> IHttpActionResult member private this.Ok () = base.Ok () :> IHttpActionResult // ReservationRendition -> Task<IHttpActionResult> member this.Post(rendition : ReservationRendition) = async { let! res = imp rendition match res with | Failure (ValidationError msg) -> return this.BadRequest msg | Failure CapacityExceeded -> return this.StatusCode HttpStatusCode.Forbidden | Success () -> return this.Ok () } |> Async.StartAsTask
As you can see, apart from the overloaded methods, the Post method is all there is. It uses the injected imp
function to perform the actual work, asynchronously translates the result into a proper HTTP response, and returns it as a Task<IHttpActionResult>
.
Summary #
The initial idea was to experiment with using Async as a surrogate for IO. If you want to derive any value from this convention, you'll need to be disciplined and make all impure functions return Async<'a> - even those not IO-bound, but impure for other reasons (like random number generators).
I'm not sure I'm entirely convinced that this would be a useful strategy to adopt, but on the other hand, I'm not deterred either. At least, the outcome of this experiment demonstrates that you can combine asynchronous work-flows with the Either monad, and that the code is still manageable, with good separation of concerns. Since it is a good idea to access IO-bound resources using asynchronous work-flows, it's nice to know that they can be combined with sane error handling.
Comments
In my opinion, the true purpose of
IO
is to allow you to write pure effectful functions. For example, Haskell'sputStrLn
is referentially transparent - i.e.,[putStrLn "hello", putStrLn "hello"]
has the same meaning asreplicate 2 $ putStrLn "hello"
. This means you can treat a function that returns an IO the same way you'd treat any other function.This is possible for two reasons: 1)
IO
actions are not run until they hit the "edge of the universe" whereunsafePerformIO
is called and 2) they don't memoize the result - if anIO
action is executed twice, it will calculate two possibly distinct values.The problem with
Task
is that it's not referentially transparent.new[] { Task.Run(() => WriteLine("hello")), Task.Run(() => WriteLine("hello")) }
is not the same asvar t = Task.Run(() => WriteLine("hello"))
+new [] { t, t }
. Even if you usenew Task()
instead ofTask.Run
to delay its execution, you still wouldn't be able to factor out the task because it memoizes the result (in this case, void), so you'd still see "hello" only once on the screen.Since Task cannot be used to model pure functions, I don't think it's a suitable replacement for IO.
Diogo, thank you for writing. The point about Tasks not being referentially transparent is well taken. You are, indeed, correct.
F# asynchronous work-flows work differently, though. Extending the experiment outlined in this article, you can attempt to 'translate'
putStrLn
to F#. Since the return type ofputStrLn
isIO ()
, you'll need a function that returnsAsync<unit>
. Here's my naive attempt:Corresponding to your two Haskell examples, consider, then, these two F# values:
Both of these values have the type
seq<Async<unit>>
, and I'd expect them to be equivalent.In Haskell, I used
sequence
to run your code in GHCI, turning[IO ()]
intoIO [()]
. In F#, you can do something similar:Using this function, you can evaluate both expressions:
As you can see, the result is the same in both cases, like you'd expect them to be.
I'm still not claiming that
Async<'a>
is a direct translation of Haskell'sIO
, but there are similarities. Admittedly, I've never considered whether F# asynchronous work-flows satisfy the monad laws, but it wouldn't surprise me if they did.Ah, I see! It wasn't clear to me that async workflows were referentially transparent. That being the case, it seems like they are, indeed, a good substitute for
IO a
.It seems like the F# team got async right :) The C# team should take a page from their book...
I know what functional programmers generally and Mark specially mean when they say that function is (or is not) referentially transparent. Namely, a function is referentially transparent if and only if its behavior is unchanged when memozied.
I see how caching behavior varies among Haskell's IO, F#'s Async, and C#'s Task. However, the previous comments are the only times that I have anyone say that a type is (or is not) referentially transparent. In general, what does it mean for a type to be (or not to be) referentially transparent?
Tyson, language is imprecise. I take it that we meant that the behaviour that the type (here: an object) affords helps preserve referential transparency. It's fairly clear to me that it makes sense to say that
Task<T>
isn't referentially transparent, because of the behaviour shown by the above examples.In C# or F#, we can't universally state that
Async
is always referentially transparent, because these languages allow impure actions to be passed to the type's members. Still, if we pass pure functions, the members are referentially transparent too.In object-oriented programming, we often think of a type as an object: data with behaviour. In Haskell, you often have a type and an associated type class. In F#, some containers (like
Async
) have an associated computation expression builder; again, that's a set of functions.If a set of associated functions or members are all referentially transparent, it makes sense to me to talk about the type as being referentially transparent, but all this is a view I've just retconned as I'm writing. You may be able to poke holes in it, so don't take it for more than it is.