本文的行文风格不求阅读意义上的可读性,而是期望读者能够跟着本文的一些探索,自己做一些尝试,即 git clone 本文涉及的代码阅读并实践。
至于 Scala 元编程的一些介绍,请阅读 @王在祥 的《神奇的 Scala Macro 之旅系列》: 一, 二, 三, 四。
我们从Macro Paradise 的例子开始。有点遗憾的是,这个例子仍然在使用旧的 Sbt。所以,我们的第一步是把构建的定义升级到当前 Sbt 的最新版。完整的项目见我 fork 的sbt-example-paradise。
首先,在 project/build.properties 中指定:
sbt.version=1.2.7
然后,再修改 build.sbt 为:
val paradiseVersion = "2.1.0"
lazy val commonSettings = Seq(
scalaVersion := "2.12.8",
addCompilerPlugin("org.scalamacros" % "paradise" % paradiseVersion cross CrossVersion.full)
)
lazy val root = (project in file("."))
.aggregate(core, macros)
lazy val macros = (project in file("macros"))
.settings(commonSettings)
.settings(
libraryDependencies ++= Seq(
"org.scala-lang" % "scala-reflect" % scalaVersion.value
)
)
lazy val core = (project in file("core"))
.settings(commonSettings)
.dependsOn(macros)
我们可以对比一下这一段 SBT 项目构建的定义和 Maven 的构建定义:
最后,运行一下这个例子:
$ sbt
> project core # 切换到 core 子项目
> compile
> run
输出结果如下:
hello
@hello
object Test extends App {
println(this.hello)
}
我们实际上运行的这段代码异常简洁。this 指代 Test 这个独立对象(stand-alone object)本身,调用了一个不存在的方法 hello。我们的问题是,@hello 施放了什么样的魔法,生成了这样一个不存在的 hello 方法。
忽略别的语法细节,我们只看下面的这段代码:
annottees.map(_.tree).toList match {
case q"object $name extends ..$parents { ..$body }" :: Nil =>
q"""
object $name extends ..$parents {
def hello: ${typeOf[String]} = "hello"
..$body
}
"""
}
从直观的感受,我们能猜想到,$name
即 Test,$parents
即 App,$body
就是代码的主体。parents 和 body 前面有两个点,区别于 name。
通过$name
、$parents
、$body
这种特殊的语法形式,我们实际上把:
object Test extends App {
println(this.hello)
}
变换成了:
object Test extends App {
println(this.hello)
def hello: String = "hello"
}
尽管我们或许不知道其中的语法所对应的语义,更不清楚具体的实现机制,但这部分代码的可读性是非常棒(intuitive)的。
现在大概知道了@hello
所施放的黑魔法。下一步,我们就得弄明白这个简单的例子中,每一行代码的含义。
否则,任何拙劣的模仿和尝试,都是在浪费时间。
那我们应该如何学习这些黑魔法呢?官网的文档可读性并不好,而且不少是过时的。网络上也没有特别友好的面向新人的教程。
追本溯源,前面的项目实际上涉及到两个子项目,scala-reflect 和 paradise。在 scala 的源代码中,scala-reflect 相关的代码单元测试并不多,所以我们从 paradise 的单元测试开始阅读。
git clone git@github.com:scalamacros/paradise.git
可以将 sbt 的版本统一到 1.2.7。这样做,主要为了防止去下载另外一个 Sbt 的版本,浪费大量时间。
很幸运,更改版本之后,项目可以正常编译,测试。
$ sbt
> compile
> project tests
> test
这个 sbt 的终端保持开启,然后用 Intelli Idea 打开整个项目,这样,应该能够更快地打开整个项目,我们在 Sbt 的会话中可以看到无端跳出来的日志:
[info] new client connected: network-1
大致浏览一下这些单元测试的代码,可以获得一些初步的印象。
另外,这个paradise 插件将在 Scala 2.13.x 中内置,所以我们还需要看一下 Scala 2.13.x 分支的代码。通过git grep paradise
,可以看到一些蛛丝马迹。paradise 的源代码主要被引入到了 compiler 和 reflect 下面,而单元测试则是在 tests/macro-annot 下面。
此时,我们可以将前面的 sbt-example-paradise 升级到 Scala 2.13.x:
val paradiseVersion = "2.1.0"
lazy val commonSettings = Seq(
scalaVersion := "2.13.0-M5",
scalacOptions ++= Seq("-Ymacro-annotations")
)
lazy val root = (project in file("."))
.aggregate(core, macros)
lazy val macros = (project in file("macros"))
.settings(commonSettings)
.settings(
libraryDependencies ++= Seq(
"org.scala-lang" % "scala-reflect" % scalaVersion.value
)
)
lazy val core = (project in file("core"))
.settings(commonSettings)
.dependsOn(macros)
为了避免构建定义太复杂,我们直接新开一个分支。
这里请注意一下编译选项scalacOptions ++= Seq("-Ymacro-annotations")
。我是在 Scala 源代码中通过git grep paradise
瞥见的这个编译选项,然后简单看了一下相关代码,了解到了其中的作用。这边第二次提及git grep
,是因为在日常工作中,发现一些小伙伴不知道有git grep
这么好用的工具,觉得十分诧异。
不过细想也很正常,很多时候,我们自己所认为的 Common Sense,别人极有可能根本不了解。
所以,我们直接研究最新的 2.13.x,不需要任何依赖,就可以探索 Scala 的元编程。
本文从一个 Macro Paradise 项目的示例项目,从构建和代码阅读的细节入手,从大体上去感知 Macro Paradise 的某个具体的应用场景。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.