Spire's type classes abstract over very basic operators like + and
*. These operations are normally very fast. This means that any
extra work that happens on a per-operation basis (like boxing or
object allocation) will cause generic code to be slower than its
direct equivalent.
Efficient, generic numeric programming is Spire's raison d'ĂȘtre. We
have developed a set of Ops macros to avoid unnecessary object
instantiations at compile-time. This post explains how, and
illustrates how you can use these macros in your code!
How implicit operators on type classes usually work
When using type classes in Scala, we rely on implicit conversions to
"add" operators to an otherwise generic type.
In this example, A is the generic type, Ordering is the type
class, and > is the implicit operator. foo1 is the code that the
programmer writes, and foo4 is a translation of that code after
implicits are resolved, and syntactic sugar is expanded.
importscala.math.OrderingimportOrdering.Implicits._deffoo1[A: Ordering](x: A, y: A): A =
x > ydeffoo2[A](x: A, y: A)(implicitev: Ordering[A]): A =
x > ydeffoo3[A](x: A, y: A)(implicitev: Ordering[A]): A =
infixOrderingOps[A](x)(ev) > ydeffoo4[A](x: A, y: A)(implicitev: Ordering[A]): A =
newev.Ops(x) > y
(This is actually slightly wrong. The expansion to foo4 won't happen
until runtime, when infixOrderingOps is called. But it helps
illustrate the point.)
Notice that we instantiate an ev.Ops instance for every call to
>. This is not a big deal in many cases, but for a call that is
normally quite fast it will add up when done many (e.g. millions) of
times.
It is possible to work around this:
defbar[A](x: A, y: A)(implicitev: Ordering[A]): A =
ev.gt(x, y)
The ev parameter contains the method we actually want (gt), so
instead of instantiating ev.Ops this code calls ev.gt directly.
But this approach is ugly. Compare these two methods:
defqux1[A: Field](x: A, y: A): A =
((xpow2) + (ypow2)).sqrtdefqux2[A](x: A, y: A)(implicitev: Field[A]): A =
ev.sqrt(ev.plus(ev.pow(x, 2), ev.pow(y, 2)))
If you have trouble reading qux2, you are not alone.
At this point, it looks like we can either write clean, readable code
(qux1), or code defensively to avoid object allocations (qux2).
Most programmers will just choose one or the other (probably the
former) and go on with their lives.
However, since this issue affects Spire deeply, we spent a bit more
time looking at this problem to see what could be done.
Having our cake and eating it too
Let's look at another example, to compare how the "nice" and "fast"
code snippets look after implicits are resolved:
defniceBefore[A: Ring](x: A, y: A): A =
(x + y) * zdefniceAfter[A](x: A, y: A)(implicitev: Ring[A]): A =
newRingOps(newRingOps(x)(ev).+(y))(ev).*(z)
deffast[A](x: A, y: A)(implicitev: Ring[A]): A =
ev.times(ev.plus(x, y), z)
As we can see, niceAfter and fast are actually quite similar. If
we wanted to transform niceAfter into fast, we'd just have to:
Figure out the appropriate name for symbolic operators. In this
example, + becomes plus and * becomes times.
Rewrite the object instantiation and method call, calling the
method on ev instead and passing x and y as arguments. In
this example, new Ops(x)(ev).foo(y) becomes ev.foo(x, y).
In a nutshell, this transformation is what Spire's Ops macros do.
Using the Ops macros
Your project must use Scala 2.10+ to be able to use macros.
To use Spire's Ops macros, you'll need to depend on the spire-macros
package. If you use SBT, you can do this by adding the following line
to build.sbt:
You will also need to enable macros at the declaration site of your
ops classes:
importscala.language.experimental.macros
Let's see an example
Consider Sized, a type class that abstracts over the notion of
having a size. Type class instances for Char, Map, and List are
provided in the companion object. Of course, users can also provide
their own instances.
(Notice that Sized[List[A]] overrides some of the "default"
implementations to be more efficient, since taking the full length of
a list is an O(n) operation.)
We'd like to be able to call these methods directly on a generic type
A when we have an implicit instance of Sized[A] available. So
let's define a SizedOps class, using Spire's Ops macros:
importspire.macrosk.Opsimportscala.language.experimental.macrosobjectImplicits {
implicitclassSizedOps[A: Sized](lhs: A) {
defsize(): Int = macroOps.unop[Int]
defisEmpty(): Boolean = macroOps.unop[Boolean]
defnonEmpty(): Boolean = macroOps.unop[Boolean]
defsizeCompare(rhs: A): Int = macroOps.binop[A, Int]
}
}
That's it!
Here's what it would look like to use this type class:
importImplicits._deffindSmallest[A: Sized](as: Iterable[A]): A =
as.reduceLeft { (x, y) =>
if ((xsizeComparey) < 0) xelsey
}
defcompact[A: Sized](as: Vector[A]): Vector[A] =
as.filter(_.nonEmpty)
deftotalSize[A: Sized](as: Seq[A]): Int =
as.foldLeft(0)(_ + _.size)
Not bad, eh?
The fine print
Of course, there's always some fine-print.
In this case, the implicit class must use the same parameter names
as above. The constructor parameter to SizedOpsmust be called
lhs and the method parameter (if any) must be called
rhs. Also, unary operators (methods that take no parameters, like
size) must have parenthesis.
How the macros handle classes with multiple constructor parameters, or
multiple method parameters? They don't. We haven't needed to support
these kinds of exotic classes, but it would probably be easy to extend
Spire's Ops macros to support other shapes as well.
If you fail to follow these rules, or if your class has the wrong
shape, your code will fail to compile. So don't worry. If your code
compiles, it means you got it right!
Symbolic names
The previous example illustrates rewriting method calls to avoid
allocations, but what about mapping symbolic operators to method
names?
Here's an example showing the mapping from * to times:
traitCanMultiply[A] {
deftimes(x: A, y: A): A
}
objectImplicits {
implicitclassMultiplyOps[A: CanMultiply](lhs: A) {
def *(rhs: A): A = macroOps.binop[A, A]
}
}
objectExample {
importImplicits._defgak[A: CanMultiply](a: A, as: List[A]): A =
as.foldLeft(a)(_ * _)
}
}
Currently, the Ops macros have a large (but Spire-specific)
mapping
from symbols to names. However, your project may want to use different names
(or different symbols). What then?
For now, you are out of luck. In Spire 0.7.0, we plan to make it
possible to use your own mapping. This should make it easier for other
libraries that make heavy use of implicit symbolic operators
(e.g. Scalaz) to use these macros as well.
Other considerations
You might wonder how the Ops macros interact with
specialization. Fortunately, macros are expanded before the
specialization phase. This means you don't need to worry about it! If
your type class is specialized, and you invoke the implicit from a
specialized (or non-generic) context, the result will be a specialized
call.
(Of course, using Scala's specialization is tricky, and deserves its
own blog post. The good news is that type classes are some of the
easiest structures to specialize correctly in Scala.)
Evaluating the macros at compile-time also means that if there are
problems with the macro, you'll find out about those at compile-time
as well. While we expect that many projects will benefit from the Ops
macros, they were designed specifically for Spire so it's possible
that your project will discover problems, or need new features.
If you do end up using these macros,
let us know how
they work for you. If you have problems, please open an
issue, and if you have bug
fixes (or new features) feel free to open a
pull request!
Conclusion
We are used to thinking about abstractions having a cost. So we often
end up doing mental accounting: "Is it worth making this generic? Can
I afford this syntactic sugar? What will the runtime impact of this
code be?" These condition us to expect that code can either be
beautiful or fast, but not both.
By removing the cost of implicit object instantiation, Spire's Ops
macros raise the abstraction ceiling. They allow us to make free use
of type classes without compromising performance. Our goal is to close
the gap between direct and generic performance, and to encourage the
widest possible use of generic types and type classes in Scala.
Erik Osheim Erik Osheim is one of the founders of Typelevel, and maintains several Scala libraries including Cats, Spire, and others. He hacks Scala for a living at Stripe, and is committed to having his cake and eating it too when it comes to functional programming. Besides programming he spends time playing music, drinking tea, and cycling around Providence, Rhode Island.