上下文抽象
现状批判
Scala 的隐式是其最显著的特性。它们是抽象上下文的基本方式。它们代表着一种具有各种用例的统一范例,其中包括:实现类型类、建立上下文、依赖项注入、表达功能、计算新类型以及证明它们之间的关系。
继 Haskell 之后,Scala 是第二种流行的语言,它具有一些形式的隐式内容。其他语言也纷纷效仿。例如 Rust 的特征 或 Swift 的协议扩展。设计提案也适用于 Kotlin,如 编译时依赖关系解析,适用于 C#,如 形状和扩展,或适用于 F#,如 特征。隐式内容也是定理证明程序的常见特性,例如 Coq 或 Agda。
即使这些设计使用截然不同的术语,它们都是术语推断核心思想的变体。给定一个类型,编译器会综合一个具有该类型的“规范”术语。Scala 以比大多数其他语言更纯粹的形式体现了这个思想:一个隐式参数直接导致一个推断出的参数术语,也可以明确地写出来。相比之下,基于类型类的设计不那么直接,因为它们将术语推断隐藏在某种形式的类型分类之后,并且不提供明确编写推断出的量(通常是字典)的选项。
鉴于术语推断是行业的发展方向,并且 Scala 以非常纯粹的形式拥有它,为什么隐式内容不太流行呢?事实上,公平地说,隐式内容既是 Scala 最显着也是最具争议性的特性。我相信这是由于许多方面共同作用,使得隐式内容比必要时更难学习,也更难防止滥用。
具体批评如下
-
隐式内容非常强大,很容易被过度使用和误用。几乎在所有情况下,当我们讨论隐式转换时,都会出现这种观察结果,即使在概念上不同,但它与其他隐式定义共享相同的语法。例如,关于这两个定义
implicit def i1(implicit x: T): C[T] = ... implicit def i2(x: T): C[T] = ...
第一个是条件隐式值,第二个是隐式转换。条件隐式值是表达类型类的基石,而隐式转换的大多数应用已被证明价值可疑。问题在于,许多语言新手从定义隐式转换开始,因为它们易于理解,并且看起来强大且方便。Scala 3 将在语言标志下放置在其他地方定义的类型之间的“无序”隐式转换的定义和应用。这是抑制过度使用隐式转换的有用步骤。但问题仍然在于,在语法上,转换和值看起来过于相似,让人不舒服。
-
另一种广泛滥用的情况是过度依赖隐式导入。这通常会导致难以理解的类型错误,而正确的导入咒语可以消除这些错误,从而留下一种挫败感。相反,很难看出程序使用了哪些隐式,因为隐式可以隐藏在很长的导入列表中的任何位置。
-
隐式定义的语法过于简单。它由一个修饰符
implicit
组成,该修饰符可以附加到大量的语言结构上。对于新手来说,这存在一个问题,即它传达的是机制而不是意图。例如,类型类实例是一个隐式对象或 val(如果无条件),或者一个隐式 def,其中隐式参数引用某个类(如果条件)。这准确地描述了隐式定义的转换内容——只需删除implicit
修饰符,就是这样!但是,定义意图的提示相当间接,并且很容易被误读,如上文i1
和i2
的定义所示。 -
隐式参数的语法也有缺点。虽然隐式参数被专门指定,但参数却没有。将参数传递给隐式参数看起来像常规应用
f(arg)
。这存在问题,因为它意味着对于在调用中实例化哪个参数可能会产生混淆。例如,在def currentMap(implicit ctx: Context): Map[String, Int]
中,不能编写
currentMap("abc")
,因为字符串"abc"
被视为隐式ctx
参数的显式参数。相反,必须编写currentMap.apply("abc")
,这很别扭且不规则。出于同样的原因,方法定义只能有一个隐式参数部分,并且它必须始终位于最后。此限制不仅降低了正交性,还阻止了一些有用的程序结构,例如具有常规参数的方法,其类型取决于隐式值。最后,隐式参数必须具有名称也有些烦人,即使在许多情况下从未引用过该名称。 -
隐式对工具提出了挑战。可用隐式的集合取决于上下文,因此命令完成必须考虑上下文。这在 IDE 中是可行的,但基于静态网页的工具(如 Scaladoc)只能提供近似值。另一个问题是,失败的隐式搜索通常会给出非常不具体的错误消息,特别是如果某些深度递归隐式搜索失败。请注意,Scala 3 编译器已经在错误诊断领域取得了很大进展。如果递归搜索在某些级别上失败,它将显示已构建的内容和缺少的内容。此外,它还建议可以将缺少的隐式引入范围的导入。
毕竟,隐式调用非常广泛,许多库和应用程序都依赖于它们,因此这些缺点都不是致命的。但是,它们共同使使用隐式调用的代码变得更加繁琐且不够清晰。
从历史上看,这些缺点中的许多都源于 Scala 中隐式调用逐渐“被发现”的方式。Scala 最初只有隐式转换,其预期用例是在定义类或特征后“扩展”它,即 Scala 后续版本中隐式类所表达的内容。隐式参数和实例定义在 2006 年才出现,我们选择了类似的语法,因为它看起来很方便。出于同样的原因,没有做出任何努力来区分隐式导入或参数和普通导入或参数。
现有的 Scala 程序员总体上已经习惯了现状,并且认为没有必要进行更改。但对于新手来说,这种现状是一个很大的障碍。我相信,如果我们想要克服这一障碍,我们就应该后退一步,让自己考虑一个全新的设计。
新设计
以下页面介绍了 Scala 中上下文抽象的重新设计。它们引入了四项基本更改
-
给定实例是一种定义可合成基本术语的新方法。它们取代了隐式定义。该提案的核心原则是,与其将
implicit
修饰符与大量特性混合在一起,我们有一种单一的方法来定义可为类型合成的术语。 -
使用子句是隐式参数及其参数的新语法。它明确地对齐参数和参数,从而解决了语言中的许多问题。它还允许我们在一个定义中使用多个
using
子句。 -
“给定”导入是一类新的导入选择器,专门导入给定内容,不导入其他内容。
-
隐式转换现在表示为标准
Conversion
类的给定实例。所有其他形式的隐式转换都将逐步淘汰。
此部分还包含描述与上下文抽象相关的其他语言特性的页面。这些是
- 上下文界限,保持不变。
- 扩展方法以更好的方式替换隐式类,从而与类型类集成。
- 实现类型类演示了如何使用新构造实现一些常见的类型类。
- 类型类派生引入了构造,用于自动派生 ADT 的类型类实例。
- 多重相等性引入了特殊类型类来支持类型安全相等性。
- 上下文函数提供了一种抽象上下文参数的方法。
- 按名称上下文参数是定义递归合成值而不进行循环的基本工具。
- 与 Scala 2 隐式的关系讨论了旧式隐式与新式给定之间的关系,以及如何从一个迁移到另一个。
总体而言,新设计实现了术语推理与语言其余部分更好的分离:只有一种方法来定义给定值,而不是采用全部采用 implicit
修饰符的多种形式。只有一种方法来引入隐式参数和参数,而不是将隐式与常规参数混为一谈。有一种单独的方法来导入给定值,不允许它们隐藏在常规导入的海洋中。并且只有一种方法来定义隐式转换,它被明确标记为隐式转换,并且不需要特殊语法。
因此,此设计避免了功能交互,使语言更加一致和正交。它将使隐式更容易学习,更难滥用。它将极大地提高使用隐式的 95% 的 Scala 程序的清晰度。因此,它有可能以一种有原则、易于理解和友好的方式实现术语推理的承诺。
我们能通过调整现有的隐式来实现相同目标吗?经过长时间的尝试,我现在相信这是不可能的。
- 首先,一些问题显然是语法问题,需要不同的语法来解决它们。
- 其次,存在如何迁移的问题。我们无法在中途更改规则。在语言演化的某个阶段,我们需要同时适应新旧规则。通过语法更改,这很容易:使用新规则引入新语法,暂时支持旧语法以促进交叉编译,在稍后的时间弃用并逐步淘汰旧语法。保持相同的语法不会提供此路径,实际上似乎没有提供任何可行的演化路径
- 第三,即使我们以某种方式成功迁移,我们仍然面临如何教授这个问题。我们无法让现有的教程消失。几乎所有现有的教程都从隐式转换开始,而这将消失;它们使用常规导入,而这将消失,并且它们通过将对具有隐式参数的方法的调用扩展到普通应用程序来解释它们,而这也将消失。这意味着我们必须对所有现有的文献和课程软件进行修改和限定,这可能会给初学者带来更多困惑,而不是更少困惑。相比之下,使用新语法有一个明确的标准:任何提到
implicit
的书籍或课程软件都已过时,应进行更新。