Scala宏:在Scala中将类的字段制作成Map

34

假设我有很多相似的数据类。下面是一个叫做User的示例类定义:

case class User (name: String, age: Int, posts: List[String]) {
  val numPosts: Int = posts.length

  ...

  def foo = "bar"

  ...
}

我对在编译时自动创建一个方法很感兴趣,该方法返回一个Map,使得每个字段名在运行时被调用时都能映射到其值。以上面的示例为例,假设我的方法名为toMap

val myUser = User("Foo", 25, List("Lorem", "Ipsum"))

myUser.toMap

应该返回

Map("name" -> "Foo", "age" -> 25, "posts" -> List("Lorem", "Ipsum"), "numPosts" -> 2)

你如何使用宏来完成这个任务?

以下是我所做的步骤:首先,我创建了一个 Model 类作为所有数据类的超类,并在其中实现了该方法:

abstract class Model {
  def toMap[T]: Map[String, Any] = macro toMap_impl[T]
}

class User(...) extends Model {
  ...
}

然后我在单独的Macros对象中定义了一个宏实现:

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))
  }
}

当我尝试编译时,sbt会出现以下错误:
[error] /Users/emre/workspace/DynamoReflection/core/src/main/scala/dynamo/Main.scala:9: not found: value posts
[error]     foo.getMap[User]
[error]               ^

首先编译的是 Macros.scala。 这是我的 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)

我做错了什么?我认为编译器在创建表达式时试图评估字段标识符,但我不知道如何正确地在表达式中返回它们。你能向我展示如何做吗? 非常感谢。

不要使用宏,这可能更容易一些。https://dev59.com/unM_5IYBdhLWcg3wymY0 - Noah
@Noah,是的,我已经看过那个了。但是我对使用宏在编译时完成它很感兴趣。谢谢你的帮助! - Emre
2
你需要使用Select(c.prefix.tree, newTermName("posts")),而不是仅仅使用Ident(newTermName(posts)) - Eugene Burmako
非常感谢@EugeneBurmako!现在它可以工作了。您能详细解释一下为什么我需要这样做吗?另外,请将其发布为答案,以便我可以选择它。也感谢您在宏方面的出色工作! - Emre
很高兴能帮到你!我认为Travis Brown给出了更全面的解释,所以如果你接受他的答案会更好。 - Eugene Burmako
2
你需要为字段选择指定一个显式前缀,因为宏扩展不是在对象的上下文中评估的(在这种情况下,会自动提供this),而是内联到调用站点中。 - Eugene Burmako
3个回答

35
请注意,这可以更加优雅地完成,无需使用 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))
  }
}

请注意,如果您想写以下内容,则需要使用c.resetAllAttrs
同时,请注意保留HTML标签。
User("a", 1, Nil).toMap[User]

如果没有它,在这种情况下,你会得到一个令人困惑的ClassCastException

顺便说一句,这是我用来避免在编写类似以下代码时出现额外类型参数user.toMap[User]的技巧:

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))
    }
  }
}

现在我们可以写下以下内容:
scala> println(User("a", 1, Nil).asMap)
Map(name -> a, age -> 1, posts -> List())

不需要特别指定我们正在谈论一个User


为什么要重置所有属性?看起来在这里不应该是必要的。 - Eugene Burmako
它可以在不重置所有属性的情况下工作。感谢您的出色答案。但有一件事,您的实现仅输出在构造函数中定义的值(即case访问器)。我使用了isAccessor。我似乎之前错过了那个方法。 - Emre
啊,对了——我在第二个例子中删除了resetAllAttrs(尽管在第一个例子中肯定是必要的)。而且我不确定非case类成员,例如numPosts是否出现在您想要的输出中。 - Travis Brown
我需要resetAllAttrs,否则在第二个例子中会出现ClassCastException - kiritsuku
@Venkat:Map[String, Any] 是所有 Scala 反模式中最反模式的一种。有时你必须这样做,在这种情况下,宏是有用的,但我很高兴它不在标准库或语言中。 - Travis Brown
显示剩余7条评论

12

3
Scala 2.13 开始,case class(这是Product的一种实现)现在提供了一个 productElementNames 方法,该方法返回它们字段名称的迭代器。

通过将字段名称与使用productIterator获取的字段值进行压缩,可以从任何 case class 中获得一个 Map
// val user = User("Foo", 25, List("Lorem", "Ipsum"))
(user.productElementNames zip user.productIterator).toMap
// Map[String, Any] = Map("name" -> "Foo", "age" -> 25, "posts" -> List("Lorem", "Ipsum"))

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接