Scala 3 中的巨集

內聯

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

內聯是一種常見的編譯時期元程式設計技術,通常用於達成效能最佳化。正如我們將看到的,在 Scala 3 中,內聯的概念為我們提供了巨集程式設計的入門點。

  1. 它將內聯引入為 軟關鍵字
  2. 它保證內聯確實發生,而不是盡力而為。
  3. 它引入了保證在編譯時期評估的運算。

內聯常數

內聯的最簡單形式是在程式中內聯常數

inline val pi = 3.141592653589793
inline val pie = "🥧"

在上述內聯值定義中使用關鍵字 inline保證pipie 的所有參照都已內聯

val pi2 = pi + pi // val pi2 = 6.283185307179586
val pie2 = pie + pie // val pie2 = "🥧🥧"

在上述程式碼中,參照 pipie 已內聯。然後,編譯器會套用稱為「常數摺疊」的最佳化,在編譯時期計算結果值 pi2pie2

內聯 (Scala 3) 與 final (Scala 2)

在 Scala 2 中,我們會在沒有回傳類型的定義中使用修飾詞 final

final val pi = 3.141592653589793
final val pie = "🥧"

修飾詞 final 會確保 pipie 會採用 *文字類型*。然後編譯器中的常數傳播最佳化可以對此類定義執行內聯。然而,這種形式的常數傳播是 *盡力而為*,無法保證。Scala 3.0 也支援 final val 內聯作為 *盡力而為* 內聯,以利於遷移。

目前,只有常數表達式可以出現在內聯值定義的右側。因此,以下程式碼無效,儘管編譯器知道右側是一個編譯時常數值

val pi = 3.141592653589793
inline val pi2 = pi + pi // error

請注意,透過定義 inline val pi,可以在編譯時計算加法。這會解決上述錯誤,而 pi2 會接收文字類型 6.283185307179586d

內聯方法

我們也可以使用修飾詞 inline 來定義應該在呼叫點內聯的方法

inline def logged[T](level: Int, message: => String)(inline op: T): T =
  println(s"[$level]Computing $message")
  val res = op
  println(s"[$level]Result of $message: $res")
  res

當呼叫內聯方法(例如 logged)時,其主體會在編譯時在呼叫點展開!也就是說,對 logged 的呼叫會被方法的主體取代。提供的引數會靜態地替換為 logged 的參數。因此,編譯器會內聯以下呼叫

logged(logLevel, getMessage()) {
  computeSomething()
}

並改寫為

val level   = logLevel
def message = getMessage()

println(s"[$level]Computing $message")
val res = computeSomething()
println(s"[$level]Result of $message: $res")
res

內聯方法的語意

我們的範例方法 logged 使用三種不同類型的參數,說明內聯如何以不同的方式處理這些參數

  1. 傳值參數。編譯器會為 *傳值* 參數產生一個 val 繫結。這樣一來,引數表達式只會在方法主體簡化之前評估一次。

    這可以在範例中的參數 level 中看到。在某些情況下,當引數是純常數值時,繫結會被省略,而值會直接內聯。

  2. 傳名參數。編譯器會為 *傳名* 參數產生一個 def 繫結。這樣一來,引數表達式會在每次使用時評估,但程式碼是共用的。

    這可以在範例中的參數 message 中看到。

  3. 內聯參數。內聯參數不會建立繫結,只會單純內聯。這樣一來,它們的程式碼會在所有使用它們的地方被複製。

    這可以在範例中的參數 op 中看到。

不同參數轉換的方式保證內聯呼叫不會改變其語義。這表示在輸入內聯方法主體時執行的初始精緻處理(重載解析、隱式搜尋等)在內聯時不會改變。

例如,考慮下列程式碼

class Logger:
  def log(x: Any): Unit = println(x)

class RefinedLogger extends Logger:
  override def log(x: Any): Unit = println("Any: " + x)
  def log(x: String): Unit = println("String: " + x)

inline def logged[T](logger: Logger, x: T): Unit =
  logger.log(x)

logger.log(x) 的個別類型檢查將解析呼叫至方法 Logger.log,其會採用類型為 Any 的引數。現在,假設有下列程式碼

logged(new RefinedLogger, "✔️")

它會擴充為

val logger = new RefinedLogger
val x = "✔️"
logger.log(x)

即使我們現在知道 xString,呼叫 logger.log(x) 仍會解析至方法 Logger.log,其會採用類型為 Any 的引數。請注意,由於延遲繫結,在執行階段實際呼叫的方法將會是覆寫方法 RefinedLogger.log

內聯保留語義

不論 logged 定義為 definline def,它都會執行相同的作業,只在效能上有些許差異。

內聯參數

內聯的一個重要應用是啟用跨方法邊界的常數摺疊最佳化。內聯參數不會建立繫結,且其程式碼會在使用它們的每個地方重複。

inline def perimeter(inline radius: Double): Double =
  2.0 * pi * radius

