在 GitHub 上編輯此頁面

CanThrow 能力

此頁面說明 Scala 3 中例外檢查的實驗性支援。它是由語言匯入啟用

import language.experimental.saferExceptions

現在發佈此擴充套件的原因是為了取得其可用性的回饋。我們正在開發更進階的類型系統,建立在擴充套件中提出的概括性概念上。這些類型系統有超出檢查例外情況的應用領域。例外檢查是一個有用的起點,因為所有 Scala 程式設計師都熟悉例外,而且它們目前的處理方式還有改進的空間。

為什麼是例外?

在許多情況下,例外情況是錯誤處理的理想機制。它們服務於以最少的樣板文件傳播錯誤條件的既定目的。它們對「快樂路徑」造成零開銷,這表示只要錯誤很少發生,它們就是非常有效率的。例外情況對偵錯也很友善,因為它們會產生堆疊追蹤,可以在處理程式位置進行檢查。因此,永遠不必猜測錯誤條件的來源。

為什麼不使用例外情況?

然而,當前 Scala 和許多其他語言中的例外情況並未反映在類型系統中。這表示函數合約的基本部分(例如,它可以產生哪些例外情況?)並未經過靜態檢查。大多數人都承認這是一個問題,但到目前為止,已檢查例外情況的替代方案實在太痛苦,無法考慮。一個很好的例子是 Java 已檢查例外情況,它們在原則上會執行正確的動作,但由於處理起來非常困難,因此普遍被視為錯誤。到目前為止,仿效 Java 或建構在 JVM 上的後繼語言都沒有複製此功能。例如,請參閱 Anders Hejlsberg 的關於 C# 沒有已檢查例外情況的說明

Java 已檢查例外情況的問題

Java 已檢查例外情況模型 的主要問題在於其不靈活,這是由於缺乏多型性。例如,考慮在 List[A] 上宣告的 map 函數,如下所示

def map[B](f: A => B): List[B]

在 Java 模型中,函數 f 不允許擲回已檢查例外情況。因此,下列呼叫會無效

xs.map(x => if x < limit then x * x else throw LimitExceeded())

解決此問題的唯一方法是將已檢查的例外狀況 LimitExceeded 包裝在未檢查的 java.lang.RuntimeException 中,在呼叫點捕獲並再次解開包裝。類似下列範例

try
    xs.map(x => if x < limit then x * x else throw Wrapper(LimitExceeded()))
  catch case Wrapper(ex) => throw ex

噁!難怪 Java 中的已檢查例外狀況不受歡迎。

單子效應

因此,兩難之處在於,只要我們忘記靜態類型檢查,例外狀況就很容易使用。這導致許多使用 Scala 的人完全放棄例外狀況,而改用像 Either 這樣的錯誤單子。這可以在許多情況下發揮作用,但也不無缺點。它使程式碼變得更複雜且更難重構。這表示人們很快就會面臨如何處理多個單子的問題。一般來說,一次處理一個單子在 Scala 中很簡單,但同時處理多個單子卻不那麼令人愉快,因為單子無法組合。已經提出、實作和推廣了許多技術來處理這個問題,從單子轉換器到自由單子,再到無標籤最終。但沒有任何一種技術受到普遍喜愛;每一種技術都引入了複雜的 DSL,非專家難以理解,會產生執行時間開銷,並使除錯變得困難。最後,相當多的開發人員更喜歡使用單一的「超級單子」,例如 ZIO,它內建錯誤傳播以及其他面向。這種一體適用的方法可以很好地發揮作用,即使(或是否因為?)它代表了一個包羅萬象的架構。

然而,程式語言並非架構;它還必須迎合不符合架構使用案例的應用程式。因此,正確進行例外狀況檢查仍然有強烈的動機。

從效應到功能

為什麼 map 與 Java 的檢查例外情況模型搭配時效果這麼差?這是因為 map 的簽章限制函式引數不拋出檢查例外情況。我們可以嘗試提出一個更具多型態的 map 公式。例如,它可以看起來像這樣

def map[B, E](f: A => B throws E): List[B] throws E

