21.4 Java的未来
让我们看看关于Java未来发展的一些讨论。本节涉及的很多内容都在JDK改进提议(JDK enhancement proposal)中有更详细的讨论。我们在这里想要特别解释的是为什么一些看起来很合理的想法,由于微妙的实现困难,以及与现存特性的协作问题,最终无法被加入到Java中。
21.4.1 声明处型变
Java支持通配符这种灵活的机制,可以接受泛型的子类型(subtyping),通常也称其为使用处型变(use-site variance)。基于这种支持,下面这种赋值操作是合法的:
List<? extends Number> numbers = new ArrayList<Integer>();
然而,接下来这个赋值操作,由于忽略了“? extends”,会导致一个编译错误:
List<Number> numbers = new ArrayList<Integer>(); ←---- 类型不兼容
很多语言,比如C#和Scala都支持另一种名叫声明处型变(declaration-site variance)的型变机制。这些语言允许程序员在定义泛型类时使用型变。这一特性对天然经常变化的类而言非常有价值。比如,Iterator就是一个天然的协变(covariant)类型,而Comparator是一个天然的逆变(contravariant)类型,使用它们时,你不需要考虑是应该使用? extends抑或是? super。在Java中引入“声明处型变”非常有价值,由于这些约定出现在规范中,而不是在类的声明中,程序员认知和理解这些特性的代价降低了。特别提一下,截至本书编写时(2018年),JDK改进提议已经将“声明处型变”列为下一个Java版本默认支持的特性。
21.4.2 模式匹配
第19章中曾讨论过,函数式语言通常都会提供某种形式的模式匹配——作为switch的一种改良形式。通过模式匹配,你可以查询“这个值是某个类的实例吗”,或者递归查询某个字段是否包含了某些值。采用Java语言,一个简单的例子如下:
if (op instanceof BinOp){Expr e = ((BinOp) op).getLeft();}
注意,即便你很清楚op的对象引用指向的是什么类型,还是需要在强制类型转换时重复执行BinOp。
你可能需要处理一个非常复杂的表达式层次结构,如果直接采用串接多个if条件判断的方式,你的代码会变得异常烦琐。值得一提的是,传统面向对象的设计不推荐大家使用switch,它更推崇使用设计模式,比如访问者模式,依赖数据类型的控制流是由方法分发器而不是switch语句选择的。而对程序设计语言的另一分支,即函数式程序设计语言来说,基于数据类型的模式匹配通常是设计程序最便捷的方式。
将Scala风格的模式匹配全盘移植到Java中无疑是个大工程,但基于switch语法最近的泛化,你完全可以设想出更现代的语法扩展,让switch直接通过instanceof语法操作对象。实际上,JDK改进提议已经将模式匹配列为Java的新语言特。下面的这个例子中,我们会对第19章介绍的示例进行重构,假设有一个类Expr,它衍生出了两个子类,分别是BinOp和Number:
switch (someExpr) {case (op instanceof BinOp):doSomething(op.getOpName(), op.getLeft(), op.getRight());case (n instanceof Number):dealWithLeafNode(n.getValue());default:defaultAction(someExpr);}
这段代码中有几点需要特别留意。首先,这段代码在case (op instanceof BinOp):中借用了模式匹配的思想,op是一个新的局部变量(类型为BinOp),它与SomeExpr都绑定到了同一个值。类似地,在Number的case判断中,n被转化为了Number类型的变量。在默认情况下,执行switch是不需要进行任何变量绑定的。与采用串接的if-then-else加“强制转换子类型”这种方式比较起来,新的实现方式避免了编写大量的模板代码。习惯了传统面向对象方式的设计者很可能会说,如果采用访问者模式,在子类型中进行重写(er-write)实现“数据类型”的分派,表达的效果会更好,然而从函数式编程的角度看,后者会让相关代码散落于多个类的定义中,也不太理想。这种经典的设计两难(design dichotomy)问题,经常会以“表达问题”(expression problem)之名出现在文学著作中。
21.4.3 更加丰富的泛型形式
本节会讨论Java泛型的两个局限性,并探讨可能的改进方案。
- 具化泛型
Java 5初次引入泛型时,花费了大量的精力让它们保持与现存JVM的后向兼容性。为了达到这一目标,ArrayList和ArrayList的运行时表示是相同的。这被称作泛型多态的消除模式(erasure model of generic polymorphism)。这种选择伴随着一定程度的运行时消耗,不过对程序员而言,这无关痛痒,最大的影响是传给泛型的参数只能是对象类型,不能是基本类型了。只要Java支持ArrayList这种类型的泛型,你就可以在堆上分配由基本数据类型构成的ArrayList对象,比如42。然而,这样一来ArrayList容器就无法了解它所容纳的到底是一个对象类型的值,比如一个String,还是一个基本类型的int值,比如42。
从某种程度上看,这种变化无关痛痒,没什么危害。如果你可以从ArrayList中得到基本类型值42,或者从ArrayList中得到String对象abc,那么为什么还要担忧ArrayList容器中的元素无法识别呢?非常不幸,答案是有影响,这种影响与垃圾收集相关,因为一旦缺少了ArrayList中内容的运行时信息,JVM就无法判断ArrayList中的元素13到底是一个String的引用(可以被垃圾收集器标记为“in use”并进行跟踪),还是int类型的简单数据(几乎是无法跟踪的)。
C#语言中,ArrayList、ArrayList以及ArrayList的运行时表示本质上就是不同的。即便它们的表示是相同的,大量的类型信息也只能由垃圾收集器在运行时获取,比如判断一个字段值到底是引用,还是基本数据类型。这种模型被称作泛型多态的具化模式(reified model of generic polymorphism),或者更简单的具化泛型(reified generic)。具化这个词意味着“将某些默认隐式的东西变为显式的”。
很明显,具化泛型是我们期望的,它们能更好地融合基本数据类型及其对应的对象类型——接下来的一节,你会看到其中的一些问题。实现具化泛型的主要难点在于,Java需要保持后向兼容性,并且这种兼容需要同时支持JVM,以及使用了反射且希望执行泛型清除的遗留代码。
- 泛型中特别为函数类型增加的语法灵活性
自从被Java 5引入,泛型就证明了其独特的价值。它们还特别适用于表示Java 8中的Lambda类型以及各种方法引用。你可以用下面的方式表示接受单一参数的函数:
Function<Integer, Integer> square = x -> x * x;
如果你有一个使用两个参数的函数,那么可以采用类型BiFunction,这里的T表示第一个参数的类型,U表示第二个参数的类型,而R是计算的结果。不过,Java 8中并未提供TriFunction这样的函数,除非你自己声明了一个!
同理,你不能让Function引用指向某个不接受任何参数,返回值为R类型的函数,你只能使用Supplier达到这一目的。
从本质上来说,Java 8的Lambda极大地拓展了我们的编程能力,但遗憾的是,它的类型系统并未跟上代码灵活度提升的脚步。在很多的函数式编程语言中,你可以用(Integer, Double) => String这样的类型实现Java 8中BiFunction调用,得到同样的效果;类似地,可以用Integer => String表示Function,甚至可以用() => String表示Supplier。你可以将=>符号看作Function、BiFunction、Supplier以及其他类似函数的中缀表达式版本。正如第20章中所讨论的,只需对现有Java语言的类型语法稍作扩展,支持中缀表达式 =>,就能提供Scala语言那样更具可读性的类型。
- 基本类型特化和泛型
在Java语言中,所有的基本数据类型,比如int,都有对应的对象类型(以刚才的例子而言,它是java.lang.Integer)。通常我们把它们称为“未装箱类型”和“装箱类型”。虽然这种区分有助于提升运行时的效率,但是以这种方式定义类型也可能带来一些困扰。比如,有人可能会问为什么Java 8中需要编写Predicate,而不是直接使用Function?事实上,Predicate类型对象在执行test方法调用时,其返回值依旧是基本类型boolean。
与此相反,和所有泛型一样,Function只能使用对象类型的参数。以Function为例,它接受的是对象类型Boolean,而不是基本数据类型boolean。所以采用Predicate更高效,因为不需要将boolean装箱为Boolean了。由于存在这样的问题,类库的设计者在设计Java时创建了多个类似的接口,比如LongToIntFunction和BooleanSupplier,而这又进一步增加了大家理解的负担。
另一个例子与各种void之间的区别有关,void只能修饰方法的返回值,并且返回值不含任何值。而对象类型Void实际包含了一个值,它有且仅有一个null值——这是一个经常在论坛上讨论的问题。对于Function的特殊情况,比如Supplier,你可以用前一节建议的新操作符将其改写为() => T,这进一步佐证了由于基本数据类型(primitive type)与对象类型(object type)的差异所导致的分歧。之前的内容中已经介绍了怎样通过具化泛型解决这其中的很多问题。
21.4.4 对不变性的更深层支持
Java 8只支持三种类型的值,分别是:
- 基本类型值;
- 指向对象的引用;
- 指向函数的引用。
听我们说起这些,有些专业的读者可能会感到失望。我们在某种程度上会坚持自己的观点,介绍说“这些值现在既可以作为方法的参数,也可以返回结果”。不过,我们也承认这种解释存在一定的问题。比如,当你返回一个指向可变数组的引用时,它多大程度上应该是一个(算术)值呢?很明显,字符串或者不可变数组都是值,不过对于可变对象或者数组而言,情况远非那么泾渭分明——可能你的方法返回一个以升序排列元素的数组,然而另一些代码之后可能对其中的某些元素进行修改。
如果想在Java中实现真正的函数式编程,那么语言层面的支持必不可少,比如“不可变值”。正如我们在第18章中所了解的那样,关键字final并未在真正意义上达到这一目标,它仅仅避免了对它所修饰字段的更新。来看一下下面这个例子:
final int[] arr = {1, 2, 3};final List<T> list = new ArrayList<>();
第一行代码禁止了直接的赋值操作arr = …,然而它并不能阻止以arr[1]=2这样的方式对数组进行修改。第二行代码禁止了对list的赋值操作,但并未禁止其他方法修改列表中的元素!关键字final对基本数据类型的值操作效果很好,然而对于对象引用,它通常只是一种虚假的安全感。
那么该如何解决这一问题呢?由于函数式编程对不修改现存数据结构有非常严格的要求,因此它提供了更强大的关键字,比如transitively_final,该关键字用于修饰引用类型的字段,确保无论是对该字段本身直接的修改,还是对通过该字段能直接或间接访问到的对象的修改都不会发生。
这些类型体现了关于值的一个理念:变量值是不可修改的,只有变量(它们负责存储值)可以被修改,修改之后变量中存储的就变成了别的不可变值。正如本节开头所介绍的,Java的作者,包括我们,时不时地都喜欢讨论Java中值是可变数组的情况。接下来的一节会讨论值类型(value type),声明为值类型的变量只能包含不可变值。然而,值类型的变量,除非使用了final关键字进行修饰,否则依旧能够被更新。
21.4.5 值类型
本节会讨论基本数据类型和对象类型之间的差异,接着继续进行值类型的讨论。对象类型是面向对象编程不可缺失的一环,同样地,值类型对进行函数式编程也大有裨益。我们讨论的很多问题都是相互交织的,很难以区隔的方式解释某个单独的问题。所以,我们会从多个角度阐述这些问题。
- 为什么编译器不能对
Integer和int一视同仁
自从Java 1.1版本以来,Java语言逐渐具备了隐式地进行装箱和拆箱的能力,你可能会问现在是否是一个恰当的时机,让Java语言一视同仁地处理基本数据类型和对象数据类型,比如将Integer和int同等对待,由Java编译器将它们优化为JVM最适合的形式。
这个想法原则上是非常美好的,不过让我们看看在Java中添加Complex类型后会引发哪些问题,以及为什么装箱会导致这样的问题。用于建模复数的Complex包含了两个部分,分别是实数(real)和虚数(imaginary),一种很直观的定义如下:
class Complex {public final double re;public final double im;public Complex(double re, double im) {this.re = re;this.im = im;}public static Complex add(Complex a, Complex b) {return new Complex(a.re+b.re, a.im+b.im);}}
不过类型Complex的值为引用类型,对Complex的每个操作都需要进行对象分配——增加了add中两次加法操作的开销。我们需要的是类似Complex的基本数据类型,也许可以称其为complex。
这就成了个问题,因为我们想要一种“未装箱的对象”,可是无论Java还是JVM,对此都没有实质的支持。至此,我们只能悲叹了,“噢,当然编译器可以对它进行优化”。坏消息是,这远比看起来复杂得多。虽然Java带有基于名为“逃逸分析”的编译器优化(这一技术自Java 1.1版本开始就已经有了),它能在某些时候判断拆箱的结果是否正确,然而其能力依旧受到一定的限制,受制于Java对对象类型的判断。以下面的这个例子来说:
double d1 = 3.14;double d2 = d1;Double o1 = d1;Double o2 = d2;Double ox = o1;System.out.println(d1 == d2 ? "yes" : "no");System.out.println(o1 == o2 ? "yes" : "no");System.out.println(o1 == ox ? "yes" : "no");
最后这段代码输出的结果为“yes”“no”“yes”。专业的Java程序员可能会说“多愚蠢的代码,每个人都知道最后这两行你应该使用equals而不是==”。不过,请允许我们继续用这个例子进行说明。虽然所有这些基本变量和对象都保存了不可变值3.14,实际上也应该是没有差别的,但是由于代码中有对o1和o2的定义,程序会创建新的对象,而==操作符(特征比较)可以将这二者区分开来。请注意,对于基本变量,特征比较采用的是逐位比较,对于对象类型它采用的是引用比较。很多时候,你可能无意之中就创建了新的Double对象,由于编译器需要遵守对象的语义,创建新的Double对象(Double对象继承自Object)也要遵守该语义。我们之前经历过好几次类似的讨论,无论是较早的时候关于值对象的讨论,还是第19章围绕函数式更新持久化数据结构以保持引用透明性的方法讨论。
- 值对象——既非基本类型又非对象类型
为了解决这个问题,我们建议的方案是重构大家对Java的假设,即(1) 任何事物,如果不是基本数据类型,就是对象类型,所有的对象类型都继承自Object;(2) 所有的引用都是指向对象的引用。
我们由此开始该方案的介绍。Java的值有两种形式:
- 一类是对象类型,它们包含着可变的字段(除非使用了
final关键字进行修饰),对这种类型值的特征,可以使用==进行比较; - 另一类是值类型,这种类型的变量是不能改变的,也不带任何的引用特征(reference identity),基本类型就属于这种更宽泛意义上的值类型。
这样,我们就能创建用户自定义值的类型了(这种类型的变量推荐小写字符开头,从而强调它们与int和boolean这些基本类型的相似性)。对于值类型,默认情况下,硬件对int进行比较时会以一个字节接着一个字节逐次的方式进行,==会以同样的方式一个元素接着一个元素地对两个变量进行比较。处理浮点数成员时,我们需要特别当心,因为它们的比较操作更加的复杂。介绍非基本值类型时,Complex是一个绝佳的例子,其类型与C#中的结构极其类似。
此外,值类型由于没有引用特征,因此占用的存储空间更少。图21-1是一个容量为3的数组示例,它包含的元素0、1和2分别用淡灰、白色和深灰色标记。左边的图展示了Pair和Complex都是对象类型时的一种比较典型的存储布局,而右边的图展示的是一种更优的布局,这时Pair和Complex都是值类型(这里特意使用了小写的pair和complex,目的就是想强调它们与基本类型的相似性)。注意,由于值类型在数据访问(用单一的索引地址指令替换多层的指针转换)和对硬件缓存的利用(因为数据存储采用连续的地址空间)上的优势,它的性能极可能好得多。

