Scala 3 中的巨集

Scala 3 巨集

語言
此文件頁面專屬於 Scala 3,可能會涵蓋 Scala 2 中沒有的新概念。除非另有說明,此頁面中的所有程式碼範例都假設您使用的是 Scala 3。

內聯方法 透過在編譯時執行一些操作,為我們提供元編程的優雅技術。然而,有時內聯還不夠,我們需要更強大的方式來分析和綜合編譯時的程式。巨集使我們能夠做到這一點:將程式視為資料並加以操作。

巨集將程式視為值

使用巨集,我們可以將程式視為值,這讓我們可以在編譯時分析和產生它們。

具有類型 T 的 Scala 表達式由類型 scala.quoted.Expr[T] 的實例表示。

我們將深入探討類型 Expr[T] 的詳細資訊,以及分析和建構實例的不同方式,在討論 引號程式碼反射 時。目前來說,只要知道巨集是操作類型 Expr[T] 的表達式的元程式即可。

以下巨集實作會在編譯器程式的標準輸出中列印提供引數的表達式,並在編譯時執行

import scala.quoted.* // imports Quotes, Expr

def inspectCode(x: Expr[Any])(using Quotes): Expr[Any] =
  println(x.show)
  x

在列印引數表達式後,我們會將原始引數傳回,作為類型 Expr[Any] 的 Scala 表達式。

如同 內聯 部分所預示的,內聯方法提供巨集定義的進入點

