Scala超程式設計:伊甸園初窺
閱讀建議
本文的行文風格不求閱讀意義上的可讀性,而是期望讀者能夠跟著本文的一些探索,自己做一些嘗試,即git clone本文涉及的程式碼閱讀並實踐。
至於Scala超程式設計的一些介紹,請閱讀 @王在祥 的《神奇的Scala Macro之旅系列》: 一, 二, 三, 四。
繞不開的Sbt
我們從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的構建定義:
- commonSettings相當於Maven裡面最頂層的pom.xml
- root相當於Maven中指定子模組的那幾行
- macros和core分別對應兩個子目錄,其中,core依賴macros。
最後,執行一下這個例子:
$ 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 [email protected]:scalamacros/paradise.git
複製程式碼
可以將sbt的版本統一到1.2.7。這樣做,主要為了防止去下載另外一個Sbt的版本,浪費大量時間。
很幸運,更改版本之後,專案可以正常編譯,測試。
$ sbt
> compile
> project tests
> test
複製程式碼
這個sbt的終端保持開啟,然後用Intelli Idea開啟整個專案,這樣,應該能夠更快地開啟整個專案,我們在Sbt的會話中可以看到無端跳出來的日誌:
[info] new client connected: network-1
複製程式碼
大致瀏覽一下這些單元測試的程式碼,可以獲得一些初步的印象。
Macro Paradise將在Scala 2.13.x中內建
另外,這個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的某個具體的應用場景。