Scrap Your Type Class Boilerplate (2/2)
For the TL; DR version, go straight to the Summary.
Recap
The previous post covered some ways to make type classes easier to use. For a quick recap, see the summary. In this post, we will attempt to eliminate some of the boilerplate associated with creating and using type classes and their instances. Here is the implementation of the Group
type class that we had at the end of the previous post :
Inside the Group
companion object, we define instances of Group
for Int
, Double
and pairs :
And here, the type class is used to define some generic functions :
More Improvements
One of the concerns raised earlier but not addressed was that the code for defining type class instances is rather verbose and repetitive, and if the type class has lots of abstract methods or if you’re defining many instances, this can lead to a lot of boilerplate. I alluded to something called the pain of overriding, which is the root of much of this boilerplate. Here is the definition from Wikipedia :
The pain of overriding : The #Scala compiler can infer batshit crazy types, still demands the full signature to override/implement methods.
— Aakash N S (@aakashns) April 4, 2015
It is annoying to write all these types, and it becomes much more annoying when you have to change something, because you have to make the same change in SO many places, and so you end up copy-pasting things all over the place, but that leaves you with a bunch of compilation errors, and then you spend a day figuring out the types to fix those errors, and then you finally fix them, and you reward yourself with a cookie or whatever, but then two days later you realize you’ve made an error in the logic because you were too busy trying to tell the compiler what it already knows!
Helper Class
To help eliminate some of this boilerplate, let’s define a class GroupAux
as follows :
This class does nothing special. It simply moves the methods to be implemented into constructor arguments. Using this helper class, the code for defining instances of Group
for Int
and Double
looks like this :
Isn’t this much nicer? By using anonymous functions as constructor arguments to GroupAux
, we’re letting the compiler do all the hard work of figuring out the right types. By the way, anonymous functions are also called lambdas (λ). And here’s the definition for pairGroup
:
You can make the anonymous functions more descriptive, if you’re not into the whole brevity thing :
Admittedly, this is not a huge improvement if you only have a few instances of the type class. Some people would argue that this not an improvement at all, because it compromises on readability. Here are a few arguments in favor of this style :
- It avoids the pain of overriding. You no longer have to tell the compiler what it can figure out on its own. If the instances of your type class go into dozens, or if you have many abstract methods to be implemented, this can save you the trouble of having to write all the types all the time.
- Anonymous functions are not unreadable if you already know the types. That’s precisely why we use them. When both the reader and the compiler already know the types, they’re just noise in the code. Moreover, you can optionally provide the types, if you really want to.
- It’s not all or nothing. You don’t have to use it all the time. Use it when it saves you precious lines without compromising on readability, like in the case of
IntGroup
andDoubleGroup
. If you think the definition ofPairGroup
feels unreadable, don’t useGroupAux
! ExtendGroup
and override the methods directly.
Note that this is a general technique that you can apply to any trait / abstract class. Just define a helper class that takes function values as constructor arguments, and uses them to implement the abstract methods.
The TypeClassCompanion Trait
This is another minor improvement, that can help you avoid having to write the apply
method for your type class companion object. We define a trait called TypeClassCompanion
that companion objects can extend, to automatically get the apply
method :
Note that the apply method is marked as @inline
, which tells the compiler to replace calls like Group[Int]
with implicitly[Group[Int]]
. Not surprisingly, the implicitly method in Predef is inlined by the complier too, so the compiler will replace implicitly[Group[Int]]
with an implicit value of type Group[Int]
summoned from the nether world. Long story short, the compiler will replace the expression Group[Int]
with IntGroup
.
Using the TypeClassCompanion
trait, the Group
companion object looks like this :
There isn’t much more to it than that. It saves you one line of code for each of your type classes.
Implicit Class for Pretty Syntax (or not)
With context bounds and the apply
method on the companion object, the syntax for using type classes is already pretty clean. However, it is often the case that the methods defined in the type class are unary (a function of type T => T
) or binary (a function of type (T, T) => T
) operations. This is certainly true for Group
: plus
and minus
are binary operations, and inverse
is a unary operation. Wouldn’t it be nice if we could simply write (1, 2) + (3, 4)
instead of Group[(Int, Int)].plus((1, 2), (3, 4))
? Here’s some advice I just made up :
As a rule of thumb, the answer to every question of the form “Can I do XYZ in #Scala?” is “Yeah, totally man! Just use implicits.”
— Aakash N S (@aakashns) April 13, 2015
Armed with this newfound wisdom, let’s define a class called GroupOps
that wraps objects and adds methods for group operations on the underlying objects :
By making GroupOps
an implicit class, we’re simply telling the compiler to add an implicit conversion from T
to GroupOps
for types T
that are members of the type class Group
. The above piece of code is equivalent to the following :
Since we cannot write a def
in the global scope, implicit classes can only be defined inside another class / trait / object, in this case GroupSyntax
. Let’s use the new syntax to simplify the code for pairGroup
and the sum functions :
Clearly, this technique substantially cleans up the interface for using group operations. But despite the benefits, you may not want to do this all the time, because :
- It uses an implicit conversion. It is not easy to tell where the operator
|+|
came from unless you know where to look. Many people find this confusing, and are going to avoid using it, no matter what you tell them. - It may potentially create a new instance of the type class each time you use it. If you find yourself using
Group[(Int, Int)]
10 times in a block, you’re better off defining a local valintPairGroup = Group[(Int, Int)]
and using that, becauseGroup[(Int, Int)]
results in a call topairGroup[Int, Int]
, which instantiates a new object each time it is called. However, if you’re writinga |+| b
10 times in a block, there is no easy way to avoid callingpairGroup[Int, Int]
that many times.
Although the pretty syntax is nice to have, it comes at a cost. You should consider the use-cases of your type classes, and evaluate how they are affected by these costs. This might upset a few people, but I personally think that explicit is indeed better that implicit in this particular instance. So think twice before doing this.
Summary
Type classes are awesome and you should use them everywhere. Make sure you do them right, and everyone will be happy. In addition to the techniques described in the previous post :
-
Define a helper class to help reduce boilerplate for defining type class instances.
-
Use a companion trait to automatically add the
apply
method to your type class companion objects. -
You may define implicit classes to provide some nice syntax and pretty operators, but be aware of the costs, and also be aware that many people just won’t use them. This is best provided as an optional feature.