inline def inspect(inline x: Any): Any = ${ inspectCode('x) }

所有巨集都使用 inline def 定義。這個進入點的實作總是具有相同的形式

  • 它們只包含一個 拼接 ${ ... }
  • 拼接包含對實作巨集的方法的單一呼叫(例如 inspectCode)。
  • 對巨集實作的呼叫會收到引號參數(即 'x 而不是 x)和一個語境 Quotes

我們將在本文和後續章節中深入探討這些概念。

呼叫我們的 inspect 巨集 inspect(sys error "abort") 會在編譯時列印引數表達式的字串表示。

scala.sys.error("abort")

巨集和類型參數

如果巨集有類型參數,實作也需要知道這些參數。就像 scala.quoted.Expr[T] 代表類型為 T 的 Scala 表達式,我們使用 scala.quoted.Type[T] 來代表 Scala 類型 T

inline def logged[T](inline x: T): T = ${ loggedCode('x)  }

def loggedCode[T](x: Expr[T])(using Type[T], Quotes): Expr[T] = ...

對應的內聯方法 (也就是 logged) 中的 splice 會自動提供 Type[T] 的實例和脈絡 Quotes,而且巨集實作可以使用這些實例和脈絡。

定義和使用巨集

內聯和巨集之間的一個關鍵差異在於它們的評估方式。內聯透過改寫程式碼並根據編譯器已知的規則進行最佳化來運作。另一方面,巨集會執行使用者撰寫的程式碼,而該程式碼會產生巨集擴充的程式碼。

技術上來說,編譯內聯程式碼 ${ inspectCode('x) } 會在編譯時 (透過 Java 反射) 呼叫方法 inspectCode,然後方法 inspectCode 會像一般程式碼一樣執行。

為了能夠執行 inspectCode,我們必須先編譯其原始碼。由於技術上的後果,我們無法在 **同一個類別/檔案** 中定義和使用巨集。不過,只要巨集的實作可以先編譯,就可以在 **同一個專案** 中定義巨集及其呼叫。

暫停檔案

為了允許在同一個專案中定義和使用巨集,只有那些已經編譯過的巨集呼叫才會展開。對於所有其他(未知的)巨集呼叫,檔案編譯會暫停。暫停的檔案只會在所有非暫停檔案都成功編譯後才會編譯。在某些情況下,您會有循環依賴關係,這會阻擋編譯完成。若要取得更多關於哪些檔案被暫停的資訊,您可以使用 -Xprint-suspension 編譯器旗標。

範例:使用巨集靜態評估 power

讓我們回顧一下我們在 內嵌 部分中對 power 的定義,它針對 n 的靜態已知值特化了 xⁿ 的運算。

inline def power(x: Double, inline n: Int): Double =
  inline if n == 0 then 1.0
  else inline if n % 2 == 1 then x * power(x, n - 1)
  else power(x * x, n / 2)

在本部分的其餘部分,我們將定義一個巨集,用於針對靜態已知值 xn 計算 xⁿ。雖然這也可以純粹使用 inline 來完成,但使用巨集來實作它將說明一些事情。

inline def power(inline x: Double, inline n: Int) =
  ${ powerCode('x, 'n)  }

def powerCode(
  x: Expr[Double],
  n: Expr[Int]
)(using Quotes): Expr[Double] = ...

簡單的表達式

我們可以如下實作 powerCode

def pow(x: Double, n: Int): Double =
  if n == 0 then 1 else x * pow(x, n - 1)

def powerCode(
  x: Expr[Double],
  n: Expr[Int]
)(using Quotes): Expr[Double] =
  val value: Double = pow(x.valueOrAbort, n.valueOrAbort)
  Expr(value)

在此,pow 作業是一個簡單的 Scala 函式,用於計算 xⁿ 的值。有趣的部分是如何建立和查看 Expr

從值建立表達式

讓我們先來看看 Expr.apply(value)。給定一個類型為 T 的值,此呼叫將回傳一個包含表示給定值的程式碼的表達式(也就是類型為 Expr[T])。傳遞給 Expr 的引數值是在編譯時計算的,在執行階段我們只需要實例化這個值。

從值建立表達式適用於所有基本類型、任何數性的ClassArraySeqSetListMapOptionEitherBigIntBigDecimalStringContext。如果實作了 ToExpr,其他類型也可以使用,我們將會在稍後看到

從表達式萃取值

我們在 powerCode 實作中使用的第二個方法是 Expr[T].valueOrAbort,它具有與 Expr.apply 相反的效果。它嘗試從類型為 Expr[T] 的表達式中萃取一個類型為 T 的值。這只有在表達式直接包含一個值的程式碼時才會成功,否則它將擲出一個例外,停止巨集擴充並報告表達式與值不符。

除了 valueOrAbort,我們也可以使用 value 操作,它會傳回一個 Option。這樣我們就可以使用自訂錯誤訊息來回報錯誤。

回報自訂錯誤訊息

情境 Quotes 參數提供了一個 report 物件,我們可以使用它來回報自訂錯誤訊息。在巨集實作方法中,你可以使用 quotes 方法(使用 import scala.quoted.* 匯入)來存取情境 Quotes 參數,然後使用 import quotes.reflect.report 匯入 report 物件。

提供自訂錯誤

我們將在 report 物件上呼叫 errorAndAbort 來提供自訂錯誤訊息,如下所示

def powerCode(
  x: Expr[Double],
  n: Expr[Int]
)(using Quotes): Expr[Double] =
  import quotes.reflect.report
  (x.value, n.value) match
    case (Some(base), Some(exponent)) =>
      val value: Double = pow(base, exponent)
      Expr(value)
    case (Some(_), _) =>
      report.errorAndAbort("Expected a known value for the exponent, but was " + n.show, n)
    case _ =>
      report.errorAndAbort("Expected a known value for the base, but was " + x.show, x)

或者,我們也可以使用 Expr.unapply 抽取器

  ...
  (x, n) match
    case (Expr(base), Expr(exponent)) =>
      val value: Double = pow(base, exponent)
      Expr(value)
    case (Expr(_), _) => ...
    case _ => ...

操作 valuevalueOrAbortExpr.unapply 會對所有基本型別、任何元組、OptionSeqSetMapEitherStringContext 運作。如果為其他型別實作了 FromExpr,也可以運作,我們將會在後續章節中看到

顯示表達式

inspectCode 的實作中,我們已經看過如何使用 .show 方法將表達式轉換為其原始碼的字串表示法。這對於對巨集實作執行除錯很有用

def debugPowerCode(
  x: Expr[Double],
  n: Expr[Int]
)(using Quotes): Expr[Double] =
  println(
    s"powerCode \n" +
    s"  x := ${x.show}\n" +
    s"  n := ${n.show}")
  val code = powerCode(x, n)
  println(s"  code := ${code.show}")
  code

使用變數參數

Scala 中的變數參數使用 Seq 表示,因此當我們使用變數參數撰寫巨集時,它會傳遞為 Expr[Seq[T]]。可以使用 scala.quoted.Varargs 抽取器來還原每個個別引數(類型為 Expr[T])。

import scala.quoted.* // imports `Varargs`, `Quotes`, etc.

inline def sumNow(inline nums: Int*): Int =
  ${ sumCode('nums)  }

def sumCode(nums: Expr[Seq[Int]])(using Quotes): Expr[Int] =
  import quotes.reflect.report
  nums match
    case  Varargs(numberExprs) => // numberExprs: Seq[Expr[Int]]
      val numbers: Seq[Int] = numberExprs.map(_.valueOrAbort)
      Expr(numbers.sum)
    case _ => report.errorAndAbort(
      "Expected explicit varargs sequence. " +
      "Notation `args*` is not supported.", nums)

抽取器會比對對 sumNow(1, 2, 3) 的呼叫,並抽取包含每個參數程式碼的 Seq[Expr[Int]]。但是,如果我們嘗試比對呼叫 sumNow(nums*) 的引數,抽取器將無法比對。

Varargs 也可用作建構函式。 Varargs(Expr(1), Expr(2), Expr(3)) 會傳回 Expr[Seq[Int]]。我們稍後會看到這如何派上用場。

複雜表達式

到目前為止,我們只看過如何建構和解構對應於簡單值的表達式。為了處理更複雜的表達式,Scala 3 提供了不同的元程式設計工具,範圍從

每個工具的複雜度越來越高,且可能會失去安全性保證。通常建議優先使用簡單的 API,而非更進階的 API。在本節的其餘部分,我們將介紹一些其他額外的建構函式和解構函式,而後續章節將介紹更進階的 API。

集合

我們已經看過如何使用 Expr.applyList[Int] 轉換成 Expr[List[Int]]。那如何將 List[Expr[Int]] 轉換成 Expr[List[Int]] 呢?我們提到 Varargs.apply 可以為序列執行此操作;同樣地,對於其他集合類型,也有對應的方法可用

  • Expr.ofList:將 List[Expr[T]] 轉換成 Expr[List[T]]
  • Expr.ofSeq:將 Seq[Expr[T]] 轉換成 Expr[Seq[T]](就像 Varargs
  • Expr.ofTupleFromSeq:將 Seq[Expr[T]] 轉換成 Expr[Tuple]
  • Expr.ofTuple:將 (Expr[T1], ..., Expr[Tn]) 轉換成 Expr[(T1, ..., Tn)]

簡單區塊

建構函式 Expr.block 提供一種簡單的方法來建立一個程式碼區塊 { stat1; ...; statn; expr }。它的第一個參數是一個包含所有陳述式的清單,第二個參數是區塊結尾的表達式。

inline def test(inline ignore: Boolean, computation: => Unit): Boolean =
  ${ testCode('ignore, 'computation) }

def testCode(ignore: Expr[Boolean], computation: Expr[Unit])(using Quotes) =
  if ignore.valueOrAbort then Expr(false)
  else Expr.block(List(computation), Expr(true))

當我們想要產生包含多個副作用的程式碼時,Expr.block 建構函式很有用。巨集呼叫 test(false, EXPRESSION) 會產生 { EXPRESSION; true},而呼叫 test(true, EXPRESSION) 會產生 false

簡單比對

方法 Expr.matches 可用於檢查一個表達式是否等於另一個表達式。使用此方法,我們可以針對 Expr[Boolean] 實作一個 value 函式,如下所示。

def value(boolExpr: Expr[Boolean]): Option[Boolean] =
  if boolExpr.matches(Expr(true)) then Some(true)
  else if boolExpr.matches(Expr(false)) then Some(false)
  else None

它也可以用於比較兩個使用者撰寫的表達式。請注意,matches 只會執行有限度的正規化,而例如 Scala 表達式 2 會比對表達式 { 2 },這不適用於表達式 { val x: Int = 2; x }

任意表達式

最後但並非最不重要的一點,可以透過將任意 Scala 程式碼封裝在 引號中,從任意 Scala 程式碼建立一個 Expr[T]。例如,'{ ${expr}; true } 會產生一個 Expr[Int],相當於 Expr.block(List(expr), Expr(true))。後續關於 引號程式碼 的區段會更詳細地介紹引號。

此頁面的貢獻者