Kotlin 负责人:我们是如何一步步设计 Kotlin 的?

作者 | Roman Elizarov

译者 | 刘雅梦

策划 | 邓艳琴

真正的编程语言是有生命的,是不断变化和发展的。与任何生产代码一样,它们的设计师的大部分时间都花在了缺陷修复和小改进上,而不是花在激进的新特性上。Kotlin 的独特之处在于:它多年来一直以用例和社区驱动的方式发展,早在 2016 年发布 1.0 稳定版本之前就开始了,即使是在 2011 年上市之前也有一段时间了。

1Kotlin 空安全的故事

以 Kotlin 空安全(null-safety)为例。任何语言设计都必须要回答的第一个问题是:既然研究文献和其他语言中包含了如此多的潜在有趣特性,为什么还要将这个或那个特性纳入到语言中呢?

Kotlin 是为那些已经在使用 Java 编程的人设计一个更好的 Java,因此它的设计目标聚焦在解决 Java 程序员经常遭受的所有已知缺点。基于这一点,添加空安全是一个自然而然的选择,因为 NullPointerException 是现实 Java 代码中最常遇到的问题。

在 Kotlin 之前,空安全及其问号语法就已经在许多研究语言中存在很多年了。这个概念上的想法很简单,而且也经过了时间的考验,所以把它整合到实用语言中看起来很容易。然而,随着这种具有非空和可空类型的直接设计开始用于实际代码,它很快就开始与 Kotlin 语言设计的另一个目标(与 Java 的无缝互操作性)产生了冲突。

Kotlin 必须与之互操作的大多数 Java 代码都没有标记为空。空安全语言必须假设 Java 方法可以返回空,但是在空安全语言中为每个 Java 方法提供一个可为空的结果类型会导致非常冗长的代码,这件事情并不实用。对于 Kotlin 的问题,我们对现实生活中的项目进行了大量实验,采用了一些没有前途的方法,并与康奈尔大学的 Ross Tate 进行了专门针对 Kotlin 的研究合作,最终以灵活类型(Flexible Types,在 Kotlin 中,这种灵活类型被俗称为平台类型)的形式给出了解决方案。

灵活类型背后的基本思想是,为了与 Java 等类型不是那么严格的语言进行互操作时,我们不使用更宽泛的可空类型,例如 String? ,它包括了所有字符串和一个空类型,或一个更窄的类型,如 String ,但我们使用一个灵活类型——从 String 到 String? 的一系列类型表示来自 Java 的未知类型,该类型位于该范围内。放松类型系统,允许在灵活类型范围内的任何类型上进行的所有操作,需借助于运行时检查以确保其正确性。该解决方案在开发人员体验方面达成了一种务实的妥协,因此 Kotlin 开发人员在使用 Java API 时不会比使用 Java 本身更糟糕,但在使用 Kotlin API 时仍然可以享受更安全的类型系统。相关的详细信息,请参阅 JVMLS 2015–Kotlin 中的灵活类型。

为什么在 Kotlin 之前没人这么做呢?在此之前,没有人试图将空安全集成到语言的类型系统中,同时以如此大的规模维护安全性和互操作性。同样的协作产生了一个混合站点方差的解决方案,出于相似的 Java 互操作性的原因,Kotlin 也需要该解决方案(请参阅 FOOL 2013:混合站点方差)。事实上,即使是在今天,Java 互操作性仍然也消耗了 Kotlin 语言设计所花费时间中相当大的一部分。

2演进和协程

在语言的初始设计中,最重要的考量是要删除哪些特性,而不是要包括哪些特性。许多研究语言都围绕着几个核心思想展开。实用语言最终会变得更加包容,尤其是当你考虑到,它必须能够被习惯于编写其他工业语言的专业开发人员所理解并易于学习时。然而,最初保持语言短小很容易。当然,由于语言开发团队的资源有限,这是很自然的。

随着语言在现实生活中使用的越来越多,它将要面对具有其特质、怪癖和模式的现实生活工业代码。现实生活中的语言面临着更好地支持所有语言的压力。随着语言的发展,语言设计的重点不可避免地会从最初的设计目标转移到特性交互和支持上。挑战在于维护语言的概念完整性,确保新特性不仅可以实现,而且还能很容易地被现有语言的用户所理解和采用,并融入其生态系统。