這假設一個類型 A throws E 來表示類型 A 的運算,它可以拋出類型 E 的例外情況。但在實務上,額外類型參數的開銷也讓這種方法不具吸引力。特別要注意的是,我們必須對每個採用函式引數的方法進行參數化,因此宣告所有這些例外情況類型的額外開銷看起來就像我們想要避免的一種儀式。

但有一種方法可以避免這種儀式。不要專注於可能的 影響,例如「這段程式碼可能會拋出例外情況」,而是專注於 功能,例如「這段程式碼需要拋出例外情況的功能」。從表達力的角度來看,這非常相似。但功能可以表示為參數,而傳統上影響則表示為結果值的某種附加項目。事實證明,這可能會產生很大的不同!

CanThrow 功能

影響即功能 模型中,影響表示為特定類型的(隱含)參數。對於例外情況,我們會預期類型為 CanThrow[E] 的參數,其中 E 代表可以拋出的例外情況。以下是 CanThrow 的定義

erased class CanThrow[-E <: Exception]

這顯示了另一個實驗性的 Scala 功能:已清除定義。粗略來說,已清除類別的值不會產生執行時期程式碼;它們會在產生程式碼之前被清除。這表示所有 CanThrow 功能都只是編譯時期的人工製品;它們沒有執行時期的足跡。

現在,如果編譯器看到一個 throw Exc() 建構,其中 Exc 是檢查例外情況,它會檢查是否有類型為 CanThrow[Exc] 的功能可以作為給定值召喚。如果不是這種情況,則會發生編譯時期錯誤。

如何產生能力?有幾種可能性

最常情況下,能力是透過在某些封閉範圍內有一個使用子句 (using CanThrow[Exc]) 產生。這大致對應到 Java 中的 throws 子句。類比更為強烈,因為除了 CanThrow 之外,在 scala 套件中還定義了以下類型別名

infix type $throws[R, +E <: Exception] = CanThrow[E] ?=> R

也就是說,R $throws E 是一個內容函數類型,它接受一個隱含的 CanThrow[E] 參數,並傳回一個 R 類型的值。此外,編譯器會根據規則將使用 throws 作為運算子的中綴類型轉換為 $throws 應用程式

                A throws E  -->  A $throws E
    A throws E₁ | ... | Eᵢ  -->  A $throws E₁ ... $throws Eᵢ

因此,像這樣編寫的方法

def m(x: T)(using CanThrow[E]): U

也可以像這樣表達

def m(x: T): U throws E

此外,拋出多種例外類型的能力也可以用幾種方式表達,如下面的範例所示

def m(x: T): U throws E1 | E2
def m(x: T): U throws E1 throws E2
def m(x: T)(using CanThrow[E1], CanThrow[E2]): U
def m(x: T)(using CanThrow[E1])(using CanThrow[E2]): U
def m(x: T)(using CanThrow[E1]): U throws E2

註解 1:像這樣的簽章

def m(x: T)(using CanThrow[E1 | E2]): U

也允許在方法主體內拋出 E1E2,但當有人嘗試從另一個方法呼叫此方法,並宣告其 CanThrow 能力(如同先前的範例)時,可能會造成問題。這是因為 CanThrow 有個逆變類型參數,所以 CanThrow[E1 | E2]CanThrow[E1]CanThrow[E2] 的子類型。因此,範圍內存在 CanThrow[E1 | E2] 的特定實例會滿足 CanThrow[E1]CanThrow[E2] 的需求,但 CanThrow[E1]CanThrow[E2] 的特定實例無法結合提供 CanThrow[E1 | E2] 的實例。

注意 2:請記住 | 將其左右參數綁定得比 throws 更緊密,因此 A | B throws E1 | E2 表示 (A | B) throws (Ex1 | Ex2),而不是 A | (B throws E1) | E2

CanThrow/throws 組合基本上將 CanThrow 需求向外傳播。但這些功能最初是在哪裡建立的?這是在 try 表達式中。給定一個像這樣的 try

try
  body
catch
  case ex1: Ex1 => handler1
  ...
  case exN: ExN => handlerN

