Scala Macros: hacer un mapa fuera de los campos de una clase en Scala
case-class scala-macros (2)
Existe una excelente publicación de blog en el mapa a / desde la conversión de clase de caso utilizando macros.
Digamos que tengo muchas clases de datos similares. Aquí hay un User
clase de ejemplo que se define de la siguiente manera:
case class User (name: String, age: Int, posts: List[String]) {
val numPosts: Int = posts.length
...
def foo = "bar"
...
}
Estoy interesado en crear automáticamente un método ( en tiempo de compilación ) que devuelva un Map
de forma que cada nombre de campo se correlacione con su valor cuando se llame en tiempo de ejecución. Para el ejemplo anterior, digamos que mi método se llama toMap
:
val myUser = User("Foo", 25, List("Lorem", "Ipsum"))
myUser.toMap
debería regresar
Map("name" -> "Foo", "age" -> 25, "posts" -> List("Lorem", "Ipsum"), "numPosts" -> 2)
¿Cómo harías esto con macros?
Esto es lo que he hecho: Primero, creé una clase Model
como una superclase para todas mis clases de datos e implementé el método allí de esta manera:
abstract class Model {
def toMap[T]: Map[String, Any] = macro toMap_impl[T]
}
class User(...) extends Model {
...
}
Luego definí una implementación de macro en un objeto Macros
separado:
object Macros {
import scala.language.experimental.macros
import scala.reflect.macros.Context
def getMap_impl[T: c.WeakTypeTag](c: Context): c.Expr[Map[String, Any]] = {
import c.universe._
val tpe = weakTypeOf[T]
// Filter members that start with "value", which are val fields
val members = tpe.members.toList.filter(m => !m.isMethod && m.toString.startsWith("value"))
// Create ("fieldName", field) tuples to construct a map from field names to fields themselves
val tuples =
for {
m <- members
val fieldString = Literal(Constant(m.toString.replace("value ", "")))
val field = Ident(m)
} yield (fieldString, field)
val mappings = tuples.toMap
/* Parse the string version of the map [i.e. Map("posts" -> (posts), "age" -> (age), "name" -> (name))] to get the AST
* for the map, which is generated as:
*
* Apply(Ident(newTermName("Map")),
* List(
* Apply(Select(Literal(Constant("posts")), newTermName("$minus$greater")), List(Ident(newTermName("posts")))),
* Apply(Select(Literal(Constant("age")), newTermName("$minus$greater")), List(Ident(newTermName("age")))),
* Apply(Select(Literal(Constant("name")), newTermName("$minus$greater")), List(Ident(newTermName("name"))))
* )
* )
*
* which is equivalent to Map("posts".$minus$greater(posts), "age".$minus$greater(age), "name".$minus$greater(name))
*/
c.Expr[Map[String, Any]](c.parse(mappings.toString))
}
}
Sin embargo, obtengo este error de sbt cuando intento compilarlo:
[error] /Users/emre/workspace/DynamoReflection/core/src/main/scala/dynamo/Main.scala:9: not found: value posts
[error] foo.getMap[User]
[error] ^
Macros.scala se está compilando primero. Aquí está el fragmento de mi Build.scala:
lazy val root: Project = Project(
"root",
file("core"),
settings = buildSettings
) aggregate(macros, core)
lazy val macros: Project = Project(
"macros",
file("macros"),
settings = buildSettings ++ Seq(
libraryDependencies <+= (scalaVersion)("org.scala-lang" % "scala-reflect" % _))
)
lazy val core: Project = Project(
"core",
file("core"),
settings = buildSettings
) dependsOn(macros)
¿Qué estoy haciendo mal? Creo que el compilador también intenta evaluar los identificadores de campo cuando crea la expresión, pero no sé cómo devolverlos correctamente en la expresión. ¿Podrías enseñarme cómo hacer eso?
Muchas gracias de antemano.
Tenga en cuenta que esto se puede hacer mucho más elegante sin el negocio toString
/ c.parse
:
import scala.language.experimental.macros
abstract class Model {
def toMap[T]: Map[String, Any] = macro Macros.toMap_impl[T]
}
object Macros {
import scala.reflect.macros.Context
def toMap_impl[T: c.WeakTypeTag](c: Context) = {
import c.universe._
val mapApply = Select(reify(Map).tree, newTermName("apply"))
val pairs = weakTypeOf[T].declarations.collect {
case m: MethodSymbol if m.isCaseAccessor =>
val name = c.literal(m.name.decoded)
val value = c.Expr(Select(c.resetAllAttrs(c.prefix.tree), m.name))
reify(name.splice -> value.splice).tree
}
c.Expr[Map[String, Any]](Apply(mapApply, pairs.toList))
}
}
Tenga en cuenta también que necesita el bit c.resetAllAttrs
si desea poder escribir lo siguiente:
User("a", 1, Nil).toMap[User]
Sin él, obtendrás una ClassCastException
confusa en esta situación.
Por cierto, he aquí un truco que he usado para evitar el parámetro de tipo extra en, por ejemplo, user.toMap[User]
cuando escribo macros como este:
import scala.language.experimental.macros
trait Model
object Model {
implicit class Mappable[M <: Model](val model: M) extends AnyVal {
def asMap: Map[String, Any] = macro Macros.asMap_impl[M]
}
private object Macros {
import scala.reflect.macros.Context
def asMap_impl[T: c.WeakTypeTag](c: Context) = {
import c.universe._
val mapApply = Select(reify(Map).tree, newTermName("apply"))
val model = Select(c.prefix.tree, newTermName("model"))
val pairs = weakTypeOf[T].declarations.collect {
case m: MethodSymbol if m.isCaseAccessor =>
val name = c.literal(m.name.decoded)
val value = c.Expr(Select(model, m.name))
reify(name.splice -> value.splice).tree
}
c.Expr[Map[String, Any]](Apply(mapApply, pairs.toList))
}
}
}
Ahora podemos escribir lo siguiente:
scala> println(User("a", 1, Nil).asMap)
Map(name -> a, age -> 1, posts -> List())
Y no necesita especificar que estamos hablando de un User
.