Kotlin 协程(Coroutines)是在该语言的 1.0 稳定版本之后才添加进来,并在 2017 年推出了第一个实验性支持。Kotlin 协程深受 C# async/await 的启发,但最终的 Kotlin 设计却与 Onward 2021《Kotlin 协程:设计和实现》中所解释的有很大差异。

造成这种差异的原因之一是事后诸葛亮。那时,我们已经意识到,C# yield 关键字的内部实现机制几乎相同,它既支持同步枚举器协程,也支持异步协程的 async/await  机制。一个自然而然的愿望是将这两者统一起来。这样,语言团队可以花费更少的精力,只需实现一个更简单的语言特性和编译器支持。然后,将通过库来提供各种类型,实现对同步和异步协程的单独支持。

另一个原因是前面提到的概念完整性。Kotlin 语言已经有了自己的传统和大量的代码,因此支持协程的新特性必须要适应现有的代码库,并且必须要能帮助现有的用户。因此,很多重点都被放在了与所有异步和响应式 Java 编程框架的互操作性(这些异步和响应式 Java 编程框架是由 Kotlin 开发人员所使用的),以及它在桌面 UI 和移动应用程序的性能和易用性(这在当时的 Kotlin 生态系统中得到了很大的关注)上了。

重点和表格上的用例存在差异不可避免地会导致设计上的差异。与基于未来 / 承诺的设计(该设计能为已经多样化的生态系统带来另一种类型的未来)不同,该设计直接基于底层的延续,并引入了一个受 LISP 启发的 call-with-current-continuation 原语(在 Kotlin 中被称为 suspendCoroutine),从而使得 Kotlin 协程与所有现有库的集成变得更简单。

3权衡取舍

许多新特性的设计充满了权衡取舍。例如,我们最近在 Kotlin 1.6 中改进了递归泛型的类型推断(请参阅 KT-40804 基于自上界的推断类型)。最初的增强请求来自在构建器模式中使用递归泛型类型的 API 用户,在这种模式中,函数的结果是具体化的,没有显式指定函数的类型参数,也没有任何上下文可以推断它。在这种情况下,用户希望推断出一个通配符类型以表示类型族。

然而,设计 Kotlin 的目的就是要抑制这种情况下的类型推断。在 Kotlin 中,对函数 listOf(1) 的调用会推断出 List的结果类型,因为参数的类型给出了类型的提示。然而,对 listOf()  的调用,由于既没有参数,也没有上下文中的类型,因此无法编译。尽管从技术上讲,它可能被推断为 List<Any?> ,表示此函数可以返回的最宽类型。相反,Kotlin 强制开发人员在调用中显式指定类型,如 istOf() 。这避免了编译器必须猜测开发人员的意图,因为这种猜测在实际代码中通常是错误的,因此可以防止代码中出现进一步的错误。

递归泛型的难题在于 Kotlin 没有明确的语法来指定这样的递归类型以使代码编译。因此,我们有多种选择。最热门的选择之一是使用一种特殊语法,告诉编译器推断类型参数的上限。然而,在实践中,这意味着在表格所列出的所有用例中,我们都必须编写一些额外的样板代码,以使编译器满意。因此,我们最终得到了一组特殊的规则,这些规则可以检测被调用函数中递归泛型的使用模式,并自动对所有此类调用启用上限类型推断。

最终,由于添加了特殊的规则,该语言变得不那么规则了,也变得更加复杂了,但对于用它编写的实际代码来说,它使用起来更加直接和简单了。

4微调和改进

大多数语言设计工作并不是关于大的特性的,而是关于到处修复小问题和不便利的。这些小问题通常是语言设计中的矛盾。首先让我们讨论一下它们可能会如何出现。

