IO container in a parallel C# universe by Mark Seemann
A C# model of IO at the type level.
This article is part of an article series about the IO container in C#. The previous article provided a conceptual overview. In this article you'll see C# code examples.
In a world... #
Imagine a parallel universe where a C# entry point is supposed to look like this:
static IO<Unit> Main(string[] args)
Like another Neo, you notice that something in your reality is odd, so you decide to follow the white rabbit. Unit
? What's that? Navigating to its definition, you see this:
public sealed class Unit { public static readonly Unit Instance; }
There's not much to see here. Unit
is a type that serves the same role as the void
keyword. They're isomorphic, but Unit
has the advantage that it's a proper type. This means that it can be a type parameter in a generically typed container like IO
.
So what's IO
? When you view its definition, this is what you see:
public sealed class IO<T> { public IO(Func<T> item) public IO<TResult> SelectMany<TResult>(Func<T, IO<TResult>> selector) }
There's a constructor you can initialise with a lazily evaluated value, and a SelectMany
method that looks strikingly like something out of LINQ.
You'll probably notice right away that while you can put a value into the IO
container, you can't get it back. As the introductory article explained, this is by design. Still, you may think: What's the point? Why would I ever want to use this class?
You get part of the answer when you try to implement your program's entry point. In order for it to compile, the Main
method must return an IO<Unit>
object. Thus, the simplest Main
method that compiles is this:
static IO<Unit> Main(string[] args) { return new IO<Unit>(() => Unit.Instance); };
That's only a roundabout no-op. What if you want write real code? Like hello world?
Impure actions #
You'd like to write a program that asks the user about his or her name. Based on the answer, and the time of day, it'll write Hello, Nearth, or Good evening, Kate. You'd like to know how to take user input and write to the standard output stream. In this parallel world, the Console
API looks like this:
public static class Console { public static IO<string> ReadLine(); public static IO<Unit> WriteLine(string value); // More members here... }
You notice that both methods return IO
values. This immediately tells you that they're impure. This is hardly surprising, since ReadLine
is non-deterministic and WriteLine
has a side effect.
You'll also need the current time of day. How do you get that?
public static class Clock { public static IO<DateTime> GetLocalTime(); }
Again, IO
signifies that the returned DateTime
value is impure; it's non-deterministic.
Pure logic #
A major benefit of functional programming is the natural separation of concerns; separation of business logic from implementation details (a.k.a. the Dependency Inversion Principle).
Write the logic of the program as a pure function:
public static string Greet(DateTime now, string name) { var greeting = "Hello"; if (IsMorning(now)) greeting = "Good morning"; if (IsAfternoon(now)) greeting = "Good afternoon"; if (IsEvening(now)) greeting = "Good evening"; if (string.IsNullOrWhiteSpace(name)) return $"{greeting}."; return $"{greeting}, {name.Trim()}."; }
You can tell that this is a pure function from its return type. In this parallel universe, all impure library methods look like the above Console
and Clock
methods. Thus, by elimination, a method that doesn't return IO
is pure.
Composition #
You have impure actions you can invoke, and a pure piece of logic. You can use ReadLine
to get the user's name, and GetLocalTime
to get the local time. When you have those two pieces of data, you can call the Greet
method.
This is where most people run aground. "Yes, but Greet needs a string, but I have an IO<string>. How do I get the string out of the IO?
If you've been following the plot so far, you know the answer: mu. You don't. You compose all the things with SelectMany
:
static IO<Unit> Main(string[] args) { return Console.WriteLine("What's your name?").SelectMany(_ => Console.ReadLine().SelectMany(name => Clock.GetLocalTime().SelectMany(now => { var greeting = Greeter.Greet(now, name); return Console.WriteLine(greeting); }))); }
I'm not going to walk through all the details of how this works. There's plenty of monad tutorials out there, but take a moment to contemplate the SelectMany
method's selector
argument. It takes a plain T
value as input, but must return an IO<TResult>
object. That's what each of the above lambda expressions do, but that means that name
and now
are unwrapped values; i.e. string
and DateTime
.
That means that you can call Greet
with name
and now
, which is exactly what happens.
Notice that the above lambda expressions are nested. With idiomatic formatting, they'd exhibit the dreaded arrow shape, but with this formatting, it looks more like the sequential composition that it actually is.
Conclusion #
The code you've seen here all works. The only difference between this hypothetical C# and the real C# is that your Main
method can't look like that, and impure library methods don't return IO
values.
The point of the article isn't to recommend this style of programming. You can't, really, since it relies on the counter-factual assumption that all impure library methods return IO
. The point of the article is to explain how a language like Haskell uses the type system to distinguish between pure functions and impure actions.
Perhaps the code in that Main
method isn't the prettiest code you've ever seen, but we can make it a little nicer. That's the topic of the next article in the series.
Next: Syntactic sugar for IO.
Comments
Why is the input of the IO constructor of type
Lazy<T>
instead of just typeT
?Tyson, thank you for writing. A future article in this article series will answer that question 😀
FWIW, the promised article is now available.
Ah, sure. I was thinking that
IO<T>
is a monad (inT
), so there should be a constructor with argumentT
. However, the function doens't need to be a constructor. The lambda expressiont => new IO<T>(new Lazy<T>(() => t))
satisifies the requirement.Yes 👍