Scala 3 中的巨集

引號程式碼

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

程式碼區塊

引號程式碼區塊 '{ ... } 在語法上類似於字串引號 " ... ",不同之處在於前者包含已輸入的程式碼。若要將程式碼插入其他程式碼,我們可以使用語法 $expr${ expr },其中 expr 的類型為 Expr[T]。直覺上,引號中的程式碼 ('{ ... }) 目前不會執行,而接合處中的程式碼 (${ ... }) 會經過評估,並將結果接合到周圍的表達式中。

val msg = Expr("Hello")
val printHello = '{ print($msg) }
println(printHello.show) // print("Hello")

一般來說,引號會延遲執行,而接合處會在周圍的程式碼之前執行。此概括讓我們也可以賦予不在引號中的 ${ ... } 意義。這會在編譯時評估接合處中的程式碼,並將結果置於產生的程式碼中。由於一些技術考量,只有頂層接合處才允許直接在我們稱為 巨集inline 定義中。

可以在引號中寫入引號,但撰寫巨集時這種模式並不常見。

層級一致性

無法在引號和拼接中寫入任意程式碼,因為程式的一部分會存在於編譯時期,而另一部分會存在於執行時期。請考慮以下結構不良的程式碼

def myBadCounter1(using Quotes): Expr[Int] = {
  var x = 0
  '{ x += 1; x }
}

此程式碼的問題在於 x 存在於編譯期間,但我們試圖在編譯器完成後(甚至在另一部電腦上)使用它。顯然,無法存取其值並更新它。

現在考慮雙重版本,我們在執行時期定義變數並嘗試在編譯時期存取它

def myBadCounter2(using Quotes): Expr[Int] = '{
  var x = 0
  ${ x += 1; 'x }
}

顯然,這無法運作,因為變數尚未存在。

為了確保無法撰寫包含這些問題的程式,我們限制引號環境中允許的參照類型。

我們將層級定義為引號數量減去表達式或定義周圍的拼接數量。

// level 0
'{ // level 1
  var x = 0
  ${ // level 0
    x += 1
    'x // level 1
  }
}

系統允許在任何層級參照全域定義,例如 println,但會限制參照區域定義。區域定義只能在與其參照定義在相同層級時存取。這將捕捉 myBadCounter1myBadCounter2 中的錯誤。

即使無法在引號內參照變數,我們仍可以使用 Expr.apply 將其目前的數值提升至表達式,並透過引號傳遞。

泛型

在引號程式碼中使用類型參數或其他類型的抽象類型時,我們需要明確追蹤其中一些類型。Scala 對其泛型使用已抹除類型語意。這表示在編譯時會從程式中移除類型,而執行時期不需要追蹤所有類型。

請考慮以下程式碼

def evalAndUse[T](x: Expr[T])(using Quotes) = '{
  val x2: T = $x // error
  ... // use x2
}

在此,我們會收到錯誤訊息,告知我們缺少內容 Type[T]。因此,我們可以輕鬆地透過撰寫以下程式碼來修正它

def evalAndUse[T](x: Expr[T])(using Type[T])(using Quotes) = '{
  val x2: T = $x
  ... // use x2
}

此程式碼等同於以下較冗長的版本

def evalAndUse[T](x: Expr[T])(using t: Type[T])(using Quotes) = '{
  val x2: t.Underlying = $x
  ... // use x2
}

請注意,Type 有稱為 Underlying 的類型成員,它參照 Type 中所包含的類型;在本例中,t.UnderlyingT。即使我們隱含地使用 Type,通常最好讓它保持內容,因為引號中的某些變更可能需要它。較不冗長的版本通常是撰寫類型的最佳方式,因為它更容易閱讀。在某些情況下,我們不會靜態得知 Type 中的類型,且需要使用 t.Underlying 來參照它。

我們何時需要這個額外的 Type 參數?

  • 當類型為抽象類型,且在高於目前層級的層級中使用時。

當您將 Type 情境參數新增至方法時,您將從另一個情境參數取得它,或透過呼叫 Type.of 隱含取得它

evalAndUse(Expr(3))
// is equivalent to
evalAndUse[Int](Expr(3))(using Type.of[Int])

