1. 理解增量重新編譯

理解增量重新編譯 

使用 scalac 編譯 Scala 程式碼很慢,但 sbt 通常可以使其更快。透過了解其原理,您甚至可以了解如何使編譯速度更快。修改具有許多依賴關係的原始檔可能只需要重新編譯那些原始檔(例如可能需要 5 秒),而不是所有依賴關係(例如可能需要 2 分鐘)。通常,您可以控制您的情況,並透過一些程式碼撰寫實務來加快開發速度。

改善 Scala 編譯效能是 sbt 的主要目標,因此它提供的加速是使用它的主要動機之一。sbt 的很大一部分原始碼和開發工作都與加速編譯的策略有關。

為了縮短編譯時間,sbt 使用兩種策略

  1. 減少重新啟動 Scalac 的開銷
    • 實作智慧且透明的增量重新編譯策略,以便僅重新編譯修改過的檔案和所需的依賴關係。
    • sbt 始終在相同的虛擬機器中執行 Scalac。如果使用 sbt 編譯原始碼、保持 sbt 運作、修改原始碼並觸發新的編譯,則此編譯將更快,因為(部分)Scalac 將已被 JIT 編譯。
  2. 減少重新編譯的來源數量。
    • 當修改原始檔 A.scala 時,sbt 會盡力僅在需要時重新編譯其他依賴於 A.scala 的原始檔 - 也就是說,僅當 A.scala 的介面被修改時。使用其他建置管理工具(尤其是 Java 的 ant 等),當開發人員以非二進位相容的方式變更原始檔時,她需要手動確保也重新編譯了依賴項 - 通常透過手動執行 clean 命令來移除現有的編譯輸出;否則,即使可能需要重新編譯依賴的類別檔案,編譯也可能會成功。更糟糕的是,對一個來源的變更可能會導致依賴項不正確,但這不會自動發現:可能會在原始碼不正確的情況下獲得編譯成功。由於 Scala 編譯時間如此之長,因此執行 clean 尤其不可取。

透過適當組織原始碼,您可以最大程度地減少變更影響的程式碼量。sbt 無法精確判斷哪些依賴項必須重新編譯;目標是計算保守的近似值,以便每當必須重新編譯檔案時,它都會重新編譯,即使我們可能會重新編譯額外的檔案。

sbt 啟發式演算法 

sbt 在原始檔的粒度層級追蹤原始碼依賴關係。對於每個原始檔,sbt 都會追蹤直接依賴它的檔案;如果檔案中類別、物件或特徵的介面發生變更,則必須重新編譯所有依賴該來源的檔案。目前,sbt 使用以下演算法來計算依賴給定原始檔的原始檔

  • 透過繼承引入的依賴關係會遞迴地包含;如果一個檔案中的類別/特徵繼承自另一個檔案中的特徵/類別,則會透過繼承引入依賴關係
  • 所有其他直接依賴關係都會透過名稱雜湊最佳化來考量;其他依賴關係也稱為「成員參考」依賴關係,因為它們是透過參考在其他原始檔中定義的成員(類別、方法、類型等)引入的
  • 名稱雜湊最佳化會在給定原始檔的介面變更內容中考量所有成員參考依賴關係;它會嘗試透過查看已修改成員的名稱並檢查依賴原始檔是否提及這些名稱來修剪不相關的依賴關係

自 sbt 0.13.6 起,預設啟用名稱雜湊最佳化。

如何利用 sbt 啟發式演算法 

