travisbrown · GitHub

Please see this blog post for some additional discussion of this proposal.

This is a proof-of-concept and proposal more than a pull request (even a WIP one), but I don't know a better way to put it up for discussion in a place where changes can be tracked, etc. It's a pretty sweeping change to the way type class instances are provided in Cats, and I haven't talked about it yet in any substantive way with other Cats maintainers.

Motivation

The basic idea is that instead of type class instances for standard library types (e.g. Monad[Option]) living in packages that require users to import them (cats.instances.option._), these instances are included in the type class companion objects, where they are available to users in implicit scope, without imports.

This means that Cats users would only ever have to think about imports for syntax, not instances. For example, the following just works:

scala> cats.Parallel[Either[String, ?]].parProductR(Left("foo"))(Left("bar"))
res0: scala.util.Either[String,Nothing] = Left(foobar)

While in Cats now you'd need something like this:

import cats.instances.parallel._, cats.instances.string._

…or the "kitchen sink" cats.implicits._ import.

This might seem like a small thing, but I've been playing with the idea for a couple months now (starting with this attempt at porting Cats to Dotty), and I really do feel like it significantly improves my experience of working with Cats. It's just one less you have to worry about everywhere—in your source, in the REPL, etc.

What this PR does

For this initial experiment, I've added these type class instances to the appropriate companion objects as methods that point to the instances in the cats.instances packages. I've changed all code in cats-core, cats-free, and the tests so that there is no use of cats.instances imports, the cats.implicits._ import, or any instances brought into scope by the Instances traits.

I'm sure I've missed some instances, and we'd want much better tests before we'd ever consider merging this, but this change verifies that all of the standard library instances used in Cats itself are available in implicit scope, without imports.

The change is binary compatible with Cats 1.x and 2.x, and while I'm sure that it breaks source compatibility, I'd be surprised if it affects anything but fairly weird cases. You can still import the instances instances, and they'll override the implicit scope instances. It's just not necessary.

Longer term changes

This is entirely hypothetical, but if we decided to go this route in Cats 3 or some other future release, I can imagine a progression like the following:

  1. A major release (3.0.0) that does only what this PR does: introduces the implicit scope instances but doesn't remove anything.
  2. A minor release (3.1.0) that deprecates the instances traits and packages.
  3. A major release (4.0.0) that removes the instances traits and packages.

The initial step would be binary compatible with previous releases, but it has such a big effect on usage that I don't personally think introducing it in a minor release is a good idea.

Challenges

I've been using Scalaz for a few months short of a decade, and in that time it's always taken the approach that Cats inherited, requiring imports for standard library instances. I think this may have been different in earlier versions of Scalaz, but that's not clear from the history in the repo on GitHub, and I haven't done any further archeology yet. I've asked around about the reasons for this design decision a few times, and this thread includes the most context I know of.

The biggest immediate difficulty with putting these instances into implicit scope is the fact that the compiler only searches supertype companions for instances, not subtypes. This means that if you provide the MonadError instance for Option in the MonadError companion object, it won't be found in a search for a Functor[Option] instance.

As far as I can tell there are two viable approaches to making instances like this available for subtype searches:

  1. Put "upcast" instances in every companion object, so that if you'll always have a Functor[F] if you have a Monad[F] in implicit scope.
  2. Put the instances in the roots of the type class hierarchy, where they'll be always be found when searching supertype companions.

I've experimented with both, and so far the second feels like the better choice. It requires a little more up-front coordination, but with a diagram of the Cats type class hierarchy it's not too bad, and it's much less invasive.

Another potential difficulty involves type class hierarchies that cross module boundaries, as @non and @johnynek point out here. I think this is addressable, and I've done some experiments in that direction, but we'll definitely need to spend more time looking at how this change would impact real projects like cats-effect, Algebra, and Spire.

Compile times

This is the primary thing I'm worried about (see for example this article by Bill Venners for some context). I don't have strong intuitions about the relative compile-time cost of imported implicits vs. the kind of implicit scope search this change requires.

I've done a few experiments in this respect, including running the following several times on both master and this branch:

sbt clean test:clean
sbt compile
time sbt test:compile

And all of the results are within a couple seconds of each other (around 150 seconds on my machine). That's not very scientific, though, and I'm not sure how accurately the Cats test usage reflects the real world usage we care about.

Jar size

I was also originally a little worried that duplicating the instances would hurt jar sizes in a significant way, especially because in this initial attempt the code-gen for kernel instances isn't as optimized as it could be, and many of the generated instance definitions are duplicated. If we go this route, eventually this wouldn't be a concern at all, since we'd remove the instance packages altogether, but I was worried that in the meantime we'd be stuck with some bloat.

It turns out that this isn't really the case. Here are the cats-core sizes after this change:

5736    cats-core_2.11.jar
4556    cats-core_2.12.jar
4664    cats-core_2.13.jar

And on current master:

5704    cats-core_2.11.jar
4528    cats-core_2.12.jar
4632    cats-core_2.13.jar

So less than a percent in all cases (+0.71% for 2.13, which has the biggest change).

Read the original on github.com ↗