添加一个新特性后,它就开始与所有其他语言特性交互了。这些交互往往会产生很多极端情况。针对所有这些极端情况进行设计是非常耗时的,并且在缺少这些极端情况实际用例的情况下,设计往往会变得不可能。关于这点,Kotlin 的做法很务实。如果我们找不到或想象不到特定的极端情况用例,那么我们就禁止它,在使用相应的特性组合时会给出编译错误。有时存在已知的用例,但它们并没有超过设计和实现的工作量。

例如,当 Kotlin 协程在 Kotlin 1.3 中变得稳定时,它们引入了一个新的函数类——挂起函数和相应的挂起函数类型。然而,不允许将挂起的函数类型用作超类型。对于如何在运行时表示它们,并同时支持使用 Kotlin 中的 is 操作符进行运行时类型检查,需要进行非常复杂的设计。这是后来在 Kotlin 1.6 中添加的,因为协程的使用变得越来越多,并且对实现此特性交互的需求也越来越多(请参阅 KT-18707 支持将挂起函数作为超类型)。

有时矛盾是历史性的,甚至早于语言的初始版本。目前,Kotlin 团队正在进行一项大规模的工程项目,即重写整个 Kotlin 编译器。编译器的架构正在重新设计,以提高性能和未来的可扩展性。在这项工作中,我们遇到了几十种极端情况,即根据一组一致的规则从头开始编写的编译器在一些实际代码中开始表现出不同的行为。其中的一些发现可以回溯到语言设计上来,重新思考旧编译器的行为是否有意义或是否需要更换。从类型推断中的怪癖到依赖于源代码中超类型出现的顺序的行为,我们已经发现了一些情况。

5弃用

当语言稳定且需要进行更改时,以完全向后兼容的方式进行更改通常是不可能或不切实际的,尤其是如果你有意修复一些旧的设计缺陷时。当缺陷能严重到会使之前版本的编译器崩溃或生成的代码立即崩溃时,这是幸运的。但有时,它确实可以工作,并且可能会产生一些代码来做一些明智的事情。

许多设计工作都是用来评估这些更改的影响,并设计迁移计划,以便将这些更改引入到语言中。在某些情况下,当更改的潜在影响不可忽略时,迁移计划可能会跨越多个版本,并且可能会跨越多年。在旧版本的编译器和 IDE 中实现警告和自动代码修复的情况是存在的,这样受该更改影响的开发人员将会有足够的时间在新版本编译器发布之前提前替换代码(新版本编译器会对这段代码进行不同的处理。)

这项工作也是关于权衡取舍的。最简单的决定往往是不更改任何东西,永远保持旧的行为,即使是有缺陷。然而,它在语言中积累了设计债,在编译器中积累了技术债。这不是一种可持续的方法,因为它将使语言的进一步发展变得越来越困难。因此,必须在保持向后兼容和语言进化之间找到平衡。

例如,从历史上看,原始编译器处理安全调用和各种 Kotlin 操作符约定( 如 a?.x += 1  )组合的方式是非常不一致的。因此,必须重新设计它,以尽量减少对现有代码的破坏,因为这些代码可能依赖于其中的某些行为。我们已经在现有的 Kotlin 代码库上进行了多个实验,并使用各种解决方案原型来选择这样的设计。关于原始问题和我们最终设计的详细信息请参见 KT-41034。

6结论

语言设计在现实世界中是一个复杂系统的维护。我们相信,只要小心谨慎,我们就可以让 Kotlin 在未来几十年里保持现代化和相关性。这是一个非常有趣的设计和工程挑战。

在这条路上,我们还会继续遇到关于类型系统、特性交互、可用性、大型代码中的真实代码模式等方面的新颖研究问题。这些领域的研究合作对将所有的改进建立在良好的基础上是至关重要的。

作者简介

Roman Elizarov 是 JetBrains Kotlin 的项目负责人,目前以首席语言设计师的身份专注于 Kotlin 的语言设计。自 2016 年以来,他一直在 JetBrains 从事 Kotlin 相关的工作,并对 Kotlin 协程的设计和 Kotlin 协程库的开发做出了贡献。

原文链接:language-design-in-the-real-world

本文文字及图片出自 InfoQ 架构头条

余下全文(1/3)
分享这篇文章:

请关注我们:

发表评论

邮箱地址不会被公开。 必填项已用*标注