您可能猜到了,並非每種類型都能用於 Type.of[..] 的參數。例如,我們無法復原已清除的抽象類型

def evalAndUse[T](x: Expr[T])(using Quotes) =
  given Type[T] = Type.of[T] // error
  '{
    val x2: T = $x
    ... // use x2
  }

但我們可以撰寫依賴於這些抽象類型的更複雜類型。例如,如果我們尋找或明確建構 Type[List[T]],系統將需要在目前的環境中使用 Type[T] 來編譯。

良好的程式碼只應將 Types 新增至情境參數,且絕不明確使用它們。然而,明確使用在除錯時很有用,儘管它會犧牲簡潔性和清晰度。

ToExpr

Expr.apply 方法使用 ToExpr 的執行個體來產生一個會建立值副本的表達式。

object Expr:
  def apply[T](x: T)(using Quotes, ToExpr[T]): Expr[T] =
    summon[ToExpr[T]].apply(x)

ToExpr 定義如下

trait ToExpr[T]:
  def apply(x: T)(using Quotes): Expr[T]

ToExpr.apply 方法會取得一個值 T,並產生一個會在執行階段建構此值的副本的程式碼。

我們可以定義自己的 ToExprs,例如

given ToExpr[Boolean] with {
  def apply(x: Boolean)(using Quotes) =
    if x then '{true}
    else '{false}
}

given ToExpr[StringContext] with {
  def apply(stringContext: StringContext)(using Quotes) =
    val parts = Varargs(stringContext.parts.map(Expr(_)))
    '{ StringContext($parts*) }
}

Varargs 建構函式只會建立一個 Expr[Seq[T]],我們可以有效地將它作為變數參數進行拼接。一般而言,任何序列都可以使用 $mySeq* 進行拼接,以將它作為變數參數進行拼接。

引號模式

引號也可以用於檢查表達式是否等於另一個表達式,或將表達式解構為其各個部分。

比對精確表達式

我們可以執行的最簡單動作是檢查表達式是否與另一個已知表達式相符。以下我們示範如何使用 case '{...} => 比對一些表達式。

def valueOfBoolean(x: Expr[Boolean])(using Quotes): Option[Boolean] =
  x match
    case '{ true } => Some(true)
    case '{ false } => Some(false)
    case _ => None

def valueOfBooleanOption(x: Expr[Option[Boolean]])(using Quotes): Option[Option[Boolean]] =
  x match
    case '{ Some(true) } => Some(Some(true))
    case '{ Some(false) } => Some(Some(false))
    case '{ None } => Some(None)
    case _ => None

比對部分表達式

為了讓事情更簡潔,我們也可以使用拼接 ($) 來比對表達式的一部分,以比對任意程式碼並擷取它。

def valueOfBooleanOption(x: Expr[Option[Boolean]])(using Quotes): Option[Option[Boolean]] =
  x match
    case '{ Some($boolExpr) } => Some(valueOfBoolean(boolExpr))
    case '{ None } => Some(None)
    case _ => None

比對表達式的類型

我們也可以針對任意類型 T 的程式碼進行比對。在下方,我們針對類型為 T$x 進行比對,並取得類型為 Expr[T]x

def exprOfOption[T: Type](x: Expr[Option[T]])(using Quotes): Option[Expr[T]] =
  x match
    case '{ Some($x) } => Some(x) // x: Expr[T]
    case '{ None } => Some(None)
    case _ => None

我們也可以檢查表達式的類型

def valueOf(x: Expr[Any])(using Quotes): Option[Any] =
  x match
    case '{ $x: Boolean } => valueOfBoolean(x) // x: Expr[Boolean]
    case '{ $x: Option[Boolean] }  => valueOfBooleanOption(x) // x: Expr[Option[Boolean]]
    case _ => None

或者類似地,針對部分表達式進行檢查

case '{ Some($x: Boolean) } => // x: Expr[Boolean]

比對方法的接收者

當我們想要比對方法的接收者時,我們需要明確地陳述其類型

case '{ ($ls: List[Int]).sum } =>