sbt 使用的啟發式演算法表示以下使用者可見的後果,這些後果決定了對類別的變更是否會影響其他類別。

  1. 新增、移除、修改 private 方法不需要重新編譯用戶端類別。因此,假設您向具有大量依賴關係的類別新增了一個方法,並且該方法僅在宣告類別中使用;將其標記為 private 將防止重新編譯用戶端。但是,這僅適用於其他類別無法存取的方法,因此適用於標記為 private 或 private[this] 的方法;對套件為私有的方法(使用 private[name] 標記)是 API 的一部分。
  2. 修改非私有方法的介面會觸發名稱雜湊最佳化
  3. 修改一個類別確實需要重新編譯在同一檔案中定義的其他類別的依賴關係(與本指南的先前版本中所述不同)。因此,在不同的原始檔中分離不同的類別可能會減少重新編譯。
  4. 變更方法的實作應影響其用戶端,除非推斷出傳回類型,並且新的實作導致推斷出略有不同的類型。因此,如果非私有方法的傳回類型比實際傳回的類型更一般,則明確註釋非私有方法的傳回類型可以減少在變更此類方法的實作時要重新編譯的程式碼。(一般來說,明確註釋公共 API 的傳回類型是一種很好的實務。)

以上關於方法的所有討論也適用於一般欄位和成員;同樣,對類別的參考也延伸到物件和特徵。

增量重新編譯的實作 

本節深入探討增量編譯器的實作細節。它首先概述增量編譯器試圖解決的問題,然後討論導致目前實作的設計選擇。

概述 

增量編譯的目標是偵測原始檔或類別路徑的變更,並判斷要重新編譯的一小組檔案,以使其產生與完整批次編譯結果相同的最終結果。在對變更做出反應時,增量編譯器有兩個相互矛盾的目標

  • 盡可能重新編譯少量的原始檔,涵蓋對類型檢查的所有變更和產生的
  • 由變更的原始檔和/或類別路徑觸發的位元組碼

第一個目標是讓重新編譯速度更快,這是增量編譯器存在的唯一意義。第二個目標是關於正確性,並設定了重新編譯檔案集合大小的下限。判斷該集合是增量編譯器試圖解決的核心問題。我們將在本概述中深入研究這個問題,以了解是什麼使得實作增量編譯器成為一項具有挑戰性的任務。

讓我們考慮這個非常簡單的範例

// A.scala
package a
class A {
  def foo(): Int = 12
}

// B.scala
package b
class B {
  def bar(x: a.A): Int = x.foo()
}

假設這兩個檔案都已編譯,且使用者變更了 A.scala,使其如下所示

// A.scala
package a
class A {
  def foo(): Int = 23 // changed constant
}

增量編譯的第一步是編譯修改過的原始檔。這是增量編譯器必須編譯的最小檔案集。修改後的 A.scala 版本將編譯成功,因為變更常數不會引入類型檢查錯誤。增量編譯的下一步是判斷套用至 A.scala 的變更是否可能影響其他檔案。在上面的範例中,只有方法 foo 傳回的常數發生了變更,這不會影響其他檔案的編譯結果。

讓我們考慮對 A.scala 的另一個變更

// A.scala
package a
class A {
  def foo(): String = "abc" // changed constant and return type
}

如同之前,增量編譯的第一步是編譯修改過的檔案。在這個例子中,我們編譯 A.scala,編譯將順利完成。第二步再次判斷對 A.scala 的變更是否影響其他檔案。我們看到 foo 公開方法的返回類型已變更,這可能會影響其他檔案的編譯結果。確實,B.scala 包含對 foo 方法的呼叫,因此必須在第二步中編譯。編譯 B.scala 將會失敗,因為 B.bar 方法中存在類型不符,並且該錯誤將回報給使用者。這就是此案例中增量編譯終止的地方。

讓我們找出在上述範例中做出決策所需的兩個主要資訊。增量編譯器演算法需要:

  • 索引原始碼檔案,以便知道是否有 API 變更可能會影響其他原始碼檔案;例如,它需要偵測如上述範例中的方法簽名變更
  • 追蹤原始碼檔案之間的相依性;一旦偵測到 API 的變更,演算法就需要判斷可能受此變更影響的檔案集合

這兩部分資訊都是從 Scala 編譯器中提取的。

與 Scala 編譯器的互動 

