Composing Implicit Derivation "Laws"

Viewed 57

I'm trying to design a toy CoversionRate implementation to understand how I can encode "laws" through chained implicit definitions. The following code compiles:

case class ConversionRate[X <: Currency, Y <: Currency](rate: Double) extends AnyVal
object ConversionRate {
  implicit val EUR2USD: ConversionRate[EUR.type, USD.type] = ConversionRate(1.1)
  implicit val GBP2USD: ConversionRate[GBP.type, USD.type] = ConversionRate(1.21)

  // "Laws"
  implicit def inverse[X <: Currency, Y <: Currency](implicit cr: ConversionRate[X, Y]): ConversionRate[Y, X] =
    ConversionRate[Y, X](1 / cr.rate)

  implicit def transitivity[X <: Currency, Z <: Currency](implicit cr: ConversionRate[X, USD.type], cr2: ConversionRate[Z, USD.type]): ConversionRate[X, Z] =
    ConversionRate(cr.rate * cr2.rate)

  private val unit = ConversionRate(1)
  implicit def self[X <: Currency]: ConversionRate[X,X] = unit.asInstanceOf
}

The call-site derivation does however not work

val cr = implicitly[ConversionRate[EUR.type, GBP.type]]
diverging implicit expansion for type ConversionRate[EUR.type,GBP.type]
starting with method transitivity in object ConversionRate

How should I go about writing the laws in a way that they can used for derivation?

2 Answers

Implicit cannot derive any possible type that you could compose from your rules, because it would easily end up in situation then you have an infinite number of possibilities:

id[X]: X => X
x: A => B
y: B => C

could be composed

z = x andThen y

but maybe it could be generated with

z = x andThen id[B] andThen y // or
z = id[A] andThen x andThen y // or
z = x andThen y andThen id[C] // or
z = id[A] andThen x andThen y andThen id[C] //
...

You see where this is going?

Meanwhile derivation should be unambiguous, when you start with your "primitives" implicits and you try to compose them into your "target" implicit, there should be only one possible way. The moment Scala finds that there is some ambiguity, it stops deriving.

In your example you are composing ConversionRate[X, USD] with ConversionRate[Y, USD] into ConversionRate[X, Y]. But it doesn't prevent you from doing something like:

ConversionRate[USD, USD] combine with
ConversionRate[USD, USD] combine with
ConversionRate[USD, USD] combine with...

One way of doing this would be defining your conversions in such a way that there should be only one way of doing it, that you cannot easily break, e.g.:

case class USDConversionRate[A <: Currency](rate: Double)
// implicit conversion rates

case class ConversionRate[X <: Currency, Y <: Currency](rate: Double) extends AnyVal
object ConversionRate {
  implicit def combine[X <: Currency, Y <: Currency](
    implicit
    ev1: X =:!= USD.type, // available in shapeless
    ev2: Y =:!= USD.type, // proves type inequality
    ev3: X =:!= Y,        // which we use to force only one way to derive each pair
    xFromUSD: USDConversionRate[X],
    yFromUSD: USDConversionRate[Y],
  ): ConversionRate[X, Y] = ...

  implicit def toUSD[X <: Currency](
    implicit X =:!= USD.type,
    xFromUSD: USDConversionRate[X]
  ): ConversionRate[X, USD.type] = ...

  implicit def fromUSD[X <: Currency](
    implicit X =:!= USD.type,
    xFromUSD: USDConversionRate[X]
  ): ConversionRate[USD.type, X] = ...

  implicit def unit: ConversionRate[X, X] = ...
}

Here you would use type bounds to cut all cycles. In original code they would appear if you'd use e.g. inverse (because anything that would derive X -> Y, would conflict with inverse(inverse(X -> Y))), etc, same with traverse and unit, etc. You have to guarantee that expansion cannot diverge and anything that would lead to cycle, or any other 2 different ways to get to the same type are forbidden.

Your biggest problem is that with 2 parameter composition like this: A -> B, you can always try to do it through something else A -> C -> B, A -> D -> B, so the best way is to remove any possibility of going through some extra steps. My proposition is that you derive A -> B conversion rate from USD -> X converson rates only, and preventing each conversion to clash with another conversion, disallowing cycles completely.

After looking a bit more into it, I think I found an even simpler solution, using lower priority implicits instead of shapeless operators to break the implicit cycles.

case class ConversionRate[X <: Currency, Y <: Currency](rate: Double) extends AnyVal
trait ConversionRateLowPriorityImplicits {
  implicit def transitivity[X <: Currency, Z <: Currency](implicit cr: ConversionRate[X, USD.type], cr2: ConversionRate[Z, USD.type]): ConversionRate[X, Z] =
    ConversionRate(cr.rate * cr2.rate)
}
object ConversionRate extends ConversionRateLowPriorityImplicits {
  implicit val EUR2USD: ConversionRate[EUR.type, USD.type] = new ConversionRate(1.1)
  implicit val GBP2USD: ConversionRate[GBP.type, USD.type] = new ConversionRate(1.21)

  // "Laws"
  implicit def inverse[X <: Currency, Y <: Currency](implicit cr: ConversionRate[X, Y]): ConversionRate[Y, X] =
    new ConversionRate[Y, X](1 / cr.rate)

  private val unit = new ConversionRate(1)
  implicit def self[X <: Currency]: ConversionRate[X,X] = unit.asInstanceOf
}

All the following now resolve nicely

implicitly[ConversionRate[EUR.type, EUR.type]]
implicitly[ConversionRate[EUR.type, USD.type]]
implicitly[ConversionRate[EUR.type, GBP.type]]
implicitly[ConversionRate[USD.type, USD.type]]
implicitly[ConversionRate[USD.type, EUR.type]]
implicitly[ConversionRate[USD.type, GBP.type]]
implicitly[ConversionRate[GBP.type, GBP.type]]
implicitly[ConversionRate[GBP.type, EUR.type]]
implicitly[ConversionRate[GBP.type, USD.type]]
Related