本章内容
Java 8的新特性以及其对编程风格颠覆性的影响
由Java 8萌生的一些尚未成熟的编程思想
Java 9以及Java 10可能发生的变化
我们在本书中讨论了很多内容,希望你现在已经有足够的信心开始使用Java 8编写你自己的代码,或者编译书中提供的例子和测验。这一章里,我们会回顾我们的Java 8学习之路和函数式编程这一潮流。除此之外,还会展望在Java 8之后的版本中可能出现的新的改进和重大的新特性。
16.1 回顾Java 8的语言特性
Java 8是一种实践性强、实用性好的语言,想要很好地理解它,方法之一是重温它的各种特性。本章不会简单地罗列Java 8的各种特性,而是会将这些特性串接起来,希望大家不仅能理解这些新特性,还能从语言设计的高度理解Java 8中语言设计的连贯性。作为回顾,本章的另一个目的是阐释Java 8的这些新特性是如何促进Java函数式编程风格的发展的。请记住,这些新特性并非语言设计上的突发奇想,而是一种刻意的设计,它源于两种趋势,即我们在第1章中所说的形势的变化。
对多核处理器处理能力的需求日益增长,虽然硅开发技术也在不断进步,但依据摩尔定律每年新增的晶体管数量已经无法使独立CPU核的速度更快了。简单来说,要让你的代码运行得更快,需要你的代码具备并行运算的能力。
更简洁地调度以显示风格处理数据的数据集合,这一趋势不断增长。比如,创建一些数据源,抽象所有数据以符合给定的标准,给结果运用一些操作,而不是概括结果或者将结果组成集合以后再做进一步处理。这一风格与使用不变对象和集合相关,它们之后会进一步生成不变值。
不过这两种诉求都不能很好地得到传统的、面向对象编程的支持,命令式的方式和通过迭代器访问修改字段都不能满足新的需要。在CPU的一个核上修改数据,在另一个核上读取该数据的值,这种方式的代价是非常高的,更不用说你还需要考虑容易出错的锁;类似地,当你的思考局限于通过迭代访问和修改现存的对象时,类流(stream-like)式编程方法看起来就非常地异类。不过,这两种新的潮流都能通过使用函数式编程非常轻松地得到支持,这也解释了为什么Java 8的重心要从我们最初理解的Java大幅地转型。
现在,我们一起从统一、宏观的角度来回顾一下,看看我们都从这本书中学习了哪些东西,它们又是如何相互协作构建出一片新的编程天地的。
16.1.1 行为参数化(Lambda以及方法引用)
为了编写可重用的方法,比如filter
,你需要为其指定一个参数,它能够精确地描述过滤条件。虽然Java专家们使用之前的版本也能达到同样的目的(将过滤条件封装成类的一个方法,传递该类的一个实例),但这种方案却很难推广,因为它通常非常臃肿,既难于编写,也不易于维护。
正如你在第2章和第3章中所了解的,Java 8通过借鉴函数式编程,提供了一种新的方式——通过向方法传递代码片段来解决这一问题。这种新的方法非常方便地提供了两种变体。
传递一个Lambda表达式,即一段精简的代码片段,比如
apple -> apple.getWeight > 150
传递一个方法引用,该方法引用指向了一个现有的方法,比如这样的代码:
Apple::isHeavy
这些值具有类似Function<T, R>
、Predicate<T>
或者BiFunction<T, U, R>
这样的类型,值的接收方可以通过apply
、test
或其他类似的方法执行这些方法。Lambda表达式自身是一个相当酷炫的概念,不过Java 8对它们的使用方式——将它们与全新的Stream API相结合,最终把它们推向了新一代Java的核心。
16.1.2 流
集合类、迭代器,以及for-each
结构在Java中历史悠久,也为广大程序员所熟知。直接在集合类中添加filter
或者map
这样的方法,利用我们前面介绍的Lambda实现类数据库查询对于Java 8的设计者而言要简单得多。不过他们并没有采用这种方式,而是引入了一套全新的Stream API,即第4章到第7章所介绍的内容——这是值得我们深思的,他们为什么要这么做呢?
集合到底有什么问题,以至于我们需要另起炉灶替换掉它们,或通过一个类似却不同的概念Stream对其进行增强。我们把二者之间的差异概括如下:如果你有一个数据量庞大的集合,你需要对这个集合应用三个操作,比如对这个集合中的对象进行映射,对其中的两个字段进行求和,这之后依据某种条件过滤出满足条件的和,最后对结果进行排序,即为得到结果你需要分三次遍历集合。Stream API则与之相反,它采用延迟算法将这些操作组成一个流水线,通过单次流遍历,一次性完成所有的操作。对于大型的数据集,这种操作方式要高效得多。不过,还有一些需要我们考虑的因素,比如内存缓存,数据集越大,越需要尽可能地减少遍历的次数。
还有其他一些原因也会影响元素并发处理的能力,这些也非常关键,对高效地利用多处理器的能力至关重要。Stream,尤其是它的parallel
方法能帮助将一个Stream标记为适合进行并行处理。还记得吗?并行处理和对象的可变状态是水火不容的,所以核心的函数式概念(如我们在第4章中介绍的,包括无副作用的操作,通过Lambda表达式和方法引用对方法进行参数化,用内部迭代替换外部迭代)对于并行使用map
、filter
或者其他方法发掘Stream的处理能力非常重要。
现在,让我们看看这些观念(介绍Stream时使用过这些术语)怎样直接影响了CompletableFuture
类的设计。
16.1.3 CompletableFuture
Java从Java 5版本就提供了Future
接口。Future
对于充分利用多核处理能力是非常有益的,因为它允许一个任务在一个新的核上生成一个新的子线程,新生成的任务可以和原来的任务同时运行。原来的任务需要结果时,它可以通过get
方法等待Future
运行结束(生成其计算的结果值)。
第11章介绍了Java 8中对Future
的CompletableFuture
实现。这里再次利用了Lambda表达式。一个非常有用,不过不那么精确的格言这么说:“Completable-Future
对于Future
的意义就像Stream之于Collection
。”让我们比较一下这二者。
通过
Stream
你可以对一系列的操作进行流水线,通过map
、filter
或者其他类似的方法提供行为参数化,它可有效避免使用迭代器时总是出现模板代码。类似地,
CompletableFuture
提供了像thenCompose
、thenCombine
、allOf
这样的操作,对Future
涉及的通用设计模式提供了函数式编程的细粒度控制,有助于避免使用命令式编程的模板代码。
这种类型的操作,虽然大多数只能用于非常简单的场景,不过仍然适用于Java 8的Optional
操作,我们一起来回顾下这部分内容。
16.1.4 Optional
Java 8的库提供了Optional<T>
类,这个类允许你在代码中指定哪一个变量的值既可能是类型T
的值,也可能是由静态方法Optional.empty
表示的缺失值。无论是对于理解程序逻辑,抑或是对于编写产品文档而言,这都是一个重大的好消息,你现在可以通过一种数据类型表示显式缺失的值——使用空指针的问题在于你无法确切了解出现空指针的原因,它是预期的情况,还是说由于之前的某一次计算出错导致的一个偶然性的空值,有了Optional
之后你就不需要再使用之前容易出错的空指针来表示缺失的值了。
正如我们在第10章中讨论的,如果在程序中始终如一地使用Optional<T>
,你的应用应该永远不会发生NullPointerException
异常。你可以将这看成另一个绝无仅有的特性,它和Java 8中其他部分都不直接相关,问自己一个问题:“为什么用一种表示值缺失的形式替换另一种能帮助我们更好地编写程序?”进一步审视,我们发现Optional
类提供了map
、filter
和ifPresent
方法。这些方法和Streams
类中的对应方法有着相似的行为,它们都能以函数式的结构串接计算,由于库自身提供了缺失值的检测机制,不再需要用户代码的干预。这种进行内部检测还是外部检测的选择和在Stream库中进行内部迭代还是在用户代码中进行外部迭代的选择极其类似。
本节最后我们不再涉及函数式编程的内容,而是要讨论一下Java 8对库的前向兼容性支持,这一技术受到了软件工程发展的推动。
16.1.5 默认方法
Java 8中增加了不少新特性,但是它们一般都不对个体程序的行为带来影响。不过,有一件事情是例外,那就是新增的默认方法。接口中新引入的默认方法对类库的设计者而言简直是如鱼得水。Java 8之前,接口主要用于定义方法签名,现在它们还能为接口的使用者提供方法的默认实现,如果接口的设计者认为接口中声明的某个方法并不需要每一个接口的用户显式地提供实现,他就可以考虑在接口的方法声明中为其定义默认方法。
对类库的设计者而言,这是个伟大的新工具,原因很简单,它提供的能力能帮助类库的设计者们定义新的操作,增强接口的能力,类库的用户们(即那些实现该接口的程序员们)不需要花费额外的精力重新实现该方法。因此,默认方法与库的用户也有关系,它们屏蔽了将来的变化对用户的影响。第9章针对这一问题进行了更加深入的探讨。
自此,我们已经完成了对Java 8中新概念的总结。现在我们会转向更为棘手的主题,那就是Java 8之后的版本中可能会有哪些新的改进以及新的特性出现。
16.2 Java的未来
让我们看看关于Java未来的一些讨论。关于这一主题的大多数内容都会在JDK改进提议(JDK Enhancement Proposal)中进行讨论,它的网址是http://openjdk.java.net/jeps/0。我们在这里想要讨论的主要是一些看起来很合理、实现起来却颇有难度的部分,以及一些由于和现存特性的协作有问题而无法引入到Java中的部分。
16.2.1 集合
Java的发展是一个循序渐进的过程,它从来就不是一蹴而就的。Java中融入了大量伟大的思想,比如:数组取代了集合,之后的Stream又进一步增强了集合的功能。当然,乌龙的情况也偶有发生,有的特性其优势变得更加明显(比如集合之于数组),但我们在做替代时却忽略了被替代特性的一些优点。一个比较典型的例子是容器的初始化。比如,Java中数组可以通过下面这种形式,在声明数组的同时进行初始化:
Double a = {1.2, 3.4, 5.9};
它是以下这种语法的简略形式:
Double a = new Double{1.2, 3.4, 5.9};
为处理诸如由数组表示的顺序数据结构,集合(通过Collection
接口)提供了一种更优秀也更一致的解决方案。不过它们的初始化被忽略了。让我们回想一下你是如何初始化一个HashMap
的。你只能通过下面这样的代码完成初始化工作:
Map<String, Integer> map = new HashMap<>;map.put("raoul", 23);map.put("mario", 40);map.put("alan", 53);
你可能更愿意通过下面的方式达到这一目标:
Map<String, Integer> map = #{"Raoul" -> 23, "Mario" -> 40, "Alan" -> 53};
这里的#{...}
是一种集合常量,它们代表了集合中的一系列值组成的列表。这似乎是一个毫无争议的特性1,不过它当前在Java中还不支持。
1当前的Java新特性提议请参考http://openjdk.java.net/jeps/186。
16.2.2 类型系统的改进
我们会讨论对Java当前类型系统的两种潜在可能的改进,分别是声明位置变量(declaration-site variance)和本地变量类型推断(local variable type inference)。
1. 声明位置变量
Java加入了对通配符的支持,来更灵活地支持泛型的子类型(subtyping), 或者我们可以更通俗地称之为“用户定义变量”(use-site variance)。这也是下面这段代码合法的原因:
List<? extends Number> numbers = new ArrayList<Integer>;
不过下面的这段赋值(省略了? extends
)会产生一个编译错误:
List<Number> numbers = new ArrayList<Integer>; ←─类型不兼容
很多编程语言(比如C#和Scala)都支持一种比较独特的变量机制,名为声明位置变量。它们允许程序员们在定义泛型时指定变量。对于天生就为变量的类而言,这一特性尤其有用。比如,Iterator
就是一个天生的协变量,而Comparator
则是一个天生的逆变量。使用它们时你无需考虑到底是应该使用? extends
,还是使用 ? super
。这也是说在Java中添加声明位置变量极其有用的原因,因为这些规范会在声明类时就出现。这样一来,程序员的认知负荷就会减少。注意,截至本书写作时(2014年6月),已经有一个提议处于研究过程中,希望能在Java 9中引入声明位置变量2。
2参见https://bugs.openjdk.java.net/browse/JDK-8043488。
2. 更多的类型推断
最初在Java中,无论何时我们使用一个变量或方法,都需要同时给出它的类型。例如:
double convertUSDToGBP(double money) { ExchangeRate e = ...; }
它包含了三种类型;这段代码给出了函数convertUSDToGBP
的结果类型,它的参数money
的类型,以及方法使用的本地变量e
的类型。随着时间的推移,这种限制被逐渐放开了。首先,你可以在一个表达式中忽略泛型参数的类型,通过上下文决定其类型。比如:
Map<String, List<String>> myMap = new HashMap<String, List<String>>;
这段代码在Java 7之后可以缩略为:
Map<String, List<String>> myMap = new HashMap<>;
其次,利用同样的思想,你可以将由上下文决定的类型交由一个表达式决定,即由Lambda表达式来决定,比如:
Function<Integer, Boolean> p = (Integer x) -> booleanExpression;
省略类型后,这段代码可以精简为:
Function<Integer, Boolean> p = x -> booleanExpression;
这两种情况都是由编译器对省略的类型进行推断的。
如果一种类型仅包含单一的标识符,类型推断能带来一系列的好处,其中比较主要的一点是,用一种类型替换另一种可以减少编辑工作量。不过,随着类型数量的增加,出现了由更加泛型的类型参数化的泛型,这时类型推断就带来了新的价值,它能帮助我们改善程序的可读性。3
3当然,以一种直观的方式进行类型推断也是非常重要的。类型推断最适合的情况是只存在一种可能性,或者一种比较容易文档化的方式,借此重建用户省略的类型。如果系统推断出的类型与用户最初设想的类型并不一致,就会带来很多问题;所以良好的类型推断设计在面临两种不可比较的类型时,都会给出一个默认的类型,利用默认类型来避免出现随机选择错误的类型。
Scala和C#中都允许使用关键词var
替换本地变量的初始化声明,编译器会依据右边的变量填充恰当的类型。比如,我们之前展示过的使用Java语法的myMap
声明可以像下面这样改写:
var myMap = new HashMap<String, List<String>>;
这种思想被称为本地变量类型推断,你可能期待Java中也提供类似的特性,因为它能消除冗余的类型,减少杂乱的代码。
然而,它也可能受到一些质疑,比如,类Car
继承类Vehicle
后,你进行了下面这样的声明:
var x = new Vehicle;
那么,你到底期望x
的类型为Car
还是Vehicle
呢?这个例子中,一个简单的解释就能解决问题,即缺失的类型就是初始化器对象的类型(这里为Vehicle
),由此我们可以得出一个结论,没有初始化器时,不要使用var
声明对象。
16.2.3 模式匹配
我们曾经在第14章中讨论过,函数式语言通常都会提供某种形式的模式匹配——作为switch
的一种改良形式。通过这种模式匹配,你可以查询“这个值是某个类的实例吗”,或者你也可以选择递归地查询某个字段是否包含了某些值。
我们有必要提醒你,即使是传统的面向对象设计也已经不推荐使用switch
了,现在大家更推荐的方式是采用一些设计模式,比如访问者模式,使用访问者模式时,程序利用dispatch
方法,依据数据类型来选择相应的控制流,不再使用传统的switch
方式。这并非另一种编程语言中的事——函数式编程语言中使用基于数据类型的模式匹配通常也是设计程序最便捷的方式。
将类Scala的模式匹配全盘地移植到Java中似乎是个巨大的工程,但是基于switch
语法最近的泛化(switch
现在已经不再局限于只允许对String
进行操作),你可以想象更加现代的语法扩展会有哪些。现在,凭借instanceof
,你可以通过switch
直接对对象进行操作。这里,我们会对14.4节中的示例进行重构,假设有这样一个类Expr
,它有两个子类,分别是BinOp
和Number
:
switch (someExpr) { case (op instanceof BinOp): doSomething(op.opname, op.left, op.right); case (n instanceof Number): dealWithLeafNode(n.val); default: defaultAction(someExpr);}
这里有几点需要特别注意。我们在case (op instanceof BinOp):
这段代码中借用了模式匹配的思想,op
是一个新的局部变量(类型为BinOp
),它和SomeExpr
都绑定到了同一个值;类似地,在Number
的case
判断中,n
被转化为了Number
类型的变量。而默认情况不需要进行任何变量绑定。和采用串接的if-then-else
加子类型转换比起来,这种实现方式避免了大量的模板代码。习惯了传统面向对象方式的设计者很可能会说如果采用访问者模式在子类型中实现这种“数据类型”式的分派,表达的效果会更好,不过从函数式编程的角度看,后者会导致相关代码散落于多个类的定义中,也不太理想。这是一种典型的设计二分法(design dichotomy)问题,经常会在技术粉间挑起以“表达问题”(expression problem)4为幌子的口舌之争。
4更加完整的解释请参见http://en.wikipedia.org/wiki/Expression_problem。
16.2.4 更加丰富的泛型形式
本节会讨论Java泛型的两个局限性,并探讨可能的解决方案。
1. 具化泛型
Java 5中初次引入泛型时,需要它们尽量保持与现存JVM的后向兼容性。为了达到这一目的,ArrayList<String>
和ArrayList<Integer>
的运行时表示是相同的。这被称作泛型多态(generic polymorphism)的消除模式(erasure model)。这一选择伴随着一定程度的运行时消耗,不过对于程序员而言,这无关痛痒,影响最大的是传给泛型的参数只能为对象类型。如果Java支持ArrayList<int>
这种类型的泛型,那么你就可以在堆上分配由简单数据值构成的ArrayList
对象,比如42
,不过这样一来ArrayList
容器就无法了解它所容纳的到底是一个对象类型的值,比如一个String
,还是一个简单的int
值,比如42
。
某种程度上看,这并没有什么危害——如果你可以从ArrayList<int>
中得到简单值42
,或者从ArrayList<String>
中得到String
对象abc
,为什么还要担忧ArrayList
容器无法辨识呢?非常不幸,答案是垃圾收集,因为一旦缺失了ArrayList
中内容的运行时信息,JVM就无法判断ArrayList
中的元素13
到底是一个Integer
的引用(可以被垃圾收集器标记为“in use”并进行跟踪),还是int
类型的简单数据(几乎可以说是无法跟踪的)。
C#语言中,ArrayList<String>
、ArrayList<Integer>
以及ArrayList<int>
的运行时表示在原则上就是不同的。即使它们的值是相同的,也伴随着足够的运行时类型信息,这些信息可以帮助垃圾收集器判断一个字段值到底是引用,还是简单数据。这被称为泛型多态的具化模式,或具化泛型。“具化”这个词意味着“将某些默认隐式的东西变为显式的”。
很明显,具化泛型是众望所归的,它们能将简单数据类型及其对应的对象类型更好地融合——下一节中,你会看到这之前的一些问题。实现具化泛型的主要难点在于,Java需要保持后向兼容性,并且这种兼容需要同时覆盖JVM,以及使用了反射且希望进行泛型清除的遗留代码。
2. 泛型中特别为函数类型增加的语法灵活性
自从被Java 5引入,泛型就证明了其独特的价值。它们还特别适用于表示Java 8中的Lambda类型以及各种方法引用。通过下面这种方式你可以表示使用单一参数的函数:
Function<Integer, Integer> square = x -> x * x;
如果你有一个使用两个参数的函数,可以采用类型BiFunction<T, U, R>
,这里的T
表示第一个参数的类型,U
表示第二个参数的类型,而R
是计算的结果。不过,Java 8中并未提供TriFunction
这样的函数,除非你自己声明了一个!
同理,你不能用Function<T, R>
引用表示某个不接受任何参数,返回值为R
类型的函数;只能通过Supplier<R>
达到这一目的。
从本质上来说,Java 8的Lambda极大地拓展了我们的编程能力,但可惜的是,它的类型系统并未跟上代码灵活度提升的脚步。在很多的函数式编程语言中,你可以用(Integer, Double) => String
这样的类型实现Java 8中BiFunction<Integer, Double, String>
调用得到同样的效果;类似地,可以用Integer => String
表示Function<Integer, String>
,甚至可以用 => String
表示Supplier<String>
。你可以将=>
符号看作Function
、BiFunction
、Supplier
,以及其他相似函数的中缀表达式版本。我们只需要对现有Java语言的类型格式稍作扩展就能提供Scala语言那样更具可读性的类型,关于Java和Scala的比较我们已经在第15章中详细讨论过了。
3. 原型特化和泛型
在Java语言中,所有的简单数据类型,比如int
,都有对应的对象类型(以刚才的例子而言,它是java.lang.Integer
);通常我们把它们称为不装箱类型和装箱类型。虽然这种区分有助于提升运行时的效率,但是这种方式定义的类型也可能带来一些困扰。比如,有人可能会问为什么Java 8中我们需要编写Predicate<Apple>
,而不是直接采用Function<Apple, Boolean>
的方式?事实上,Predicate<Apple>
类型的对象在执行test
方法调用时,其返回值依旧是简单类型boolean
。
与此相反,和所有泛型一样,Function
只能使用对象类型的参数。以Function<Apple, Boolean>
为例,它使用的是对象类型Boolean
,而不是简单数据类型boolean
。所以使用Predicate<Apple>
更加高效,因为它无需将boolean
装箱为Boolean
。因为存在这样的问题,导致类库的设计者在Java时创建了多个类似的接口,比如LongToIntFunction
和BooleanSupplier
,而这又进一步增加了大家理解的负担。另一个例子和void
之间的区别有关,void
只能修饰不带任何值的方法,而Void
对象实际包含了一个值,它有且仅有一个null
值——这是一个经常在论坛上讨论的问题。对于Function
的特殊情况,比如Supplier<T>
,你可以用前面建议的新操作符将其改写为 => T
,这进一步佐证了由于简单数据类型(primitive type)与对象类型(object type)的差异所导致的分歧。我们在之前的内容中已经介绍了怎样通过具化泛型解决这其中的很多问题。
16.2.5 对不变性的更深层支持
Java 8只支持三种类型的值,分别为:
简单类型值
指向对象的引用
指向函数的引用
听我们说起这些,有些专业的读者可能会感到失望。我们在某种程度上会坚持自己的观点,介绍说“现在方法可以使用这些值作为参数,并返回相应的结果了”。不过,我们也承认这其中的确还存在着一定的问题,比如,当你返回一个指向可变数组的引用时,它多大程度上应该是一个(算术)值?很明显,字符串或者不可变数组都是值,不过对于可变对象或者数组而言,情况远非那么泾渭分明——你的方法可能返回一个元素以升序排列的数组,不过另一些代码可能在之后对其中的某些元素进行修改。
如果我们想在Java中真正实现函数式编程,那么语言层面的支持就必不可少了,比如“不可变值”。正如我们在第13章中所了解的那样,关键字final
并未在真正意义上是要达到这一目标,它仅仅避免了对它所修饰字段的更新。我们看看下面这个例子:
final int arr = {1, 2, 3};final List<T> list = new ArrayList<>;
前者禁止了直接的赋值操作arr = ...
,不过它并未阻止以arr[1]=2
这样的方式对数组进行修改;而后者禁止了对列表的赋值操作,但并未禁止以其他方法修改列表中的元素!关键字final
对于简单数据类型的值操作效果很好,不过对于对象引用,它通常只是一种错误的安全感。
那么我们该如何解决这一问题呢?由于函数式编程对不能修改现存数据结构有非常严格的要求,所以它提供了一个更强大的关键字,比如 transitively_final
,该关键字用于修饰引用类型的字段,确保无论是直接对该字段本身的修改,还是对通过该字段能直接或间接访问到的对象的修改都不会发生。
这些类型体现了关于值的一个理念:变量值是不可修改的,只有变量(它们存储着具体的值)可以被修改,修改之后变量中包含了其他一些不可变值。正如我们在本节开头所提及的,Java的作者,包括我们,时不时地都喜欢针对Java中值与可变数组的转化展开讨论。接下来的一节,我们会讨论一下值类型(value type),声明为值类型的变量只能包含不可变值,然而,除非使用了final
关键词进行修饰,否则变量的值还是能够进行更新。
16.2.6 值类型
这一节,我们会讨论简单数据类型和对象类型之间的差异,并结合前文针对值类型的讨论,希望能借此帮助你以函数式的方式进行编程,就像对象类型是面向对象编程不可缺失的一环那样。我们讨论的很多问题都是相互交织的,所以,很难以区隔的方式解释某一个单独的问题。所以,我们会从不同的角度定位这些问题。
1. 为什么编译器不能对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
的定义,程序会创建新的对象,而==
操作符(利用特征比较)可以将这二者区分开来。请注意,对于简单变量,特征比较采用的是逐位比较(bitwise comparison),对于对象类型它采用的是引用比较(reference equality)。因此,很多时候由于编译器需要遵守对象的语义,我们随机创建的新的Double
对象(Double
对象继承自Object
)也需要遵守该语义。你之前见过这些讨论,无论是较早的时候关于值对象的讨论,还是第14章围绕更新持久化数据结构保证引用透明性的方法讨论。
2. 值对象——无论简单类型还是对象类型都不能包打天下
关于这个问题,我们建议的解决方案是重新回顾一下Java的初心:(1) 任何事物,如果不是简单数据类型,就是对象类型,所有的对象类型都继承自Object
;(2) 所有的引用都是指向对象的引用。
事情的发展是这样开始的。Java中有两种类型的值:一类是对象类型,它们包含着可变的字段(除非使用了final
关键字进行修饰),对这种类型值的特征,可以使用==
进行比较;还有一类是值类型,这种类型的变量是不能改变的,也不带任何的引用特征(reference identity),简单类型就属于这种更宽泛意义上的值类型。这样,我们就能创建用户自定义值的类型了(这种类型的变量推荐小写字符开头,突出它们与int
和boolean
这类简单类型的相似性)。对于值类型,默认情况下,硬件对int
进行比较时会以一个字节接着一个字节逐次的方式进行,==
会以同样的方式一个元素接着一个元素地对两个变量进行比较。你可以将这看成对浮点比较的覆盖,不过这里会进行一些更加复杂的操作。Complex
是一个绝佳的例子用于介绍非简单类型的值;它们和C#中的结构struct
极其类似。
此外,值类型可以减少对存储的要求,因为它们并不包含引用特征。图16-1引用了容量为3的一个数组,其中的元素0
、1
和2
分别用淡灰、白色和深灰色标记。左边的图展示了一种比较典型的存储需求,其中的Pair
和Complex
都是对象类型,而右边展示的是一种更优的布局,这里的Pair
和Complex
都是值类型(注意,我们在这里特意使用了小写的pair
和complex
,目的就是想强调它们与简单类型的相似性)。也请注意,值类型极有可能提供更好的性能,无论是数据访问(用单一的索引地址指令替换多层的指针转换),还是对硬件缓存的利用率(因为数据现在采用的是连续存储)。
图 16-1 对象与值类型
注意,由于值类型并不包含引用特征,编译器可以随意对它们进行装箱和拆箱。如果你将一个complex
变量作为参数从一个函数传递给另一个函数,编译器可以很自然地将它们拆分为两个单独的double
类型的参数。(由于JVM只提供了以64位寄存器传递值的方法返回指令,所以在JVM中要实现不装箱,直接返回是比较复杂的。)不过,如果你传递一个很大的值作为参数(比如说一个很大的不可变数组),那么编译器可以以透明的方式(透明于用户),对其进行装箱,将其转化为一个引用进程传递。类似的技术已经在C#中存在;下面引用了一段微软的介绍5:
5如需了解结构语法和使用,以及类与结构之间的差异,请访问http://msdn.microsoft.com/en-us/library/aa288471(v=vs.71).aspx。
结构看起来和类十分相似,但是二者之间存在重大差异,你应该了解它们之间的不同。首先,类[这里指的是C#中的类]属于引用类型,而结构(struct)属于值类型。使用结构,你可以创建对象[比如sic],它的行为就像那些内置[简单]类型一样,享受同等的待遇。
截至本书写作时(2014年6月),Java也已经接受了一份采用值类型的具体建议6。
6John Rose等, “值的状态”,2014年4月初始版本,http://cr.openjdk.java.net/~jrose/values/values-0.html。
3. 装箱、泛型、值类型——互相交织的问题
我们希望能够在Java中引入值类型,因为函数式编程处理的不可变对象并不含有特征。我们希望简单数据类型可以作为值类型的特例,但又不要有当前Java所携带的泛型的消除模式,因为这意味着值类型不做装箱就不能使用泛型。由于对象的消除模式,简单类型(比如int
)的对象(装箱)版本(比如Integer
)对集合和Java泛型依旧非常重要,不过它们继承自Object
(并因此引用相等),这被当成了一种缺点。解决这些问题中的任何一个就意味着解决了所有的问题。
16.3 写在最后的话
本书探索了Java 8新增加的一系列新特性;它们所代表的可能是自Java创建以来最大的一次演进——唯一可以与之相提并论的大的演进也是在10年之前,即Java 5中所引入的泛型。这一章里我们还了解了Java进一步发展所面临的压力。用一句话来总结,我们会说:
Java 8已经占据了一个非常好的位置,可以暂时歇口气,但这绝不是终点!
我们希望你能享受这一段Java 8的探索旅程,也希望本书能燃起你对了解函数式编程及Java 8进一步发展的兴趣。