Scala 編譯器外掛程式

語言
此文件頁面特別針對 Scala 2 中發布的功能,這些功能已在 Scala 3 中移除或由其他功能取代。除非另有說明,此頁面中的所有程式碼範例都假設您使用的是 Scala 2。

Lex Spoon (2008)
Seth Tisue (2018)

簡介

編譯器外掛程式是一個編譯器元件,存在於與主編譯器不同的 JAR 檔案中。然後,編譯器可以載入該外掛程式並獲得額外的功能。

本教學課程將簡要引導您撰寫 Scala 編譯器的外掛程式。它不會深入探討如何讓您的外掛程式實際執行有用的操作,而只是展示撰寫外掛程式並將其連結到 Scala 編譯器所需的基本知識。

你可以閱讀,也可以觀看電視

本指南的內容與 Seth Tisue 的演講「Scala Compiler Plugins 101」有相當大的重疊(32 分鐘影片)。儘管該演講是 2018 年 4 月發表的,但其中幾乎所有資訊仍然適用(截至 2020 年 11 月)。

何時撰寫外掛程式

外掛程式讓你可以在不變更主要 Scala 發行版的狀況下修改 Scala 編譯器的行為。如果你撰寫一個包含編譯器修改的外掛程式,那麼你將外掛程式發布給任何人都可以使用你的修改。

你實際上不需要非常頻繁地修改 Scala 編譯器,因為 Scala 輕巧且彈性的語法通常允許你使用聰明的函式庫提供更好的解決方案。

不過,在某些情況下,即使對於 Scala 而言,編譯器修改也是最佳選擇。熱門的編譯器外掛程式(截至 2018 年)包括

某些在早期 Scala 版本中需要編譯器外掛才能執行的任務,現在可以使用巨集來完成;請參閱 巨集

運作方式

編譯器外掛包含

  • 實作額外編譯階段的程式碼。
  • 使用編譯器外掛 API 來指定這個新階段應該在何時執行的程式碼。
  • 指定外掛接受哪些選項的額外程式碼。
  • 包含外掛相關資訊的 XML 檔案

然後將所有這些打包成 JAR 檔案。

若要使用外掛,使用者可以將 JAR 檔案新增到其編譯時期的類別路徑,並透過使用 scalac 搭配 -Xplugin:... 來啟用它。(某些建置工具提供這個功能的捷徑;請參閱下方。)

所有這些內容將在下方更詳細地說明。

一個簡單的外掛,從頭到尾

這個區段將逐步說明如何撰寫一個簡單的外掛。

假設您想要撰寫一個外掛來偵測明顯情況下的除以零。例如,假設有人編譯一個像這樣的愚蠢程式

object Test {
  val five = 5
  val amount = five / 0
  def main(args: Array[String]): Unit = {
    println(amount)
  }
}

我們的外掛將會產生一個像這樣的錯誤

Test.scala:3: error: definitely division by zero
  val amount = five / 0
                    ^

製作外掛有幾個步驟。首先,您需要撰寫並編譯外掛本身的原始碼。以下是它的原始碼

package localhost

import scala.tools.nsc
import nsc.Global
import nsc.Phase
import nsc.plugins.Plugin
import nsc.plugins.PluginComponent

class DivByZero(val global: Global) extends Plugin {
  import global._

  val name = "divbyzero"
  val description = "checks for division by zero"
  val components = List[PluginComponent](Component)

  private object Component extends PluginComponent {
    val global: DivByZero.this.global.type = DivByZero.this.global
    val runsAfter = List[String]("refchecks")
    val phaseName = DivByZero.this.name
    def newPhase(_prev: Phase) = new DivByZeroPhase(_prev)
    class DivByZeroPhase(prev: Phase) extends StdPhase(prev) {
      override def name = DivByZero.this.name
      def apply(unit: CompilationUnit): Unit = {
        for ( tree @ Apply(Select(rcvr, nme.DIV), List(Literal(Constant(0)))) <- unit.body
             if rcvr.tpe <:< definitions.IntClass.tpe)
          {
            global.reporter.error(tree.pos, "definitely division by zero")
          }
      }
    }
  }
}

