Named typeclasses in Haskell
One of the best features of Haskell are typeclasses. Still, it is not easily possible to have multiple implementation of the same typeclass for the same data type. For example, to define an additive and a multiplicative instance of Monoid
on Int
, one needs to use specific newtype wrappers such as Sum
and Product
.
On the other hand, in Idris it is possible to define multiple named implementations of the same typeclass for the same data type.
A question now naturally arises:
is there a way in #haskell to have named typeclasses implementations (i.e. different implementations of a typeclass on the same data structure, without newtypes)? Or is there a reason why they are not there?
— Marcosh (@marcoshuttle) November 11, 2019
Some extensions later…
Almost immediately Sjoerd came to the rescue and proposed the following
Maybe you can add an extra parameter and then use phatom types, AllowAmbiguousInstances and TypeApplications to pick one?
— Sjoerd 슕 Visscher (@sjoerd_visscher) November 11, 2019
Let’s briefly review what these mentioned things are.
A phantom type is a parametrised type whose parameters do not all appear on the right-hand side of its definition.
For example newtype Const a b = Const { getConst :: a }
is a phantom type because the parameter b
does not appear after the =
sign.
Phantom types are useful because they allow to add at the type level information which is not present at the value level.
A type parameter is said to be ambiguous
if it can not be correctly inferred by the type checker. This happens with phantom types because the compiler, having no way to infer the actual type of the phantom parameter, considers it ambiguous. The extension AllowAmbiguousTypes
allows us to ask the compiler to be a little more patient and not error immediately when it detects an ambiguous type.
Still, since the compiler is not able to infer it, we need a way to manually specify the value of an ambiguous type. This is possible with the @
syntax using the TypeApplications
extension.
Let’s see these techniques in action while we try to solve our initial question.
Named typeclasses
We will start from the
class Monoid a where
(<>) :: a -> a -> a
mempty :: a
typeclass, which lives in the Haskell Prelude
.
As Sjoerd suggested, we can start by adding a new type parameter to it, which will be used later to do some type-level magic.
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE MultiParamTypeClasses #-}
class Monoid b a where
(<>) :: a -> a -> a
mempty :: a
Then we can define some instances on the Int
type.
import Prelude hiding (Monoid, mempty, (<>))
data Sum
data Product
instance Monoid Sum Int where
x <> y = x + y
mempty = 0
instance Monoid Product Int where
x <> y = x * y
mempty = 1
We need to define the ancillary data types Sum
and Product
, whithout any constructor, which we use as tags to distinguish the two different implementations of Monoid
on Int
1.
Next we want to define a function which uses our Monoid
class. One typical operation which is performed on Monoid
s is a fold
which recursively combines the elements of a list into a single element.
fold :: Monoid b a => [a] -> a
To implement it we actually need to use TypeApplications
but also the ScopedTypeVariables
extension. This is due to the fact that generally type variables have no notion of scope; this means that anytime we would use (<>)
or mempty
, the compiler would introduce a new b
type variable. This leads to errors as Could not deduce (NamedTypeclass.Monoid b0 a) from the context: NamedTypeclass.Monoid b a
, leaving us to wonder where that b0
comes from. ScopedTypevariables
allows us to use the forall
keyword to define explicitly the scope of a type variable.
Our definition of fold
then becomes
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications #-}
fold :: forall b a . Monoid b a => [a] -> a
fold [] = mempty @b
fold (x : xs) = (<>) @b x (fold @b xs)
Notice also how we had to use the @
syntax of TypeApplications
to explicitly state that we are using the same b
everywhere.
At this point it becomes just a matter of using TypeApplications
to define specific folds on both our instances of Monoid
.
For example we could define
sum :: [Int] -> Int
sum = fold @Sum
product :: [Int] -> Int
product = fold @Product
This allows us to use two different instances of the Monoid
class on the same Int
data type, without having to resort to newtype wrappers.
Single instances
Suppose now that for a datatype we want to define a unique instance of Monoid
, as we did in the good old days when the phantom parameter b
was not there.
For example, suppose that for lists we always want to define the same instance of Monoid
, where the operation is given by concatenation.
{-# LANGUAGE FlexibleInstances #-}
instance Monoid b [a] where
x <> y = x ++ y
mempty = []
Notice that we needed to enable the FlexibleInstances
extension, which allows us to use [a]
as a type parameter.
Our instance definition amounts to say that for any possible b
the definition of our instance is the same. The compiler is then smart enough to use this information and actually not require us to write any TypeApplication
when we use the fold
function on the [a]
data type.
concat :: [[a]] -> [a]
concat = fold
Conclusion
We just went through a possible way to implement named typeclasses in Haskell. It could be a useful technique for newly defined typeclasses which you control, while it still remains hard to make such an approach interact nicely with already defined typeclasses.
There are other approaches to this same problem, as mentioned by @Iceland_jack
There are several approaches to it, there is https://t.co/aCGEABQUtP which works at the level of dictionaries and my own wip https://t.co/UdIgyR4G34 that works at the level of types
— _j (@Iceland_jack) November 11, 2019
If you want to take a look at complete code, take a look at this gist.
-
Again from Sjoerd: if you want, you could use
GHC.Types (Symbol)
and writeinstance Monoid "sum" Int where ...
and avoid defining the ancillary types. ↩