增量編譯器以多種方式與 Scala 編譯器互動

  • 提供三個額外的階段,以提取所需的資訊
    • api 階段透過遍歷樹狀結構和索引類型來提取已編譯來源的公開介面
    • 相依性階段,提取原始碼檔案(編譯單元)之間的相依性
    • 分析器階段,捕獲已發出的類別檔案清單
  • 定義一個自訂的報告器,允許 sbt 收集錯誤和警告
  • 繼承 Global 以
    • 新增 api、相依性和分析器階段
    • 設定自訂報告器
  • 管理自訂 Global 的實例,並使用它們來編譯它判斷需要編譯的檔案

API 提取階段 

API 提取階段從 Trees、Types 和 Symbols 中提取資訊,並將其映射到增量編譯器的內部資料結構,這些結構在 api.specification 檔案中描述。這些資料結構允許以獨立於 Scala 編譯器版本的方式表達 API。此外,這種表示是持久的,因此它會序列化到磁碟上,並在編譯器執行或甚至是 sbt 執行之間重複使用。

API 提取階段由兩個主要元件組成

  1. 將 Types 和 Symbols 映射到增量編譯器提取的 API 表示形式
  2. 雜湊該表示形式
映射 Types 和 Symbols 

負責映射 Types 和 Symbols 的邏輯在 API.scala 中實作。隨著 Scala 反射的引入,我們有多種 Types 和 Symbols 變體。增量編譯器使用 scala.reflect.internal 套件中定義的變體。

此外,還有一個可能不明顯的設計選擇。當映射對應於類別或特徵的類型時,會複製所有繼承的成員,而不是該類別/特徵中的宣告。這樣做的原因是,它極大地簡化了 API 表示的分析,因為類別的所有相關資訊都儲存在一個地方,因此無需查詢父類型表示。這種簡潔性是有代價的:相同的資訊會被反覆複製,導致效能下降。例如,每個類別都會複製 java.lang.Object 的成員,以及其簽名的完整資訊。

雜湊 API 表示 

增量編譯器(目前實作的方式)不需要關於 API 的非常細粒度的資訊。增量編譯器只需要知道 API 自上次索引以來是否已變更。為此,雜湊總和就足夠了,並且可以節省大量記憶體。因此,API 表示會在處理單個編譯單元後立即進行雜湊處理,並且僅將雜湊總和持久儲存。

在較早的版本中,增量編譯器不會雜湊。這導致非常高的記憶體消耗和較差的序列化/反序列化效能。

雜湊邏輯在 HashAPI.scala 檔案中實作。

相依性階段 

增量編譯器提取給定編譯單元所依賴(參考)的所有 Symbols,然後嘗試將它們映射回對應的來源/類別檔案。將 Symbol 映射回原始碼檔案是透過使用 Symbol 從原始碼檔案設定的 sourceFile 屬性來執行。將 Symbol 映射回(二進位)類別檔案比較棘手,因為 Scala 編譯器不會追蹤從二進位檔案衍生的 Symbols 的來源。因此,使用簡單的啟發式方法,將限定的類別名稱映射到對應的類別路徑條目。此邏輯在相依性階段中實作,該階段可以存取完整的類別路徑。

給定編譯單元所依賴的 Symbol 集合是透過執行樹狀結構遍歷獲得的。樹狀結構遍歷檢查所有可以引入相依性(參考另一個 Symbol)的樹狀節點,並收集分配給它們的所有 Symbol。Symbol 是在類型檢查階段由 Scala 編譯器分配給樹狀節點的。

增量編譯器過去依賴 CompilationUnit.depends 來收集相依性。但是,名稱雜湊需要更精確的相依性資訊。有關詳細資訊,請查看 #1002.

分析器階段 

透過檢查包含後端將作為 JVM 類別檔案發出的所有 ICode 類別的 CompilationUnit.icode 屬性的內容,來提取產生的類別檔案集合。

名稱雜湊演算法 

動機 

讓我們考慮以下範例

// A.scala
class A {
  def inc(x: Int): Int = x+1
}

// B.scala
class B {
  def foo(a: A, x: Int): Int = a.inc(x)
}

