The IO Container by Mark Seemann
How a type system can distinguish between pure and impure code.
Referential transparency is the foundation of functional architecture. If you categorise all operations into pure functions and impure actions, then most other traits associated with functional programming follow.
Unfortunately, mainstream programming languages don't distinguish between pure functions and impure actions. Identifying pure functions is tricky, and the knowledge is fleeting. What was a pure function today may become impure next time someone changes the code.
Separating pure and impure code is important. It'd be nice if you could automate the process. Perhaps you could run some tests, or, even better, make the compiler do the work. That's what Haskell and a few other languages do.
In Haskell, the distinction is made with a container called IO
. This static type enforces the functional interaction law at compile time: pure functions can't invoke impure actions.
Opaque container #
Regular readers of this blog know that I often use Haskell to demonstrate principles of functional programming. If you don't know Haskell, however, its ability to guarantee the functional interaction law at compile time may seem magical. It's not.
Fortunately, the design is so simple that it's easy to explain the fundamental concept: Results of impure actions are always enclosed in an opaque container called IO. You can think of it as a box with a label.
The label only tells you about the static type of the value inside the box. It could be an int
, a DateTime
, or your own custom type, say Reservation
. While you know what type of value is inside the box, you can't see what's in it, and you can't open it.
Name #
The container itself is called IO
, but don't take the word too literally. While all I/O (input/output) is inherently impure, other operations that you don't typically think of as I/O is impure as well. Generation of random numbers (including GUIDs) is the most prominent example. Random number generators rely on the system clock, which you can think of as an input device, although I think many programmers don't.
I could have called the container Impure instead, but I chose to go with IO, since this is the word used in Haskell. It also has the advantage of being short.
What's in the boooox? #
A question frequently comes up: How do I get the value out of my IO? As always, the answer is mu. You don't. You inject the desired behaviour into the container. This goes for all monads, including IO.
But naturally you wonder: If you can't see the value inside the IO box then what's the point?
The point is to enforce the functional interaction law at the type level. A pure function that calls an impure action will receive a sealed, opaque IO box. There's no API that enables a pure function to extract the contents of the container, so this effectively enforces the rule that pure functions can't call impure actions.
The other three types of interactions are still possible.
- Pure functions should be able to call pure functions. Pure functions return 'normal' values (i.e. values not hidden in IO boxes), so they can call each other as usual.
- Impure actions should be able to call pure functions. This becomes possible because you can inject pure behaviour into any monad. You'll see example of that in later articles in this series.
- Impure actions should be able to call other impure actions. Likewise, you can compose many IO actions into one IO action via the IO API.
On the other hand, if you're already inside the box, you can see the contents. And there's one additional rule: If you're already inside an IO box, you can open other IO boxes and see their contents!
In subsequent articles in this article series, you'll see how all of this manifests as C# code. This article gives a high-level view of the concept. I suggest that you go back and re-read it once you've seen the code.
The many-worlds interpretation #
If you're looking for metaphors or other ways to understand what's going on, there's two perspectives I find useful. None of them offer the full picture, but together, I find that they help.
A common interpretation of IO is that it's like the box in which you put Schrödinger's cat. IO<Cat>
can be viewed as the superposition of the two states of cat (assuming that Cat
is basically a sum type with the cases Alive
and Dead
). Likewise, IO<int>
represents the superposition of all 4,294,967,296 32-bit integers, IO<string>
the superposition of infinitely many strings, etcetera.
Only when you observe the contents of the box does the superposition collapse to a single value.
But... you can't observe the contents of an IO box, can you?
The black hole interpretation #
The IO container represents an impenetrable barrier between the outside and the inside. It's like a black hole. Matter can fall into a black hole, but no information can escape its event horizon.
In high school I took cosmology, among many other things. I don't know if the following is still current, but we learned a formula for calculating the density of a black hole, based on its mass. When you input the estimated mass of the universe, the formula suggests a density near vacuum. Wait, what?! Are we actually living inside a black hole? Perhaps. Could there be other universes 'inside' black holes?
The analogy to the IO container seems apt. You can't see into a black hole from the outside, but once beyond the blue event horizon, you can observe everything that goes on in that interior universe. You can't escape to the original universe, though.
As with all metaphors, this one breaks down if you think too much about it. Code running in IO can unpack other IO boxes, even nested boxes. There's no reason to believe that if you're inside a black hole that you can then gaze beyond the event horizon of nested black holes.
Code examples #
In the next articles in this series, you'll see C# code examples that illustrate how this concept might be implemented. The purpose of these code examples is to give you a sense for how IO
works in Haskell, but with more familiar syntax.
- IO container in a parallel C# universe
- Syntactic sugar for IO
- Referential transparency of IO
- Implementation of the C# IO container
- Task asynchronous programming as an IO surrogate
Conclusion #
When you saw the title, did you think that this would be an article about IoC Containers? It's not. The title isn't a typo, and I never use the term IoC Container. As Steven and I explain in our book, Inversion of Control (IoC) is a broader concept than Dependency Injection (DI). It's called a DI Container.
IO, on the other hand, is a container of impure values. Its API enables you to 'build' bigger structures (programs) from smaller IO boxes. You can compose IO actions together and inject pure functions into them. The boxes, however, are opaque. Pure functions can't see their contents. This effectively enforces the functional interaction law at the type level.
Comments
Come on, you know there is a perfect metaphor. Monads are like burritos.
Christer, I appreciate that this is all in good fun 🤓
For the benefit of readers who may still be trying to learn these concepts, I'll point out that just as this isn't an article about IoC containers, it's not a monad tutorial. It's an explanation of a particular API called IO, which, among other traits, also forms a monad. I'm trying to downplay that aspect here, because I hope that you can understand most of this and the next articles without knowing what a monad is.
"While you know what type of value is inside the box, you can't see what's in it, and you can't open it."
Well you technically can, with unsafePerformIO ;) although it defeats the whole purpose.