¿Cómo modelar parámetros nombrados en invocaciones de métodos con macros de Scala?
scala-macros (2)
Hay casos de uso en los que es útil crear una copia de un objeto que es una instancia de una clase de caso de un conjunto de clases de casos, que tienen un valor específico en común.
Por ejemplo, consideremos las siguientes clases de casos:
case class Foo(id: Option[Int])
case class Bar(arg0: String, id: Option[Int])
case class Baz(arg0: Int, id: Option[Int], arg2: String)
Luego se puede llamar a copy
en cada una de estas instancias de clase de caso:
val newId = Some(1)
Foo(None).copy(id = newId)
Bar("bar", None).copy(id = newId)
Baz(42, None, "baz").copy(id = newId)
Como se describe here y here no hay una forma simple de abstraer esto de esta manera:
type Copyable[T] = { def copy(id: Option[Int]): T }
// THIS DOES *NOT* WORK FOR CASE CLASSES
def withId[T <: Copyable[T]](obj: T, newId: Option[Int]): T =
obj.copy(id = newId)
Así que creé una macro scala, que hace este trabajo (casi):
import scala.reflect.macros.Context
object Entity {
import scala.language.experimental.macros
import scala.reflect.macros.Context
def withId[T](entity: T, id: Option[Int]): T = macro withIdImpl[T]
def withIdImpl[T: c.WeakTypeTag](c: Context)(entity: c.Expr[T], id: c.Expr[Option[Int]]): c.Expr[T] = {
import c.universe._
val currentType = entity.actualType
// reflection helpers
def equals(that: Name, name: String) = that.encoded == name || that.decoded == name
def hasName(name: String)(implicit method: MethodSymbol) = equals(method.name, name)
def hasReturnType(`type`: Type)(implicit method: MethodSymbol) = method.typeSignature match {
case MethodType(_, returnType) => `type` == returnType
}
def hasParameter(name: String, `type`: Type)(implicit method: MethodSymbol) = method.typeSignature match {
case MethodType(params, _) => params.exists { param =>
equals(param.name, name) && param.typeSignature == `type`
}
}
// finding method entity.copy(id: Option[Int])
currentType.members.find { symbol =>
symbol.isMethod && {
implicit val method = symbol.asMethod
hasName("copy") && hasReturnType(currentType) && hasParameter("id", typeOf[Option[Int]])
}
} match {
case Some(symbol) => {
val method = symbol.asMethod
val param = reify((
c.Expr[String](Literal(Constant("id"))).splice,
id.splice)).tree
c.Expr(
Apply(
Select(
reify(entity.splice).tree,
newTermName("copy")),
List( /*id.tree*/ )))
}
case None => c.abort(c.enclosingPosition, currentType + " needs method ''copy(..., id: Option[Int], ...): " + currentType + "''")
}
}
}
El último argumento de Apply
(ver el bloque de código inferior) es una lista de parámetros (aquí: parámetros del método ''copy''). ¿Cómo se puede c.Expr[Option[Int]]
id
de tipo c.Expr[Option[Int]]
como parámetro con nombre al método de copia con la ayuda de la nueva macro API?
En particular, la siguiente expresión macro
c.Expr(
Apply(
Select(
reify(entity.splice).tree,
newTermName("copy")),
List(/*?id?*/)))
debería resultar en
entity.copy(id = id)
de modo que lo que sigue es
case class Test(s: String, id: Option[Int] = None)
// has to be compiled by its own
object Test extends App {
assert( Entity.withId(Test("scala rulz"), Some(1)) == Test("scala rulz", Some(1)))
}
La parte que falta se indica con el marcador de posición /*?id?*/
.
Aquí hay una implementación que también es un poco más genérica:
import scala.language.experimental.macros
object WithIdExample {
import scala.reflect.macros.Context
def withId[T, I](entity: T, id: I): T = macro withIdImpl[T, I]
def withIdImpl[T: c.WeakTypeTag, I: c.WeakTypeTag](c: Context)(
entity: c.Expr[T], id: c.Expr[I]
): c.Expr[T] = {
import c.universe._
val tree = reify(entity.splice).tree
val copy = entity.actualType.member(newTermName("copy"))
val params = copy match {
case s: MethodSymbol if (s.paramss.nonEmpty) => s.paramss.head
case _ => c.abort(c.enclosingPosition, "No eligible copy method!")
}
c.Expr[T](Apply(
Select(tree, copy),
params.map {
case p if p.name.decoded == "id" => reify(id.splice).tree
case p => Select(tree, p.name)
}
))
}
}
Funcionará en cualquier clase de caso con un miembro llamado id
, sin importar su tipo:
scala> case class Bar(arg0: String, id: Option[Int])
defined class Bar
scala> case class Foo(x: Double, y: String, id: Int)
defined class Foo
scala> WithIdExample.withId(Bar("bar", None), Some(2))
res0: Bar = Bar(bar,Some(2))
scala> WithIdExample.withId(Foo(0.0, "foo", 1), 2)
res1: Foo = Foo(0.0,foo,2)
Si la clase de caso no tiene un miembro de id
, withId
compilará, simplemente no hará nada. Si desea un error de compilación en ese caso, puede agregar una condición adicional a la coincidencia en la copy
.
Editar: Como acaba de señalar Eugene Burmako en Twitter , puedes escribir esto de forma más natural usando AssignOrNamedArg
al final:
c.Expr[T](Apply(
Select(tree, copy),
AssignOrNamedArg(Ident("id"), reify(id.splice).tree) :: Nil
))
Esta versión no se compilará si la clase de caso no tiene un miembro de id
, pero de todos modos es más probable que sea el comportamiento deseado.
Esta es la solución de Travis donde todas las partes se juntan:
import scala.language.experimental.macros
object WithIdExample {
import scala.reflect.macros.Context
def withId[T, I](entity: T, id: I): T = macro withIdImpl[T, I]
def withIdImpl[T: c.WeakTypeTag, I: c.WeakTypeTag](c: Context)(
entity: c.Expr[T], id: c.Expr[I]
): c.Expr[T] = {
import c.universe._
val tree = reify(entity.splice).tree
val copy = entity.actualType.member(newTermName("copy"))
copy match {
case s: MethodSymbol if (s.paramss.flatten.map(_.name).contains(
newTermName("id")
)) => c.Expr[T](
Apply(
Select(tree, copy),
AssignOrNamedArg(Ident("id"), reify(id.splice).tree) :: Nil))
case _ => c.abort(c.enclosingPosition, "No eligible copy method!")
}
}
}