在上方的範例中,我們預期如果 radius 在靜態上已知,則整個運算可以在編譯階段執行。下列呼叫

perimeter(5.0)

會改寫為

2.0 * pi * 5.0

然後 pi 會內聯(我們假設從一開始就有 inline val 定義)

2.0 * 3.141592653589793 * 5.0

最後,它會常數摺疊為

31.4159265359
內聯參數應僅使用一次

多次使用內聯參數時,我們需要小心。考慮下列程式碼

inline def printPerimeter(inline radius: Double): Double =
  println(s"Perimeter (r = $radius) = ${perimeter(radius)}")

當常數或對 val 的參考傳遞給它時,它會完美地運作。

printPerimeter(5.0)
// inlined as
println(s"Perimeter (r = ${5.0}) = ${31.4159265359}")

但如果傳遞較大的表達式(可能具有副作用),我們可能會意外地重複工作。

printPerimeter(longComputation())
// inlined as
println(s"Perimeter (r = ${longComputation()}) = ${6.283185307179586 * longComputation()}")

內聯參數的一個有用應用是避免建立封閉,這是由依名稱參數的使用所產生的。

inline def assert1(cond: Boolean, msg: => String) =
  if !cond then
    throw new Exception(msg)

assert1(x, "error1")
// is inlined as
val cond = x
def msg = "error1"
if !cond then
    throw new Exception(msg)

在上方的範例中,我們可以看到依名稱參數的使用會導致一個區域定義 msg,其會在檢查條件之前配置一個封閉。

如果我們改用內聯參數,我們可以保證在觸發任何處理例外狀況的程式碼之前,會先檢查條件。如果發生斷言,就不應該觸發此程式碼。

inline def assert2(cond: Boolean, inline msg: String) =
  if !cond then
    throw new Exception(msg)

assert2(x, "error2")
// is inlined as
val cond = x
if !cond then
    throw new Exception("error2")

內聯條件式

如果 if 的條件是已知的常數 (truefalse),可能是在內聯和常數摺疊之後,條件式會經過部分評估,而且只會保留一個分支。

例如,下列冪次方法包含一些 if,這些 if 可能會展開遞迴並移除所有方法呼叫。

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

使用靜態已知常數呼叫 power 會產生下列程式碼

  power(2, 2)
  // first inlines as
  val x = 2
  if (2 == 0) 1.0 // dead branch
  else if (2 % 2 == 1) x * power(x, 2 - 1) // dead branch
  else power(x * x, 2 / 2)
  // partially evaluated to
  val x = 2
  power(x * x, 1)

請參閱其餘內聯步驟

// then inlined as
val x = 2
val x2 = x * x
if (1 == 0) 1.0 // dead branch
else if (1 % 2 == 1) x2 * power(x2, 1 - 1)
else power(x2 * x2, 1 / 2) // dead branch
// partially evaluated to
val x = 2
val x2 = x * x
x2 * power(x2, 0)
// then inlined as
val x = 2
val x2 = x * x
x2 * {
  if (0 == 0) 1.0
  else if (0 % 2 == 1) x2 * power(x2, 0 - 1) // dead branch
  else power(x2 * x2, 0 / 2) // dead branch
}
// partially evaluated to
val x = 2
val x2 = x * x
x2 * 1.0

相反地,讓我們想像我們不知道 n 的值

power(2, unknownNumber)

受參數上內聯註解的驅動,編譯器會嘗試展開遞迴。但由於參數不是靜態已知的,因此無法成功。

請參閱內聯步驟

// first inlines as
val x = 2
if (unknownNumber == 0) 1.0
else if (unknownNumber % 2 == 1) x * power(x, unknownNumber - 1)
else power(x * x, unknownNumber / 2)
// then inlined as
val x = 2
if (unknownNumber == 0) 1.0
else if (unknownNumber % 2 == 1) x * {
  if (unknownNumber - 1 == 0) 1.0
  else if ((unknownNumber - 1) % 2 == 1) x2 * power(x2, unknownNumber - 1 - 1)
  else power(x2 * x2, (unknownNumber - 1) / 2)
}
else {
  val x2 = x * x
  if (unknownNumber / 2 == 0) 1.0
  else if ((unknownNumber / 2) % 2 == 1) x2 * power(x2, unknownNumber / 2 - 1)
  else power(x2 * x2, unknownNumber / 2 / 2)
}
// Oops this will never finish compiling
...

若要保證分支確實可以在編譯時執行,我們可以使用 ifinline if 變體。使用 inline 註解條件式會保證可以在編譯時縮減條件式,而且如果條件不是靜態已知的常數,就會發出錯誤訊息。

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

我們稍後會回到這個範例,並了解如何進一步控制程式碼的產生方式。

內聯方法覆寫

為了確保將 inline def 的靜態功能與介面和覆寫的動態功能結合時的正確行為,必須施加一些限制。

實際上是最終的

首先,所有內聯方法都是實際上是最終的。這可確保編譯時的重載解析與執行時的解析行為相同。