編譯器會產生一個累積功能,類型為 CanThrow[Ex1 | ... | Ex2],可在 body 的範圍內作為給定項使用。它會透過大致如下方式擴充 try 來執行此操作

try
  erased given CanThrow[Ex1 | ... | ExN] = compiletime.erasedValue
  body
catch ...

請注意,合成給定項的右側是 compiletime.erasedValue。這沒問題,因為此給定項已刪除;它不會在執行階段執行。

注意 1:saferExceptions 功能設計為僅適用於檢查例外。如果例外類型是 Exception 的子類型,但不是 RuntimeException 的子類型,則該例外類型為已檢查CanThrow 的簽章仍然允許 RuntimeException,因為 RuntimeException 是其約束 Exception 的適當子類型。但不會為 RuntimeException 產生任何功能。此外,throws 子句也不得參照 RuntimeException

注意 2:為保持簡潔,編譯器目前僅會為以下形式的 catch 子句產生功能

case ex: Ex =>

其中 ex 是任意變數名稱(也允許使用 _),而 Ex 是任意已檢查的例外類型。不允許使用建構函數模式(例如 Ex(...))或帶有防護的模式。如果使用其中一種模式來捕捉已檢查的例外,並且已啟用 saferExceptions,編譯器會發出錯誤訊息。

範例

就這樣。讓我們在範例中看看它的實際運作。首先,新增一個匯入

import language.experimental.saferExceptions

以啟用例外檢查。現在,定義一個例外 LimitExceeded 和一個函數 f 如下

val limit = 10e9
class LimitExceeded extends Exception
def f(x: Double): Double =
  if x < limit then x * x else throw LimitExceeded()

你會收到這個錯誤訊息

  if x < limit then x * x else throw LimitExceeded()
                               ^^^^^^^^^^^^^^^^^^^^^
The capability to throw exception LimitExceeded is missing.

此功能可以由下列其中一項提供

  • 將 using 子句 (using CanThrow[LimitExceeded]) 新增到內含方法的定義
  • 在內含方法的結果類型後新增 throws LimitExceeded 子句
  • 將這段程式碼包覆在會擷取 LimitExceededtry 區塊中

下列匯入可能會解決問題

import unsafeExceptions.canThrowAny

正如錯誤訊息所暗示的,你必須宣告 f 需要拋出 LimitExceeded 例外的功能。最簡潔的方法是新增 throws 子句

def f(x: Double): Double throws LimitExceeded =
  if x < limit then x * x else throw LimitExceeded()

現在在會擷取 LimitExceededtry 中呼叫 f

@main def test(xs: Double*) =
  try println(xs.map(f).sum)
  catch case ex: LimitExceeded => println("too large")

以一些輸入執行程式

> scala test 1 2 3
14.0
> scala test
0.0
> scala test 1 2 3 100000000000
too large

所有類型檢查都通過,且運作如預期。但等等 - 我們沒有任何儀式就呼叫 map!那是怎麼運作的?以下是編譯器如何擴充 test 函數

// compiler-generated code
@main def test(xs: Double*) =
  try
    erased given ctl: CanThrow[LimitExceeded] = compiletime.erasedValue
    println(xs.map(x => f(x)(using ctl)).sum)
  catch case ex: LimitExceeded => println("too large")

CanThrow[LimitExceeded] 功能會在合成 using 子句中傳遞給 f,因為 f 需要它。然後,將結果閉包傳遞給 mapmap 的簽章不必考慮效果。它會像往常一樣接收閉包,但該閉包可能會在其自由變數中參考功能。這表示 map 已經是效果多型,即使我們完全沒有變更其簽章。因此,重點在於,效果作為功能的模型自然地提供了效果多型,而這是其他方法難以應付的。

透過匯入的漸進式輸入

另一個優點是,此模型允許從目前的未檢查例外逐漸移轉到更安全的例外。想像一下,experimental.saferExceptions 已在各處啟用。由於函式尚未適當地加上 throws 注解,因此會有許多程式碼中斷。但建立一個逃生艙口很容易,讓我們可以暫時忽略中斷:只要加入匯入

import scala.unsafeExceptions.canThrowAny