假設這兩個檔案都已編譯,並且使用者變更 A.scala,使其看起來像這樣

// A.scala
class A {
  def inc(x: Int): Int = x+1
  def dec(x: Int): Int = x-1
}

一旦使用者按下儲存並要求增量編譯器重新編譯其專案,它將執行以下操作

  1. 重新編譯 A.scala,因為原始碼已變更(第一次迭代)
  2. 在重新編譯時,它會重新索引 A.scala 的 API 結構,並偵測到它已變更
  3. 它會判斷 B.scala 依賴於 A.scala,並且由於 A.scala 的 API 結構已變更,B.scala 也必須重新編譯(B.scala 已失效)
  4. 重新編譯 B.scala,因為它在 3. 中因相依性變更而失效
  5. 重新索引 B.scala 的 API 結構,並發現它沒有變更,因此我們完成了

總而言之,我們將呼叫 Scala 編譯器兩次:一次重新編譯 A.scala,然後重新編譯 B.scala,因為 A 有一個新的方法 dec

但是,很容易看出,在這個簡單的場景中,不需要重新編譯 B.scala,因為將 dec 方法新增到 A 類別與 B 類別無關,因為它沒有使用它,並且沒有以任何方式受到它的影響。

對於兩個檔案,我們重新編譯太多聽起來不太糟糕。但是,在實務上,相依性圖相當密集,因此,您可能會在變更與整個專案中幾乎所有檔案都無關的情況下,最終重新編譯整個專案。這正是修改路由時 Play 專案中發生的情況。路由和反向路由的性質是,每個模板和每個控制器都依賴於這兩個類別(RoutesReversedRoutes)中定義的一些方法,但是對特定路由定義的變更通常只會影響所有模板和控制器的一小部分。

名稱雜湊背後的想法是利用這個觀察結果,並使失效演算法更聰明地處理可能影響少量檔案的變更。

偵測不相關的相依性(直接方法) 

如果對給定原始碼檔案 X.scala 的 API 的變更不影響檔案 Y.scala 的編譯結果,即使 Y.scala 依賴於 X.scala,則可以將其稱為不相關的。

從該定義可以很容易地看出,只能針對給定的相依性宣告變更不相關。相反,如果變更不影響另一個檔案的編譯結果,則可以宣告兩個原始碼檔案之間的相依性與其中一個檔案中給定 API 變更無關。從現在開始,我們將專注於偵測不相關的相依性。

解決偵測不相關相依性問題的一種非常簡單的方法是,我們追蹤 Y.scala 中所有已使用的方法,因此如果 X.scala 中的方法被新增/刪除/修改,我們只需檢查它是否在 Y.scala 中使用,如果沒有,那麼我們認為 Y.scalaX.scala 的相依性在這種特定情況下是不相關的。

為了讓您先了解一下如果您考慮這種策略會快速出現的問題,讓我們考慮以下兩種情況。

繼承 

我們將看到未在另一個原始碼檔案中使用的方法如何影響其編譯結果。讓我們考慮這個結構

// A.scala
abstract class A

// B.scala
class B extends A

讓我們將一個抽象方法新增到 A 類別

// A.scala
abstract class A {
  def foo(x: Int): Int
}

現在,一旦我們重新編譯 A.scala,我們就可以說,由於 A.foo 沒有在 B 類別中使用,那麼我們不需要重新編譯 B.scala。但是,這是不正確的,因為 B 沒有實作新引入的抽象方法,並且應該回報錯誤。

因此,僅查看已使用的方法以判斷給定的相依性是否相關是不夠的。

豐富模式 

在這裡,我們將看到另一個新引入的方法(尚未在任何地方使用)的案例,該方法會影響其他檔案的編譯結果。這次,不會涉及繼承,但我們將改為使用豐富模式(隱式轉換)。

假設我們有以下結構

// A.scala
class A