如果我們寫 $ls.sum,我們將無法得知 ls 的類型,以及我們呼叫哪一個 sum 方法。

我們需要類型註解的另一個常見情況是中綴運算

case '{ ($x: Int) + ($y: Int) } =>
case '{ ($x: Double) + ($y: Double) } =>
case ...

比對函式表達式

即將推出

比對類型

到目前為止,我們假設引號模式中的類型會被靜態地得知。引號模式也允許泛型類型和存在類型,我們將在本節中看到。

模式中的泛型類型

考慮我們已經看過的函式 exprOfOption

def exprOfOption[T: Type](x: Expr[Option[T]])(using Quotes): Option[Expr[T]] =
  x match
    case '{ Some($x: T) } => Some(x) // x: Expr[T]
                // ^^^ type ascription with generic type T
    ...

請注意,這次我們在模式中明確地加入 T,即使它可以被推論出來。透過在模式中參照泛型類型 T,我們需要在範圍內有一個給定的 Type[T]。這表示 $x: T 僅在 x 的類型為 Expr[T] 時才會比對成功。在這個特定情況下,這個條件將永遠為真。

現在考慮以下變體,其中 x 是具有(靜態)未知元素類型的選用值

def exprOfOptionOf[T: Type](x: Expr[Option[Any]])(using Quotes): Option[Expr[T]] =
  x match
    case '{ Some($x: T) } => Some(x) // x: Expr[T]
    case _ => None

這次,模式 Some($x: T) 僅在 Option 的類型為 Some[T] 時才會比對成功。

