-
-
Notifications
You must be signed in to change notification settings - Fork 423
How to deal with side effects
- Introduction
- Pure World
IO<A>
IO<Env, A>
-
Aff
andEff
- monads built to capture side-effects- Setting up your application to work with runtimes
-
Built-in features
-
LanguageExt.Sys
- nu-get package that wraps up common BCL functions - Some examples
- Lifting pure functions into the
Aff
andEff
- Working with other monads within an
Aff
orEff
context
-
- Error handling and filtering
- API overview - with examples
One of the main tenets of functional programming is to have no side-effects. Code without side-effects is deterministic and doesn't code rot. This is especially valuable when writing large applications, it minimises technical debt and long-term maintenance costs. It is also cognitively much easier for the engineers working on the code to manage: composing code without side-effects leads to larger pieces of functionality that also don't have side-effects.
The way to write code without side-effects is to write pure functions.
What are pure functions?
- Functions depend entirely on the arguments passed to them
- Functions don't cause side-effects (i.e. writing to a log, writing to a database, writing to a global variable, ...)
- Functions don't use external sources of global state (i.e. reading from a database, reading from a global variable, ...)
- Functions shouldn't throw exceptions
This clearly limits somewhat the ability to write useful programs. Programs tend to need to update the state of the world to be considered viable. So, how do we define this?
Before going any further, you'll need to use these for many of the examples to work.
using LanguageExt;
using LanguageExt.Common;
using static LanguageExt.Prelude;
The most pure way to do this is to contain all 'global' state in a world, and then fold over it. Imagine a single, pure, HandleEvent
function which took the entire state of the World
as an argument, an event instance, and then returned a new state of the World
:
World HandleEvent(World world, Event event) => ...;
Now, we could fold over a series of events to have an entirely pure application:
eventualWorldState = events.Fold(initialWorldState, HandleEvent);
This works, but it requires all global state to be in-memory, in a type called World
. This isn't practical for all but the simplest applications. It certainly doesn't support a file-system, a 3rd-party database, etc.
If you haven't come across
Fold
yet, it works likeAggregate
in LINQ. i.e. for the stream ofevents
it will first callHandleEvent
withinitialWorldState
and the first event in the stream.HandleEvent
will return a newWorld
instance, which is then passed back toHandleEvent
for the subsequent event in the stream. This continues until theevents
stream is exhausted. You can think of this as modelling time, with a history ofWorld
instances. When the finalWorld
instance is returned from theFold
operation which represents now.
To deal with the real-world World
, we need to somehow capture actions on the world as pure data, so that we can have a controlled fold-like operation on the real-world.
What is a pure data representation of side-effect? The simplest is a lambda:
static Func<Option<string>> readAllText(string path) =>
() => {
try
{
return File.ReadAllText(path);
}
catch
{
return None;
}
};
The Func<A>
is a data structure that captures the invocation, it captures the exception, and can be run on-demand. So, if you collected a sequence of Func
then you could force them to invoke in-sequence, or build in whatever constraints you need.
Instead of using Func<A>
we could instead define a delegate
:
public delegate Either<Error, A> IO<A>();
Then update our readAllText
and add a writeAllText
function:
static IO<string> readAllText(string path) =>
() => File.ReadAllText(path);
static IO<Unit> writeAllText(string path, string text) =>
() => { File.WriteAllText(path, text); return unit; }
And some supporting functions that make IO<A>
into a functor and a monad:
public static class IO
{
// Allows us to lift pure values into the IO domain
public static IO<A> Pure<A>(A value) =>
() => value;
// Wrap up the error handling
public static Either<Error, A> Run<A>(this IO<A> ma)
{
try
{
return ma();
}
catch(Exception e)
{
return Error.New("IO error", e);
}
}
// Functor map
public static IO<B> Select<A, B>(this IO<A> ma, Func<A, B> f) => () =>
ma().Match(
Right: x => f(x),
Left: Left<Error, B>);
// Functor map
public static IO<B> Map<A, B>(this IO<A> ma, Func<A, B> f) =>
ma.Select(f);
// Monadic bind
public static IO<B> SelectMany<A, B>(this IO<A> ma, Func<A, IO<B>> f) => () =>
ma().Match(
Right: x => f(x)(),
Left: Left<Error, B>);
// Monadic bind
public static IO<B> Bind<A, B>(this IO<A> ma, Func<A, IO<B>> f) =>
SelectMany(ma, f);
// Monadic bind + projection
public static IO<C> SelectMany<A, B, C>(
this IO<A> ma,
Func<A, IO<B>> bind,
Func<A, B, C> project) =>
ma.SelectMany(a => bind(a).Select(b => project(a, b)));
}
We can now compose our IO:
var computation = from text in readAllText(inpath)
from _ in writeAllText(outpath, text)
select unit;
This gives us a pure data representation of the IO, we can then safely invoke it:
Either<Error, Unit> result = computation.Run();
What have we gained?
- The construction of
computation
is pure - All side-effects are now encapsulated and run in a single function
Run
- Exceptions are elegantly handled, returning a type that we can work with:
Either<Error, A>
What about our pure functions? How are they used with IO<A>
? We lift them into the IO monad:
// A pure function for turning all lower-case characters into upper-case characters
static string Capitalise(string text) =>
new string(text.Map(x => Char.IsLower(x) ? Char.ToUpper(x) : x).ToArray());
// Use it in the IO context
var computation = from text in readAllText(inpath)
from ntext in IO.Pure(Capitalise(text))
from _ in writeAllText(outpath, ntext )
select unit;
That's great, we can now elevate pure functions into the IO monad. A slightly more elegant way would be:
var computation = from ntext in readAllText(inpath).Map(Capitalise)
from _ in writeAllText(outpath, ntext)
select unit;
Or even:
var computation = from text in readAllText(inpath)
let ntext = Capitalise(text)
from _ in writeAllText(outpath, ntext)
select unit;
All approaches are valid and have the effect of lifting the context of pure functions into the IO domain. You can always go up to a higher level, i.e. from pure A
to IO<A>
, but you should never go back down: i.e. by calling computation.Run()
- once you go back down, you're back into the land of uncontrolled/interleaved side-effects. And so our goal is to never call Run
, except once:
class Program
{
public static void Main(string[] args)
{
MainIO(args).Run();
}
static IO<Unit> MainIO(string[] args) =>
// ... build your entire application here
}
If you think about it, that makes sense, we want the side-effects to be at the outer edges of everything, and then everything within to be pure. Remember the IO<A>
monad is a pure data representation, and so it's only realised concretely when Run
is called.
There are some edge-cases here though, because C# doesn't have any native support for an IO monad, we often have to work with other frameworks like ASP.NET which are loaded with side-effects. A place you may want to explicitly call Run
might be in a request/response handler - clearly you're going to need the concrete value to respond to a client, so getting the value out of the computation requires Run
.
In theory you could build your own web-server framework that sat entirely in an IO monad, but that's waaaay to much effort in C#-land. Another approach is to wrap (lift) an existing framework in IO<A>
(as I showed above with File.ReadAllText
and File.WriteAllText
). This is a good approach, but sometimes pragmatism needs to prevail. If you only have a few places in your app where Run
is called, then you are already winning. If you have lots of Run
invocations, you're losing.
You should always think that "If I'm in an IO monad, I can't get out".
If you look at every Haskell project, you'll see its main function declared like so:
main :: IO ()
main = -- build your entire application here
IO ()
is the equivalent to IO<Unit>
in C#. And so this is a function that takes no arguments and returns an IO monad of Unit
. The Haskell runtime will do the difficult work of actually running the real-world IO. And so, this is why Haskell is known as a pure language, because its side-effects are pushed right outside of your application.
Ok, this is pretty good, we're some way along the journey of capturing side-effects. For experienced users of this library IO<A>
may be familiar to you in its behaviour: it's Try<A>
.
What can't we do?
- Abstract away from the IO implementations themselves:
- We can't inject mocked IO calls
- Running the computation is a bit of a 'black box'
Let's try to solve the injection of mocked IO. How would we do that? Using a DI framework is not an option, we want to do this in a purely functional way. If we extend the IO<A>
type to have an environment, then we could thread the environment through the computation. You could think of the environment like the World
type mentioned earlier:
public delegate Either<Error, A> IO<Env, A>(Env env);
We've extended the IO delegate
to have an additional parameter of Env
. It can be anything we want. But importantly, we don't return an environment, and so it's fixed. Let's change the implementation:
public static class IO
{
// Allows us to lift pure values into the IO domain
public static IO<Env, A> Pure<A, Env>(A value) =>
(Env env) => value;
// Wrap up the error handling
public static Either<Error, A> Run<Env, A>(this IO<Env, A> ma, Env env)
{
try
{
return ma(env);
}
catch(Exception e)
{
return Error.New("IO error", e);
}
}
public static IO<Env, B> Select<Env, A, B>(this IO<Env, A> ma, Func<A, B> f) => env =>
ma(env).Match(
Right: x => f(x),
Left: Left<Error, B>);
public static IO<Env, B> Map<Env, A, B>(this IO<Env, A> ma, Func<A, B> f) =>
Select(ma, f);
public static IO<Env, B> SelectMany<Env, A, B>(this IO<Env, A> ma, Func<A, IO<Env, B>> f) =>
env =>
ma(env).Match(
Right: x => f(x)(env),
Left: Left<Error, B>);
public static IO<Env, B> Bind<Env, A, B>(this IO<Env, A> ma, Func<A, IO<Env, B>> f) =>
SelectMany(ma, f);
public static IO<Env, C> SelectMany<Env, A, B, C>(
this IO<Env, A> ma,
Func<A, IO<Env, B>> bind,
Func<A, B, C> project) =>
ma.SelectMany(a => bind(a).Select(b => project(a, b)));
}
Then update our readAllText
and add a writeAllText
function:
public interface FileIO
{
string ReadAllText(string path);
Unit WriteAllText(string path, string text);
}
static IO<Env, string> readAllText<Env>(string path)
where Env : FileIO =>
env => env.ReadAllText(path);
static IO<Env, Unit> writeAllText<Env>(string path, string text)
where Env : FileIO =>
env => env.WriteAllText(path, text);
We have now defined an interface called FileIO
and added constraints to the Env
that is passed to readAllText
and writeAllText
. That means we can inject our own definitions of readAllText
and writeAllText
. It also means we're declaring upfront (via the constraints) the expected IO operations in the function.
public class LiveEnv : FileIO
{
public string ReadAllText(string path) => File.ReadAllText(path);
public Unit WriteAllText(string path, string text) { File.WriteAllText(path, text); return unit; }
}
// Create the application environment (we do this once at app startup)
var appEnv = new LiveEnv();
// Create the pure computation
var computation = from text in readAllText<LiveEnv>(inpath)
from _ in writeAllText<LiveEnv>(outpath, text)
select unit;
// Run the computation with the environment:
Either<Error, Unit> result = computation.Run(appEnv);
What have we gained?
- The construction of
computation
is pure - All side-effects are now encapsulated and run in a single function
Run
- Exceptions are elegantly handled, returning a type that we can work with:
Either<Error, A>
- More declarative functions (constraints)
- We have completely abstracted away from the side-effecting operations
- We can inject our own effects, which makes testing easier
- As well as injectable behaviours, we can use the
Env
to pass through configuration data, which also abstracts away from global configuration state.
We have captured some of the original HandleEvent
example from the beginning of this document. The main difference is we don't return a new Env
, because we're modifying the real world.
This new definition of IO<Env, A>
is the Reader<Env, A>
from language-ext. So think of the Reader
as a way of threading a static world through the computation.
What haven't we gained?
- An opinionated way to build
Reader
environments, or any infrastructure aroundReader
to deal with common IO or the real-world async/sync mismatch.
I have lost count of how many times I've been asked about how to do IO in functional programming, as you can see above there are several approaches. I figured it would be valuable for this library to have an opinionated approach, with infrastructure built around those opinions. This is where the Aff
and Eff
monads come in:
Type | Behaviour |
---|---|
Eff<A> |
A synchronous effect - equivalent to Try<A>
|
Aff<A> |
An asynchronous effect - equivalent to TryAsync<A>
|
Eff<RT, A> |
A synchronous effect with an injectable runtime environment - equivalent to Reader<RT, A>
|
Aff<RT, A> |
An asynchronous effect with an injectable runtime environment - no current equivalent |
These types have all been designed to work seamlessly with each other, and so can be combined in the same LINQ expression for example. Eff<A>
and Aff<A>
are explicitly for capturing side-effects that don't need to reference an external environment. Eff<RT, A>
and Aff<RT, A>
are there to allow external injection of 'global' configuration and behaviour, like Reader
.
They have also been highly optimised to minimise allocations and to stop repeated lazy invocation (memoisation). They're structs which reduces heap-allocation, and internally [the asynchronous types] use ValueTask
, which again is a struct and is much more optimal than Task
.
Finally, there are built-in wrappers for the existing IO behaviours in the .NET BCL. With support for building an extensible runtime.
Before we get on to the runtime versions of Aff
and Eff
, let's look at the non-runtime versions. The idea with the Aff<A>
and Eff<A>
types is to lift pure functions into the effect-space. Because they have no runtime, they can't possibly interact with the outside world, right?
Obviously, this is C#, so they can. It is up to you to decide how pure you want to go. It is possible to do side-effects within an Aff<A>
or Eff<A>
, but then you'd lose the benefits of creating an injectable runtime. However, you could avoid the runtime versions of Aff
and Eff
altogether. Here's an example:
public static class FileAff
{
public static Aff<Seq<string>> readAllLines(string path) =>
Aff(async () => (await File.ReadAllLinesAsync(path)).ToSeq());
public static Aff<Unit> writeAllLines(string path, Seq<string> lines) =>
Aff(async () =>
{
await File.WriteAllLinesAsync(path, lines);
return unit;
});
}
This wraps up File.ReadAllLinesAsync
and File.WriteAllLinesAsync
into a new FileAff
type. It's not too much of a stretch to think you could make this use an injected interface, or similar:
public interface FileIO
{
ValueTask<string[]> ReadAllLinesAsync(string path);
ValueTask<Unit> WriteAllLinesAsync(string path, string[] lines);
}
public static class FileAff
{
static readonly FileIO injected;
public static Aff<Seq<string>> readAllLines(string path) =>
Aff(async () => (await injected.ReadAllLinesAsync(path)).ToSeq());
public static Aff<Unit> writeAllLines(string path, Seq<string> lines) =>
Aff(async () =>
{
await injected.WriteAllLinesAsync(path, lines.ToArray());
return unit;
});
}
It's for the reader to decide how
injected
would get populated. I am not necessarily advocating this as the best approach to injection of side-effects, but it's certainly a way to simplify the usage ofAff
andEff
, so that a runtime isn't needed.
Now, we've got that out of the way. I'd like to make it clear that the original intention of Aff<A>
and Eff<A>
was for lifting pure values and functions into the effect-space. i.e.
var mx = SuccessEff(100); // lift a pure int into `Eff<int>`
var my = SuccessAff(100); // lift a pure int into `Aff<int>`
var mf = FailAff<int>("There was an error");
These 'pure' effects can then be combined easily with Aff<RT, A>
and Eff<RT, A>
to allow pure and effectful behaviours to work side-by-side with minimal friction.
The key aspect of this opinionated approach is less about the monadic types themselves, and more about how we build a runtime environment for our application. We use ad-hoc polymorphism to make this work. Let's define our FileIO
from before.
public interface FileIO
{
string ReadAllText(string path);
Unit WriteAllText(string path, string text);
}
It's the same. The difference is how this makes it into the runtime, we don't derive our runtime environment type from FileIO
, instead we create a trait interface. By convention we will prefix the trait interface name with Has
, where we state the runtime 'Has file IO' for example:
public interface HasFile<RT>
where RT : struct, HasFile<RT>
{
Eff<RT, FileIO> FileEff { get; }
}
It is important to follow the constraint convention too. We're saying the runtime must be a struct
, and must be derived from HasFile<RT>
. These constraints will help the C# type-checker to give us friendly errors when we mess up.
The FileEff
property simply returns a FileIO
implementation lifted into an Eff
. Let's see how that works in the runtime:
public struct LiveRuntime : HasFile<LiveRuntime>
{
public Eff<LiveRuntime, FileIO> FileEff => SuccessEff(LiveFileIO.Default);
}
This is where the use of traits makes this a bit easier to manage. Instead of including all of the functions of FileIO
in our runtime environment, we have a single property that redirects to our live FileIO
implementation. This also allows us to plug 'n' play existing implementations, making it easy for you or others to wrap up other side-effecting libraries and provide them as nu-get packages, we then simply add the trait to our runtime and we're ready to go.
If we can't take an off-the-shelf library, then we can implement ourselves:
public struct LiveFileIO : FileIO
{
public static readonly FileIO Default = new LiveFileIO();
public string ReadAllText(string path) =>
System.IO.File.ReadAllText(path);
public Unit WriteAllText(string path, string text)
{
System.IO.File.WriteAllText(path, text);
return unit;
}
}
Now we have a simple runtime and an implementation of FileIO
, let's try to use it for real:
// Define a runtime agnostic file copying function
public static Eff<RT, Unit> CopyFile<RT>(string source, string dest)
where RT : struct, HasFile<RT> =>
from text in default(RT).FileEff.Map(rt => rt.ReadAllText(source))
from _ in default(RT).FileEff.Map(rt => rt.WriteAllText(dest, text))
select unit;
// Try it
var result = CopyFile<LiveRuntime>(src, dest);
result.RunIO(new LiveRuntime());
Eurggh! That's not an improvement, that default(RT).FileEff.Map
nonsense is ugly and hard to read. We're not declarative here. So we wrap them in static
functions
public static class File<RT>
where RT : struct, HasFile<RT>
{
public static Eff<RT, string> readAllText(string path) =>
default(RT).FileEff.Map(rt => rt.ReadAllText(path));
public static Eff<RT, Unit> writeAllText(string path, string text) =>
default(RT).FileEff.Map(rt => rt.WriteAllText(path, text));
}
Now we can write the CopyFile
function like this:
// Define a runtime agnostic file copying function
public static Eff<RT, Unit> CopyFile<RT>(string source, string dest)
where RT : struct, HasFile<RT> =>
from text in File<RT>.readAllText(source)
from _ in File<RT>.writeAllText(dest, text)
select unit;
This may seem like a lot of effort to define the IO operations: building an interface, building a trait-interface, and building a static class to run them. But we do this once for each type of IO we do. We build our runtime behaviours once as well. Once the IO behaviours are built, we just use them like we use
System.IO.File.ReadAllText
, it's baked in. Luckily, a lot of this is built into language-ext, so you only need to build your own runtime and supplement with any bespoke IO you have. I hope that others will start to build their own libraries that wrap access to SQL Server, ASP.NET Core, etc.
How would we mock the file-system? There are two approaches:
- Build a bespoke runtime type for each unit-test
- Build a single test runtime that can accept mocked data
I believe the second option is simplest and easier to work with:
First let's define the FileIO
implementation:
public struct TestFileIO : FileIO
{
readonly Dictionary<string, string> files;
public TestFileIO(Dictionary<string, string> files) =>
this.files = files;
public string ReadAllText(string path) =>
files.ContainsKey(path)
? files[path]
: throw new FileNotFoundException(path);
public Unit WriteAllText(string path, string text)
{
if(files.ContainsKey(path))
{
files[path] = text;
}
else
{
files.Add(path, text);
}
return unit;
}
}
You might be shocked to see the use of Dictionary
as its a mutable data-structure, and flies in the face of everything we believe as functional programmers. But remember we're trying to replicate a mutable file-system. So this is fine. It's a slightly naïve implementation, we're not necessarily replicating the exceptions properly for example, but it shows that we could have an in-memory file-system.
Now we can implement the test-runtime:
public record TestRuntimeEnv(Dictionary<string, string> Files);
public struct TestRuntime : HasFile<TestRuntime>
{
readonly TestRuntimeEnv Env;
public TestRuntime(TestRuntimeEnv env) =>
Env = env;
public Eff<TestRuntime, FileIO> FileEff =>
Eff<TestRuntime, FileIO>(static rt => new TestFileIO(rt.Env.Files));
}
The major difference from before is that the TestRuntime
takes its own environment called TestRuntimeEnv
. This isn't a requirement, but because TestRuntime
is a struct, the more fields we have, or field-backed properties, the less efficient it will be. So, for the environmental state we want to create a simple record type that we'll pass through. In that enviroment is a Dictionary
of files, this will be what we will use to mock the file-system.
The other difference is the implementation of FileEff
. We're not working with a stateless FileIO
derived class any more, and so it needs to be initialised from the state within the runtime. Remember the implementation of the static functions:
public static class File<RT>
where RT : struct, HasFile<RT>
{
public static Eff<RT, string> readAllText(string path) =>
default(RT).FileEff.Map(rt => rt.ReadAllText(path));
public static Eff<RT, Unit> writeAllText(string path, string text) =>
default(RT).FileEff.Map(rt => rt.WriteAllText(path, text));
}
They use default(RT)
. And so we won't have any state if we simply wrote:
// This will fail
public Eff<TestRuntime, FileIO> FileEff =>
SuccessEff(Env.Files);
The environmental state will be available when the computation runs, it isn't available when the pure computation is being built. And so, we create an 'inline effect', that will be run when the computation runs:
// This will work
public Eff<TestRuntime, FileIO> FileEff =>
Eff<TestRuntime, FileIO>(static rt => new TestFileIO(rt.Env.Files));
rt
is the real stateful runtime, and so we can access the members to initialise the stateful TestFileIO
.
The LanguageExt.Sys
nu-get package is where all of the built-in traits, bar HasCancel
, are defined (HasCancel
is the only trait in LanguageExt.Core
). It also includes live and test implementations of those traits.
On top of the implementations of the traits, there are two pre-built runtimes:
You can use these without having to build your own runtimes, but you will be limited to only the IO functionality that language-ext provides out-of-the-box. Instead you should copy these as the basis for your own application-runtime, and supplement them with any additional IO functionality you need. You should only need one runtime for any application (and a test runtime, for unit-tests), remember we're trying to describe the real-world, and there's only one of those.
Purists may argue that having progressively more constrained runtimes might help, especially across domain boundaries. This makes sense, but note you will need to map to those more constrained runtimes, and I'm not sure it's really necessary for the vast majority of use-cases. The main way to constrain what sub-systems do is to constrain the functions in your application to only use the
Has..
traits that they need.
At the time of writing the following traits exist:
-
HasCancel
- provides a cancellation token for asynchronous operations - this is a requirement for allAff
usage -
HasConsole
- provides an interface toSystem.Console
- The test implementation is a fully in-memory console, with the ability to iterate over what's in the console feed and write mocked key-presses
-
HasFile
- provides an interface toSystem.IO.File
- The test implementation is a fully in-memory file-system
-
HasDirectory
- provides an interface toSystem.IO.Directory
- The test implementation is a fully in-memory file-system
-
HasEncoding
- threads a text-encoding configuration through the computation -
HasTextRead
- provides an interface toSystem.IO.TextReader
-
HasTime
- provides an interface toDateTime.Now
,DateTime.UtcNow
,Task.Delay
, etc.- The test implementation allows time to stop completely, or run from a fixed point in time
-
HasEnvironment
- provides an interface toSystem.Environment
- The test implementation is entirely in-memory, and can be initialised from the machine's real environment but then updated with mocked data
As well as the traits, the live, and test implementations, there's also the static
wrapper functions to make use easy.
Here's a simple example of reading a line from the console and writing it back out
using LanguageExt;
using LanguageExt.Sys;
using LanguageExt.Sys.Live;
using static LanguageExt.Prelude;
using static LanguageExt.Sys.Console<LanguageExt.Sys.Live.Runtime>;
namespace SimpleExample
{
internal class Program
{
public static void Main(string[] args) =>
Main().Run(Runtime.New())
.ThrowIfFail();
static Eff<Runtime, Unit> Main() =>
repeat(from l in readLine
from _ in writeLine(l)
select unit);
}
}
Note, how I've baked the Runtime
in with a using static
for access to Sys.Console
. This simple application isn't really supporting any injectable IO., but it is abstracting away from side-effects.
The using static
technique is quite useful when building unit-tests, because you know you're always going to be using the test-runtime. For the main application to make it fully unit-testable, you want to do something like this:
using LanguageExt;
using LanguageExt.Sys;
using LanguageExt.Sys.Live;
using LanguageExt.Sys.Traits;
using static LanguageExt.Prelude;
namespace SimpleExample
{
class Program
{
public static void Main(string[] args) =>
Main<Runtime>().Run(Runtime.New())
.ThrowIfFail();
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
repeat(from l in Console<RT>.readLine
from _ in Console<RT>.writeLine(l)
select unit);
}
}
It's starting to look like the Haskell main
! That is intentional. But now Main<RT>()
is also unit testable, by providing a test-runtime as the RT
parameter. And in fact there's a unit-test that looks just like this in the language-ext unit-tests, it uses mocked data to pretend a user is writing the text.
One other interesting part of this is the repeat
function. It will continually re-run the Eff
or Aff
computation until it fails. In our case it simply loops: waiting for user input, printing it to the console, then going back around again. Note, there are fluent variants of all the functions, i.e.
(from l in Console<RT>.readLine
from _ in Console<RT>.writeLine(l)
select unit)
.Repeat();
There are many support functions for Aff
and Eff
that are explicitly in-place to support effectful operations. This is what I meant by being more opinionated about an approach. The other IO like monadic types are more general, the Aff
and Eff
types are being built for the sole reason of representing effects, and mostly IO effects.
The
LanguageExt.Sys
nu-get package will expand over the coming years to cover as much of the .NET BCL as possible, to help functional programmers get a head-start with handling side-effects. Each trait will have both a 'live' and a 'test' implementation provided by default. I would also expect users of this library to start building their own libraries that wrap around (lift) common projects that have side-effects into theAff
andEff
space.
One aspect of using the Aff
and Eff
monads (with a runtime) is that we're forced to declare the traits we need upfront in function and method constraints. This might seem quite annoying to use, especially as it's more effort for every side-effecting function you will write. To a certain extent this is intentional. The idea is to encourage you to partition your application properly: split out the pure functionality from your side-effecting functionality.
If we try to reproduce the lifting of the, pure, Capitalise
function from earlier we would do this:
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
repeat(from l in Console<RT>.readLine
from u in SuccessEff(Capitalise(l))
from _ in Console<RT>.writeLine(u)
select unit);
// A pure function for turning all lower-case characters into upper-case characters
static string Capitalise(string text) =>
new string(text.Map(x => Char.IsLower(x) ? Char.ToUpper(x) : x).ToArray());
The Capitalise
function doesn't need a runtime, it doesn't need any constraints, it is pure.
Some of the awkwardness of the
Aff
andEff
monads will hopefully take you down a much more principled software development route.
One of the core values of this library is to make an ecosystem that just works. Each monadic type has a ToEff
or ToAff
(where appropriate) which does a natural transformation between the types. For example:
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
repeat(from l in Console<RT>.readLine
from v in readInteger(l)
from _ in Console<RT>.writeLine($"{v}")
select unit);
static Eff<int> readInteger(string line) =>
parseInt(line)
.ToEff(Error.New("Integers only please"));
Here we take the parseInt
function from the language-ext Prelude. It returns an Option<int>
. We use ToEff
with a default error value to transform it into a non-runtime Eff
. This example will continue running whilst the user types integer values, and will fail with "Integers only please"
when a non-integer string is typed.
The non-runtime
Eff
andAff
types are there to represent pure values and pure functions that are lifted into the effectful space. You can use them to wrap side-effects too, but then they're acting likeTry<A>
or the firstIO<A>
defined in this post: they're not easily unit testable. So it's better to use them to lift pure values and functions only.
Sometimes we will want a computation to end. We don't want to throw exceptions (although those will be caught automatically by the Eff
and Aff
monad); exceptions should be for exceptional events, often failure is expected.
Internally
Aff
andEff
uses theLanguageExt.Common.Error
type. This has support for exceptional and non-exceptional errors (through error code and messages).
When the Aff
and Eff
monads run, they result in a Fin<A>
. The Fin
monad is equivalent to Either<Error, A
>, i.e. it can either be an Error
or an A
. This is how success or failure is made concrete when a pure Aff
or Eff
is run.
And so to fail, we need to propagate an Error
. This is done with FailEff
or FailAff
:
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
repeat(from l in Console<RT>.readLine
from d in l == ""
? FailEff<Unit>(Error.New("user exited"))
: unitEff
from _ in Console<RT>.writeLine(l)
select unit);
Here we're testing if the user typed anything, if they didn't we fail with "user exited"
. The FailEff
is using a Unit
bound-type, and so we can use the built-in unitEff
, which is an Eff
that always successfully returns a Unit
value (there's also trueEff
and falseEff
) - that means, continue processing because we have a value.
This pattern of testing a predicate and then choosing SuccessEff<Unit>
or FailEff<Unit>
looks a lot like a where
operation, and it is very similar, except where
can't provide an alternative value (the error). This pattern is so common that there is a built in way to do this: guard
:
repeat(from l in Console<RT>.readLine
from f in guard(l != "", Error.New("user exited"))
from _ in Console<RT>.writeLine(l)
select unit);
guard
will only allow the computation to continue if the predicate value is true
, otherwise it fails with the error value. guardnot
is the same but only allows the computation to continue if the predicate value is false
:
repeat(from l in Console<RT>.readLine
from f in guardnot(l == "", Error.New("user exited"))
from _ in Console<RT>.writeLine(l)
select unit);
And so this allows us to shortcut out of the computation, like
None
inOption
, orLeft
inEither
.
Clearly we won't want the entire application to end every time we get an error. We need strategies for dealing with errors in sub-expressions, something like catch
, but functional:
First approach is the |
operator, which will lazily coalesce:
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
AskUser<RT>() | AskUser<RT>() | FailEff<Unit>(Error.New("user really exited"));
static Eff<RT, Unit> AskUser<RT>()
where RT : struct, HasConsole<RT> =>
repeat(from l in Console<RT>.readLine
from f in guardnot(l == "", Error.New("user exited"))
from _ in Console<RT>.writeLine(l)
select unit);
Here we've slightly changed the implementation, we've renamed the Main
function to AskUser
and added a new Main
function. The new Main
function now expects the user to enter two empty lines before really failing.
We could use SuccessEff
to catch any errors and provide a default value if necessary:
AskUser<RT>() | AskUser<RT>() | SuccessEff(unit);
That provides a default value of unit
, and will stop the real entry Main
function from throwing an exception. Obviously, you can use any default value you like as long as the types align with the other operands in the |
expression.
The above can also be written:
AskUser<RT>() | AskUser<RT>() | unitEff;
This approach is very useful, but other monadic types allow you to use various combinations of Match
, IfFail
, IfLeft
, etc. to get a concrete value out of them (and deal with errors). Aff
and Eff
have a similar set of functions for handling the two cases: Succ
and Fail
, however they won't give a concrete value, they themselves will return an Aff
or an Eff
:
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
from r in AskUser<RT>().Match(Succ: x => "success",
Fail: e => "failure")
from _ in Console<RT>.writeLine(r)
select unit;
Here we map both the success and error values to a string, we then continue. The Console<RT>.writeLine(r)
will run with the value of r
.
It is also possible to run sub-expressions in the Match
cases (using MatchEff
and MatchAff
):
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
AskUser<RT>().MatchEff(Succ: x => Console<RT>.writeLine($"{x}"),
Fail: e => Console<RT>.writeLine($"{e}"));
This allows for branching based on the success or failure state, and recovery from error if required.
There are lots of additional helper overloads for MatchEff
and MatchAff
. For example, if we didn't care about the values of the match and just want to branch:
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
AskUser<RT>().MatchEff(Succ: Console<RT>.writeLine($"success"),
Fail: Console<RT>.writeLine($"failure"));
Or, if we want to deal with just the error case:
static Eff<RT, Unit> Main<RT>()
where RT : struct, HasConsole<RT> =>
AskUser<RT>().IfFailEff(e => Console<RT>.writeLine($"{e}"));
This just prints the error to the console and then continues on without ending the computation.
Using Match
or IfFail
is a valid approach to dealing with errors, but in the real-world there's often different types of errors for any one sub-system, and we may want to deal with them differently. We may, from the previous example, consider the user-exiting to be something we can safely ignore - but any exceptions we may want to handle differently.
Here's a refactored version of the previous example. I have moved the RT
to the class definition, which simplifies the definitions of Main
and AskUser
: now they're properties that return an Eff
computation:
public class AffTests<RT>
where RT : struct, HasConsole<RT>
{
static readonly Error UserExited = Error.New(100, "user exited");
static readonly Error SafeError = Error.New(200, "there was a problem");
public static Eff<RT, Unit> main =>
from _1 in askUser
| @catch(ex => ex is SystemException, Console<RT>.writeLine("system error"))
| @catch(SafeError)
from _2 in Console<RT>.writeLine("goodbye")
select unit;
static Eff<RT, Unit> askUser =>
repeat(from ln in Console<RT>.readLine
from _1 in guard(notEmpty(ln), UserExited)
from _2 in guardnot(ln == "sys", () => throw new SystemException())
from _3 in guardnot(ln == "err", () => throw new Exception())
from _4 in Console<RT>.writeLine(ln)
select unit)
| @catch(UserExited, unit);
}
There are a few other things to note here:
- Two error fields have been defined:
UserExited
andSafeError
- By predefining our errors, we can use them to match upon
- By using error codes, the matching will be done using just those codes, not the text
- Useful if localising error messages
- In the
main
computation we're using@catch
to match on the type of error resulting fromaskUser
:- The first one matches on an exceptional error of
SystemException
, if it matches it logs a message to the console, this flips the error into aSuccessEff(unit)
(becauseConsole.writeLine
will always succeed) - The second one matches on any error and will change it to
SafeError
- This can be useful when you don't want to leak sensitive error information across a domain boundary.
- It is also functionally equivalent to the earlier example of
AskUser<RT>() | FailEff<Unit>(SafeError)
- The first one matches on an exceptional error of
- The
askUser
computation now has three guards in it:- One to test for the user pressing enter, which produces a non-exceptional error
- One to test if the user typed
"sys"
and if so, throw aSystemException
- One to test if the user typed
"err"
and if so, throw anException
- It also has a
@catch
in there. We know thatrepeat
will only stop repeating when the inner computation is in an errored state. And so, we catch the error we expect (UserExited
) and flip it to be aSuccessEff(unit)
.
The result of running this is that if the user presses enter on an empty line, they will see the message "goodbye"
shown in the console. If they type "sys"
they will see "system error"
on the screen, followed by "goodbye"
; and if they type "err"
the computation will end without printing "goodbye"
and it will be in a failure state of Error(200, "there was a problem")
.
@catch
therefore is a very powerful way of matching on the exceptional and non-exceptional errors that come out of a side-effecting computation. It allows for errors to be flipped to successes, and allows for errors to be sanitised or processed before being passed on. It works, and feels, a lot like catching exceptions in 'imperative-land', but it's purely functional and has power to work with non-exceptional errors in a way that throwing exceptions can never do.
Cancels the current running Aff<RT, A>
and any forked processes by triggering the cancelation token in the runtime.
Aff<RT, A> cancel<RT>();
public class CancelExample<RT>
where RT: struct,
HasCancel<RT>,
HasConsole<RT>
{
public static Aff<RT, Unit> main =>
repeat(from k in Console<RT>.readKey
from _ in k.Key == ConsoleKey.Enter
? cancel<RT>()
: unitEff
from w in Console<RT>.write(k.KeyChar)
select unit);
}
Repeatedly calls the ma
target effect to retrieve a stream of A
values. Each one is passed to the fold
delegate, along with an aggregate state.
- The fold operation will end when if an error is raised by the
ma
operation - If the
Schedule
expires, then the latestS
state will be returned - Or, if the predicate triggers (
true → false
forfoldWhile
andfalse → true
forfoldUntil
)- This also returns the latest
S
state
- This also returns the latest
There's .Fold
, .FoldWhile
, and .FoldUntil
fluent variants that work with Aff
and Eff
instances.
Aff<S> fold<S, A>(Aff<A> ma, S state, Func<S, A, S> fold);
Eff<S> fold<S, A>(Eff<A> ma, S state, Func<S, A, S> fold);
Aff<S> fold<S, A>(Schedule schedule, Aff<A> ma, S state, Func<S, A, S> fold);
Eff<S> fold<S, A>(Schedule schedule, Eff<A> ma, S state, Func<S, A, S> fold);
Aff<RT, S> fold<RT, S, A>(Aff<RT, A> ma, S state, Func<S, A, S> fold);
Eff<RT, S> fold<RT, S, A>(Eff<RT, A> ma, S state, Func<S, A, S> fold);
Aff<RT, S> fold<RT, S, A>(Schedule schedule, Aff<RT, A> ma, S state, Func<S, A, S> fold);
Eff<RT, S> fold<RT, S, A>(Schedule schedule, Eff<RT, A> ma, S state, Func<S, A, S> fold);
Aff<S> foldWhile<S, A>(Aff<A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Eff<S> foldWhile<S, A>(Eff<A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Aff<S> foldWhile<S, A>(Schedule schedule, Aff<A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Eff<S> foldWhile<S, A>(Schedule schedule, Eff<A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Aff<RT, S> foldWhile<RT, S, A>(Aff<RT, A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Eff<RT, S> foldWhile<RT, S, A>(Eff<RT, A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Aff<RT, S> foldWhile<RT, S, A>(Schedule schedule, Aff<RT, A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Eff<RT, S> foldWhile<RT, S, A>(Schedule schedule, Eff<RT, A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Aff<S> foldUntil<S, A>(Aff<A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Eff<S> foldUntil<S, A>(Eff<A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Aff<S> foldUntil<S, A>(Schedule schedule, Aff<A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Eff<S> foldUntil<S, A>(Schedule schedule, Aff<A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Aff<RT, S> foldUntil<RT, S, A>(Aff<RT, A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Eff<RT, S> foldUntil<RT, S, A>(Eff<RT, A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Aff<RT, S> foldUntil<RT, S, A>(Schedule schedule, Aff<RT, A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Eff<RT, S> foldUntil<RT, S, A>(Eff<RT, A> ma, S state, Func<S, A, S> fold, Func<A, bool> pred);
Causes the effect to run without being awaited. This allows for many sub-tasks to be launched without stalling a parent task.
The Aff<RT, A>
version of this will return an Eff<Unit>
as its bound value. This Eff<Unit>
can be used to cancel the running forked operation. Aff<A>
effects can't be cancelled and are effectively fire and forget.
There's a .Fork
fluent variant that works with Aff
instances
Eff<RT, Eff<Unit>> fork<RT, A>(Aff<RT, A> ma)
Eff<Unit> fork<A>(Aff<A> ma)
This example forks the inner
effect, which loops over digit
ten times, each with a delay of 1 second. Each time it prints a *
to the screen. After ten iterations the sum of 1+1+1+1+1+1+1+1+1+1
is shown on the screen.
The parent effect (that launched the forked operation) will immediately continue to the readKey
stage, waiting for a key-press. If the user presses a key before the 10 seconds is up, then cancel
runs and forces the forked operation to quit - therefore not showing the total.
public class ForkCancelExample<RT>
where RT: struct,
HasCancel<RT>,
HasConsole<RT>,
HasTime<RT>
{
public static Aff<RT, Unit> main =>
from cancel in fork(inner)
from key in Console<RT>.readKey
from _1 in cancel
from _2 in Console<RT>.writeLine("done")
select unit;
static Aff<RT, Unit> inner =>
from x in sum
from _ in Console<RT>.writeLine($"total: {x}")
select unit;
static Aff<RT, int> sum =>
digit.Fold(Schedule.Recurs(10) | Schedule.Spaced(1*second), 0, (s, x) => s + x);
static Aff<RT, int> digit =>
from one in SuccessAff<RT, int>(1)
from _ in Console<RT>.writeLine("*")
select one;
}
Repeats the effect until:
- An error occurs - the error is propagated
- The
Schedule
expires - the last value from the effect is propagated - The
predicate
returnsfalse
forrepeatWhile
- the last value from the effect is propagated - The
predicate
returnstrue
forrepeatUntil
- the last value from the effect is propagated
NOTE: Using a
Schedule
that has a time-delay in will block synchronous effect threads (Eff
). It's best to use schedule delays withAff
There's a .Repeat
fluent variant that works with Aff
and Eff
instances
Aff<A> repeat<A>(Aff<A> ma);
Eff<A> repeat<A>(Eff<A> ma);
Aff<A> repeat<A>(Schedule schedule, Aff<A> ma);
Eff<A> repeat<A>(Schedule schedule, Eff<A> ma);
Aff<RT, A> repeat<RT, A>(Aff<RT, A> ma);
Eff<RT, A> repeat<RT, A>(Eff<RT, A> ma);
Aff<RT, A> repeat<RT, A>(Schedule schedule, Aff<RT, A> ma);
Eff<RT, A> repeat<RT, A>(Schedule schedule, Eff<RT, A> ma);
Aff<A> repeatWhile<A>(Aff<A> ma, Func<A, bool> predicate);
Eff<A> repeatWhile<A>(Eff<A> ma, Func<A, bool> predicate);
Aff<A> repeatWhile<A>(Schedule schedule, Aff<A> ma, Func<A, bool> predicate);
Eff<A> repeatWhile<A>(Schedule schedule, Eff<A> ma, Func<A, bool> predicate);
Aff<RT, A> repeatWhile<RT, A>(Aff<RT, A> ma, Func<A, bool> predicate);
Eff<RT, A> repeatWhile<RT, A>(Eff<RT, A> ma, Func<A, bool> predicate);
Aff<RT, A> repeatWhile<RT, A>(Schedule schedule, Aff<RT, A> ma, Func<A, bool> predicate);
Eff<RT, A> repeatWhile<RT, A>(Schedule schedule, Eff<RT, A> ma, Func<A, bool> predicate);
Aff<A> repeatUntil<A>(Aff<A> ma, Func<A, bool> predicate);
Eff<A> repeatUntil<A>(Eff<A> ma, Func<A, bool> predicate);
Aff<A> repeatUntil<A>(Schedule schedule, Aff<A> ma, Func<A, bool> predicate);
Eff<A> repeatUntil<A>(Schedule schedule, Eff<A> ma, Func<A, bool> predicate);
Aff<RT, A> repeatUntil<RT, A>(Aff<RT, A> ma, Func<A, bool> predicate);
Eff<RT, A> repeatUntil<RT, A>(Eff<RT, A> ma, Func<A, bool> predicate);
Aff<RT, A> repeatUntil<RT, A>(Schedule schedule, Aff<RT, A> ma, Func<A, bool> predicate);
Eff<RT, A> repeatUntil<RT, A>(Schedule schedule, Eff<RT, A> ma, Func<A, bool> predicate);
This example repeatedly writes the current time to the console. Each loop there's a delay that grows using the fibonnaci sequence, and therefore gets larger and larger, it is clamped to a maximum delay of 10 seconds. After 15 iterations the process ends.
public class TimeExample<RT>
where RT : struct,
HasTime<RT>,
HasCancel<RT>,
HasConsole<RT>
{
public static Eff<RT, Unit> main =>
repeat(Schedule.Spaced(10 * second) | Schedule.Recurs(15) | Schedule.Fibonacci(1*second),
from tm in Time<RT>.now
from _1 in Console<RT>.writeLine(tm.ToLongTimeString())
select unit);
}
Retries the effect until the effect succeeds, unless:
- The
Schedule
expires - the last error from the effect is propagated - The
predicate
returnsfalse
forretryWhile
- the last error from the effect is propagated - The
predicate
returnstrue
forretryUntil
- the last error from the effect is propagated
NOTE: Using a
Schedule
that has a time-delay in will block synchronous effect threads (Eff
). It's best to use schedule delays withAff
There's a .Retry
fluent variant that works with Aff
and Eff
instances
Aff<A> retry<A>(Aff<A> ma);
Eff<A> retry<A>(Eff<A> ma);
Aff<A> retry<A>(Schedule schedule, Aff<A> ma);
Eff<A> retry<A>(Schedule schedule, Eff<A> ma);
Aff<RT, A> retry<RT, A>(Aff<RT, A> ma);
Eff<RT, A> retry<RT, A>(Eff<RT, A> ma);
Aff<RT, A> retry<RT, A>(Schedule schedule, Aff<RT, A> ma);
Eff<RT, A> retry<RT, A>(Schedule schedule, Eff<RT, A> ma);
Aff<A> retryWhile<A>(Aff<A> ma, Func<Error, bool> predicate);
Eff<A> retryWhile<A>(Eff<A> ma, Func<Error, bool> predicate);
Aff<A> retryWhile<A>(Schedule schedule, Aff<A> ma, Func<Error, bool> predicate);
Eff<A> retryWhile<A>(Schedule schedule, Eff<A> ma, Func<Error, bool> predicate);
Aff<RT, A> retryWhile<RT, A>(Aff<RT, A> ma, Func<Error, bool> predicate);
Eff<RT, A> retryWhile<RT, A>(Eff<RT, A> ma, Func<Error, bool> predicate);
Aff<RT, A> retryWhile<RT, A>(Schedule schedule, Aff<RT, A> ma, Func<Error, bool> predicate);
Eff<RT, A> retryWhile<RT, A>(Schedule schedule, Eff<RT, A> ma, Func<Error, bool> predicate);
Aff<A> retryUntil<A>(Aff<A> ma, Func<Error, bool> predicate);
Eff<A> retryUntil<A>(Eff<A> ma, Func<Error, bool> predicate);
Aff<A> retryUntil<A>(Schedule schedule, Aff<A> ma, Func<Error, bool> predicate);
Eff<A> retryUntil<A>(Schedule schedule, Eff<A> ma, Func<Error, bool> predicate);
Aff<RT, A> retryUntil<RT, A>(Aff<RT, A> ma, Func<Error, bool> predicate);
Eff<RT, A> retryUntil<RT, A>(Eff<RT, A> ma, Func<Error, bool> predicate);
Aff<RT, A> retryUntil<RT, A>(Schedule schedule, Aff<RT, A> ma, Func<Error, bool> predicate);
Eff<RT, A> retryUntil<RT, A>(Schedule schedule, Eff<RT, A> ma, Func<Error, bool> predicate);
This example repeatedly writes the current time to the console. Each loop there's a delay that grows using the fibonnaci sequence, and therefore gets larger and larger, it is clamped to a maximum delay of 10 seconds. After 15 iterations the process ends.
This example asks the user to say hello. If they don't type "hello"
then an error is raised. This is caught by the retry
and the question is asked again.
There's a Schedule
that states we must retry at-most 5 times. If the user doesn't answer with "hello"
in 5 attempts then the error is propagated.
public class RetryExample<RT>
where RT : struct,
HasCancel<RT>,
HasConsole<RT>
{
readonly static Error Failed =
("I asked you to say hello, and you can't even do that?!");
public static Eff<RT, Unit> main =>
retry(Schedule.Recurs(5),
from _ in Console<RT>.writeLine("Say hello")
from t in Console<RT>.readLine
from e in guard(t == "hello", Failed)
from m in Console<RT>.writeLine("Hi")
select unit);
}
[TODO]
[TODO]
[TODO]
[TODO]
AffMaybe(..)
Aff(..)
EffMaybe(..)
Eff(..)
SuccessEff(..)
FailEff(..)
[TODO]
- How
Aff
andEff
work together - Parallel processing
- Infinite folds
[TODO]
[TODO]
[TODO]
- Using LINQ and the
||
operation
[TODO]
use
[TODO]
-
Run
once inMain
-
Run
on each web-request
WIP -- more to come