這將為任何例外提供 CanThrow 功能,從而允許所有 throws 和所有其他呼叫,無論 throws 宣告的目前狀態為何。以下是 canThrowAny 的定義

package scala
object unsafeExceptions:
  given canThrowAny: CanThrow[Exception] = ???

當然,定義像這樣的全域功能等同於作弊。但作弊有助於漸進式輸入。匯入可用於移轉現有程式碼,或在不考慮完整例外安全性之下,啟用更流暢的程式碼探索。在這些移轉或探索結束時,應移除匯入。

擴充的範圍

總之,更安全的例外檢查擴充包含以下元素

  • 它將類別 scala.CanThrow、型別 scala.$throwsscala.unsafeExceptions 物件新增到標準函式庫,如上所述。
  • 它新增一些 desugaring 規則,將 throws 型別改寫為串接的 $throws 型別。
  • 它透過要求 CanThrow 功能或拋出的例外來擴充 throw 的型別檢查。
  • 它透過提供每個捕捉的例外的 CanThrow 功能來擴充 try 的型別檢查。

就這樣。以這種方式進行例外檢查非常值得注意,而無需對類型系統進行任何特殊新增。我們只需要常規給定值和內容函式。使用 erased 消除了任何執行時期的開銷。

注意事項

我們的功能模型允許宣告和檢查一階程式碼引發的例外。但就目前而言,它沒有提供足夠的機制來強制執行高階函式引數沒有功能。考慮一個 map 變體 pureMap,它應強制執行其引數不引發例外或產生任何其他影響(可能是因為想要透明地重新排序運算)。現在我們無法強制執行,因為 pureMap 的函式引數可以在其自由變數中擷取任意功能,而這些功能不會出現在其類型中。解決此問題的一種可能方法是引入純函式類型(可能寫成 A -> B)。不允許純函式封閉功能。然後可以這樣撰寫 pureMap

def pureMap(f: A -> B): List[B]

缺乏純度需求的另一個領域是在功能從有界範圍逸出時。考慮以下函式

def escaped(xs: Double*): () => Int =
  try () => xs.map(f).sum
  catch case ex: LimitExceeded => -1

使用這裡提供的系統,此函式會進行類型檢查,並進行擴充

// compiler-generated code
def escaped(xs: Double*): () => Int =
  try
    given ctl: CanThrow[LimitExceeded] = ???
    () => xs.map(x => f(x)(using ctl)).sum
  catch case ex: LimitExceeded => -1

但如果你嘗試這樣呼叫 escaped

val g = escaped(1, 2, 1000000000)
g()

結果將會在呼叫 g 的第二行拋出 LimitExceeded 例外。遺漏的部分是 try 應強制其產生的能力不會作為其主體結果中的自由變數逸出。將此類範圍效果描述為短暫能力是有道理的 - 它們的生命週期無法延伸到 lambda 中的延遲程式碼。

展望

我們正在開發一種新的類型系統類別,透過追蹤值的自由變數來支援短暫能力。一旦該研究成熟,希望可以擴充 Scala 語言,以便我們強制執行遺漏的屬性。

除了上述之外,還有許多其他應用:例外情況是代數效應的特例,這在過去 20 年一直是一個非常活躍的研究領域,並已逐漸應用於程式語言(例如 KokaEffMulticore OCamlUnison)。事實上,代數效應已被描述為等同於具有額外恢復運算的例外情況。在此為例外情況開發的技術可能可以概括到其他類別的代數效應。

但即使沒有這些額外的機制,例外狀況檢查本身就已經很有用了。它提供了一個明確的途徑,可以讓使用例外狀況的程式碼更安全、文件更完善,且更容易重構。唯一的漏洞出現在範圍化能力中,我們必須手動驗證這些能力不會逸出。特別是,try 必須始終放置在與它啟用的 throws 相同的運算階段中。

換句話說:如果現狀是 0% 靜態檢查,因為 100% 太痛苦,那麼提供 95% 靜態檢查且非常符合人體工學的替代方案看起來像是一種勝利。而且我們未來仍有可能達到 100%。

如需更多資訊,請參閱我們的 2021 年 ACM Scala 研討會論文