exprOfOptionOf[Int]('{ Some(3) })   // Some('{3})
exprOfOptionOf[Int]('{ Some("a") }) // None

引號模式中的類型變數

引號程式碼可能包含引號外未知的類型。我們可以使用模式類型變數對它們進行比對。就像在一般模式中一樣,類型變數使用小寫名稱撰寫。

def exprOptionToList(x: Expr[Option[Any]])(using Quotes): Option[Expr[List[Any]]] =
  x match
    case '{ Some($x: t) } =>
                // ^^^ this binds the type `t` in the body of the case
      Some('{ List[t]($x) }) // x: Expr[List[t]]
    case '{ None } =>
      Some('{ Nil })
    case _ => None

模式 $x: t 將比對任何類型的表達式,而 t 將繫結到模式的類型。這個類型變數僅在 case 的右側有效。在此範例中,我們使用它來建構清單 List[t]($x)List($x) 也可行)。由於這是一個靜態未知的類型,我們需要在範圍內有一個給定的 Type[t]。幸運的是,引號模式會自動為我們提供這個類型。

如果我們想要知道表達式的精確類型,簡單的模式 case '{ $expr: tpe } => 非常有用。

val expr: Expr[Option[Int]] = ...
expr match
  case '{ $expr: tpe } =>
    Type.show[tpe] // could be: Option[Int], Some[Int], None, Option[1], Option[2], ...
    '{ val x: tpe = $expr; x } // binds the value without widening the type
    ...

在某些情況下,我們需要定義一個模式變數,它會被多次參照或具有某些類型界限。為達成此目的,可以使用 type t 與類型模式變數在模式的開頭建立模式變數。

/**
 * Use: Converts a redundant `list.map(f).map(g)` to only use one call
 * to `map`: `list.map(y => g(f(y)))`.
 */
def fuseMap[T: Type](x: Expr[List[T]])(using Quotes): Expr[List[T]] = x match {
  case '{
    type u
    type v
    ($ls: List[`u`])
      .map($f: `u` => `v`)
      .map($g: `v` => T)
    } =>
    '{ $ls.map(y => $g($f(y))) }
  case _ => x
}

在此,我們定義兩個類型變數 uv,然後使用 `u``v` 參照它們。我們不使用 uv(沒有反引號)參照它們,因為這些會被解釋為具有相同變數名稱的新類型變數。此表示法遵循正常的 穩定識別碼模式 語法。此外,如果類型變數需要受到約束,我們可以在類型定義中直接新增界限:case '{ type u <: AnyRef; ... } =>

請注意,前一個案例也可以寫成 case '{ ($ls: List[u]).map[v]($f).map[T]($g) =>

引號類型模式

使用 Type[T] 表示的類型可以使用模式 case '[...] => 進行比對。

inline def mirrorFields[T]: List[String] = ${mirrorFieldsImpl[T]}

def mirrorFieldsImpl[T: Type](using Quotes): Expr[List[String]] =

  def rec[A : Type]: List[String] = Type.of[A] match
    case '[field *: fields] =>
      Type.show[field] :: rec[fields]
    case '[EmptyTuple] =>
      Nil
    case _ =>
      quotes.reflect.report.errorAndAbort("Expected known tuple but got: " + Type.show[A])

  Expr(rec)
mirrorFields[EmptyTuple]         // Nil
mirrorFields[(Int, String, Int)] // List("scala.Int", "java.lang.String", "scala.Int")
mirrorFields[Tuple]              // error: Expected known tuple but got: Tuple

與表達式引號模式一樣,類型變數使用小寫名稱表示。

FromExpr

Expr.valueExpr.valueOrAbortExpr.unapply 方法使用 FromExpr 的執行個體在可能的情況下擷取值。

extension [T](expr: Expr[T]):
  def value(using Quotes)(using fromExpr: FromExpr[T]): Option[T] =
    fromExpr.unapply(expr)

  def valueOrError(using Quotes)(using fromExpr: FromExpr[T]): T =
    fromExpr.unapply(expr).getOrElse(eport.throwError("...", expr))
end extension

object Expr:
  def unapply[T](expr: Expr[T])(using Quotes)(using fromExpr: FromExpr[T]): Option[T] =
    fromExpr.unapply(expr)

FromExpr 定義如下

trait FromExpr[T]:
  def unapply(x: Expr[T])(using Quotes): Option[T]

FromExpr.unapply 方法會取得一個值 x,並產生會在執行階段建立此值副本的程式碼。

我們可以定義自己的 FromExpr 如下所示

given FromExpr[Boolean] with {
  def unapply(x: Expr[Boolean])(using Quotes): Option[Boolean] =
    x match
      case '{ true } => Some(true)
      case '{ false } => Some(false)
      case _ => None
}

given FromExpr[StringContext] with {
  def unapply(x: Expr[StringContext])(using Quotes): Option[StringContext] = x match {
    case '{ new StringContext(${Varargs(Exprs(args))}*) } => Some(StringContext(args*))
    case '{     StringContext(${Varargs(Exprs(args))}*) } => Some(StringContext(args*))
    case _ => None
  }
}

請注意,我們處理了 StringContext 的兩個案例。由於它是一個 case class,因此可以使用 new StringContext 或伴隨物件的 StringContext.apply 來建立它。我們還使用了 Varargs 抽取器,將類型為 Expr[Seq[String]] 的引數配對到 Seq[Expr[String]] 中。然後,我們使用 Exprs 來配對 Seq[Expr[String]] 中已知的常數,以取得 Seq[String]

引號

Quotes 是建立所有引號的主要入口點。此內容通常只透過內容抽象(using?=>)傳遞。每個引號範圍都會有自己的 Quotes。每次引入拼接時,就會引入新的範圍(${ ... })。儘管看起來拼接會將表達式作為引數,但它實際上會將 Quotes ?=> Expr[T] 作為引數。因此,我們實際上可以明確地將它寫成 ${ (using q) => ... }。在除錯時,這可能會很有用,以避免為這些範圍產生名稱。

方法 scala.quoted.quotes 提供了一個簡單的方法來使用目前的 Quotes,而不需要命名它。它通常會與 Quotes 一起使用 import scala.quoted.* 匯入。

${ (using q1) => body(using q1) }
// equivalent to
${ body(using quotes) }

警告:如果您明確地命名 Quotes quotes,您將會遮蔽這個定義。

當我們在巨集中撰寫頂層拼接時,我們呼叫的是類似於下列定義的內容。此拼接將提供與巨集擴充相關聯的初始 Quotes

def $[T](x: Quotes ?=> Expr[T]): T = ...

當我們在引號中有一個拼接時,內部引號內容會取決於外部引號內容。這個連結使用 Quotes.Nested 類型表示。引號的使用者幾乎不需要使用 Quotes.Nested。這些詳細資料只對會檢查程式碼並可能遭遇引號和拼接詳細資料的高階巨集有用。

def f(using q1: Quotes) = '{
  ${ (using q2: q1.Nested) ?=>
      ...
  }
}

