This works... to the extent that we want only to work with
scala.collection.Sets. As it stands we cannot talk about
other sets such as bloom filters or sets controlled by other threads.
Our language isn't abstract enough, so let's remove
all traces of Set.
traitSetLang[F[_]] {
defadd(i: Int, set: F[Int]): F[Int]
defremove(i: Int, set: F[Int]): F[Int]
defexists(i: Int, set: F[Int]): Boolean// Given unknown F we no longer know how to create an empty set
// so we add the capability to our language
defempty: F[Int]
}
We've parameterized our language with a higher-kinded type which
represents the context of our set. A similar parameterization could be
done with a *-kinded type (e.g. SetLang[A]) but since this series
focuses on monadic EDSLs, the choice is made for us.
Now we can write mini-programs which talk about some abstract set
yet to be determined.
Interpretation of our program is done by implementing SetLang and
passing an instance into program.
However, our language is still not abstract enough. Replacing Set
with F allows us to swap in implementations of sets, but doesn't
allow us to talk about the context. Consider the behavior of exists if F
represents some remote set. Since exists returns a Boolean,
checking membership must be a synchronous operation despite the set living
on another node.
It's also tedious to thread the set through each method manually.
We can solve both problems by generalizing the use of F to some
context that is able to read and write to some set
(think Set[Int] => (Set[Int], A)).
traitSetLang[F[_]] {
defadd(i: Int): F[Unit]
defremove(i: Int): F[Unit]
defexists(i: Int): F[Boolean]
// No longer need `empty` since the "context" has it already
}
SetLang can now talk about the effects around interpretation, such as
asynchronity.
This new encoding introduces a new but important problem: how do we
combine the results of multiple calls to SetLang methods? In the previous
encoding we could add and remove by threading the set from one call to
the next. With this change to represent a context, it's not clear how to do
that.
Fortunately we are now in a position to leverage a powerful tool:
monads. By extending our set language to be monadic
we recover composition in an elegant way. The Cats library is used
for demonstration purposes, but the discussion applies equally to
Scalaz.
importcats.Monadimportcats.implicits._traitSetLang[F[_]] {
// See: subtype-typeclasses.md
// for why the `Monad` instance is defined as a member as opposed to inherited
defmonad: Monad[F]
defadd(i: Int): F[Unit]
defremove(i: Int): F[Unit]
defexists(i: Int): F[Boolean]
}
defprogram[F[_]](lang: SetLang[F]): F[Boolean] = {
importlang._implicitvalmonadInstance = monadfor {
_ <- add(5)
_ <- add(10)
_ <- remove(5)
b <- exists(10)
} yieldb
}
Defining an interpreter starts by identifying a target context. Since the context
computes values while updating state, this suggests the state monad.
Note that calling program did not require any context-specific knowledge -
we could define another interpreter, perhaps one that talks to a set
concurrently.
Consider a program that is open to failure and computes with some state. This
suggests a combinator of Either and State, both of which have
monad transformers. All that is left is to decide which transformer to use.
While App1 and App2 are both valid compositions, the
semantics of the compositions differ. App1 describes a program where
the computation of a value at each transition may fail - but any changes
are preserved - whereas App2 describes a program where the entire
transition may fail.
We can abstract away the difference by creating a type class which provides
the relevant operations we need.
Similar type classes exist for the Reader and Writer data types.
These type classes are provided in both Cats and Scalaz,
with some caveats.
With these type classes in place we can write functions against these as
opposed to specific transformer stacks. Furthermore our functions can specify
exactly what operations they need which helps correctness and
parametricity.
From one angle we can view our set language, or more generally any EDSL
in MTL-style, as an effect like MonadError and MonadState. From another
angle we can view MonadError and MonadState as EDSLs that talk about errors
and stateful computations. We can eliminate the distinctions by renaming
SetLang to MonadSet and treating it as a type class.
Composing multiple languages then becomes adding constraints to functions, and
interpretation becomes instantiating type parameters that satisfy the
constraints.
traitMonadCalc[F[_]] {
defmonad: Monad[F]
deflit(i: Int): F[Int]
defplus(l: F[Int], r: F[Int]): F[Int]
}
defsetProgram[F[_]: MonadSet](i: Int): F[Boolean] =
implicitly[MonadSet[F]].exists(i)
defcalcProgram[F[_]: MonadCalc]: F[Int] = {
valcalc = implicitly[MonadCalc[F]]
calc.plus(calc.lit(1), calc.lit(2))
}
defcomposedProgram[F[_]: MonadCalc: MonadSet]: F[Boolean] = {
implicitvalmonad: Monad[F] = implicitly[MonadCalc[F]].monadfor {
i <- calcProgram[F]
b <- setProgram(i)
} yieldb
}
// Instance
// Instances are defined together but nothing is stopping us from defining
// these separately, perhaps one in the MonadSet object and another in the
// SetState object.
implicitvalstateInstance: MonadSet[State[Set[Int], ?]] withMonadCalc[State[Set[Int], ?]] =
newMonadSet[State[Set[Int], ?]] withMonadCalc[State[Set[Int], ?]] {
valmonad = Monad[State[Set[Int], ?]]
defadd(i: Int): State[Set[Int], Unit] = State.modify(_ + i)
defremove(i: Int): State[Set[Int], Unit] = State.modify(_ - i)
defexists(i: Int): State[Set[Int], Boolean] = State.inspect(_(i))
deflit(i: Int): State[Set[Int], Int] = State.pure(i)
defplus(l: State[Set[Int], Int], r: State[Set[Int], Int]): State[Set[Int], Int] =
(l |@| r).map(_ + _)
}
As before, composedProgram, calcProgram, and setProgram are defined
independent of interpretation, so alternative interpretations simply require
defining appropriate instances.
A note about laws
Type classes should come with laws - this lets us give meaning to their use.
The Monoid type class requires data types to have an associative binary
operation and a corresponding identity element. These laws allow us to
parallelize batch operations, such as partitioning a List[A] into
multiple chunks to be scattered across threads or machines and gathered
back.
Since our EDSLs are type classes, we should think about what laws we expect
to hold. Below are some possible candidates for laws:
// MonadSet
set *> add(i) *> remove(i) = set
set *> remove(i) *> exists(i) = false
set *> add(i) *> exists(i) = true
// MonadCalc - these are just the Monoid laws
plus(lit(0), x) = plus(x, lit(0)) = x
plus(x, plus(y, z)) = plus(plus(x, y), z)
Next up we'll take a look at some pitfalls of this approach, and a modified
encoding that solves some of them.
This article was tested with Scala 2.11.8, Cats 0.7.2, kind-projector 0.9.0,
and si2712fix-plugin 1.2.0 using tut.
Adelbert Chang Adelbert is an engineer at Box where he attempts to reliably copy bytes from one machine to another. He enjoys writing pure functional programs, teaching functional programming, and learning more about computing.