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

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

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


person Emre    schedule 20.06.2013    source источник
comment
Вместо использования макроса может быть проще сопоставить класс stackoverflow.com/questions/1226555/case-class-to-map-in-scala   -  person Noah    schedule 21.06.2013
comment
@ Нет, да, уже видел. Но мне интересно делать это во время компиляции с макросами. Спасибо за помощь!   -  person Emre    schedule 21.06.2013
comment
Вместо просто Ident(newTermName(posts)) вам нужно использовать Select(c.prefix.tree, newTermName("posts")).   -  person Eugene Burmako    schedule 21.06.2013
comment
Большое спасибо @EugeneBurmako! Теперь это работает. Не могли бы вы пояснить, зачем мне это нужно? Также опубликуйте его как ответ, чтобы я мог его выбрать. Спасибо за отличную работу над макросами!   -  person Emre    schedule 21.06.2013
comment
Рад, что помог! Я думаю, что Трэвис Браун дал гораздо более исчерпывающее объяснение, поэтому я думаю, что было бы лучше, если бы вы приняли его ответ.   -  person Eugene Burmako    schedule 21.06.2013
comment
Вам необходимо указать явный префикс для выбора поля, потому что расширение макроса не оценивается в контексте объекта (где автоматически будет this), а скорее встроено в сайт вызова.   -  person Eugene Burmako    schedule 21.06.2013


Ответы (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.

person Travis Brown    schedule 20.06.2013
comment
Зачем resetAllAttrs? Похоже, здесь в этом нет необходимости. - person Eugene Burmako; 21.06.2013
comment
Работает без resetAllAttrs. Спасибо за отличный ответ. Одна вещь, однако, ваша реализация выводит только те значения, которые определены в конструкторе (то есть в методе доступа case). Вместо этого я использовал isAccessor. Кажется, я раньше пропускал этот метод. - person Emre; 21.06.2013
comment
Ах да, я удалил resetAllAttrs во втором примере (хотя в первом он определенно необходим). И я не был уверен в членах класса, не относящихся к регистру, поскольку, например, numPosts не отображается в желаемом выводе. - person Travis Brown; 21.06.2013
comment
Мне нужно resetAllAttrs даже во втором примере - иначе получить ClassCastException. - person kiritsuku; 21.06.2013
comment
@TravisBrown, забыл добавить это. Исправлено сейчас. Что бы вы порекомендовали мне прочитать, чтобы узнать больше о макросах (кроме макросов на веб-сайте Scala)? Мне все еще сложно думать, что делать дальше при написании макроса. - person Emre; 21.06.2013
comment
Это очень мило. Вы также сделали обратное (Сопоставить с классом)? - person sourcedelica; 22.05.2014
comment
@sourcedelica: все пары ключ-значение должны быть литералами, чтобы делать обратное во время компиляции, что снижает полезность. - person Travis Brown; 23.05.2014
comment
@sourcedelica: Вы, конечно, можете сгенерировать (во время компиляции) код для анализа пар во время выполнения и вернуть Option[Foo]. - person Travis Brown; 23.05.2014
comment
@TravisBrown: Поскольку Case Class to Map - это тривиальная вещь, почему эта утилита не является частью std. lib? - person Sudheer Aedama; 09.10.2014
comment
@Venkat: Map[String, Any] - едва ли не самый антипаттерн из всех антипаттернов Scala. Иногда вам нужно это делать, и в этом случае макрос полезен, но я рад, что его нет в стандартной библиотеке или языке. - person Travis Brown; 09.10.2014
comment
@TravisBrown: Совершенно верно, иногда вам нужно это делать. Я понимаю, что такие антипаттерны в std. lib, но что насчет многих других вещей в std. lib, которые являются анти-шаблонами, но служат для некоторых ограничений? - person Sudheer Aedama; 10.10.2014
comment
@TravisBrown - взаимодействие между классом case и строкой обязательно с отражением? - person crak; 13.06.2016

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

person lisak    schedule 03.12.2014

Начиная с Scala 2.13, case classes (которые являются реализацией _3 _) теперь предоставляются с помощью productElementNames, который возвращает итератор по именам их полей.

Заархивировав имена полей со значениями полей, полученными с помощью productIterator можно получить Map вне зависимости от класса case:

// 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"))
person Xavier Guihot    schedule 06.10.2018