// B.scala
class B {
  class AOps(a: A) {
    def foo(x: Int): Int = x+1
  }
  implicit def richA(a: A): AOps = new AOps(a)
  def bar(a: A): Int = a.foo(12) // this is expanded to richA(a).foo so we are calling AOPs.foo method
}

現在,讓我們將 foo 方法直接新增到 A

// A.scala
class A {
  def foo(x: Int): Int = x-1
}

現在,一旦我們重新編譯 A.scala 並偵測到 A 類別中定義了一個新方法,我們就需要考慮這是否與 B.scalaA.scala 的相依性相關。請注意,在 B.scala 中,我們不使用 A.foo(在編譯 B.scala 時它不存在),但我們使用 AOps.foo,並且不清楚 AOps.fooA.foo 有何關係。我們需要偵測到由於隱式轉換 richA 而呼叫 AOps.foo 的事實,這是因為我們之前未能找到 A 上的 foo 而插入的。

這種分析會使我們很快進入 Scala 類型檢查器的實作複雜性,並且在一般情況下是不可行的。

要追蹤的資訊太多 

以上所有內容都假設我們實際上擁有關於 API 結構和保留的已使用方法的完整資訊,因此我們可以利用它。但是,如 雜湊 API 表示 中所述,我們不儲存 API 的完整表示,而只儲存其雜湊總和。此外,相依性是在原始碼檔案層級追蹤,而不是在類別/方法層級追蹤。

人們可以想像重新設計目前的設計,以追蹤更多資訊,但這將是一項非常大的工作。此外,增量編譯器過去會保留整個 API 結構,但由於產生的不可行的記憶體需求,它切換到雜湊。

偵測無關的相依性(名稱雜湊) 

如同我們在前一章看到的,直接追蹤來源檔案中使用的更多資訊的方法很快就會變得棘手。人們會希望提出一個更簡單、更不精確的方法,但仍然可以比現有的實作帶來更大的改進。

這個想法是不追蹤所有使用的成員,並且非常精確地判斷對某些成員的特定變更何時會影響其他檔案的編譯結果。我們只會追蹤使用的簡單名稱,並且也會追蹤具有相同簡單名稱的所有成員的雜湊總和。簡單名稱僅指術語或類型的不合格名稱。

讓我們先看看這個簡化的策略如何解決豐富化模式的問題。我們將透過模擬名稱雜湊演算法來做到這一點。讓我們從原始碼開始

// A.scala
class A

// B.scala
class B {
  class AOps(a: A) {
    def foo(x: Int): Int = x+1
  }
  implicit def richA(a: A): AOps = new AOps(a)
  def bar(a: A): Int = a.foo(12) // this is expanded to richA(a).foo so we are calling AOPs.foo method
}

在編譯這兩個檔案期間,我們將提取以下資訊

usedNames("A.scala"): A
usedNames("B.scala"): B, AOps, a, A, foo, x, Int, richA, AOps, bar

nameHashes("A.scala"): A -> ...
nameHashes("B.scala"): B -> ..., AOps -> ..., foo -> ..., richA -> ..., bar -> ...

usedNames 關係追蹤指定來源檔案中提及的所有名稱。nameHashes 關係給我們一個雜湊總和,其中成員會依據相同的簡單名稱分組到一個桶子中。除了上面呈現的資訊外,我們仍然追蹤 B.scalaA.scala 的相依性。

現在,如果我們在 A 類別中新增一個 foo 方法

// A.scala
class A {
  def foo(x: Int): Int = x-1
}

並重新編譯,我們將得到以下(更新的)資訊

usedNames("A.scala"): A, foo
nameHashes("A.scala"): A -> ..., foo -> ...

增量編譯器會比較變更前後的名稱雜湊,並偵測到 foo 的雜湊總和已變更(已新增)。因此,它會查看所有相依於 A.scala 的來源檔案,在我們的例子中只有 B.scala,並檢查 foo 是否以已使用的名稱出現。它的確出現了,因此它會如預期般重新編譯 B.scala

