web-dev-qa-db-ja.com

Scalaマクロ:Scalaのクラスのフィールドからマップを作成する

同様のデータクラスがたくさんあるとしましょう。次のように定義されているクラス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)

私は何が間違っているのですか?コンパイラは式を作成するときにフィールド識別子も評価しようとしていると思いますが、式で正しく返す方法がわかりません。その方法を教えていただけますか?

よろしくお願いします。

33
Emre

これは、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ビットが必要であることにも注意してください。

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について話していることを指定する必要はありません。

32
Travis Brown

マクロを使用したケースクラスへの変換とケースクラスからの変換に 優れたブログ投稿 があります。

12
lisak

開始Scala 2.13case classes( Product の実装)に、フィールド名のイテレータを返す productElementNames メソッドが提供されるようになりました。

productIterator で取得したフィールド値でフィールド名を圧縮することにより、どのような場合のクラスからでも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"))
1
Xavier Guihot