像木材一样,编程语言也有“纹理”——无论是在做木工还是在编程的过程中,当你顺着“纹理”工作时,事情就会很顺利;当你不按“纹理”工作时,事情就会变得更难。如果你不按编程语言的“纹理”工作,就必须写更多的代码,代码性能也会受到影响,更容易引入缺陷,通常必须覆盖预置的默认值,并且每进行一步都需要与工具进行“战斗”。
不按“纹理”工作,就需要不断努力,且不一定有回报。
例如,使用函数式的方式编写Java代码一直以来都是可行的,但在Java 8之前很少有程序员这样做。
下面是一段Kotlin的代码,它通过使用加法运算符对列表进行折叠,以计算列表中数字的和:
将上述代码与实现相同功能的Java 1.0代码进行比较。那是1995年,函数不是Java 1.0的“头等公民”,所以我们必须把函数实现为对象,并且为不同类型的函数定义自己的接口。例如,加法函数需要两个参数,所以我们必须定义一个双参函数:
然后,我们必须编写fold高阶函数,隐藏Vector类所需的迭代和变换过程(1995年的Java标准库尚未包含Collections Framework)。
我们必须为每一个想要传递给fold(高阶)函数的函数单独定义一个类,加法运算符不能作为值传递,而且当时的Java语言并没有方法引用、lambda或闭包,甚至没有内部类。Java 1.0也没有泛型或自动装箱功能——我们必须将参数强制转换为预期的类型,并编写引用类型和原语之间的装箱代码:
最后,我们可以用这些代码来计算列表的和:
在2020年主流语言中,这只是一个表达式,但需要付出很大的工作量。
但这还没有结束,因为Java没有标准的函数类型,我们还不能轻松地将不同的函数库结合起来。我们必须编写适配器类来映射不同库中定义的函数类型。而且,由于虚拟机没有JIT,只有一个简单的垃圾收集器,函数式代码的性能要比命令式代码的性能差。
1995年,没有足够的好处来证明用函数式风格编写Java代码是正确的,Java程序员发现编写迭代集合和修改状态的命令式代码更容易。编写函数式代码有悖于Java 1.0的“纹理”。
一种语言的“纹理”是随着时间的推移而形成的,因为它的设计者和使用者建立了对语言特性如何相互作用的一致理解,并将他们的理解和偏好植入其他人所依赖的库中。语言的“纹理”影响着程序员使用它编写代码的方式,而后者又影响着语言及其库和编程工具的演变,从而改变了“纹理”,改变了程序员使用该语言编写代码的方式,不断地进行着相互反馈和演变的循环。
例如,随着时间的推移,Java 1.1中加入了匿名内部类,而Java 2则向标准库中加入了Collections Framework。匿名内部类意味着我们不再需要为传递给fold函数的每个函数单独命名一个类,但由此产生的代码可能更加难以阅读:
函数式编程习惯仍然与Java 2的“纹理”背道而驰。
快进到2004年,Java 5是Java的下一个重大升级版本。它增加了泛型和自动装箱,这提高了类型安全性并减少了样板代码:
Java开发者经常使用谷歌的Guava库( https://oreil.ly/dMX73 )来为集合添加一些常见的高阶函数(虽然fold不在其中),但即便是Guava的作者也建议首选编写命令式代码,因为它有更好的性能,通常也更容易阅读。
函数式编程在很大程度上仍然与Java 5的“纹理”相悖,但我们可以看到向这个方向演进的趋势。
Java 8中增加了匿名函数(又称为lambda表达式)和方法引用,并在标准库中增加了Streams API。编译器和虚拟机优化了lambda,以避免匿名内部类的性能开销。Streams API完全接纳了函数式的风格,最终允许:
然而,这一切并非一帆风顺。我们仍然不能把加法运算符作为参数传递给Streams的reduce函数,但标准库函数Integer::sum可以做同样的事情。由于引用类型和原始类型之间的区别,Java的类型系统仍然会产生棘手的边缘场景。Streams API缺少一些我们所期望的、常见于函数式编程语言(例如Ruby)中的高阶函数。受检异常(checked exception)与Streams API和一般的函数式编程并不兼容。在编写具有值语义的不可变类时,仍然需要大量的样板代码。但是在Java 8中,已经做出了根本上的改变,使得函数式风格编程得以运行,即便与语言的“纹理”并不完全一致,至少也不会违背它。
Java 8之后的版本增加了各种较小的语言特性和库特性,支持更多的函数式编程风格,但没有改变求和的计算方式。
就Java而言,语言的“纹理”以及程序员适应它的方式,经历了几种不同的编程风格的演变。