您現在可以看到,如果我們在 A 中新增另一個方法,例如 xyz,則不會重新編譯 B.scala,因為 B.scala 中沒有任何地方提及名稱 xyz。因此,如果您擁有合理的不衝突名稱,您應該可以從許多被標記為不相關的來源檔案之間的相依性中受益。

這個簡單的基於名稱的啟發式方法能夠經受「豐富化模式」測試,這非常好。然而,名稱雜湊無法通過繼承的其他測試。為了處理這個問題,我們需要更仔細地研究繼承引入的相依性與成員參照引入的相依性。

由成員參照和繼承引入的相依性 

名稱雜湊演算法背後的核心假設是,如果使用者新增/修改/移除類別的成員(例如方法),除非其他類別正在使用該特定成員,否則其他類別的編譯結果不會受到影響。繼承及其各種覆寫檢查使整個情況變得更加複雜;如果您將它與引入新欄位到從特質繼承的類別的混入組合結合,您會很快意識到繼承需要特殊處理。

我們的想法是,每當涉及繼承時,我們現在會切換回舊的方案。因此,我們將成員參照引入的相依性與繼承引入的相依性分開追蹤。所有由繼承引入的相依性都不受名稱雜湊分析的約束,因此它們永遠不會被標記為不相關。

繼承引入的相依性背後的直覺非常簡單:它是類別/特質透過繼承另一個類別/特質而引入的相依性。所有其他相依性都稱為成員參照的相依性,因為它們是透過參照(選擇)另一個類別的成員(方法、類型別名、內部類別、val 等)而引入的。請注意,為了從類別繼承,您需要參照它,因此繼承引入的相依性是成員參照相依性的嚴格子集。

以下是一個說明區別的範例

// A.scala
class A {
  def foo(x: Int): Int = x+1
}

// B.scala
class B(val a: A)

// C.scala
trait C

// D.scala
trait D[T]

// X.scala
class X extends A with C with D[B] {
  // dependencies by inheritance: A, C, D
  // dependencies by member reference: A, C, D, B
}

// Y.scala
class Y {
  def test(b: B): Int = b.a.foo(12)
  // dependencies by member reference: B, Int, A
}

有兩件事需要注意

  1. X 不會透過繼承相依於 B,因為 B 作為類型參數傳遞給 D;我們

    只考慮作為 X 父類別出現的類型

  2. Y 相依於 A,即使來源檔案中沒有明確提及 A;我們

    選擇在 A 中定義的方法 foo,這足以引入相依性

總結來說,我們希望處理繼承及其引入問題的方式是將所有繼承引入的相依性分開追蹤,並以更嚴格的方式使相依性失效。基本上,每當存在繼承相依性時,它都會對父類別類型的任何(即使是微小的)變更做出反應。

計算名稱雜湊 

到目前為止,我們略過的一件事是名稱雜湊實際上是如何計算的。

如前所述,所有定義都會按其簡單名稱分組在一起,然後以一個桶子的形式進行雜湊。如果定義(例如類別)包含其他定義,則這些巢狀定義會影響雜湊總和。巢狀定義將影響依其名稱選擇的桶子的雜湊。

Scala 類別的介面中包含哪些內容 

要了解類別的哪些變更需要重新編譯其用戶端,這出奇地棘手。對 Java 有效的規則要簡單得多(即使它們也包含一些細微之處);嘗試將它們應用於 Scala 將會令人沮喪。以下是一些令人驚訝的重點列表,僅用於說明概念;此列表並非旨在完整。

  1. 由於 Scala 支援方法調用中的具名引數,因此方法引數的名稱是其介面的一部分。
  2. 在特質中新增方法需要重新編譯所有實作類別。對於特質中方法簽名的多數變更也是如此。
  3. 在特質中對 super.methodName 的調用會解析為對名為 fullyQualifiedTraitName$$super$methodName 的抽象方法的調用;只有在使用了這些方法時,它們才會存在。因此,針對特定方法名稱新增第一個對 super.methodName 的調用會變更介面。目前,這尚未處理—請參閱 #466
  4. sealed case 類別的階層允許檢查模式比對的詳盡性。因此,使用 case 類別的模式比對必須相依於完整的階層 - 這是在類別層級無法輕易追蹤相依性的原因之一(範例請參閱 Scala 問題 SI-2559)。如需在類別層級追蹤相依性的詳細討論,請查看 #1104