即使是這個簡單的外掛,也包含很多內容。以下是幾個值得注意的面向。

  • 外掛程式由繼承自 Plugin 的頂層類別描述,採用 Global 作為建構函式參數,並將該參數作為名為 globalval 匯出。
  • 外掛程式必須定義一個或多個繼承自 PluginComponent 的元件物件。在此情況下,唯一的元件是巢狀 Component 物件。外掛程式的元件會列在 components 欄位中。
  • 每個元件都必須定義 newPhase 方法,用來建立元件唯一的編譯階段。該階段會插入在指定的編譯階段之後,在此情況下為 refchecks
  • 每個階段都必須定義 apply 方法,用來對指定的編譯單元執行您想要的任何動作。通常這會包含檢查單元中的樹狀結構,並對樹狀結構執行一些轉換。
  • apply 主體內的模式比對顯示偵測使用者程式碼中特定樹狀結構的一種方式。(準引號是另一種方式。)Apply 表示方法呼叫,而 Select 表示成員的「選取」,例如 a.b。樹狀結構處理的詳細資訊不在本文件討論範圍內,但請參閱下方的「深入探討」以取得更多文件連結。

runsAfter 方法讓外掛作者可以控制階段執行時間。如上所示,它預期會傳回階段名稱清單。這使得指定多個階段名稱在外掛之前成為可能。也可以指定 runsBefore 約束,但這是選用的,表示這個階段應該在哪些階段之前執行。同樣地,也可以指定 runsRightAfter 約束,但這也是選用的,表示在特定階段之後立即執行。

可以在 編譯器階段與外掛初始化 SID 中找到有關如何控制階段順序的更多資訊。(這份文件最後更新於 2009 年,因此某些細節可能已過時。)

指定順序最簡單的方法是實作 runsRightAfter

那是外掛本身。接下來你需要做的是為它撰寫外掛描述檔。外掛描述檔是一個小型 XML 檔案,提供外掛的名稱和進入點。在這種情況下,它應該如下所示

<plugin>
  <name>divbyzero</name>
  <classname>localhost.DivByZero</classname>
</plugin>

外掛的名稱應與在 Plugin 子類別中指定的內容相符,而外掛的 classnamePlugin 子類別的名稱。有關外掛的所有其他資訊都在 Plugin 子類別中。

將這個 XML 放入一個名為 scalac-plugin.xml 的檔案中,然後使用那個檔案和已編譯的程式碼建立一個 jar

mkdir classes
scalac -d classes ExPlugin.scala
cp scalac-plugin.xml classes
(cd classes; jar cf ../divbyzero.jar .)

這就是它在沒有建置工具的情況下運作的方式。如果你使用 sbt 建置你的外掛,那麼 XML 檔案會進入 src/main/resources

使用 scalac 的外掛

現在,您可以透過新增 -Xplugin: 選項,使用 scalac 外掛

$ scalac -Xplugin:divbyzero.jar Test.scala
Test.scala:3: error: definitely division by zero
  val amount = five / 0
                    ^
one error found

發布您的外掛

當您對外掛的行為感到滿意時,您可能希望將 JAR 發布到 Maven 或 Ivy 儲存庫,以便建置工具可以解析它。(為了測試目的,您也可以只將其發布到您的本機電腦。在 sbt 中,這是透過 publishLocal 來完成的。)

在大部分方面,編譯器外掛都是一般的 Scala 函式庫,因此發布外掛就像發布任何函式庫一樣。請參閱 函式庫作者指南 和/或您的建置工具關於發布的說明文件。

從 sbt 使用外掛

為了讓最終使用者在發布您的外掛後可以方便地使用您的外掛,sbt 提供了一個 addCompilerPlugin 方法,您可以在建置定義中呼叫它,例如

addCompilerPlugin("org.divbyzero" %% "divbyzero" % "1.0")

addCompilerPlugin 執行多項動作。它透過 libraryDependencies 將 JAR 新增到類別路徑(僅編譯類別路徑,而非執行時期類別路徑),並且它也自訂 scalacOptions 以使用 -Xplugin 來啟用外掛。

有關更多詳細資料,請參閱 sbt 手冊中的 編譯器外掛支援

在 Mill 中使用您的外掛

若要在您的 Mill 專案中使用 scalac 編譯器外掛,您可以覆寫 scalacPluginIvyDeps 目標,以新增您的外掛相依性座標。

外掛選項可以在 scalacOptions 中指定。

範例

// build.sc
import mill._, mill.scalalib._

object foo extends ScalaModule {
  // Add the compiler plugin divbyzero in version 1.0
  def scalacPluginIvyDeps = Agg(ivy"org.divbyzero:::divbyzero:1.0")
  // Enable the `verbose` option of the divbyzero plugin
  def scalacOptions = Seq("-P:divbyzero:verbose:true")
  // other settings
  // ...
}

