此文件頁面特定於 Scala 2 中提供的功能,這些功能已在 Scala 3 中移除或由其他功能取代。除非另有說明,此頁面中的所有程式碼範例都假設您使用的是 Scala 2。
實驗性
Eugene Burmako
Def 巨集自 Scala 2.10.0 版本起作為 Scala 的實驗性功能發布。在徹底規範之前,def 巨集的子集暫定排程在 Scala 的未來版本之一中穩定下來。
更新本指南為 Scala 2.10.0 編寫,現在我們已經進入 Scala 2.11.x 發布週期,因此文件內容自然已經過時。儘管如此,本指南並未過時 - 此處撰寫的所有內容在 Scala 2.10.x 和 Scala 2.11.x 中仍然有效,因此閱讀本指南將有所幫助。閱讀完本指南後,請查看 準引號 和 巨集套件 的文件,以熟悉最新發展,這些發展極大地簡化了巨集的編寫。然後,建議您關注 我們的巨集工作坊,以獲取更深入的範例。
直覺
以下是原型巨集定義
def m(x: T): R = macro implRef
乍看之下,巨集定義等同於一般函數定義,但主體除外,主體以條件關鍵字 macro
開頭,後接可能限定的識別碼,該識別碼參照靜態巨集實作方法。
如果編譯器在類型檢查期間遇到巨集 m(args)
的應用,它會透過呼叫對應的巨集實作方法來擴充該應用,其中抽象語法樹的引數表達式 args 作為引數。巨集實作的結果是另一個抽象語法樹,它會在呼叫位置內嵌,並會依序進行類型檢查。
下列程式碼片段宣告一個巨集定義 assert,它參照巨集實作 Asserts.assertImpl(assertImpl 的定義如下)
def assert(cond: Boolean, msg: Any) = macro Asserts.assertImpl
呼叫 assert(x < 10, "limit exceeded")
然後會在編譯時期導致呼叫
assertImpl(c)(<[ x < 10 ]>, <[ “limit exceeded” ]>)
其中 c
是包含編譯器在呼叫位置收集的資訊的內容參數,而其他兩個參數是代表兩個表示式 x < 10
和 limit exceeded
的抽象語法樹。
在此文件中,<[ expr ]>
表示代表表示式 expr 的抽象語法樹。此符號在我們建議的 Scala 語言擴充中沒有對應符號。實際上,語法樹會從特質 scala.reflect.api.Trees
中的類型建構,而上述兩個表示式會如下所示
Literal(Constant("limit exceeded"))
Apply(
Select(Ident(TermName("x")), TermName("$less"),
List(Literal(Constant(10)))))
以下是 assert
巨集的可能實作
import scala.reflect.macros.Context
import scala.language.experimental.macros
object Asserts {
def raise(msg: Any) = throw new AssertionError(msg)
def assertImpl(c: Context)
(cond: c.Expr[Boolean], msg: c.Expr[Any]) : c.Expr[Unit] =
if (assertionsEnabled)
<[ if (!cond) raise(msg) ]>
else
<[ () ]>
}
正如範例所示,巨集實作會使用幾個參數清單。首先會出現單一參數,類型為 scala.reflect.macros.Context
。接著會出現一個參數清單,其名稱與巨集定義參數的名稱相同。但是,在原始巨集參數的類型為 T
的地方,巨集實作參數的類型為 c.Expr[T]
。 Expr[T]
是在 Context
中定義的類型,它會包裝類型為 T
的抽象語法樹。 assertImpl
巨集實作的結果類型也是包裝的樹,類型為 c.Expr[Unit]
。
另請注意,巨集被視為實驗性和進階功能,因此若要撰寫巨集,您需要啟用它們。透過每個檔案的 import scala.language.experimental.macros
或透過每個編譯的 -language:experimental.macros
(提供編譯器切換)來執行此動作。不過,您的使用者不需要啟用任何功能 - 巨集看起來像一般的方法,而且可以當作一般的方法使用,而不需要任何編譯器切換或額外的設定。
一般巨集
巨集定義和巨集實作都可以是通用的。如果巨集實作具有類型參數,則實際類型引數必須在巨集定義的主體中明確提供。實作中的類型參數可能附帶 WeakTypeTag
內容界限。在這種情況下,當巨集展開時,會傳遞描述在應用程式中實例化的實際類型引數的對應類型標記。
下列程式碼片段宣告巨集定義 Queryable.map
,它參照巨集實作 QImpl.map
class Queryable[T] {
def map[U](p: T => U): Queryable[U] = macro QImpl.map[T, U]
}
object QImpl {
def map[T: c.WeakTypeTag, U: c.WeakTypeTag]
(c: Context)
(p: c.Expr[T => U]): c.Expr[Queryable[U]] = ...
}
現在考慮類型為 Queryable[String]
的值 q
和巨集呼叫
q.map[Int](s => s.length)
呼叫會展開為下列反射巨集呼叫
QImpl.map(c)(<[ s => s.length ]>)
(implicitly[WeakTypeTag[String]], implicitly[WeakTypeTag[Int]])
完整範例
本節提供 printf
巨集的端對端實作,它會在編譯時驗證和套用格式字串。為了簡潔起見,討論中使用主控台 Scala 編譯器,但如下所述,Maven 和 sbt 也支援巨集。
撰寫巨集從巨集定義開始,它代表巨集的外觀。巨集定義是具有簽章中任何花俏功能的常態函數。不過,它的主體只是一個實作的參考。如上所述,若要定義巨集,您需要匯入 scala.language.experimental.macros
或啟用特殊編譯器切換 -language:experimental.macros
。
import scala.language.experimental.macros
def printf(format: String, params: Any*): Unit = macro printf_impl
巨集實作必須對應使用它的巨集定義(通常只有一個,但也有可能很多)。簡而言之,巨集定義簽章中每個 T
型別的參數都必須對應巨集實作簽章中 c.Expr[T]
型別的參數。規則的完整清單相當複雜,但這永遠不會是個問題,因為如果編譯器不滿意,它會在錯誤訊息中列印它預期的簽章。
import scala.reflect.macros.Context
def printf_impl(c: Context)(format: c.Expr[String], params: c.Expr[Any]*): c.Expr[Unit] = ...
編譯器 API 在 scala.reflect.macros.Context
中公開。它最重要的部分,反射 API,可透過 c.universe
存取。慣例上會匯入 c.universe._
,因為它包含許多例行使用的函式和型別
import c.universe._
首先,巨集需要解析提供的格式字串。巨集在編譯時期執行,因此它們作用在樹狀結構上,而非值上。這表示 printf
巨集的格式參數將會是編譯時期的文字,而非 java.lang.String
型別的物件。這也表示下列程式碼無法用於 printf(get_format(), ...)
,因為在這種情況下 format
就不會是字串文字,而是一個代表函式應用的 AST。
val Literal(Constant(s_format: String)) = format.tree
典型的巨集(此巨集也不例外)需要建立代表 Scala 程式碼的 AST(抽象語法樹)。若要深入了解 Scala 程式碼的產生,請參閱反射概觀。除了建立 AST 之外,以下提供的程式碼也會處理型別。請注意我們如何取得對應於 Int
和 String
的 Scala 型別。上方連結的反射概觀詳細說明了型別處理。程式碼產生的最後步驟會將所有產生的程式碼組合成一個 Block
。請注意呼叫 reify
,它提供了建立 AST 的捷徑。
val evals = ListBuffer[ValDef]()
def precompute(value: Tree, tpe: Type): Ident = {
val freshName = TermName(c.fresh("eval$"))
evals += ValDef(Modifiers(), freshName, TypeTree(tpe), value)
Ident(freshName)
}
val paramsStack = Stack[Tree]((params map (_.tree)): _*)
val refs = s_format.split("(?<=%[\\w%])|(?=%[\\w%])") map {
case "%d" => precompute(paramsStack.pop, typeOf[Int])
case "%s" => precompute(paramsStack.pop, typeOf[String])
case "%%" => Literal(Constant("%"))
case part => Literal(Constant(part))
}
val stats = evals ++ refs.map(ref => reify(print(c.Expr[Any](ref).splice)).tree)
c.Expr[Unit](Block(stats.toList, Literal(Constant(()))))
以下程式片段表示 printf
巨集的完整定義。若要遵循範例,請建立一個空的目錄,並將程式碼複製到名為 Macros.scala
的新檔案中。
import scala.reflect.macros.Context
import scala.collection.mutable.{ListBuffer, Stack}
object Macros {
def printf(format: String, params: Any*): Unit = macro printf_impl
def printf_impl(c: Context)(format: c.Expr[String], params: c.Expr[Any]*): c.Expr[Unit] = {
import c.universe._
val Literal(Constant(s_format: String)) = format.tree
val evals = ListBuffer[ValDef]()
def precompute(value: Tree, tpe: Type): Ident = {
val freshName = TermName(c.fresh("eval$"))
evals += ValDef(Modifiers(), freshName, TypeTree(tpe), value)
Ident(freshName)
}
val paramsStack = Stack[Tree]((params map (_.tree)): _*)
val refs = s_format.split("(?<=%[\\w%])|(?=%[\\w%])") map {
case "%d" => precompute(paramsStack.pop, typeOf[Int])
case "%s" => precompute(paramsStack.pop, typeOf[String])
case "%%" => Literal(Constant("%"))
case part => Literal(Constant(part))
}
val stats = evals ++ refs.map(ref => reify(print(c.Expr[Any](ref).splice)).tree)
c.Expr[Unit](Block(stats.toList, Literal(Constant(()))))
}
}
若要使用 printf
巨集,請在同一個目錄中建立另一個檔案 Test.scala
,並將以下程式碼放入其中。請注意,使用巨集就像呼叫函式一樣簡單。它也不需要匯入 scala.language.experimental.macros
。
object Test extends App {
import Macros._
printf("hello %s!", "world")
}
巨集學的一個重要面向是分開編譯。若要執行巨集擴充,編譯器需要可執行形式的巨集實作。因此,巨集實作需要在主要編譯之前編譯,否則您可能會看到以下錯誤
~/Projects/Kepler/sandbox$ scalac -language:experimental.macros Macros.scala Test.scala
Test.scala:3: error: macro implementation not found: printf (the most common reason for that is that
you cannot use macro implementations in the same compilation run that defines them)
pointing to the output of the first phase
printf("hello %s!", "world")
^
one error found
~/Projects/Kepler/sandbox$ scalac Macros.scala && scalac Test.scala && scala Test
hello world!
秘訣和技巧
使用命令列 Scala 編譯器搭配巨集
前一節已說明此情境。簡單來說,使用 scalac
的分開呼叫編譯巨集及其使用方式,一切都應運作良好。如果您使用 REPL,那就更好了,因為 REPL 會在分開的編譯執行中處理每一行,因此您將能夠定義巨集並立即使用它。
使用 Maven 或 sbt 搭配巨集
本指南中的演練使用最簡單的命令列編譯,但巨集也可以與 Maven 和 sbt 等建置工具搭配使用。查看 https://github.com/scalamacros/sbt-example 或 https://github.com/scalamacros/maven-example 以取得端對端範例,但總而言之,您只需要知道兩件事
- 巨集需要在函式庫相依性中使用 scala-reflect.jar。
- 獨立編譯限制需要將巨集放置在獨立專案中。
使用 Intellij IDEA 的巨集
在 Intellij IDEA 中,已知巨集可以正常運作,前提是已移至獨立專案。
除錯巨集
除錯巨集(即驅動巨集擴充的邏輯)相當簡單。由於巨集是在編譯器中擴充,因此您需要做的就是使用除錯器執行編譯器。為此,您需要:1) 將 Scala 安裝目錄中 lib 目錄中的所有(!)函式庫(包括下列 jar 檔:scala-library.jar
、scala-reflect.jar
和 scala-compiler.jar
)加入除錯組態的類別路徑,2) 設定 scala.tools.nsc.Main
作為進入點,3) 為 JVM 提供 -Dscala.usejavacp=true
系統屬性(非常重要!),4) 設定編譯器的命令列引數為 -cp <path to the classes of your macro> Test.scala
,其中 Test.scala
代表包含要擴充的巨集呼叫的測試檔案。在完成所有這些步驟後,您應該可以在巨集實作中設定中斷點並啟動除錯器。
工具中真正需要特別支援的是除錯巨集擴充的結果(即巨集產生的程式碼)。由於此程式碼從未手動寫出,因此您無法在此處設定中斷點,也無法逐步執行它。Intellij IDEA 團隊可能會在某個時間點為其除錯器新增此支援,但目前除錯巨集擴充的唯一方法是診斷列印:-Ymacro-debug-lite
(如下所述),它會列印出巨集發出的程式碼,以及 println 來追蹤產生的程式碼執行。
檢查產生的程式碼
使用 -Ymacro-debug-lite
,可以同時看到巨集擴充所產生的程式碼的偽 Scala 表示法和擴充的原始 AST 表示法。兩者都有其優點:前者可用於表面分析,而後者對於精細除錯非常有價值。
~/Projects/Kepler/sandbox$ scalac -Ymacro-debug-lite Test.scala
typechecking macro expansion Macros.printf("hello %s!", "world") at
source-C:/Projects/Kepler/sandbox\Test.scala,line-3,offset=52
{
val eval$1: String = "world";
scala.this.Predef.print("hello ");
scala.this.Predef.print(eval$1);
scala.this.Predef.print("!");
()
}
Block(List(
ValDef(Modifiers(), TermName("eval$1"), TypeTree().setType(String), Literal(Constant("world"))),
Apply(
Select(Select(This(TypeName("scala")), TermName("Predef")), TermName("print")),
List(Literal(Constant("hello")))),
Apply(
Select(Select(This(TypeName("scala")), TermName("Predef")), TermName("print")),
List(Ident(TermName("eval$1")))),
Apply(
Select(Select(This(TypeName("scala")), TermName("Predef")), TermName("print")),
List(Literal(Constant("!"))))),
Literal(Constant(())))
巨集拋出未處理的例外狀況
如果巨集拋出未處理的例外狀況,會發生什麼事?例如,讓我們提供無效的輸入來讓 printf
巨集崩潰。如列印輸出所示,並未發生任何嚴重的事情。編譯器會保護自己免於行為不當的巨集,列印堆疊追蹤中的相關部分,並報告錯誤。
~/Projects/Kepler/sandbox$ scala
Welcome to Scala version 2.10.0-20120428-232041-e6d5d22d28 (Java HotSpot(TM) 64-Bit Server VM, Java 1.6.0_25).
Type in expressions to have them evaluated.
Type :help for more information.
scala> import Macros._
import Macros._
scala> printf("hello %s!")
<console>:11: error: exception during macro expansion:
java.util.NoSuchElementException: head of empty list
at scala.collection.immutable.Nil$.head(List.scala:318)
at scala.collection.immutable.Nil$.head(List.scala:315)
at scala.collection.mutable.Stack.pop(Stack.scala:140)
at Macros$$anonfun$1.apply(Macros.scala:49)
at Macros$$anonfun$1.apply(Macros.scala:47)
at scala.collection.TraversableLike$$anonfun$map$1.apply(TraversableLike.scala:237)
at scala.collection.TraversableLike$$anonfun$map$1.apply(TraversableLike.scala:237)
at scala.collection.IndexedSeqOptimized$class.foreach(IndexedSeqOptimized.scala:34)
at scala.collection.mutable.ArrayOps.foreach(ArrayOps.scala:39)
at scala.collection.TraversableLike$class.map(TraversableLike.scala:237)
at scala.collection.mutable.ArrayOps.map(ArrayOps.scala:39)
at Macros$.printf_impl(Macros.scala:47)
printf("hello %s!")
^
報告警告和錯誤
與使用者互動的標準方式是透過 scala.reflect.macros.FrontEnds
的方法。 c.error
會報告編譯錯誤, c.warning
會發出警告, c.abort
會報告錯誤並終止巨集的執行。
scala> def impl(c: Context) =
c.abort(c.enclosingPosition, "macro has reported an error")
impl: (c: scala.reflect.macros.Context)Nothing
scala> def test = macro impl
defined term macro test: Any
scala> test
<console>:32: error: macro has reported an error
test
^
請注意,目前報告功能不支援每個位置有多個警告或錯誤,如 SI-6910 中所述。這表示每個位置只會報告第一個錯誤或警告,其他都會遺失(此外,即使較早報告,錯誤也會優先於警告)。
撰寫更大的巨集
當巨集實作的程式碼變大到需要模組化,而且超過實作方法的主體時,顯然需要攜帶 context 參數,因為大多數感興趣的事項都依賴於 context 的路徑。
其中一種方法是撰寫一個類別,它採用 Context
類型的參數,然後將巨集實作分割成該類別的一系列方法。這是自然且簡單的,但很難正確執行。以下是一個典型的編譯錯誤。
scala> class Helper(val c: Context) {
| def generate: c.Tree = ???
| }
defined class Helper
scala> def impl(c: Context): c.Expr[Unit] = {
| val helper = new Helper(c)
| c.Expr(helper.generate)
| }
<console>:32: error: type mismatch;
found : helper.c.Tree
(which expands to) helper.c.universe.Tree
required: c.Tree
(which expands to) c.universe.Tree
c.Expr(helper.generate)
^
此程式碼片段中的問題在於路徑依賴的類型不匹配。Scala 編譯器無法理解 impl
中的 c
與 Helper
中的 c
是同一個物件,即使輔助程式是使用原始 c
建構的。
幸運的是,編譯器只需要一點小小的提示就能找出發生了什麼事。其中一種可能的方法是使用精緻類型(以下範例是最簡單的構想應用;例如,也可以撰寫一個從 Context
到 Helper
的隱式轉換,以避免明確的實例化並簡化呼叫)。
scala> abstract class Helper {
| val c: Context
| def generate: c.Tree = ???
| }
defined class Helper
scala> def impl(c1: Context): c1.Expr[Unit] = {
| val helper = new { val c: c1.type = c1 } with Helper
| c1.Expr(helper.generate)
| }
impl: (c1: scala.reflect.macros.Context)c1.Expr[Unit]
另一種方法是在明確的類型參數中傳遞內容識別碼。請注意 Helper
的建構函式如何使用 c.type
來表達 Helper.c
與原始 c
相同的事實。Scala 的類型推論無法自行找出這一點,因此我們需要協助它。
scala> class Helper[C <: Context](val c: C) {
| def generate: c.Tree = ???
| }
defined class Helper
scala> def impl(c: Context): c.Expr[Unit] = {
| val helper = new Helper[c.type](c)
| c.Expr(helper.generate)
| }
impl: (c: scala.reflect.macros.Context)c.Expr[Unit]