在 GitHub 上编辑此页面

抛出能力

此页面描述了 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

Ugh!难怪 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 声明的状态如何。以下是 canThrowAny 的定义

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

当然,定义这样的全局能力相当于作弊。但这种作弊对于逐步类型化很有用。导入可以用来迁移现有代码,或者在不考虑完全异常安全的情况下,更流畅地探索代码。在这些迁移或探索结束时,应该删除导入。

扩展范围

总结一下,更安全的异常检查扩展包含以下元素

  • 它在标准库中添加了类scala.CanThrow、类型scala.$throws以及scala.unsafeExceptions对象,如上所述。
  • 它添加了一些反糖规则来将throws类型重写为级联的$throws类型。
  • 它通过要求CanThrow能力或抛出的异常来增强throw的类型检查。
  • 它通过提供每个捕获异常的CanThrow能力来增强try的类型检查。

仅此而已。非常值得注意的是,可以在不向类型系统添加任何特殊内容的情况下以这种方式进行异常检查。我们只需要常规的 given 和上下文函数。任何运行时开销都使用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 必须始终放置在与它启用的抛出相同的计算阶段。

换句话说:如果现状是 0% 的静态检查,因为 100% 太痛苦了,那么一个可以让你获得 95% 的静态检查并具有出色的人机工程学的替代方案看起来像是一个胜利。我们将来可能仍然可以达到 100%。

有关更多信息,请参阅我们在 ACM Scala 研讨会 2021 上的论文