請注意,編譯器外掛通常會繫結到編譯器的完整版本,因此您必須在組織和成品名稱之間使用 :::(而非一般的 ::)來宣告您的依賴關係。

如需瞭解 Mill 中外掛用法的更多資訊,請參閱 Mill 文件

使用 IDE 開發編譯器外掛

內部而言,Scala 編譯器中路徑依賴型別的使用可能會讓某些 IDE(例如 IntelliJ)感到困惑。正確的外掛程式碼有時可能會被標示為錯誤。在這種情況下,IDE 通常仍然有用,但請記得對其回饋抱持懷疑的態度。如果錯誤標示會造成困擾,IDE 可能有設定可以讓您停用它。

有用的編譯器選項

前一節已帶您瞭解撰寫、使用和安裝編譯器外掛的基礎知識。有幾個與外掛相關的編譯器選項您應該要知道。

  • -Xshow-phases—顯示所有編譯器階段的清單,包括來自外掛的階段。
  • -Xplugin-list—顯示所有已載入外掛的清單。
  • -Xplugin-disable:...—停用外掛。每當編譯器遇到指定外掛的外掛描述符時,它都會略過它,甚至不會載入相關的 Plugin 子類別。
  • -Xplugin-require:...—要求載入外掛,否則中止。這在建置指令碼中特別有用。
  • -Xpluginsdir—指定編譯器將掃描以載入外掛的目錄。同樣地,這在建置指令碼中特別有用。

下列選項並非特定於撰寫外掛,但經常由外掛撰寫者使用

  • -Xprint:—在指定的階段執行後立即列印編譯器樹。
  • -Ybrowse:—類似 -Xprint:,但不是列印樹狀結構,而是開啟一個基於 Swing 的 GUI 來瀏覽樹狀結構。

加入您自己的選項

編譯器外掛程式可以提供命令列選項給使用者。所有此類選項都以 -P: 開頭,後接外掛程式的名稱。例如,-P:foo:bar 會將選項 bar 傳遞給外掛程式 foo

若要將選項加入您自己的外掛程式,您必須執行兩件事。首先,將 processOptions 方法加入您的 Plugin 子類別,並使用下列類型簽章

override def processOptions(
    options: List[String],
    error: String => Unit)

編譯器會呼叫此方法,並傳入使用者為您的外掛程式指定的所有選項。為方便起見,-P: 後接您的外掛程式名稱的常見字首會從傳入的所有選項中移除。

您應該執行的第二件事是為您的外掛程式選項加入說明訊息。您只需要覆寫名為 optionsHelpval。您指定的字串會列印為編譯器 -help 輸出的部分。依慣例,每個選項都列印在一行。選項本身從第 3 欄開始列印,選項的說明從第 31 欄開始列印。輸入 scalac -help 以確保您的說明字串看起來正確。

以下是具有選項的完整外掛程式。此外掛程式沒有其他行為,只會列印其選項。

package localhost

import scala.tools.nsc
import nsc.Global
import nsc.Phase
import nsc.plugins.Plugin
import nsc.plugins.PluginComponent

class Silly(val global: Global) extends Plugin {
  import global._

  val name = "silly"
  val description = "goose"
  val components = List[PluginComponent](Component)

  var level = 1000000

  override def processOptions(options: List[String], error: String => Unit): Unit = {
    for (option <- options) {
      if (option.startsWith("level:")) {
        level = option.substring("level:".length).toInt
      } else {
        error("Option not understood: "+option)
      }
    }
  }

  override val optionsHelp: Option[String] = Some(
    "  -P:silly:level:n             set the silliness to level n")

  private object Component extends PluginComponent {
    val global: Silly.this.global.type = Silly.this.global
    val runsAfter = List[String]("refchecks");
    val phaseName = Silly.this.name
    def newPhase(_prev: Phase) = new SillyPhase(_prev)

    class SillyPhase(prev: Phase) extends StdPhase(prev) {
      override def name = Silly.this.name
      def apply(unit: CompilationUnit): Unit = {
        println("Silliness level: " + level)
      }
    }
  }
}

深入探討

有關如何讓外掛程式執行某項任務的詳細資訊,您必須參閱編譯器內部結構的其他文件。相關文件包括

  • 符號、樹狀結構和類型是關於編譯器內部所使用資料結構最重要的參考文件。
  • 準引號對於 AST 上的模式比對很有用。
    • 準引號指南中的語法摘要是使用者層級語法和 AST 節點類型之間有用的對照表。

檢視其他外掛程式和研究編譯器原始碼中的現有階段也很有幫助。

此頁面的貢獻者