Here is my proposal to make working with type classes easier, “using the bounded type as if it had a companion object” (better name TBD).
Currently
So imagine you have the type-class:
trait Summable[T]:
def zero: T
extension (x: T)
def + (y: T): T
An instance would then look like:
given [U]: Summable[List[U]] with
override def zero = List()
extension (x: List[U])
override def + (y: List[U]) = x ++ y
Okay, now let’s try to use it to make a sum
method:
def sum[T : Summable](seq: Seq[T]) =
seq.fold(???.zero)(_ + _)
As you can see, we can’t access zero
, it’s not in scope, unlike +
.
We can solve this by … not using a context bound:
def sum[T](seq: Seq[T])(using ev: Summable[T]) =
seq.fold(ev.zero)(_ + _)
Or by cleverly not using a context bound:
def zero[T](using ev: Summable[T]) = ev.zero
def sum[T : Summable](seq: Seq[T]) =
seq.fold(zero)(_ + _)
And it’s hard to be sure visually that that zero
is really related to Summable
.
This is especially the case since we could have a val
named zero
, and overloading would pick for us.
As I’ve hopefully demonstrated, the current solution works, but is not very satisfying, adding potentially confusing boilerplate.
My proposal
Here is my proposal; to be able to access the members defined by case classes by using their argument type as if it was a companion object:
def sum[T](seq: Seq[T])(using Summable[T]) =
seq.fold(T.zero)(_ + _)
// or
def sum[T : Summable](seq: Seq[T]) =
seq.fold(T.zero)(_ + _)
It is also possible to restrict that feature to only context bounded types.
This avoid cases where the type is not just an identifier, for example:
def sum[T](p: Seq[(T,T)])(using Summable[(T,T)]) =
seq.fold((T,T).zero)(_ + _)
And the downside is minimal, as if we’re already using a using
parameter, we can name it to access it’s fields.
For these reasons, I feel restricting the feature thusly is advisable.
If I remember correctly, it is possible to do [List[_] : Summable]
, as I’m not sure what to do in that case, it might be better to forbid that feature on these cases.
Alternative
It is also possible to automatically create synthetic forwarders like:
def zero[T](using ev: Summable[T]) = ev.zero
But not having any prefix on the members makes it hard to track where they come from, with the “type as companion object”, we know it has to come from a context bound (or potentially a using
).