Generically morph a class into another using Shapeless + LabelledGenerics

163 views Asked by At

I know using Shapeless I can do something like this:

  import shapeless._, syntax.singleton._, record._

  case class Foo(x: Int, y: String)
  case class RichFoo(x: Int, y: String, z: Double)

  def makeRich(foo: Foo): RichFoo = {
    val x = ('z ->> 0.9)
    val repr = LabelledGeneric[Foo].to(foo) + x
    LabelledGeneric[RichFoo].from(repr)
  }

  val a = Foo(1, "hello")
  val b = makeRich(a)

Now I want to write a generic way to do this:

trait Morph[A, B, AR, BR] {
  def apply(a: A)(f: AR => BR): B
}

object Morph {
  implicit def genericMorph[A, B, AR, BR](implicit genA: LabelledGeneric.Aux[A, AR], genB: LabelledGeneric.Aux[B, BR]): Morph[A, B, AR, BR] =
    new Morph[A, B, AR, BR] {
      override def apply(a: A)(f: AR => BR) = genB.from(f(genA.to(a)))
    }

  implicit class Syntax[A](a: A) {
    def morph[AR, BR, B](f: AR => BR)(implicit morph: Morph[A, B, AR, BR]): B =
      morph(a)(f)
  }
}

But, now the usage is unweidly?

  val a = Foo(1, "hello")
  a.morph[???, ???, RichFoo](_ + ('z ->> 0.9))

What is a better way to design this API?

I tried something like this:

  implicit class Syntax[A](a: A) {
    def morphTo[B] = new {
      def using[AR <: HList, BR <: HList](f: AR => BR)(implicit morph: Morph[A, B, AR, BR]): B =
        morph(a)(f)
    }
  }

a.morphTo[RichFoo].using(_ :+ ('z ->> 0.9))

But it does not really work

3

There are 3 answers

0
g.krastev On BEST ANSWER

There are two restrictions that prevent type inference from working the way you want in your example (both have nothing to do with shapeless btw):

  1. In current scalac specifying type parameters explicitly is all or nothing. But you want to specify only B leaving the rest to be inferred. Currying is one solution to this problem. So your attempt was on the right track, but didn't account for 2.

  2. Type inference for method parameters flows from left to right one parameter list at a time. But you want to infer the type of f based on the type of morph which comes last because it is implicit. The solution here is... Currying again.

So from 1. and 2. it follows that you must curry twice:

implicit class Syntax[A](a: A) {
  def morphTo[B] = new {
    def by[AR <: HList, BR <: HList](implicit morph: Morph[A, B, AR, BR]) = new {
      def using(f: AR => BR): B = morph(a)(f)
    }
  }
}

a.morphTo[RichFoo].by.using(_ :+ ('z ->> 0.9))

There is an alternative solution to 1. - use a dummy argument to specify the type parameter B:

trait To[-A]
object To {
  private val instance = new To[Any] { }
  def apply[A]: To[A] = instance
}

implicit class Syntax[A](a: A) {
  def morph[B, AR <: HList, BR <: HList](to: To[B])(
    implicit morph: Morph[A, B, AR, BR]
  ) = new {
    def using(f: AR => BR): B = morph(a)(f)
  }
}

a morph To[RichFoo] using (_ :+ ('z ->> 0.9))

For future reference on how these issues are addressed in Dotty:

  1. You can already partially specify type parameters: a.morph[B = RichFoo]
  2. There is ongoing work on more consistent implicit parameter syntax: lampepfl/dotty#1260

Edit: Usually it's a good idea to define types that depend on other types as type members:

trait Morph[A, B] {
  type AR
  type BR
  def apply(a: A)(f: AR => BR): B
}

object Morph {
  type Aux[A, B, AR0, BR0] = Morph[A, B] {
    type AR = AR0
    type BR = BR0
  }

  implicit def genericMorph[A, B, AR0, BR0](
    implicit genA: LabelledGeneric.Aux[A, AR0], genB: LabelledGeneric.Aux[B, BR0]
  ): Aux[A, B, AR0, BR0] = new Morph[A, B] {
    type AR = AR0
    type BR = BR0
    def apply(a: A)(f: AR => BR) = genB.from(f(genA.to(a)))
  }

  implicit class Syntax[A](a: A) {
    def morphTo[B](implicit morph: Morph[A, B]) = new {
      def using(f: morph.AR => morph.BR) = morph(a)(f)
    }
  }
}
0
PH88 On
import shapeless._, syntax.singleton._, record._

case class Foo(x: Int, y: String)
case class RichFoo(x: Int, y: String, z: Double)

class Morph[A, B, AR](a: A, genA: LabelledGeneric.Aux[A, AR]) {
  def apply[BR](f: AR => BR)(implicit genB: LabelledGeneric.Aux[B, BR]) = genB.from(f(genA.to(a)))
}

implicit class Syntax[A, AR](val a: A)(implicit genA: LabelledGeneric.Aux[A, AR]) {
  def morph[B]: Morph[A, B, AR] = new Morph(a, genA)
}

val a = Foo(1, "hello")
a.morph[RichFoo](_ + ('z ->> 0.9)) // => RichFoo(1,hello,0.9)
0
pathikrit On

Based on @g.krastev's answer I went with this DSL approach:

import shapeless._, syntax.singleton._, record._, ops.hlist._

case class Morph[A, AR](a: A)(implicit reprA: LabelledGeneric.Aux[A, AR]) {
  def to[B] = new {
    def apply[BR](f: AR => BR)(implicit reprB: LabelledGeneric.Aux[B, BR]): B =
      reprB.from(f(reprA.to(a)))
  }
}

Then we can use it like:

val a = Foo(1, "hello")
val b = Morph(a).to[RichFoo](_ + ('z ->> 0.9)) // => RichFoo(1,hello,0.9)

We can also make it handle re-orderings of fields like this:

case class Morph[A, AR](a: A)(implicit reprA: LabelledGeneric.Aux[A, AR]) {
  def to[B] = new {       
    def apply[BR <: HList, BR2 <: HList](f: AR => BR2)(implicit reprB: LabelledGeneric.Aux[B, BR], align: Align[BR2, BR]): B =
      reprB.from(align(f(reprA.to(a))))
  }
}