How to define an environment to which we can add "capabilities" without running into overlapping instances?
Suppose we have the following data-types and type-classes:
type Name = String
data Fruit = Orange | Pear | Apple
data Vegetable = Cucumber | Carrot | Spinach
data Legume = Lentils | Chickpeas | BlackEyedPeas
class HasFruit e where
getFruit :: e -> Name -> Maybe Fruit
class HasVegetable e where
getVegetable :: e -> Name -> Maybe Vegetable
class HasLegume e where
getLegume :: e -> Name -> Maybe Legume
Now we would like to define a couple of functions that require certain ingredients from the environment:
data Smootie
mkSmoothie :: (HasFruit e, HasVegetable e) => e -> Smootie
mkSmoothie = undefined
data Salad
mkSalad :: (HasVegetable e, HasLegume e) => e -> Salad
mkSalad = undefined
And we define some instances for Has*:
instance HasFruit [Fruit] where
getFruit = undefined
instance HasVegetable [Vegetable] where
getVegetable = undefined
instance HasLegume [Legume] where
getLegume = undefined
And finally, we would like to define a function that prepares a smoothie and a salad:
cook :: (Smootie, Salad)
cook = let ingredients = undefined in
(mkSmoothie ingredients, mkSalad ingredients)
Now the first question is, what to pass as ingredients to that the instances defined above can be used? My first solution to this was to use tuples:
instance HasFruit e0 => HasFruit (e0, e1, e2) where
getFruit (e0, _, _) = getFruit e0
instance HasVegetable e1 => HasVegetable (e0, e1, e2) where
getVegetable (_, e1, _) = getVegetable e1
instance HasLegume e2 => HasLegume (e0, e1, e2) where
getLegume (_, _, e2) = getLegume e2
cook :: (Smootie, Salad)
cook = let ingredients = ([Orange], [Cucumber], [BlackEyedPeas]) in
(mkSmoothie ingredients, mkSalad ingredients)
This, even though cumbersome, works. But now assume that we
decide to add a mkStew, which requires some HasMeat instance.
Then we'd have to change all the instances above. Furthermore,
if we would like to use mkSmothie in isolation, we cannot just
pass ([Orange], [Cucumber]) since there is no instance defined
for it.
I could define:
data Sum a b = Sum a b
and instances like:
instance HasFruit e0 => HasFruit (Sum e0 e1) where
getFruit (Sum e0 _) = getFruit e0
instance HasVegetable e1 => HasVegetable (Sum e0 e1) where
getVegetable (Sum _ e1) = getVegetable e1
instance HasLegume e1 => HasLegume (Sum e0 e1) where
getLegume (Sum _ e1) = getLegume e1
But the following won't work (No instance for HasVegetable [Legume]):
cook1 :: (Smootie, Salad)
cook1 = let ingredients = Sum [Orange] (Sum [Cucumber] [BlackEyedPeas]) in
(mkSmoothie ingredients, mkSalad ingredients)
And This instance will overlap!
instance HasVegetable e0 => HasVegetable (Sum e0 e1) where
getVegetable (Sum e0 e1) = getVegetable e0
Is there a way to solve this problem in an elegant way?
The problem with the present
Suminstances is that we don't know whether the object we are looking for is to the left or to the right.Here's the plan: each component of the environment should declare what capabilities it offers so that we can then search for it.
Gist of this answer.
Declaring capabilities
As environments will be composed, we will need a (type-level) data structure to carry the capabilities from their different parts. We will use a binary tree, so we can preserve the structure of components.
Capabilities associated with an environment are declared via this type family.
Looking up a capability
As mentioned at the beginning, when encountering a pair
:&, we will need to tell whether to find the capability in the left or right component. Thus we start with a function (at the type level) that returnsTrueif the capability can be found in the tree.The
HasclassThis class now has a superclass constraint: that the capability we are looking for is indeed available.
It may seem superfluous, because instance resolution would fail anyway if the capability is not found, but a precise superclass constraint has benefits:
preventing mistakes: the compiler will complain earlier if something is missing;
a form of documentation, informing us of when an instance may exist.
Leaf instances
We don't need to write dubious instances like
Has Fruit [Vegetable]; we actually can't: they would contradict the superclass constraint.Instance for
(:&)We need to defer to a new class,
PairHasthat will discriminate on the result of theInpredicate on both sides to determine which part of the environment to zoom in.Again, we make the superclass constraints extra precise about the intent of
PairHas.inAandinBcan only be instantiated withIn item (Contents a)andIn item (Contents b)respectively, and their disjunction should beTrue, meaning thatitemcan be found in at least one of them.Of course we have two instances to go left and right respectively, using recursive
Hasconstraints (note thatHasprovides one equality via its own superclass constraint).What if both sides have the same capability? We shall consider that an error and require the user to explicitly hide one of the duplicate capabilities via other mechanisms. We can use
TypeErrorto print a custom error message at compile-time. We could also pick either side by default.We can also write a custom error message for the case when both sides are false. It is a bit surprising because that contradicts the superclass constraint
(inA || inB) ~ 'True, but the message does get printed so we won't complain.Let's cook
Now we can safely write
cook:You can also see what happens if you duplicate or forget some ingredients