Макросы 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)

Что я делаю неправильно? Я думаю, что компилятор пытается также оценить идентификаторы полей при создании выражения, но я не знаю, как правильно их вернуть в выражении. Не могли бы вы показать мне, как это сделать?

Большое спасибо заранее.

3 ответа

Решение

Обратите внимание, что это можно сделать гораздо более элегантно без 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,

На карте есть отличная запись блога в / из класса преобразования с использованием макросов.

Начало Scala 2.13, case class ES (которые являются реализацией 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"))
Другие вопросы по тегам