从 Java 到 Kotlin(七)
原文:
zh.annas-archive.org/md5/60979b6b5be768c04f4e9f76ebdb673a译者:飞龙
第二十三章:继续旅程
我们已经到达了本书的结尾。感谢您加入我们的旅程。您的作者很荣幸能与许多优秀的开发者共事,并从他们学到了很多,现在您也加入了这个名单。即使您跳过了几章,或者在某次重构中不知不觉地走神,能与您交流还是非常愉快的。我们不能再一起改进 Travelator 了,但我们从旅行中学到了什么呢?
当 O’Reilly 问我们是否想要写一本关于 Kotlin 的书时,我们不得不思考我们想要写什么,以及足够多的人可能想要阅读什么。我们知道我们在采用这门语言的旅程上,并且在目的地感到舒适,但我们也知道我们的起点并不是典型的 Java 开发者的起点。我们看到大多数现有的书籍教 Kotlin 就好像它只是 Java 的另一种语法,可以在减少打字的同时实现更多,但并不需要改变方法。但这并不是我们的经验;我们发现 Kotlin 的最佳实践需要比 Java 更多的函数式思维。然而,关于 Kotlin 中的函数式编程的书籍似乎要求读者放下他们对对象编程的所有了解,并加入一个新的信仰。我们对此也感到不舒服。类和对象是表达行为的一种人道主义方式,特别是与许多函数式习语相比。既然有足够的空间,为什么要从我们的工具箱中移除工具呢?我们不能只是拥有更多的工具,并为工作选择合适的工具吗?
粒子
这种思维方式使得 Nat 想出了一个比喻:编程语言有一个粒子,影响我们在其中编写程序的设计。这种粒子使得某些设计风格容易应用,而使其他设计艰难或有风险。
Kotlin 的粒子与 Java 不同。Java 的粒子偏爱可变对象和反射,而以组合性和类型安全为代价。与 Java 相比,Kotlin 更倾向于转换不可变值和独立函数,并拥有一个不显眼但有帮助的类型系统。虽然可以使用 IntelliJ 轻松将 Java 转换为 Kotlin,但如果我们也改变了思维方式,我们可以利用这门新语言所能提供的一切,而不是简单地在 Kotlin 语法中使用 Java。
Java 和 Kotlin 可以在同一代码库中共存,它们之间的交互界限几乎是无缝的,但是当您从 Kotlin 的严格类型世界传递信息到 Java 的松散类型世界时,会存在一些风险。通过小心谨慎,我们发现可以使用自动重构工具在小而安全的步骤中将习惯用法的 Java 代码转换为习惯用法的 Kotlin 代码,必要时可以通过编辑文本来修改。在必须维护 Java 代码的同时,我们还可以同时支持两种语言的约定。
函数式思维
正如我们在一些历史课上看到的那样,Java 的基因形成于 20 世纪 90 年代,当时我们相信面向对象编程是神奇的银弹。当面向对象并未能解决所有问题时,主流编程语言,甚至是 Java 本身,开始采纳函数式编程的思想。Kotlin 就诞生于这个时代的 Java,并且,就像我们的孩子比我们更适应未来一样,Kotlin 比 Java 更适合现代编程。
我们说的函数式思维是什么意思?
我们的软件终究受限于我们理解它的能力。我们的理解能力受到我们创建的软件复杂性的限制,其中许多复杂性源于对何时发生事情的困惑。函数式程序员学会了简化这种复杂性的最简单方法就是减少事情发生的次数。他们称发生事情为效应:在某些范围内可观察到的变化。
在函数内部改变变量或集合是一种影响,但除非该变量在函数外共享,否则不会影响其他任何代码。当影响的范围局限于函数内部时,我们在推理系统行为时可以忽略它。一旦我们改变了共享状态(例如函数的参数、全局变量、或者文件或网络套接字),我们的局部影响就成为任何能看到共享对象的范围内的影响,这迅速增加了复杂性并使理解变得更加困难。
函数不实际改变共享状态是不够的。如果有可能函数可能改变共享状态,我们必须检查函数的来源,并递归地检查每个调用的函数,以理解我们的系统行为。每一片全局可变状态都使每个函数都成为嫌疑对象。同样地,如果我们在一个每个函数都可以写入数据库的环境中编程,我们将失去预测此类写入何时发生以及相应规划的能力。
函数式程序员通过减少变异来驯服复杂性。有时,他们会使用强制控制变异的语言(比如 Clojure 和 Haskell)。否则,他们依靠约定来工作。如果我们在更通用的语言中采纳这些约定,我们就能更好地推理我们的代码。Kotlin 选择不强制控制效果,但语言及其运行时提供了一些内置约定来引导我们朝正确的方向前进。例如,与 Java 相比,我们有一个不可变的val声明,而不是可选的final修饰符,集合的只读视图,以及简洁的数据类来鼓励写时复制而不是变异。本书的许多章节描述了更微妙的约定,目的都是相同的:第五章,从 Bean 到 Value,第六章,Java 到 Kotlin 集合,第七章,从操作到计算,第十四章,累积对象到转换,以及第二十章,执行 I/O 到传递数据。
函数式编程远不止于简单地避免突变共享状态。但是,如果我们专注于解决问题而不突变(或者突变是目的,我们最小化其范围),我们的系统变得更容易理解和改变。就像“不要重复自己”(又称“一次且仅一次”),坚持应用一个简单的规则会产生深远的影响。不要突变共享状态和“一次且仅一次”有一个共同的特性——如果我们不小心,应用这些规则可能会比减少复杂性更快增加复杂性。我们需要学习技术,使我们能够管理突变(并消除重复、便于测试等),而不使我们的代码变得更难理解,并且在看到这些技术时能够识别它们。这些技术在不同的语言、环境和领域可能有所不同,是我们职业的工艺。
如果你研究函数式技术,你会遇到很多反对面向对象的情绪。这似乎根植于一种看法,即面向对象编程完全是关于可变对象,但我们不应该把消息传递的婴儿与可变的洗澡水一起倒掉。尽管我们可以使用面向对象编程来管理共享的可变状态,但实际上,现如今我们通常使用对象来封装不可变状态,或表示服务及其依赖关系。我们在第十六章,接口与函数中看到,我们可以使用闭包函数和具有属性的类来封装数据。两者都可以隐藏代码细节,并允许客户端与不同的实现交互。我们需要这些转折点来构建灵活、稳健和可测试的系统。在 Java 中,我们传统上使用子类化作为工具,而 Kotlin 则通过其默认封闭的类,鼓励一种更具组合性的风格。我们不再覆盖受保护的方法,而是使用表示策略或协作者的函数类型属性。我们应该倾向于这种风格,但在简化我们的实现时,不必尴尬地定义类和子类层次。同样,在第十章,函数到扩展函数中,扩展函数非常好,它们可以在我们的代码库中减少不同关注点之间的耦合,但当我们需要多态方法时,它们不能替代多态方法。
最终,编程的一个吸引人的地方是它的数学与人文的结合。对象和类,对你们的作者来说,至少是一种更具人性化的建模世界的方式,这往往是一个很好的起点。当我们需要严谨性(这通常是需要的,但不像普通人认为的那样频繁)时,函数式编程就在那里为我们服务。当我们可以有两个帐篷并在两个帐篷之间移动时,我们没有理由必须选择一个阵营,而 Kotlin 让我们比我们找到的任何其他语言做得更好。
简单设计
如果复杂性是我们软件的限制因素,而函数式思维是减少复杂性的工具,那么它如何与其他格言相配合——特别是 Kent Beck 的简单设计规则(极限编程解释:拥抱变化)?这两条规则已经为我们服务了二十年,指出一个简单的设计:
-
通过测试
-
揭示意图
-
没有重复
-
元素最少
在这些规则中,“揭示意图”是最开放于解释的,因此让我们来探讨这个问题。
意图是“一个目标或计划”:它意味着变化。它意味着行动。通过区分我们代码中的行为和计算,我们展示了我们期望发生的事情和不期望发生的事情:哪些事情可能会受到其他事情的影响,哪些事情不会。当我们的大部分代码都是计算形式时,我们可以明确指出哪些函数是行为,进而更好地展示我们的意图。
正如我们在第七章,从动作到计算,和第二十章,执行 I/O 到传递数据中所看到的,我们将解开计算与动作的纠缠的主要技术是将动作移到我们交互的入口点,以使其污染最少的代码。这既不容易也不是灵丹妙药,但我们发现这确实能够产生更简单的设计和更少复杂的代码。
函数式编程和文本推理
当我们完成这本书时,我们惊讶地发现我们没有包含任何软件设计图示。
坦率地说,部分原因是出于懒惰。光是在通过重构时管理示例代码的多个版本就已经够难的了,更别提还要担心其他视图了。但我们也养成了一种习惯,尽可能地仅使用我们手头的编程语言来表达自己。如果我们在原始文本中能够达到足够的理解,那么在日常工作中,我们就不会被迫切换上下文去查看可能与代码不同步的图示。
当我们谈到面向对象设计时,我们依赖于图示来展示软件的动态结构和行为,以及源代码变动如何影响其动态行为。在面向对象软件中,这种动态结构——对象之间的图形及其消息传递方式——大部分是隐含的。这使得很难将源代码中看到的内容与运行时的实际发生联系起来,因此可视化是面向对象编程的重要组成部分。在 20 世纪 80 年代和 90 年代,软件设计界的权威们创造了各种图示符号来可视化面向对象软件。到了 1990 年代中期,最流行的符号设计师格雷迪·布奇、伊瓦尔·雅各布森和詹姆斯·兰博合并了他们的努力,创造了统一建模语言(UML)。
函数式编程社区并不像面向对象设计那样专注于图示和可视化。函数式编程的目标是代数推理:通过操作其文本表达式来推理程序的行为。引用透明度和静态类型允许我们仅通过使用源代码的语法来推理我们的程序。这导致我们的代码越来越函数式时,我们能够更加理解我们系统的行为,而无需深思疑难于源代码中并不立即显现但必须通过可视化理解的机制。
重构
除了务实的函数式编程之外,重构是本书的另一个关键原则。重构在我们的专业生活中扮演着重要角色,因为如果我们对系统的最终形式知之甚少,无法一次就将其设计正确,那么我们就必须将现有的内容转换为我们需要的内容。至少在你的作者们看来,从未有过对系统的最终形式有足够了解,以便第一次就把设计做对。即使是那些我们从详细的需求开始的应用,最终交付时也与规格书中的说明大不相同。
在项目的后期和受到时间压力的情况下学习如何重构代码并不合适。相反,我们抓住每一个机会来练习重构。正如我们在第二十二章,从类到函数中看到的,即使是从头开始编写代码,我们也经常会硬编码值以使测试通过,然后重构以消除测试与生产代码之间的重复。我们始终在寻找快速通过测试的新方法,然后通过重构的方式使代码看起来像是我们早已计划好的那样。有时我们会发现 IntelliJ 内置了一个新的自动重构;有时我们会找到一种方法来结合现有的重构来实现我们的目标。
当改动范围较小时,我们可以手动编辑一个定义,然后调整其用法以匹配,或者有时,更有用的是反过来。然而,当一个改动影响到许多文件时,这种做法就变得乏味且容易出错,因此,练习使用工具来实现即使是小改动,都会让我们在面对更大的重构挑战时做好准备。当我们进行多阶段的重构,或者当我们需要在多个地方手动应用改动时,“扩展和收缩重构”使我们能够在整个过程中保持系统的构建和工作。当一个改动可能需要多天甚至几周时,这是至关重要的,因为它允许我们不断地将我们的工作与系统中的其他变更合并。一旦你因为最后的大爆炸合并不可能而放弃了一个月的工作,你就会意识到这种技术的价值,并且在没有严格必要时也想要练习它。
我们希望本书中的重构能够扩展你的野心。你的作者们有幸与一些世界级的从业者合作过,这些人如果在重构过程中引发了编译错误就会抱怨。我们展示的转换可能不是最优的(即使它们是最优的,技术和语言的发展也会随着工具和语言的变化而改变),但它们是真实的,并且确实反映了我们编写和重构代码的方式。
重构和功能式思维
正如我们在我们的旅程中所看到的,功能性思维与重构之间存在着一种关系。重构是我们代码的重新排列,而当该代码表示动作(“动作”)——即依赖于运行时机的代码——重排可能会改变动作运行的时间,从而影响软件的功能。相比之下,计算(“计算”)则可以安全地重排,但最终是无效的。(如果没有读写操作,我们的代码只是在产生热量。)功能性思维鼓励我们识别和控制动作,并通过这样做使得重构变得更加安全。
作者们是通过艰难的方式学到了这一点。我们在可变对象时代学会了重构,并在未能预测后果时引入了错误。这本来可能会导致我们放弃重构,但我们仍然不够聪明,不能在设计系统时一开始就做到正确。相反,我们发现一种特定的编程风格——面向对象但使用不可变对象——是表达性和可理解的,可以重构且安全。当我们在我们的 Java 代码中采用这种风格时,经常是在逆流中努力,但尽管如此,它比其他选择更加高效。在发现了 Kotlin 后,我们意识到这对我们来说是一个甜蜜点。现在我们可以使用一种现代化语言,其中功能性思维是设计的一部分,对象仍然得到良好支持,而重构工具不是一个事后的想法。
正如肯特·贝克所说:“让变化变得容易,然后再做容易的变化。”持续进行重构,以便每次需要做出的变更都变得容易。重构是解决软件固有复杂性的基本实践。
一路平安。