Programming with algebraic effects and handlers, a method for reasoning about computational effects of programs that originates from functional programming research, has recently found increasing adoption in more mainstream languages. Implementations can be found in OCaml, Haskell, Scala, F# and the Koka language.
Algebraic effects are an immensely powerful language feature that can be used to implement a diverse array of features such as dependency injection, cancellation, nondeterminism, debug tracing, first-class continuations and replayable computations.
For a beginner’s introduction to the concept of algebraic effects, I would recommend reading Dan Abramov’s excellent article on the subject, which provides a primer using a hypothetical dialect of JavaScript.
In this article I will be talking about Eff, an experimental C# library that provides a pragmatic adaptation of the concept by taking advantage of the async method extensibility features available since C# 7. It was originally created by my former colleague, Nick Palladinos, and recently I have also been contributing to the project.
I have decided to start writing about Eff since I strongly believe in its experimental potential, and since hardly any documentation exists about it online.
Key Concepts
At its core, the library defines a task-like type, Eff
, which can be built using async
methods:
using Nessos.Effects; async Eff<int> Sqr(int x) => x * x;
and can be composed using the await
keyword:
async Eff<int> SumOfSquares(params int[] inputs) { int sum = 0; foreach (int input in inputs) { sum += await Sqr(input); } return sum; }
An Eff
computation has to be run explicitly by passing an effect handler:
using Nessos.Effects.Handlers; Eff<int> computation = SumOfSquares(1,2,3); computation.Run(new DefaultEffectHandler());
It should be noted that unlike Task
, Eff values have delayed semantics and so running
async Eff HelloWorld() => Console.WriteLine("Hello, World!"); Eff hello = HelloWord();
will have no observable side-effect. It is useful to think of Eff values as delegates, as opposed to futures.
Finally, Eff methods are capable of awaiting regular async awaitables
async Eff Delay() { await Task.Delay(1000); }
It should be noted however that the opposite is not possible, that is Eff values cannot be awaited inside regular async methods.
Programming with Effects
The examples thus far illustrate what is effectively an alternative implementation for async method builders. We have not talked about effects yet.
In the Eff library, abstract effects can be defined by inheriting the abstract Effect<T>
class:
public class DateTimeNowEffect : Effect<DateTime> { }
Abstract effects can be awaited inside Eff computations:
async Eff<bool> IsSummerWeekend() { DateTime now = await new DateTimeNowEffect(); return (now.Month, now.DayOfWeek) switch { (6 or 7 or 8, DayOfWeek.Saturday or DayOfWeek.Sunday) => true, _ => false, }; }
And Eff computations depending on Effects can be further composed with other Eff computations:
async Eff SomeDomainLogic(Customer customer) { if (await IsSummerWeekend()) { await SendIceCreamPromo(customer); } }
Let’s now see how we can run this effect computation. Suppose I try to pass it the default effect handler:
SomeDomainLogic(customer).Run(new DefaultEffectHandler());
Then execution will fail with an exception:
System.NotSupportedException: Abstract effects not supported by this handler. at Nessos.Effects.Handlers.DefaultEffectHandler.Handle[TResult](EffectAwaiter`1 awaiter)
The failure is caused by the fact that the effect handler we used does not provide semantics for DateTimeNowEffect
. In order to get this to work we need to implement an effect handler of our own:
public class DateTimeEffectHandler : EffectHandler { public override async ValueTask Handle<TResult>(EffectAwaiter<TResult> awaiter) { switch (awaiter) { case EffectAwaiter<DateTime> { Effect: DateTimeNowEffect } dateTimeEffectAwaiter: dateTimeEffectAwaiter.SetResult(DateTime.Now); break; } } }
The handler runs a pattern match over an EffectAwaiter
argument, and populates any DateTimeNowEffect
awaiter with the result of System.DateTime.Now
. We can now run the previous computation with our new effect handler:
SomeDomainLogic(customer).Run(new DateTimeEffectHandler());
which will execute with the expected result.
We can also use effect handlers to mock the result of an effect:
public class MockedDateTimeEffectHandler : EffectHandler { public DateTime Now { get; set; } public override async ValueTask Handle<TResult>(EffectAwaiter<TResult> awaiter) { switch (awaiter) { case EffectAwaiter<DateTime> { Effect: DateTimeNowEffect } dateTimeEffectAwaiter: dateTimeEffectAwaiter.SetResult(Now); break; } } } var handler = new MockedDateTimeEffectHandler() { Now = DateTime.Parse("2020-07-19") }; SomeDomainLogic(customer).Run(handler);
Combining Effects
Consider the following set of abstract console effects
public static class ConsoleEffect { public static WriteLineEffect WriteLine(string line) => new WriteLineEffect() { LogEntry = line }; public static ReadLineEffect ReadLine() => new ReadLineEffect(); public class WriteLineEffect : Effect { public string? LogEntry { get; set; } } public class ReadLineEffect : Effect<string> { } }
and relevant handler that is backed by System.Console:
public class ConsoleEffectHandler : EffectHandler { public override async ValueTask Handle<TResult>(EffectAwaiter<TResult> awaiter) { switch (awaiter) { case EffectAwaiter { Effect: ConsoleEffect.WriteLineEffect writeLineEffect } writeLineAwaiter: Console.WriteLine(writeLineEffect.LogEntry); writeLineAwaiter.SetResult(); break; case EffectAwaiter<string> { Effect: ConsoleEffect.ReadLineEffect } readLineAwaiter: string line = Console.ReadLine(); readLineAwaiter.SetResult(line); break; } } }
Now suppose we have an Eff computation that combines effects from both examples:
async Eff Test() { DateTime now = await new DateTimeNowEffect(); await ConsoleEffect.WriteLine($"The current date is {now}."); }
Then defining an effect handler that supports both effect families is very straightforward:
public class CombinedDateTimeConsoleEffectHandler : EffectHandler { private readonly EffectHandler[] _nestedHandlers = new EffectHandler[] { new ConsoleEffectHandler(), new DateTimeEffectHandler(), }; public override async ValueTask Handle<TResult>(EffectAwaiter<TResult> awaiter) { foreach (EffectHandler handler in _nestedHandlers) { await handler.Handle(awaiter); if (awaiter.IsCompleted) { return; } } } } Test().Run(new CombinedDateTimeConsoleEffectHandler());
It should be then clear by the examples that effects can be used to define a form of dependency injection. There is however a twist: since effect handlers flow with function calls, there is no need to pass dependencies via constructor parameters. Domain logic can be expressed by composing static methods, with the effect handler serving as the composition root for the application. This provides a truly functional approach to dependency injection.
Encoding Cancellation
A common criticism of C# is that async methods do not flow cancellation, that is any cancellation tokens will have to be passed manually to operations that require it. We will now see how one might use effects to work around that limitation; the Eff library comes with a baked-in effect handler that provides cancellation semantics. Consider the following example:
using Nessos.Effects.Cancellation; async Eff Test() { while (true) { await Delay(); async Eff Delay() { await Task.Delay(1000); } } } var cts = new CancellationTokenSource(millisecondsDelay:10_000); var handler = new CancellationEffectHandler(cts.Token); Test().Run(handler);
While one might expect the above to diverge, it will actually throw an OperationCanceledException
after ten seconds. This is because the effect handler will actively check for cancellation before evaluating the state machine of a nested Eff
call.
But what happens if we need to pass the cancellation token to a non-eff asynchronous operation? We can recover it by awaiting on the cancellation token effect:
async Eff<HttpResponseMessage> Get(HttpClient httpClient, string requestUri) { // get the cancellation token from the effect handler CancellationToken token = await CancellationTokenEffect.Value; return await httpClient.GetAsync(requestUri, token); }
For those familiar with F#, this is precisely how its async method implementation flows cancellation.
Conclusions
This concludes my introductory overview of the Eff library. I have tried to convey its most basic concepts, however this article hardly covers all capabilities of effects. I will try to follow up with future articles digging deeper into both applications and the implementation details of Eff itself. For the impatient, there is a fairly extensive catalog of samples in the Eff repo itself, including a proof-of-concept AspNetCore application.
I do hope that you have found the concepts interesting, and that this might furthermore spark a conversation about future programming paradigms.