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 useIORef
s , thenIORef
is 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 thev
type. We will pick another entirely arbitrary type variable,m
. ForIORef
s, this type is going to beIO
. - 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)
newVar = newIORef
readVar :: IORef a -> IO a
readVar = readIORef
writeVar :: IORef a -> a -> IO ()
writeVar = writeIORef
Let's look at another mutable-variable type: TVar
.
newVar :: a -> STM (TVar a)
newVar = newTVar
readVar :: TVar a -> STM a
readVar = readTVar
writeVar :: TVar a -> a -> STM ()
writeVar = writeTVar
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
readVar Write:: v a -> m a
class WriteVar v m where
newVar :: a -> m (v a)
writeVar :: v a -> a -> m ()
In practice, we will address two additional concerns:
- 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 inferv
from a givenm
. - 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
readVar Write:: v a -> m a
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
readVar Write:: v a -> m a
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
readVar = readTVar
instance WriteVar TVar STM where
newVar = newTVar
writeVar = writeTVar
instance ReadVar IORef IO where
readVar = readIORef
instance WriteVar IORef IO where
newVar = newIORef
writeVar = writeIORef
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
{ render = \this g -> do
position <- readVar (labelPosition label)
txt <- readVar (labelText label)
drawText g AlignLeft AlignBaseline position txt
}
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:
atomically $ do
lbl <- newLabel "Hello!"
lbl ==> render
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.