This post is an attempt to provide some additional context and justification for an experiment that I’ve been working on as a proposal for a future version of Cats (probably 3.0). The argument is that by moving Cats’s type class instances for standard library types into implicit scope, we can provide a better user experience along a couple of dimensions (fewer imports to think about, faster compile times), while also making the library better aligned with future changes in the language and compiler.
Type classes in Scala
In Scala 2, type classes are typically encoded as generic classes or traits, with type class constraints encoded as implicit parameters. For example, a monoid type class implementation might look like this:
And a generic method that requires an instance of this type class would look like this:
We could rewrite this method using a context bound, we could provide it as an extension method on
Option, we could add a
Semigroup supertype to our type class hierarchy, etc. There are several good introductions to type classes in Scala that deal with topics like these, and this post won’t try to be one of them. Instead the next few sections will focus on where we can (and should) define instances of our type classes, and on how the compiler finds them.
One common choice for where to define our instances is “wherever we want, in some object that users can import from”. You’ll often see Scala libraries that do something like this:
Now our users just have to remember to import
MonoidInstances._ and these instances will be available implicitly:
In Scalaz and Cats there are a bunch of objects named things like
all in a
std (for “standard”) or
instances package object, in Spray JSON these implicit-holding objects are typically named something ending in
Protocol, etc. While the naming conventions vary, the principle is the same: the library author defines instances in some more or less arbitrarily made-up object, and the library user has to import those instances in their code.
When users import these instances, they’re putting them into lexical scope. If the import is at the top of the source file,
intMonoid will be available for use by that name everywhere in the file, assuming it’s not shadowed by some subsequent import or definition (possibly in some more specific scope).
Lexical scope is the first place the Scala compiler looks for implicits. When we write
getOrEmpty(Option(123)), the compiler will infer that the type of the argument is
Option[Int], so the method’s
Int, which means it needs an implicit
Monoid[Int] value. It will then look through all of the implicit definitions in the current scope for one that has that type, or a more specific type (if we had a
CommutativeMonoid class that extended
Monoid and had used it to define
intMonoid, for example).
It’s worth noting that the way that importing implicits into lexical scope works is likely to change in Scala 3, which will require a new
given selector for things like type class instances. This is just one of many reasons to avoid relying on importing implicits into lexical scope whenever we can, and instead to use implicit scope.
If the compiler doesn’t find an implicit definition of the right type in lexical scope, it doesn’t give up, but looks in another set of places. Suppose we try the following:
This doesn’t compile because we don’t have a monoid instance for
Vector, but before it failed the compiler looked at
listMonoid, and then at the implicit definitions in the companion objects of both
Monoid, as well as in the companion objects of all of their supertypes.
We can start a new REPL session and redefine the
Monoid trait as above, but with a new companion object like this:
Now if we also redefine our
getOrEmpty it’ll just work for this case:
vectorMonoid isn’t in lexical scope—we can’t refer to it by name:
But because it’s in the companion object for
Monoid, the compiler will find it in an implicit search for a
Monoid for any
Vector, regardless of what we’ve imported (assuming we haven’t imported a different instance that matches first).
An implicit scope search for a
Monoid[Foo] will search the companion objects for both
Foo might not be available when we define
Monoid in our library. If we have a
ItemStats case class in our Scala application, for example, we can’t expect Cats (or whatever library provides our
Monoid) to define a
Monoid[ItemStats] in the
Monoid companion object. We can put it in the
ItemStats companion object, though:
Monoid instance for
ItemStats will be available to the compiler anywhere it’s needed, without any imports.
The details of how implicit search works are beyond the scope of this post, but I’d encourage anyone who’s interested to read the official tutorial on implicit search in Scala.
More generally, our type class definitions are often either going to be upstream or downstream from the types we’re defining instances for. For standard library types, the library defining the type classes will be downstream, so standard library instances can go in the type class companion objects. For user-defined types in a project that depends on our type class library, the type class definitions will be upstream, and the instances can be defined in the companion objects of the user-defined types.
For either of those cases we can use Scala’s implicit scope. Where implicit scope doesn’t work is when we have two libraries that have no dependency in either direction. For example, the Squants library doesn’t depend on Circe, and Circe doesn’t depend on Squants, so if we wanted to provide an
io.circe.Decoder instance for
squants.space.Length, we wouldn’t be able to define it in either the
Length companion objects. We’d have to fall back to a separate compatibility module that depended on both, with instances that the user would have to import into lexical scope.
Scala isn’t the only language that uses the type class pattern, and other languages also make a distinction between instances that are defined in the modules of either the type class or the characterized type (Scala’s implicit scope), and instances that are defined somewhere else and have to be imported (Scala’s lexical scope). In languages like Haskell and Rust, for example, the latter are called “orphan instances”, and their use is discouraged or not fully supported by the language.
The question of whether orphan instances should be allowed is outside the scope of this post, which is about Scala 2, where we’re stuck with them whether we like it or not. While there may be good reasons to support orphan instances, they definitely do come with a cost, and in the case of Cats’s standard library instances they aren’t necessary (at least in theory), since Cats does depend on the Scala standard library.
Orphan instances in Cats
So if we think it’s a good idea to avoid orphan instances whenever we can, why does Cats (and Scalaz before it) use lexical scope to provide instances for standard library types? I’ve asked this question many times over the past decade, and the most sensible answer I’ve heard is that it’s because it can be difficult to know how to arrange instances in complex type class hierarchies.
(The wackiest answer I’ve heard is that it’s because the Scalaz authors wanted to punish users who chose standard library types over Scalaz’s equivalents, but this seems unlikely for chronological reasons—types like
scalaz.Maybe came after the decision to use lexical scope for standard library instances.)
We can take the following type class inheritance hierarchy from the
cats.arrow package as an example (note that all of the diagrams in this post are derived from Rob Norris’s Cats Infographic):
We know we can define
CommutativeArrow instances for the standard library’s
Function1, but if we want to put these instances into implicit scope, we have to make some decisions. We can’t just put them in their respective companion objects (
CommutativeArrow), because then they wouldn’t be found during an implicit search for e.g.
Compose[Function1] (remember that when the compiler is looking in implicit scope for a
Compose[Function1], it will only search the companion objects of
Function1, and their supertypes, not their subtypes).
We have a couple of options. We could keep the instances in the maximally specific companion objects and have “upcast” instances in every non-leaf type class, raising e.g. our
Choice[Function1] to a
Choice, and a
Category to a
Compose. This works, but it’s a lot of invasive machinery, and we still have to worry about prioritization for type classes with multiple children. Alternatively we could put the instances into the type class hierarchy roots, where they’ll always be found by supertype searches, but again we have to worry about prioritization (because some type classes like
CommutativeArrow will descend from multiple roots).
This problem becomes even more complicated for more complex hierarchies. The graph that includes
Traverse for example, has dozens of elements, six or seven levels, and four or five roots. Coming up with a strategy for distributing hundreds of standard library instances across a hierarchy like this is a challenge, and apparently nobody working on Scalaz or Cats has thought that the advantages of implicit scope for standard library instances would be worth making the effort.
I’m stubborn, though, and a couple of months ago while I was experimenting with porting Cats and Circe to Dotty I decided to try to come up with a principled way to arrange the standard library instances in the Cats type class companion objects. I ended up going with the roots approach, and I handled prioritization by splitting the inheritance graphs into proper singly-rooted trees (color-coded here):
To return to our
Function1 example, we know we have
CommutativeArrow instances (because those are the
cats.arrow instances in our
cats.instances.function package). Our strategy for deciding where to put these instances is to walk from each specific instance to each of its roots, and to include the instance there. For example, we’ll have the following instance in the
Compose companion object:
Since we can reach that root from both our
Choice and our
CommutativeArrow instances. We need one more instance, though, since from
CommutativeArrow we can also reach the
Profunctor root. In that case, though, we cross a boundary from the pink tree into the green tree, and when we do that, we change the instance we’re tracking to the first green type class (in this case
Strong). This gives us the following instance for the
Profunctor companion object:
This approach seems to work fairly mechanically, even for the much more complex graph with
Monad and friends. You have to make some arbitrary decisions about how to cut up the graph, but Cats is full of arbitrary decisions.
I currently have a pull request that makes all of the type class instances provided in
cats.instances available in implicit scope in this manner. The branch is verified to be binary compatible with Cats 2.0.0, and I’ve modified the tests to remove all use of
Instances traits and imports, to show that all of the instances required by the tests are actually really in implicit scope.
The diff isn’t that big (
+925 −118), and even in the current intermediate form, where
cats.instances imports are still supported, the cats-core jar is only about 1% larger than for Cats 2.0.0.
I’ve also published the branch to Maven Central, so you can try it out now:
No imports necessary. These cats-core and cats-kernel artifacts are a drop-in replacement for Cats 2.0.0, and should work with any other dependencies that depend on Cats 1.x or 2.x, but note that to be safe you should make sure that the original
org.typelevel artifacts aren’t pulled in transitively:
I’ve tried building a few libraries like Circe with this dependency, both with and without
instances imports, and everything seems to work fine. If you try it and run into any issues, please comment on the pull request.
The most obvious advantage of this change is that it means users never have to think about one whole class of imports. For some people this is a big deal—here’s one Reddit commenter, for example:
For me this change would fix one of the most annoying aspects of using cats (or scalaz).
I’m one of these people. I’ve spent some time over the past couple of months working with this arrangement, both in my Dotty port and in my Cats 2 fork, and I’ve found it to be a surprisingly big quality-of-life improvement. It’s almost strange how nice it is just to be able to open a REPL and start typing…
…or whatever, without worrying about setting up your environment.
For other people this isn’t as much of a selling point, though. You can use sbt’s
initialCommands option to have the Cats instances automatically imported in your REPL, and in Scala 2.13 you can even customize the implicit imports in your source files. The rest of this post will be about another benefit of the implicit scope approach: it makes your programs compile faster.
When I started this experiment, I didn’t really have intuitions about how the change would affect compile times. In Cats 2.0.0 on Scala 2.13,
cats.instances.all contains 489 implicit definitions, and
cats.implicits has 644. Putting all of these instances into lexical scope—which is where the compiler starts every implicit search—seemed like it must have a cost, but so does implicit scope search, which requires the compiler to look in many more places (potentially dozens of companion objects).
In the first weekend when I was putting together the Cats pull request, I made a mistake while removing
cats.instances from the tests, and I wasn’t able to see any differences in compile times. I’ve since fixed that mistake, and the following graph shows the difference between the current version of the branch and Cats 2.x for
testsJVM/test:clean (after an initial full build). The measurements are in seconds, and smaller values are better.
The improvement here ranges from 40% to 46%. I’ve seen similar results for generated files that summon hundreds of instances compiled against Cats 2.0.0 with imports and the
2.0.0-CATS_3043-1 artifacts without.
Compile times are often cited as one of the worst things about working in Scala, and this improvement is likely to benefit anyone who uses Cats, especially if they’re using the
instances.all imports. These imports clutter your lexical scope, and have an impact on all of the implicit searches that your program requires, even if they don’t involve Cats type classes.
I’d really love to see this change happen in Cats 3, and the pull request goes into more detail about how the change could be made in steps that would ease the migration path for users. In the meantime I think the branch and forked artifacts are ready for experimentation—there are still likely to be some rough edges, but I’d appreciate help in finding them.