我們可以想像,巢狀拼接就像下列方法,其中 ctx 是周圍引號接收到的內容。

def $[T](using q: Quotes)(x: q.Nested ?=> Expr[T]): T = ...

β 遞迴

當我們在引號中將 lambda 套用至引數 '{ ((x: Int) => x + x)(y) } 時,我們不會在引號中簡化它;程式碼會保持原樣。有一個最佳化會將直接套用至參數的所有 lambda 進行 β 簡化,以避免建立封閉。這不會從引號的角度顯示出來。

有時在引號上直接執行此 β 簡化會很有用。我們提供函式 Expr.betaReduce[T],它接收 Expr[T],並在包含直接套用的 lambda 時進行 β 簡化。

Expr.betaReduce('{ ((x: Int) => x + x)(y) }) // returns '{ val x = y; x + x }

呼叫值

有兩種方法可以在巨集中呼叫值。第一個方法是在內聯方法中有一個 using 參數,並將其明確傳遞給巨集實作。

inline def setOf[T](using ord: Ordering[T]): Set[T] =
  ${ setOfCode[T]('ord) }

def setOfCode[T: Type](ord: Expr[Ordering[T]])(using Quotes): Expr[Set[T]] =
  '{ TreeSet.empty[T](using $ord) }

在這個情況下,會在巨集展開之前找到 context 參數。如果找不到,巨集將不會展開。

第二個方法是使用 Expr.summon。這讓我們可以以程式化方式搜尋不同的給定表達式。以下範例類似於前一個範例

inline def setOf[T]: Set[T] =
  ${ setOfCode[T] }

def setOfCode[T: Type](using Quotes): Expr[Set[T]] =
  Expr.summon[Ordering[T]] match
    case Some(ord) => '{ TreeSet.empty[T](using $ord) }
    case _ => '{ HashSet.empty[T] }

不同之處在於,在第二個情況下,我們在執行隱式搜尋之前展開巨集。因此,我們可以撰寫任意程式碼來處理找不到 Ordering[T] 的情況。這裡,我們使用 HashSet 而不是 TreeSet,因為前者不需要 Ordering

引號類型類別

在前面的範例中,我們展示如何透過利用 using 引數子句,明確使用 Expr[Ordering[T]] 類型類別。這完全沒問題,但如果我們需要多次使用類型類別,這不是很方便。為了展示這一點,我們將使用一個 powerCode 函式,它可用於任何數字類型。

首先,讓 Expr 類型類別成為給定參數會很有用。為此,我們確實需要在 power 中明確輸入 powerCode,因為我們有一個給定的 Numeric[Num],但需要一個 Expr[Numeric[Num]]。但之後我們可以在 powerMacro 和任何其他只傳遞它的位置忽略它。

inline def power[Num](x: Num, inline n: Int)(using num: Numeric[Num]) =
  ${ powerMacro('x, 'n)(using 'num) }

def powerMacro[Num: Type](x: Expr[Num], n: Expr[Int])(using Expr[Numeric[Num]])(using Quotes): Expr[Num] =
  powerCode(x, n.valueOrAbort)

要使用此類型類別,我們需要一個給定的 Numeric[Num],但我們有一個 Expr[Numeric[Num]],因此我們需要在產生的程式碼中拼接此表達式。為了讓它可用,我們可以將它拼接在給定的定義中。

def powerCode[Num: Type](x: Expr[Num], n: Int)(using num: Expr[Numeric[Num]])(using Quotes): Expr[Num] =
  if (n == 0) '{ $num.one }
  else if (n % 2 == 0) '{
    given Numeric[Num] = $num
    val y = $x * $x
    ${ powerCode('y, n / 2) }
  }
  else '{
    given Numeric[Num] = $num
    $x * ${ powerCode(x, n - 1) }
  }

此頁面的貢獻者