Sunday, 11 February 2018

How to apply functions to case classes with Shapeless

I’m sure you’ve written code like this before. You have a case class …

Foo(b: Boolean, i: Int)

… and a function that has the same arguments as the case class fields.

def showFoo(b: Boolean, i: Int)

And you need to write a function that unpacks the case class fields and passes them into the function.

def receive(f: Foo) = showFoo(f.b, f.i)

That doesn’t look so bad, but imagine Foo has 5 fields. Do you really want to write showFoo(f.b, f.i, f.a, f.c, f.d)?

First steps

Luckily, we can convert a function that takes many arguments into one that takes a tuple of those arguments:

val showFooTuple = (showFoo _).tupled

And we can also convert a case class to a tuple.

val foo = Foo(true, 1)
val fooTuple = Foo.unapply(foo).get

So now we can write receive as

receive(f: Foo) = (showFoo _).tupled.apply(Foo.unapply(foo).get)

Hmm, that doesn’t look great. Let’s write a helper function that applies a function to a Foo.

def applyFoo[R](f: (Boolean, Int) => R, f: Foo) = f.tupled.apply(Foo.unapply(foo).get)

Now our receive function is more readable:

receive(f: Foo) = applyFoo(showFoo)(f)

applyFoo does what we want it to do for Foo, but we have dozens of case classes. Can we write a function that works for arbitrary ones? Unfortunately, the approach above won’t get us anywhere. How are we supposed to do Foo.unapply generically?

A generic solution

Shapeless to the rescue! Shapeless has generic functions that allow you to convert case classes into HLists as well as converting function so that they take HLists as arguments.

import shapeless._
import shapeless.ops.function.FnToProduct

def applyGeneric[
    C, // our case class
    H <: HList, // the HList representation of the case class
    R, // the return type of the function
    F] // the type of the function
    (f: F, c: C) // the function f and the case class instance c
    (implicit gc: Generic[C]{ type Repr <: H }, // conversion of the case class to the HList
              fnHLister: FnToProduct.Aux[F, H => R]) // conversion of the function
    : R =
  fnHLister(f)(gc.to(c)) // convert the function, convert the case class instance, apply

Looks scary, doesn’t it? Fortunately, we only have to write this monstrosity once. Client code doesn’t need to know about shapeless or HLists and requires no magic imports.

applyGeneric(showFoo _, foo)

Why the _?

Scala only converts defs to functions when a function type is required and the first parameter to applyGeneric has type F, which could be any type. We can make the type more specific, but then we have to write one function for every arity, replacing F with (P1, P2, ... Pn) => R.

def ag2[C, H <: HList, R, P1, P2]
    (f: (P1, P2) => R, c: C)
    (implicit gc: Generic[C]{ type Repr <: H },
    fnHLister: FnToProduct.Aux[(P1, P2) => R, H => R]): R =
  fnHLister(f)(gc.to(c))

def ag3[C, H <: HList, R, P1, P2, P3]
    (f: (P1, P2, P3) => R, c: C)
    (implicit gc: Generic[C]{ type Repr <: H },
    fnHLister: FnToProduct.Aux[(P1, P2, P3) => R, H => R]): R =
  fnHLister(f)(gc.to(c))

build.sbt

Compiled with sbt 1.0, Scala 2.12, and Shapeless 2.3.3

scalaVersion := "2.12.4"

scalacOptions ++= Seq("-feature", "-language:_", "-Ypartial-unification")

resolvers ++= Seq (
  Resolver.sonatypeRepo("releases"),
  Resolver.sonatypeRepo("snapshots")
)

libraryDependencies ++= Seq("com.chuusai" %% "shapeless" % "2.3.3")