图 21-1 对象与值类型
注意,由于值类型并不包含引用特征,因此编译器可以很灵活地对它们进行装箱和拆箱操作。如果你将一个complex类型的参数由一个函数传递给另一个函数,那么编译器可以毫不费力地将它拆解为两个独立的double型的参数。(由于JVM只支持以64位寄存器传值的方式返回指令,因此在JVM中要实现不装箱,直接返回很困难)。不过,如果你要传递一个很大的值类型参数(比如一个巨型不可变数组),那么装箱后编译器能以透明的方式(透明于用户)将其作为引用传递给对方。类似的技术在C#中已经存在,下面是一段来自微软的介绍:
基于值类型的变量直接包含值。将一个值类型变量赋值给另一个变量时会复制其包含的值。这与引用类型变量的赋值不同,引用类型变量的赋值只复制对象的引用,不会复制对象本身。
截至本书写作时(2018年),JDK改进建议还在就值类型的引入进行讨论。
- 装箱、泛型、值类型——互相交织的问题
我们希望能够在Java中引入值类型,因为函数式编程处理的不可变对象都没有特征。我们希望基本数据类型可以作为值类型的特例,但又不要有Java当前的泛型消除模式,因为这意味着值类型不做装箱就不能使用泛型。由于对象的消除模式,基本类型(比如int)对象的(装箱)版本(比如Integer)对集合和Java泛型依旧非常重要,然而,由于它们继承自Object(并因此存在引用特征),这是我们不想要的。解决这些问题中的任何一个就意味着解决了所有的问题。