簽章保留

其次,覆寫必須與被覆寫的方法有完全相同的簽章,包括內聯參數。這可確保兩個方法的呼叫語意相同。

保留的內聯方法

可以使用內聯方法來實作或覆寫一般方法。

考慮以下範例

trait Logger:
  def log(x: Any): Unit

class PrintLogger extends Logger:
  inline def log(x: Any): Unit = println(x)

然而,直接在 PrintLogger 上呼叫 log 方法會將程式碼內嵌,而呼叫 Logger 上的 log 方法則不會。為了同時支援後者,log 的程式碼必須在執行時期存在。我們稱這為保留內嵌方法。

對於任何非保留內嵌 defval,程式碼永遠可以在所有呼叫位置完全內嵌。因此,這些方法在執行時期不需要,可以從位元組碼中刪除。然而,保留內嵌方法必須相容於未內嵌的情況。特別是,保留內嵌方法不能接受任何內嵌參數。此外,inline if(如 power 範例)將無法運作,因為 if 在保留情況下無法常數摺疊。其他範例包含僅在內嵌時才有意義的元程式設計建構。

抽象內嵌方法

也可以建立抽象內嵌定義

trait InlineLogger:
  inline def log(inline x: Any): Unit

class PrintLogger extends InlineLogger:
  inline def log(inline x: Any): Unit = println(x)

這強制 log 的實作為內嵌方法,並允許 inline 參數。反直覺地,InlineLogger 介面上的 log 無法直接呼叫。方法實作在靜態上未知,因此我們不知道要內嵌什麼。因此,呼叫抽象內嵌方法會導致錯誤。抽象內嵌方法在用於其他內嵌方法時,其用途才會顯現

inline def logged(logger: InlineLogger, x: Any) =
  logger.log(x)

讓我們假設在 PrintLogger 的具體實例上呼叫 logged

logged(new PrintLogger, "🥧")
// inlined as
val logger: PrintLogger = new PrintLogger
logger.log(x)

內嵌後,對 log 的呼叫會解除虛擬化,並已知在 PrintLogger 上。因此,log 的程式碼也可以內嵌。

內嵌方法摘要

  • 所有 inline 方法都是最後的。
  • 抽象 inline 方法只能由內嵌方法實作。
  • 如果內嵌方法覆寫/實作常規方法,則必須保留,而保留方法不能有內嵌參數。
  • 摘要 inline 方法無法直接呼叫(內嵌程式碼除外)。

透明內嵌方法

透明內嵌是一種簡單但強大的 inline 方法擴充,並解鎖許多元程式設計用例。呼叫透明內嵌允許內嵌程式碼根據內嵌表達式的精確類型調整回傳類型。在 Scala 2 的術語中,透明內嵌捕捉了白盒巨集的精髓。

transparent inline def default(inline name: String): Any =
  inline if name == "Int" then 0
  else inline if name == "String" then ""
  else ...
val n0: Int = default("Int")
val s0: String = default("String")

請注意,即使 default 的回傳類型是 Any,第一個呼叫仍會被型別為 Int,而第二個呼叫則會被型別為 String。回傳類型表示內嵌項內類型上限。我們也可以寫得更精確,改寫成

transparent inline def default(inline name: String): 0 | "" = ...

雖然在此範例中回傳類型似乎沒有必要,但當內嵌方法是遞迴時,它就變得重要。在那裡,它應該足夠精確才能型別遞迴,但內嵌後會變得更精確。

透明內嵌會影響二進位相容性

重要的是要注意,變更 transparent inline def 的主體會變更呼叫站點的型別方式。這表示主體在這個介面的二進位和原始碼相容性中扮演角色。

編譯時期操作

我們也提供一些會在編譯時期評估的操作。

內嵌比對

就像內嵌 if,內嵌比對保證模式比對可以在編譯時期靜態縮減,並且只保留一個分支。

在以下範例中,受檢驗者 x 是我們可以在編譯時期比對模式的內嵌參數。

inline def half(x: Any): Any =
  inline x match
    case x: Int => x / 2
    case x: String => x.substring(0, x.length / 2)

half(6)
// expands to:
// val x = 6
// x / 2

half("hello world")
// expands to:
// val x = "hello world"
// x.substring(0, x.length / 2)

這說明內嵌比對提供一種比對某些表達式靜態類型的途徑。由於我們比對表達式的靜態類型,以下程式碼會編譯失敗。

val n: Any = 3
half(n) // error: n is not statically known to be an Int or a Double

值得注意的是,值 n 沒有標記為 inline,因此在編譯時期沒有足夠的受檢驗者資訊來決定要採取哪個分支。

scala.compiletime

套件 scala.compiletime 提供有用的元程式設計抽象,可以在 inline 方法中使用,以提供自訂語意。

巨集

內聯也是用來撰寫巨集的核心機制。巨集提供一種方法,可以在呼叫內聯後控制程式碼產生和分析。

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

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

本頁面的貢獻者