偵錯介面表示法 

如果您看到虛假的增量重新編譯,或者您想了解對提取的介面所做的哪些變更會導致增量重新編譯,則 sbt 0.13 具有適合的工具。

為了偵錯介面表示法及其在您修改和重新編譯原始碼時的變更,您需要執行兩件事

  1. 啟用增量編譯器的 apiDebug 選項。
  2. diff-utils 程式庫新增至 sbt 的類別路徑。請查看命令列參考中的 sbt.extraClasspath 系統屬性的文件。

警告

啟用 apiDebug 選項會顯著增加記憶體消耗,並降低增量編譯器的效能。根本原因是,為了產生關於介面差異的有意義偵錯資訊,增量編譯器必須保留介面的完整表示法,而不是像預設情況下那樣僅保留雜湊總和。

只有在偵錯增量編譯器問題時,才保持啟用此選項。

以下是一個完整的記錄,顯示如何在您的專案中啟用介面偵錯。首先,我們下載 diffutils jar 並將其傳遞給 sbt

curl -O https://java-diff-utils.googlecode.com/files/diffutils-1.2.1.jar
sbt -Dsbt.extraClasspath=diffutils-1.2.1.jar
[info] Loading project definition from /Users/grek/tmp/sbt-013/project
[info] Set current project to sbt-013 (in build file:/Users/grek/tmp/sbt-013/)
> set incOptions := incOptions.value.withApiDebug(true)
[info] Defining *:incOptions
[info] The new value will be used by compile:incCompileSetup, test:incCompileSetup
[info] Reapplying settings...
[info] Set current project to sbt-013 (in build file:/Users/grek/tmp/sbt-013/)

假設您在 Test.scala 中有以下原始碼

class A {
  def b: Int = 123
}

編譯它,然後變更 Test.scala 檔案,使其如下所示

class A {
   def b: String = "abc"
}

並再次執行 compile。現在,如果您執行 last compile,您應該會在偵錯記錄中看到以下幾行

