Digital circuit simulator in Haskell (SICP 3.3)
I have a copy of sicp, or as it is also known, The Wizard<br>Book.11 Structure and Interpretation of Computer Programs; Abelson and<br>Sussman; mit Press; 1996. This book is widely praised, but I can’t take the<br>time to work my way through all of it. Instead, I’m going to occasionally jump<br>into the parts of it that look interesting.
In the previous two installations of this series, we looked at ways of<br>implementing generic functions. In the first one, by tagging values and<br>dispatching operations on tags. In the second case, by filling a mutable table<br>of operation–tag pairs. We saw how these are roughly equivalent to the existing<br>Haskell features of sum types and type classes.
Now we’ll simulate a digital circuit. The reason this is interesting is that the<br>solution in sicp uses hidden mutable state and message-passing to make the<br>code object-oriented. It even uses a mutable global variable for scheduling! I<br>wasn’t sure if it would be possible to replicate that in Haskell, but as we’ll<br>see, it can get incredibly close.
Haskell does have mutable variables
There are many ways to implement mutable state in Haskell, but to begin with,<br>we’ll use the way that’s most similar to the sicp solution: IORefs. An<br>IORef is a mutable variable, like in any other programming language.22 The<br>only major difference is that we need a bit of extra machinery to read from<br>it, because it’s designed so that we cannot accidentally read from it in pure<br>code. (That would make the code impure, after all.)
We define a type Wire, which starts out with its signal Low, and with an<br>empty list of action procedures.
The idea of action procedures comes from the sicp implementation of this code;<br>action procedures are subscribers to the signal on this wire, and they will be<br>invoked whenever the signal changes. Action procedures will be installed by<br>components connected downstream of this wire, so those components can update<br>themselves when the wire value changes.
In[1]:<br>data Signal = Low | High<br>deriving (Show, Eq, Ord, Bounded)
data Wire = Wire<br>{ action_procedures :: IORef [IO ()]<br>, signal_value :: IORef Signal
make_wire = do<br>initial_procs newIORef []<br>initial_signal newIORef Low<br>pure (Wire initial_procs initial_signal)
We can get the signal from a wire by reading the mutable variable holding its<br>current signal.
In[2]:<br>get_signal (Wire _ signal) =<br>readIORef signal
To set a signal on a wire we write to the mutable variable. If this causes a<br>state change of the wire, we’ll also run the installed action procedures to let<br>downstream components know.33 This is the observer pattern, in case it sounds<br>familiar.
In[3]:<br>set_signal (Wire procs signal) new_value = do<br>current readIORef signal<br>when (new_value /= current) $ do<br>writeIORef signal new_value<br>actions readIORef procs<br>sequence_ actions
Finally, we have a method on the wire that lets downstream components install<br>new action procedures. To ensure wires are initialised with the proper value<br>when components are connected, we immediately run all action procedures we<br>install.
In[4]:<br>add_action (Wire procs _) action = do<br>modifyIORef procs (action:)<br>action
A probe in the sicp implementation is an action procedure installed on a<br>wire that reports the value of the wire to the user of the program.
In[5]:<br>probe name wire =<br>add_action wire $ do<br>current get_signal wire<br>putStrLn (name <> " new value: " <> show current)
At this point the wire object is done, and we can experiment with it in the<br>repl. We create a wire and add a probe to it. If we set its signal to the same<br>thing it was before, nothing changes. If we set its signal to a new value, the<br>probe fires.
In[6]:<br>λ> w make_wire<br>λ> probe "wire" w<br>wire new value: Low<br>λ> set_signal w Low<br>λ> set_signal w High<br>wire new value: High
Then we can start to implement the most basic components. An inverter is, as in<br>the sicp implementation, an action procedure on the input wire that sets the<br>signal on its output wire to the opposite of the input.
In[7]:<br>inverter input output =<br>add_action input $ do<br>current get_signal input<br>set_signal output $ case current of<br>Low -> High<br>High -> Low
The and_gate works similarly, except it reads two inputs before determining<br>its output.
In[8]:<br>and_gate a1 a2 output =<br>let<br>action = do<br>c1 get_signal a1<br>c2 get_signal a2<br>set_signal output $ case (c1, c2) of<br>(Low, Low) -> Low<br>(Low, High) -> Low<br>(High, High) -> High<br>(High, Low) -> Low<br>in do<br>add_action a1 action<br>add_action a2 action
The or_gate is like the and_gate except with a different truth<br>table.44 Like a true designer of digital circuits, I have put the truth table<br>in Grey code.
In[9]:<br>or_gate a1 a2 output =<br>let<br>action = do<br>c1 get_signal a1<br>c2 get_signal a2<br>set_signal output $ case (c1, c2) of<br>(Low, Low) -> Low<br>(Low, High) -> High<br>(High, High) -> High<br>(High, Low) -> High<br>in do<br>add_action a1 action<br>add_action a2 action
So far, we have invented nothing here; this is all in...