Object-Oriented Haskell
Part 3: Generalized Mutability
Nov 21, 2017
In Part 1, we have seen how an interface-based OOP model can be implemented in idiomatic Haskell, and we then extended this model to allow for mutable objects by using IORef
in Part 2. In this part, we will extend the model further to decouple the mutable-variable implementation from mutability in the objects themselves.
Generalizing Mutable Variables
Let’s look at the bare minimum interface for mutable variables. We need the following building blocks:
- A type for our mutable variables; since we want variables of all sorts of payload types, this is going to be a type of kind `* -> *
. If we're going to use
IORefs , then
IORefis that type, and it does have the right kind. Let's use a completely arbitrary type variable name for this type,
v`. - A type to represent effectful computations that involve mutable variables. This type, too, is going to be of kind `* -> *
, and it is going to somehow be linked to the
vtype. We will pick another entirely arbitrary type variable,
m. For
IORefs, this type is going to be
IO`. - A function to create a new variable:
newVar :: a -> m (v a)
- A function to read a variable:
readVar :: v a -> m a
- A function to write a variable:
writeVar :: v a -> a -> m ()
We can implement these primitives for IORef
like this:
newVar :: a -> IO (IORef a)
= newIORef
newVar
readVar :: IORef a -> IO a
= readIORef
readVar
writeVar :: IORef a -> a -> IO ()
= writeIORef writeVar
Let’s look at another mutable-variable type: TVar
.
newVar :: a -> STM (TVar a)
= newTVar
newVar
readVar :: TVar a -> STM a
= readTVar
readVar
writeVar :: TVar a -> a -> STM ()
= writeTVar writeVar
Obviously the m
type for TVar
is STM
.
Mutability Typeclasses
The above means that we will want the three functions there to be polymorphic over m
and v
. The idiomatic way of achieving that in Haskell is to use a typeclass; since two types are involved here, we are going to need the MultiParamTypeClasses
extension here. So:
class MutableVars v m where
newVar :: a -> m (v a)
readVar :: v a -> m a
writeVar :: v a -> a -> m ()
This works, but we will take a slightly different approach: instead of one typeclass, we will introduce two of them:
class ReadVar v m where
Write:: v a -> m a
readVar
class WriteVar v m where
newVar :: a -> m (v a)
writeVar :: v a -> a -> m ()
In practice, we will address two additional concerns: 1. The v
type is not generally going to be mentioned in our method signatures, which means that we need to somehow tell the compiler how to infer v
from a given m
. 2. Whenever we are going to write to a variable, this will generally also involve reading from variables, either the same one or another.
Issue 2 is easy to address: we will simply use subclassing to make sure that WriteVar
always implies ReadVar
. So:
class ReadVar v m where
Write:: v a -> m a
readVar
class ReadVar v m => WriteVar v m where
newVar :: a -> m (v a)
writeVar :: v a -> a -> m ()
GHC has two extensions that can both solve the second problem: FunctionalDependencies
(a.k.a. fundeps), and TypeFamilies
. Using fundeps, this is what the typeclasses end up looking like:
class ReadVar v m | m -> v where
Write:: v a -> m a
readVar
class ReadVar v m => WriteVar v m | m -> v where
newVar :: a -> m (v a)
writeVar :: v a -> a -> m ()
For those unfamiliar with fundeps, the m -> v
part means “the choice of v
depends on the choice of m
”, or, put differently, “the choice of m
determines the choice of v
”. Meaning that once we have written one instance for any combination of m
and v
, we are not allowed to write another instance that involves the same m
.
With this functional dependency in place, we can specify just the m
and have the compiler infer the correct v
from it.
And now we’ll write instances:
instance ReadVar TVar STM where
= readTVar
readVar
instance WriteVar TVar STM where
= newTVar
newVar = writeTVar
writeVar
instance ReadVar IORef IO where
= readIORef
readVar
instance WriteVar IORef IO where
= newIORef
newVar = writeIORef writeVar
Mutable Objects Generalized
Armed with the above, let’s generalize our mutable objects and interfaces, starting with the Renderable
interface:
data Renderable v
= Renderable
render :: forall m. ReadVar m v => Renderable v -> Graphics -> m ()
{ }
The Label
type also needs to be generalized:
data Label v
= Label
labelPosition :: v Position
{ labelText :: v String
,
}
newLabel :: (Applicative m, WriteVar v m)
=> Position
-> String
-> m (Label v)
=
newLabel position txt Label <$> newVar position
<*> newVar txt
instance (Label v) `Is` (Renderable v) where
=
cast label Renderable
= \this g -> do
{ render <- readVar (labelPosition label)
position <- readVar (labelText label)
txt AlignLeft AlignBaseline position txt
drawText g }
Conclusion
We have achieved two important goals here.
First, neither the Label
type, nor the Render
interface, nor the Render
implementation for Label
, mention IO
or IORef
anywhere, they are completely expressed in terms of the abstract ReadVar
and WriteVar
typeclasses. We have decoupled mutability semantics from mutability implementation. And this means that we can now use them in an STM
context without further ado - at least if we also generalize our drawing primitives:
$ do
atomically <- newLabel "Hello!"
lbl ==> render lbl
Second, by splitting up the mutability typeclass into “read-only access” and “read-write access” typeclasses, we can declare some methods as “read-only”, and as long as our ReadVar
instances are lawful, it will be impossible to implement them such that they mutate anything. At the same time, if we want to call such a read-only method from a read-write context, we can, because every read-write context is also a read-only context (because ReadVar v m => WriteVar v m
), very much similar to how const
works in C++.
By the way, the OOP framework laid out in this blog series is also available on Hackage, under the name boop, feel free to read along and see what it looks like when you put it all together.