> last compile
[...]
[debug] Detected a change in a public API:
[debug] --- /Users/grek/tmp/sbt-013/Test.scala
[debug] +++ /Users/grek/tmp/sbt-013/Test.scala
[debug] @@ -23,7 +23,7 @@
[debug]  ^inherited^ final def ##(): scala.this#Int
[debug]  ^inherited^ final def synchronized[ java.lang.Object.T0 >: scala.this#Nothing <: scala.this#Any](x$1: <java.lang.Object.T0>): <java.lang.Object.T0>
[debug]  ^inherited^ final def $isInstanceOf[ java.lang.Object.T0 >: scala.this#Nothing <: scala.this#Any](): scala.this#Boolean
[debug]  ^inherited^ final def $asInstanceOf[ java.lang.Object.T0 >: scala.this#Nothing <: scala.this#Any](): <java.lang.Object.T0>
[debug]  def <init>(): this#A
[debug] -def b: scala.this#Int
[debug] +def b: java.lang.this#String
[debug]  }

您可以看到兩個介面文字表示法的統一差異。如您所見,增量編譯器偵測到 b 方法的傳回類型發生了變更。

為什麼變更方法的實作可能會影響用戶端,以及為什麼類型註釋會有幫助 

本節說明為什麼依賴類型推斷來取得公用方法的傳回類型並不總是適當的。然而,這是一個重要的設計問題,因此我們無法給出固定的規則。此外,這種變更通常具有侵入性,縮短編譯時間通常不是一個很好的動機。這也是為什麼我們要從二進位相容性和軟體工程的角度討論一些影響。

考慮以下來源檔案 A.scala

import java.io._
object A {
  def openFiles(list: List[File]) = 
    list.map(name => new FileWriter(name))
}

現在讓我們考慮特質 A 的公用介面。請注意,方法 openFiles 的傳回類型未明確指定,而是由類型推斷計算為 List[FileWriter]。假設在編寫此原始碼後,我們引入一些用戶端程式碼,然後如下修改 A.scala

import java.io._
object A {
  def openFiles(list: List[File]) =
    Vector(list.map(name => new BufferedWriter(new FileWriter(name))): _*)
}

類型推斷現在會將結果類型計算為 Vector[BufferedWriter];換句話說,變更實作會導致公用介面發生變更,這會導致兩個不良後果

  1. 關於我們的主題,用戶端程式碼需要重新編譯,因為在 JVM 中,變更方法的傳回類型是一個二進位不相容的介面變更。
  2. 如果我們的元件是一個已發佈的函式庫,使用新版本需要重新編譯所有用戶端程式碼、變更版本號等等。如果發佈的函式庫有二進位相容性的問題,這通常不是個好方法。
  3. 更廣泛來說,用戶端程式碼甚至可能變成無效。例如,以下的程式碼在變更後會變成無效:
val res: List[FileWriter] = A.openFiles(List(new File("foo.input")))

以下的程式碼也會壞掉:

val a: Seq[Writer] = new BufferedWriter(new FileWriter("bar.input"))
A.openFiles(List(new File("foo.input")))

我們該如何避免這些問題?

當然,我們無法完全解決這些問題:如果我們想要修改模組的介面,就可能會造成程式碼損壞。然而,我們通常可以從模組的介面中移除實作細節。以上述範例來說,例如,預期的回傳型別可能更通用,也就是Seq[Writer]。也可能並非如此,這是一個設計上的選擇,需要視情況而定。在這個範例中,我假設設計者選擇了Seq[Writer],因為無論是在上述簡化範例或是在實際應用中擴展上述程式碼,這都是一個合理的選擇。

上述的用戶端程式碼片段現在會變成:

val res: Seq[Writer] =
  A.openFiles(List(new File("foo.input")))

val a: Seq[Writer] =
  new BufferedWriter(new FileWriter("bar.input")) +:
  A.openFiles(List(new File("foo.input")))

位元碼增強器 

sbt 加入了一個擴充點,讓使用者可以有效地操作 Java 位元碼 (.class 檔案),遞增編譯器嘗試快取類別檔案雜湊值之前。這讓像 Ebean 這樣的函式庫能夠在 sbt 中運作,而不會損壞編譯器快取,並導致每隔幾秒就重新編譯。

這將編譯任務拆分為幾個子任務

  1. previousCompile:此任務會傳回此專案先前保存的 Analysis 物件。
  2. compileIncremental:這是將 Scala/Java 檔案一起編譯的核心邏輯。此任務實際上執行專案的遞增編譯,包括確保編譯最少數量的原始碼檔案。此方法執行後,所有 scalac + javac 會產生的 .class 檔案都將可用。
  3. manipulateByteCode:這是一個虛擬任務,它會接收 compileIncremental 的結果並傳回。需要操作位元碼的外掛程式應該覆寫此任務,並使用它們自己的實作,確保呼叫先前的行為。
  4. compile:此任務依賴於 manipulateBytecode,然後會保存包含所有遞增編譯器資訊的 Analysis 物件。

以下是如何在您自己的外掛程式中掛勾新的 manipulateBytecode 鍵的範例

    Compile / manipulateBytecode := {
      val previous = (Compile / manipulateBytecode).value
      // Note: This must return a new Compiler.CompileResult with our changes.
      doManipulateBytecode(previous)
    }

更多參考資料 

遞增編譯邏輯實作在 https://github.com/sbt/sbt/blob/0.13/compile/inc/src/main/scala/inc/Incremental.scala。關於遞增重新編譯策略的一些討論可在 issue #322#288#1010 中找到。