MTL-style circuit simulator
After the first attempt at making a very side-effectful circuit simulator, and then an ambitious attempt at making it pure, I was reminded of a middle ground, using a pattern common in production Haskell code. It is called mtl-style effects, and it is a specific type of final tagless encoding.
The idea is that we first find a set of operations that our circuit simulator needs to support. Drawing this boundary well requires some experience and taste. Then we encode that limited set of operations into a type class. Here’s one way to do it.
class CircuitOperations m where
make_wire :: m Wire
get_signal :: Wire -> m Signal
set_signal :: Wire -> Signal -> m ()
add_action :: Wire -> m () -> m ()
after_delay :: Int -> m () -> m ()
get_time :: m Int
probe_output :: String -> m ()
propagate :: m ()
Having read the previous article, these operations should sound familiar. I’m sure it’s possible to create a better set of operations, but this will suffice to illustrate the point.
Previously, the get_signal function had type
get_signal :: Wire -> IO Signal
which means that it takes a wire, and to execute its effects and return a signal
value, it must run in the IO context. This context allows the implementation
to do all kinds of naughtiness, like reading files or launching missiles. With
this type signature, the function knows it runs in the IO context, so it can
do all those things.
With the new type class, the signature is instead
get_signal :: CircuitOperations m => Wire -> m Signal
This hides from the get_signal function which context it runs in. The
actual, concrete context it will run in is going to be selected at some point
later by the compiler, and chucked into the variable m. But the implementation
of get_signal has no idea what context it will run in. All get_signal knows
is that whatever context it gets, it will be a context that supports
CircuitOperations.
Thus, even if the function ends up running in the IO context, the
implementation doesn’t know that, so it won’t be able to launch missiles. All
the implementation is allowed to do is call the operations specified in the
CircuitOperations interface.
Even components we define outside of this set of operations are now implemented in terms of that interface. Previously we had e.g.
inverter :: Wire -> Wire -> IO ()
inverter input output =
add_action input $ do
--------------- >8 -------
Now, with no change of any code, this function has type
inverter :: CircuitOperations m => Wire -> Wire -> m ()
Thus, other component implementations are also no longer allowed to perform
arbitrary side effects. They can only do whatever is allowed by the
CircuitOperations interface. This does not mean a component cannot misbehave;
it can, for example, unschedule other component’s effects. But it’s restricted
to a smaller set of misbehaviour than before.
So far we have only have the specification of the CircuitOperations interface,
and the components that use it. We don’t have an implementation of it, so in
practice it is impossible to run this program, because the compiler won’t know
which type to instantiate for that interface.
This is where we will be cheeky. Without changing our previous implementations of the operations involved, we will simply move them into an instance definition for this interface.
instance CircuitOperations IO where
make_wire = do
initial_procs <- newIORef []
initial_signal <- newIORef Low
pure (Wire initial_procs initial_signal)
get_signal (Wire _ signal) =
readIORef signal
--------------- >8 -------
This definition says that the IO type supports all circuit operations. We
already knew that, because the previous version of the program ran only in IO.
What this definition does is teach the compiler how that happens, using the
exact same implementations as before.
In fact, at this point, the only type that supports all circuit operations is
the IO type! So when the compiler is faced with the choice of which type to
pick for simulating the circuit, it will pick IO. At run-time, everything will
happen in IO, just as it did before.
But that’s not the point! The point is that in the code, the circuit simulation
is now restricted to a smaller set of circuit simulation operations, rather than
the whole wide world of IO. This doesn’t make it pure, because it’s still
impossible to run it from any context other than IO. But it does make it a
little pure…er?
Postscriptum: Due to the mutable global variable the_agenda, we cannot
implement this interface for any other type than IO. But we could, for
example, add the agenda-specific operations to this interface (or a
sub-interface) and then implement the interface with a type that encapsulates
the impurity, like ST.
Then our components would still be generic and be able to run in IO, but they
could also run in ST. This is useful because IO is convenient when possible,
but ST is executable from a pure context because it encapsulates its impurity.
It’s also useful because it unties the components from the specific context they’re implemented in. It clearly delineates which operations make up a circuit simulation.