对象传递和返回
[TOC]
# 附录:对象传递和返回
到现在为止,你已经对“传递”对象实际上是传递引用这一想法想法感到满意。
在许多编程语言中,你可以使用该语言的“常规”方式来传递对象,并且大多数情况下一切正常。 但是通常会出现这种情况,你必须做一些不平常的事情,突然事情变得更加复杂。 Java也不例外,当您传递对象并对其进行操作时,准确了解正在发生的事情很重要。 本附录提供了这种见解。
提出本附录问题的另一种方法是,如果你之前使用类似C++的编程语言,则是“ Java是否有指针?” Java中的每个对象标识符(除原语外)都是这些指针之一,但它们的用法是不仅受编译器的约束,而且受运行时系统的约束。 换一种说法,Java有指针,但没有指针算法。 这些就是我一直所说的“引用”,您可以将它们视为“安全指针”,与小学的安全剪刀不同-它们不敏锐,因此您不费吹灰之力就无法伤害自己,但是它们有时可能很乏味。
# 传递引用
当你将引用传递给方法时,它仍指向同一对象。 一个简单的实验演示了这一点:
// references/PassReferences.java
public class PassReferences {
public static void f(PassReferences h) {
System.out.println("h inside f(): " + h);
}
public static void main(String[] args) {
PassReferences p = new PassReferences();
System.out.println("p inside main(): " + p);
f(p);
}
}
/* Output:
p inside main(): PassReferences@15db9742
h inside f(): PassReferences@15db9742
*/
方法 toString()
在打印语句中自动调用,并且 PassReferences
直接从 Object
继承而无需重新定义 toString()
。 因此,使用的是 Object
的 toString()
版本,它打印出对象的类,然后打印出该对象所在的地址(不是引用,而是实际的对象存储)。
# 别名问题
“别名”意味着多个引用都试图指向同一个对象,就象前面的例子展示的那样。若有人向那个对象里写入一点什么东西,就会产生别名问题。若其他引用的所有者不希望那个对象改变,恐怕就要失望了。这可用下面这个简单的例子说明:
// references/Alias1.java
// Aliasing two references to one object
public class Alias1 {
private int i;
public Alias1(int ii) {
i = ii;
}
public static void main(String[] args) {
Alias1 x = new Alias1(7);
Alias1 y = x; // Assign the reference (1)
System.out.println("x: " + x.i);
System.out.println("y: " + y.i);
System.out.println("Incrementing x");
x.i++; // [2]
System.out.println("x: " + x.i);
System.out.println("y: " + y.i);
}
}
/* Output:
x: 7
y: 7
Incrementing x
x: 8
y: 8
*/
对下面这行:
Alias1 y = x; // Assign the handle
它会新建一个Alias1
引用,但不是把它分配给由new创建的一个新鲜对象,而是分配给一个现有的引用。所以引用x的内容——即对象x
指向的地址——被分配给y
,所以无论x
还是y
都与相同的对象连接起来。这样一来,一旦x
的i
在下述语句中自增:
x.i++;
y
的i
值也必然受到影响。从最终的输出就可以看出:
x: 7
y: 7
Incrementing x
x: 8
y: 8
此时最直接的一个解决办法就是干脆不这样做:不要有意将多个引用指向同一个作用域内的同一个对象。这样做可使代码更易理解和调试。然而,一旦准备将引用作为一个变量或参数传递——这是Java设想的正常方法——别名问题就会自动出现,因为创建的本地引用可能修改“外部对象”(在方法作用域之外创建的对象)。下面是一个例子:
// references/Alias2.java
// Method calls implicitly alias their arguments
public class Alias2 {
private int i;
public Alias2(int i) {
this.i = i;
}
public static void f(Alias2 reference) {
reference.i++;
}
public static void main(String[] args) {
Alias2 x = new Alias2(7);
System.out.println("x: " + x.i);
System.out.println("Calling f(x)");
f(x);
System.out.println("x: " + x.i);
}
}
/* Output:
x: 7 Calling f(x) x: 8
*/
方法改变了自己的参数——外部对象。一旦遇到这种情况,必须判断它是否合理,用户是否愿意这样,以及是不是会造成问题。
通常,我们调用一个方法是为了产生返回值,或者用它改变为其调用方法的那个对象的状态(方法其实就是我们向那个对象“发一条消息”的方式)。很少需要调用一个方法来处理它的参数;这叫作利用方法的“副作用”(Side Effect)。所以倘若创建一个会修改自己参数的方法,必须向用户明确地指出这一情况,并警告使用那个方法可能会有的后果以及它的潜在威胁。由于存在这些混淆和缺陷,所以应该尽量避免改变参数。
若需在一个方法调用期间修改一个参数,且不打算修改外部参数,就应在自己的方法内部制作一个副本,从而保护那个参数。本章的大多数内容都是围绕这个问题展开的。
# 制作本地副本
稍微总结一下:Java中的所有参数传递都是通过传递引用进行的。也就是说,当我们传递“一个对象”时,实际传递的只是指向位于方法外部的那个对象的“一个引用”。所以一旦要对那个引用进行任何修改,便相当于修改外部对象。此外:
- 参数传递过程中会自动产生别名问题
- 不存在本地对象,只有本地引用
- 引用有自己的作用域,而对象没有
- 对象的“存在时间”在Java里不是个问题
- 没有语言上的支持(如常量)可防止对象被修改(以避免别名的副作用)
若只是从对象中读取信息,而不修改它,传递引用便是参数传递中最有效的一种形式。这种做非常恰当;默认的方法一般也是最有效的方法。然而,有时仍需将对象当作“本地的”对待,使我们作出的改变只影响一个本地副本,不会对外面的对象造成影响。许多程序设计语言都支持在方法内自动生成外部对象的一个本地副本(注释①)。尽管Java不具备这种能力,但允许我们达到同样的效果。
①:在C语言中,通常控制的是少量数据位,默认操作是按值传递。C++也必须遵照这一形式,但按值传递对象并非肯定是一种有效的方式。此外,在C++中用于支持按值传递的代码也较难编写,是件让人头痛的事情。
# 按值传递
首先要解决术语的问题,最适合“按值传递”的看起来是参数。“按值传递”以及它的含义取决于如何理解程序的运行方式。最常见的意思是获得要传递的任何东西的一个本地副本,但这里真正的问题是如何看待自己准备传递的东西。对于“按值传递”的含义,目前存在两种存在明显区别的见解:
(1) Java按值传递任何东西。若将基本数据类型传递进入一个方法,会明确得到基本数据类型的一个副本。但若将一个引用传递进入方法,得到的是引用的副本。所以人们认为“一切”都按值传递。当然,这种说法也有一个前提:引用肯定也会被传递。但Java的设计模式似乎有些超前,允许我们忽略(大多数时候)自己处理的是一个引用。也就是说,它允许我们将引用假想成“对象”,因为在发出方法调用时,系统会自动照管两者间的差异。
(2) Java主要按值传递(无参数),但对象却是按引用传递的。得到这个结论的前提是引用只是对象的一个“别名”,所以不考虑传递引用的问题,而是直接指出“我准备传递对象”。由于将其传递进入一个方法时没有获得对象的一个本地副本,所以对象显然不是按值传递的。Sun公司似乎在某种程度上支持这一见解,因为它“保留但未实现”的关键字之一便是byvalue
(按值)。但没人知道那个关键字什么时候可以发挥作用。
尽管存在两种不同的见解,但其间的分歧归根到底是由于对“引用”的不同解释造成的。我打算在本书剩下的部分里回避这个问题。大家不久就会知道,这个问题争论下去其实是没有意义的——最重要的是理解一个引用的传递会使调用者的对象发生意外的改变。
# 克隆对象
若需修改一个对象,同时不想改变调用者的对象,就要制作该对象的一个本地副本。这也是本地副本最常见的一种用途。若决定制作一个本地副本,只需简单地使用clone()
方法即可。Clone
是“克隆”的意思,即制作完全一模一样的副本。这个方法在基类Object
中定义成protected
(受保护)模式。但在希望克隆的任何派生类中,必须将其覆盖为public
模式。例如,标准库类ArrayList
覆盖了clone()
,所以能为ArrayList
调用clone()
,如下所示:
// references/CloneArrayList.java
// The clone() operation works for only a few
// items in the standard Java library
import java.util.*;
import java.util.stream.*;
class Int {
private int i;
Int(int ii) {
i = ii;
}
public void increment() {
i++;
}
@Override
public String toString() {
return Integer.toString(i);
}
}
public class CloneArrayList {
public static void main(String[] args) {
ArrayList<Int> v = IntStream.range(0, 10)
.mapToObj(Int::new)
.collect(Collectors
.toCollection(ArrayList::new));
System.out.println("v: " + v);
@SuppressWarnings("unchecked") ArrayList<Int> v2 = (ArrayList<Int>) v.clone();
// Increment all v2's elements:
v2.forEach(Int::increment);
// See if it changed v's elements:
System.out.println("v: " + v);
}
}
/* Output:
v: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
v: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
*/
clone()
方法产生了一个Object
,后者必须立即重新转换为正确类型。这个例子指出ArrayList
的clone()
方法不能自动尝试克隆ArrayList
内包含的每个对象——由于别名问题,老的ArrayList
和克隆的ArrayList
都包含了相同的对象。我们通常把这种情况叫作“简单复制”或者“浅层复制”,因为它只复制了一个对象的“表面”部分。实际对象除包含这个“表面”以外,还包括引用指向的所有对象,以及那些对象又指向的其他所有对象,由此类推。这便是“对象网”或“对象关系网”的由来。若能复制下所有这张网,便叫作“全面复制”或者“深层复制”。
在输出中可看到浅层复制的结果,注意对v2
采取的行动也会影响到v
:
v: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
v: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
一般来说,由于不敢保证ArrayList
里包含的对象是“可以克隆”(注释②)的,所以最好不要试图克隆那些对象。
②:“可以克隆”用英语讲是cloneable
,请留意Java库中专门保留了这样的一个关键字。
# 使类具有克隆能力
尽管克隆方法是在所有类最基本的Object
中定义的,但克隆仍然不会在每个类里自动进行。这似乎有些不可思议,因为基类方法在派生类里是肯定能用的。但Java确实有点儿反其道而行之;如果想在一个类里使用克隆方法,唯一的办法就是专门添加一些代码,以便保证克隆的正常进行。
(1) 使用protected
时的技巧
为避免我们创建的每个类都默认具有克隆能力,clone()
方法在基类Object
里得到了“保留”(设为protected
)。这样造成的后果就是:对那些简单地使用一下这个类的客户程序员来说,他们不会默认地拥有这个方法;其次,我们不能利用指向基类的一个引用来调用clone()
(尽管那样做在某些情况下特别有用,比如用多态性的方式克隆一系列对象)。在编译期的时候,这实际是通知我们对象不可克隆的一种方式——而且最奇怪的是,Java库中的大多数类都不能克隆。因此,假如我们执行下述代码:
Integer x = new Integer(l);
x = x.clone();
那么在编译期,就有一条讨厌的错误消息弹出,告诉我们不可访问clone()
——因为Integer
并没有覆盖它,而且它对protected
版本来说是默认的)。
但是,假若我们是在一个从Object
派生出来的类中(所有类都是从Object
派生的),就有权调用Object.clone()
,因为它是protected
,而且我们在一个迭代器中。基类clone()
提供了一个有用的功能——它进行的是对派生类对象的真正“按位”复制,所以相当于标准的克隆行动。然而,我们随后需要将自己的克隆操作设为public
,否则无法访问。总之,克隆时要注意的两个关键问题是:几乎肯定要调用super.clone()
,以及注意将克隆设为public
。
有时还想在更深层的派生类中覆盖clone()
,否则就直接使用我们的clone()
(现在已成为public
),而那并不一定是我们所希望的(然而,由于Object.clone()
已制作了实际对象的一个副本,所以也有可能允许这种情况)。protected
的技巧在这里只能用一次:首次从一个不具备克隆能力的类继承,而且想使一个类变成“能够克隆”。而在从我们的类继承的任何场合,clone()
方法都是可以使用的,因为Java不可能在派生之后反而缩小方法的访问范围。换言之,一旦对象变得可以克隆,从它派生的任何东西都是能够克隆的,除非使用特殊的机制(后面讨论)令其“关闭”克隆能力。
(2) 实现Cloneable
接口
为使一个对象的克隆能力功成圆满,还需要做另一件事情:实现Cloneable
接口。这个接口使人稍觉奇怪,因为它是空的!
interface Cloneable {}
之所以要实现这个空接口,显然不是因为我们准备向上转换成一个Cloneable
,以及调用它的某个方法。有些人认为在这里使用接口属于一种“欺骗”行为,因为它使用的特性打的是别的主意,而非原来的意思。Cloneable interface
的实现扮演了一个标记的角色,封装到类的类型中。
两方面的原因促成了Cloneable interface
的存在。首先,可能有一个向上转换引用指向一个基类型,而且不知道它是否真的能克隆那个对象。在这种情况下,可用instanceof
关键字(第11章有介绍)调查引用是否确实同一个能克隆的对象连接:
if(myHandle instanceof Cloneable) // ...
第二个原因是考虑到我们可能不愿所有对象类型都能克隆。所以Object.clone()
会验证一个类是否真的是实现了Cloneable
接口。若答案是否定的,则“抛”出一个CloneNotSupportedException
异常。所以在一般情况下,我们必须将implement Cloneable
作为对克隆能力提供支持的一部分。
# 成功的克隆
理解了实现clone()
方法背后的所有细节后,便可创建出能方便复制的类,以便提供了一个本地副本:
// references/LocalCopy.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Creating local copies with clone()
class Duplo implements Cloneable {
private int n;
Duplo(int n) {
this.n = n;
}
@Override
public Duplo clone() { // [1]
try {
return (Duplo) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
public int getValue() {
return n;
}
public void setValue(int n) {
this.n = n;
}
public void increment() {
n++;
}
@Override
public String toString() {
return Integer.toString(n);
}
}
public class LocalCopy {
public static Duplo g(Duplo v) {
// Passing a reference, modifies outside object:
v.increment();
return v;
}
public static Duplo f(Duplo v) {
v = v.clone(); // [2] Local copy
v.increment();
return v;
}
public static void main(String[] args) {
Duplo a = new Duplo(11);
Duplo b = g(a);
// Reference equivalence, not object equivalence:
System.out.println("a == b: " + (a == b) +
"\na = " + a + "\nb = " + b);
Duplo c = new Duplo(47);
Duplo d = f(c);
System.out.println("c == d: " + (c == d) +
"\nc = " + c + "\nd = " + d);
}
}
/* Output:
a == b: true
a = 12
b = 12
c == d: false
c = 47
d = 48
*/
不管怎样,clone()
必须能够访问,所以必须将其设为public
(公共的)。其次,作为clone()
的初期行动,应调用clone()
的基类版本。这里调用的clone()
是Object
内部预先定义好的。之所以能调用它,是由于它具有protected
(受到保护的)属性,所以能在派生的类里访问。
Object.clone()
会检查原先的对象有多大,再为新对象腾出足够多的内存,将所有二进制位从原来的对象复制到新对象。这叫作“按位复制”,而且按一般的想法,这个工作应该是由clone()
方法来做的。但在Object.clone()
正式开始操作前,首先会检查一个类是否Cloneable
,即是否具有克隆能力——换言之,它是否实现了Cloneable
接口。若未实现,Object.clone()
就抛出一个CloneNotSupportedException
异常,指出我们不能克隆它。因此,我们最好用一个try-catch
块将对super.clone()
的调用代码包围(或封装)起来,试图捕获一个应当永不出现的异常(因为这里确实已实现了Cloneable
接口)。
在LocalCopy
中,两个方法g()
和f()
揭示出两种参数传递方法间的差异。其中,g()
演示的是按引用传递,它会修改外部对象,并返回对那个外部对象的一个引用。而f()
是对参数进行克隆,所以将其分离出来,并让原来的对象保持独立。随后,它继续做它希望的事情。甚至能返回指向这个新对象的一个引用,而且不会对原来的对象产生任何副作用。注意下面这个多少有些古怪的语句:
v = (MyObject)v.clone();
它的作用正是创建一个本地副本。为避免被这样的一个语句搞混淆,记住这种相当奇怪的编码形式在Java中是完全允许的,因为有一个名字的所有东西实际都是一个引用。所以引用v
用于克隆一个它所指向的副本,而且最终返回指向基类型Object
的一个引用(因为它在Object.clone()
中是那样被定义的),随后必须将其转换为正确的类型。
在main()
中,两种不同参数传递方式的区别在于它们分别测试了一个不同的方法。输出结果如下:
a == b
a = 12
b = 12
c != d
c = 47
d = 48
大家要记住这样一个事实:Java对“是否等价”的测试并不对所比较对象的内部进行检查,从而核实它们的值是否相同。==
和!=
运算符只是简单地对比引用的内容。若引用内的地址相同,就认为引用指向同样的对象,所以认为它们是“等价”的。所以运算符真正检测的是“由于别名问题,引用是否指向同一个对象?”
# Object.clone()
的效果
调用Object.clone()
时,实际发生的是什么事情呢?当我们在自己的类里覆盖clone()
时,什么东西对于super.clone()
来说是最关键的呢?根类中的clone()
方法负责建立正确的存储容量,并通过“按位复制”将二进制位从原始对象中复制到新对象的存储空间。也就是说,它并不只是预留存储空间以及复制一个对象——实际需要调查出欲复制之对象的准确大小,然后复制那个对象。由于所有这些工作都是在由根类定义之clone()
方法的内部代码中进行的(根类并不知道要从自己这里继承出去什么),所以大家或许已经猜到,这个过程需要用RTTI判断欲克隆的对象的实际大小。采取这种方式,clone()
方法便可建立起正确数量的存储空间,并对那个类型进行正确的按位复制。
不管我们要做什么,克隆过程的第一个部分通常都应该是调用super.clone()
。通过进行一次准确的复制,这样做可为后续的克隆进程建立起一个良好的基础。随后,可采取另一些必要的操作,以完成最终的克隆。
为确切了解其他操作是什么,首先要正确理解Object.clone()
为我们带来了什么。特别地,它会自动克隆所有引用指向的目标吗?下面这个例子可完成这种形式的检测:
// references/Snake.java
// Tests cloning to see if reference
// destinations are also cloned
public class Snake implements Cloneable {
private Snake next;
private char c;
// Value of i == number of segments
public Snake(int i, char x) {
c = x;
if (--i > 0)
next = new Snake(i, (char) (x + 1));
}
public void increment() {
c++;
if (next != null) next.increment();
}
@Override
public String toString() {
String s = ":" + c;
if (next != null)
s += next.toString();
return s;
}
@Override
public Snake clone() {
try {
return (Snake) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
public static void main(String[] args) {
Snake s = new Snake(5, 'a');
System.out.println("s = " + s);
Snake s2 = s.clone();
System.out.println("s2 = " + s2);
s.increment();
System.out.println(
"after s.increment, s2 = " + s2);
}
}
/* Output:
s = :a:b:c:d:e
s2 = :a:b:c:d:e
after s.increment, s2 = :a:c:d:e:f
*/
一条Snake
(蛇)由数段构成,每一段的类型都是Snake
。所以,这是一个一段段链接起来的列表。所有段都是以循环方式创建的,每做好一段,都会使第一个构造器参数的值递减,直至最终为零。而为给每段赋予一个独一无二的标记,第二个参数(一个Char
)的值在每次循环构造器调用时都会递增。
increment()
方法的作用是循环递增每个标记,使我们能看到发生的变化;而toString
则循环打印出每个标记。输出如下:
s = :a:b:c:d:e
s2 = :a:b:c:d:e
after s.increment, s2 = :a:c:d:e:f
这意味着只有第一段才是由Object.clone()
复制的,所以此时进行的是一种“浅层复制”。若希望复制整条蛇——即进行“深层复制”——必须在被覆盖的clone()
里采取附加的操作。
通常可在从一个能克隆的类里调用super.clone()
,以确保所有基类行动(包括Object.clone()
)能够进行。随着是为对象内每个引用都明确调用一个clone()
;否则那些引用会别名变成原始对象的引用。构造器的调用也大致相同——首先构造基类,然后是下一个派生的构造器……以此类推,直到位于最深层的派生构造器。区别在于clone()
并不是个构造器,所以没有办法实现自动克隆。为了克隆,必须由自己明确进行。
# 克隆组合对象
试图深层复制组合对象时会遇到一个问题。必须假定成员对象中的clone()
方法也能依次对自己的引用进行深层复制,以此类推。这使我们的操作变得复杂。为了能正常实现深层复制,必须对所有类中的代码进行控制,或者至少全面掌握深层复制中需要涉及的类,确保它们自己的深层复制能正确进行。
下面这个例子总结了面对一个组合对象进行深层复制时需要做哪些事情:
// references/DepthReading.java
// Cloning a composed object
package references;
public class DepthReading implements Cloneable {
private double depth;
public DepthReading(double depth) {
this.depth = depth;
}
@Override
public DepthReading clone() {
try {
return (DepthReading) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
public double getDepth() {
return depth;
}
public void setDepth(double depth) {
this.depth = depth;
}
@Override
public String toString() {
return String.valueOf(depth);
}
}
// references/TemperatureReading.java
// Cloning a composed object
package references;
public class TemperatureReading implements Cloneable {
private long time;
private double temperature;
public TemperatureReading(double temperature) {
time = System.currentTimeMillis();
this.temperature = temperature;
}
@Override
public TemperatureReading clone() {
try {
return (TemperatureReading) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
public double getTemperature() {
return temperature;
}
public void setTemperature(double temp) {
this.temperature = temp;
}
@Override
public String toString() {
return String.valueOf(temperature);
}
}
// references/OceanReading.java
// Cloning a composed object
// references/OceanReading.java
// (c)2021 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Cloning a composed object
package references;
public class OceanReading implements Cloneable {
private DepthReading depth;
private TemperatureReading temperature;
public
OceanReading(double tdata, double ddata) {
temperature = new TemperatureReading(tdata);
depth = new DepthReading(ddata);
}
@Override public OceanReading clone() {
OceanReading or = null;
try {
or = (OceanReading)super.clone();
} catch(CloneNotSupportedException e) {
throw new RuntimeException(e);
}
// Must clone references:
or.depth = (DepthReading)or.depth.clone();
or.temperature =
(TemperatureReading)or.temperature.clone();
return or;
}
public TemperatureReading getTemperatureReading() {
return temperature;
}
public void
setTemperatureReading(TemperatureReading tr) {
temperature = tr;
}
public DepthReading getDepthReading() {
return depth;
}
public void setDepthReading(DepthReading dr) {
this.depth = dr;
}
@Override public String toString() {
return "temperature: " + temperature +
", depth: " + depth;
}
}
Now we can test it using JUnit:
// references/tests/DeepCopyTest.java
package references;
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
public class DeepCopyTest {
@Test
public void testClone() {
OceanReading reading =
new OceanReading(33.9, 100.5);
// Now clone it:
OceanReading clone = reading.clone();
TemperatureReading tr = clone.getTemperatureReading();
tr.setTemperature(tr.getTemperature() + 1);
clone.setTemperatureReading(tr);
DepthReading dr = clone.getDepthReading();
dr.setDepth(dr.getDepth() + 1);
clone.setDepthReading(dr);
assertEquals(reading.toString(), "temperature: 33.9, depth: 100.5");
assertEquals(clone.toString(), "temperature: 34.9, depth: 101.5");
}
}
DepthReading
和TemperatureReading
非常相似;它们都只包含了基本数据类型。所以clone()
方法能够非常简单:调用super.clone()
并返回结果即可。注意两个类使用的clone()
代码是完全一致的。
OceanReading
是由DepthReading
和TemperatureReading
对象合并而成的。为了对其进行深层复制,clone()
必须同时克隆OceanReading
内的引用。为达到这个目标,super.clone()
的结果必须转换成一个OceanReading
对象(以便访问depth
和temperature
引用)。
# 12.2.7 用ArrayList
进行深层复制
下面让我们复习一下本章早些时候提出的ArrayList
例子。这一次Int2
类是可以克隆的,所以能对ArrayList
进行深层复制:
// references/AddingClone.java
// references/AddingClone.java
// (c)2021 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// You must go through a few gyrations
// to add cloning to your own class
import java.util.*;
import java.util.stream.*;
class Int2 implements Cloneable {
private int i;
Int2(int ii) { i = ii; }
public void increment() { i++; }
@Override public String toString() {
return Integer.toString(i);
}
@Override public Int2 clone() {
try {
return (Int2)super.clone();
} catch(CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}
// Inheritance doesn't remove cloneability:
class Int3 extends Int2 {
private int j; // Automatically duplicated
Int3(int i) { super(i); }
}
public class AddingClone {
@SuppressWarnings("unchecked")
public static void main(String[] args) {
Int2 x = new Int2(10);
Int2 x2 = x.clone();
x2.increment();
System.out.println(
"x = " + x + ", x2 = " + x2);
// Anything inherited is also cloneable:
Int3 x3 = new Int3(7);
x3 = (Int3)x3.clone();
ArrayList<Int2> v = IntStream.range(0, 10)
.mapToObj(Int2::new)
.collect(Collectors
.toCollection(ArrayList::new));
System.out.println("v: " + v);
ArrayList<Int2> v2 =
(ArrayList<Int2>)v.clone();
// Now clone each element:
IntStream.range(0, v.size())
.forEach(i -> v2.set(i, v.get(i).clone()));
// Increment all v2's elements:
v2.forEach(Int2::increment);
System.out.println("v2: " + v2);
// See if it changed v's elements:
System.out.println("v: " + v);
}
}
/* Output:
x = 10, x2 = 11
v: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
v2: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
v: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
*/
Int3
自Int2
继承而来,并添加了一个新的基本类型成员int j
。大家也许认为自己需要再次覆盖clone()
,以确保j
得到复制,但实情并非如此。将Int2
的clone()
当作Int3
的clone()
调用时,它会调用Object.clone()
,判断出当前操作的是Int3
,并复制Int3
内的所有二进制位。只要没有新增需要克隆的引用,对Object.clone()
的一个调用就能完成所有必要的复制——无论clone()
是在层次结构多深的一级定义的。
至此,大家可以总结出对Vector
进行深层复制的先决条件:在克隆了Vector
后,必须在其中遍历,并克隆由Vector
指向的每个对象。为了对Hashtable
(散列表)进行深层复制,也必须采取类似的处理。
这个例子剩余的部分显示出克隆已实际进行——证据就是在克隆了对象以后,可以自由改变它,而原来那个对象不受任何影响。
# 通过序列化进行深层复制
若研究一下第10章介绍的那个Java 1.1对象序列化示例,可能发现若在一个对象序列化以后再撤消对它的序列化,或者说进行装配,那么实际经历的正是一个“克隆”的过程。
那么为什么不用序列化进行深层复制呢?下面这个例子通过计算执行时间对比了这两种方法:
// references/Compete.java
// references/Compete.java
// (c)2021 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
import java.io.*;
import onjava.Timer;
class Thing1 implements Serializable {
}
class Thing2 implements Serializable {
Thing1 t1 = new Thing1();
}
class Thing3 implements Cloneable {
@Override
public Thing3 clone() {
try {
return (Thing3) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}
class Thing4 implements Cloneable {
private Thing3 t3 = new Thing3();
@Override
public Thing4 clone() {
Thing4 t4 = null;
try {
t4 = (Thing4) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
// Clone the field, too:
t4.t3 = t3.clone();
return t4;
}
}
public class Compete {
public static final int SIZE = 100000;
public static void
main(String[] args) throws Exception {
Thing2[] a = new Thing2[SIZE];
for (int i = 0; i < SIZE; i++)
a[i] = new Thing2();
Thing4[] b = new Thing4[SIZE];
for (int i = 0; i < SIZE; i++)
b[i] = new Thing4();
Timer timer = new Timer();
try (
ByteArrayOutputStream buf =
new ByteArrayOutputStream();
ObjectOutputStream oos =
new ObjectOutputStream(buf)
) {
for (Thing2 a1 : a) {
oos.writeObject(a1);
}
// Now get copies:
try (
ObjectInputStream in =
new ObjectInputStream(
new ByteArrayInputStream(
buf.toByteArray()))
) {
Thing2[] c = new Thing2[SIZE];
for (int i = 0; i < SIZE; i++)
c[i] = (Thing2) in.readObject();
}
}
System.out.println(
"Duplication via serialization: " +
timer.duration() + " Milliseconds");
// Now try cloning:
timer = new Timer();
Thing4[] d = new Thing4[SIZE];
for (int i = 0; i < SIZE; i++)
d[i] = b[i].clone();
System.out.println(
"Duplication via cloning: " +
timer.duration() + " Milliseconds");
}
}
/* Output:
Duplication via serialization: 385 Milliseconds
Duplication via cloning: 38 Milliseconds
*/
其中,Thing2
和Thing4
包含了成员对象,所以需要进行一些深层复制。一个有趣的地方是尽管Serializable
类很容易设置,但在复制它们时却要做多得多的工作。克隆涉及到大量的类设置工作,但实际的对象复制是相当简单的。结果很好地说明了一切。下面是几次运行分别得到的结果:
Duplication via serialization: 3400 Milliseconds
Duplication via cloning: 110 Milliseconds
Duplication via serialization: 3410 Milliseconds
Duplication via cloning: 110 Milliseconds
Duplication via serialization: 3520 Milliseconds
Duplication via cloning: 110 Milliseconds
除了序列化和克隆之间巨大的时间差异以外,我们也注意到序列化技术的运行结果并不稳定,而克隆每一次花费的时间都是相同的。
# 使克隆具有更大的深度
若新建一个类,它的基类会默认为Object
,并默认为不具备克隆能力(就象在下一节会看到的那样)。只要不明确地添加克隆能力,这种能力便不会自动产生。但我们可以在任何层添加它,然后便可从那个层开始向下具有克隆能力。如下所示:
// references/HorrorFlick.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Insert Cloneability at any level of inheritance
class Person {
}
class Hero extends Person {
}
class Scientist extends Person implements Cloneable {
@Override
public Scientist clone() {
try {
return (Scientist) super.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException(e);
}
}
}
class MadScientist extends Scientist {
}
public class HorrorFlick {
public static void main(String[] args) {
Person p = new Person();
Hero h = new Hero();
Scientist s = new Scientist();
MadScientist m = new MadScientist();
//- p = (Person)p.clone(); // Compile error
//- h = (Hero)h.clone(); // Compile error
s = s.clone();
m = (MadScientist) m.clone();
}
}
添加克隆能力之前,编译器会阻止我们的克隆尝试。一旦在Scientist
里添加了克隆能力,那么Scientist
以及它的所有“后裔”都可以克隆。
# 为什么有这个奇怪的设计
之所以感觉这个方案的奇特,因为它事实上的确如此。也许大家会奇怪它为什么要象这样运行,而该方案背后的真正含义是什么呢?后面讲述的是一个未获证实的故事——大概是由于围绕Java的许多买卖使其成为一种设计优良的语言——但确实要花许多口舌才能讲清楚这背后发生的所有事情。
最初,Java只是作为一种用于控制硬件的语言而设计,与因特网并没有丝毫联系。象这样一类面向大众的语言一样,其意义在于程序员可以对任意一个对象进行克隆。这样一来,clone()
就放置在根类Object
里面,但因为它是一种公用方式,因而我们通常能够对任意一个对象进行克隆。看来这是最灵活的方式了,毕竟它不会带来任何害处。
正当Java看起来象一种终级因特网程序设计语言的时候,情况却发生了变化。突然地,人们提出了安全问题,而且理所当然,这些问题与使用对象有关,我们不愿望任何人克隆自己的保密对象。所以我们最后看到的是为原来那个简单、直观的方案添加的大量补丁:clone()
在Object
里被设置成protected
。必须将其覆盖,并使用implement Cloneable
,同时解决异常的问题。
只有在准备调用Object
的clone()
方法时,才没有必要使用Cloneable
接口,因为那个方法会在运行期间得到检查,以确保我们的类实现了Cloneable
。但为了保持连贯性(而且由于Cloneable
无论如何都是空的),最好还是由自己实现Cloneable
。
# 克隆的控制
为消除克隆能力,大家也许认为只需将clone()
方法简单地设为private
(私有)即可,但这样是行不通的,因为不能采用一个基类方法,并使其在派生类中更“私有”。所以事情并没有这么简单。此外,我们有必要控制一个对象是否能够克隆。对于我们设计的一个类,实际有许多种方案都是可以采取的:
(1) 保持中立,不为克隆做任何事情。也就是说,尽管不可对我们的类克隆,但从它继承的一个类却可根据实际情况决定克隆。只有Object.clone()
要对类中的字段进行某些合理的操作时,才可以作这方面的决定。
(2) 支持clone()
,采用实现Cloneable
(可克隆)能力的标准操作,并覆盖clone()
。在被覆盖的clone()
中,可调用super.clone()
,并捕获所有异常(这样可使clone()
不“抛”出任何异常)。
(3) 有条件地支持克隆。若类容纳了其他对象的引用,而那些对象也许能够克隆(集合类便是这样的一个例子),就可试着克隆拥有对方引用的所有对象;如果它们“抛”出了异常,只需让这些异常通过即可。举个例子来说,假设有一个特殊的ArrayList
,它试图克隆自己容纳的所有对象。编写这样的一个ArrayList
时,并不知道客户程序员会把什么形式的对象置入这个ArrayList
中,所以并不知道它们是否真的能够克隆。
(4) 不实现Cloneable()
,但是将clone()
覆盖成protected
,使任何字段都具有正确的复制行为。这样一来,从这个类继承的所有东西都能覆盖clone()
,并调用super.clone()
来产生正确的复制行为。注意在我们实现方案里,可以而且应该调用super.clone()
——即使那个方法本来预期的是一个Cloneable
对象(否则会抛出一个异常),因为没有人会在我们这种类型的对象上直接调用它。它只有通过一个派生类调用;对那个派生类来说,如果要保证它正常工作,需实现Cloneable
。
(5) 不实现Cloneable
来试着防止克隆,并覆盖clone()
,以产生一个异常。为使这一设想顺利实现,只有令从它派生出来的任何类都调用重新定义后的clone()
里的suepr.clone()
。
(6) 将类设为final
,从而防止克隆。若clone()
尚未被我们的任何一个上级类覆盖,这一设想便不会成功。若已被覆盖,那么再一次覆盖它,并“抛”出一个CloneNotSupportedException
(克隆不支持)异常。为担保克隆被禁止,将类设为final
是唯一的办法。除此以外,一旦涉及保密对象或者遇到想对创建的对象数量进行控制的其他情况,应该将所有构造器都设为private
,并提供一个或更多的特殊方法来创建对象。采用这种方式,这些方法就可以限制创建的对象数量以及它们的创建条件——一种特殊情况是第16章要介绍的singleton(单例)方案。
下面这个例子总结了克隆的各种实现方法,然后在层次结构中将其“关闭”:
// references/CheckCloneable.java
// (c)2021 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Check to see if a reference can be cloned
// Can't clone this -- doesn't override clone():
class Ordinary {}
// Overrides clone, doesn't implement Cloneable:
class WrongClone extends Ordinary {
@Override public Object clone()
throws CloneNotSupportedException {
return super.clone(); // Throws exception
}
}
// Does all the right things for cloning:
class IsCloneable extends Ordinary
implements Cloneable {
@Override public Object clone()
throws CloneNotSupportedException {
return super.clone();
}
}
// Turn off cloning by throwing the exception:
class NoMore extends IsCloneable {
@Override public Object clone()
throws CloneNotSupportedException {
throw new CloneNotSupportedException();
}
}
class TryMore extends NoMore {
@Override public Object clone()
throws CloneNotSupportedException {
// Calls NoMore.clone(), throws exception:
return super.clone();
}
}
class BackOn extends NoMore {
private BackOn duplicate(BackOn b) {
// Somehow make a copy of b and return that
// copy. A dummy copy, just to make a point:
return new BackOn();
}
@Override public Object clone() {
// Doesn't call NoMore.clone():
return duplicate(this);
}
}
// You can't inherit from this, so you can't
// override clone() as you can in BackOn:
final class ReallyNoMore extends NoMore {}
public class CheckCloneable {
public static
Ordinary tryToClone(Ordinary ord) {
String id = ord.getClass().getName();
System.out.println("Attempting " + id);
Ordinary x = null;
if(ord instanceof Cloneable) {
try {
x = (Ordinary)((IsCloneable)ord).clone();
System.out.println("Cloned " + id);
} catch(CloneNotSupportedException e) {
System.out.println(
"Could not clone " + id);
}
} else {
System.out.println("Doesn't implement Cloneable");
}
return x;
}
public static void main(String[] args) {
// Upcasting:
Ordinary[] ord = {
new IsCloneable(),
new WrongClone(),
new NoMore(),
new TryMore(),
new BackOn(),
new ReallyNoMore(),
};
Ordinary x = new Ordinary();
// This won't compile because
// clone() is protected in Object:
//- x = (Ordinary)x.clone();
// Checks first to see if the class
// implements Cloneable:
for(Ordinary ord1 : ord) {
tryToClone(ord1);
}
}
}
/* Output:
Attempting IsCloneable
Cloned IsCloneable
Attempting WrongClone
Doesn't implement Cloneable
Attempting NoMore
Could not clone NoMore
Attempting TryMore
Could not clone TryMore
Attempting BackOn
Cloned BackOn
Attempting ReallyNoMore
Could not clone ReallyNoMore
*/
第一个类Ordinary
代表着大家在本书各处最常见到的类:不支持克隆,但在它正式应用以后,却也不禁止对其克隆。但假如有一个指向Ordinary
对象的引用,而且那个对象可能是从一个更深的派生类向上转换来的,便不能判断它到底能不能克隆。
WrongClone
类揭示了实现克隆的一种不正确途径。它确实覆盖了Object.clone()
,并将那个方法设为public
,但却没有实现Cloneable
。所以一旦发出对super.clone()
的调用(由于对Object.clone()
的一个调用造成的),便会无情地抛出CloneNotSupportedException
异常。
在IsCloneable
中,大家看到的才是进行克隆的各种正确行动:先覆盖clone()
,并实现了Cloneable
。但是,这个clone()
方法以及本例的另外几个方法并不捕获CloneNotSupportedException
异常,而是任由它通过,并传递给调用者。随后,调用者必须用一个try-catch
代码块把它包围起来。在我们自己的clone()
方法中,通常需要在clone()
内部捕获CloneNotSupportedException
异常,而不是任由它通过。正如大家以后会理解的那样,对这个例子来说,让它通过是最正确的做法。
类NoMore
试图按照Java设计者打算的那样“关闭”克隆:在派生类clone()
中,我们抛出CloneNotSupportedException
异常。TryMore
类中的clone()
方法正确地调用super.clone()
,并解析成NoMore.clone()
,后者抛出一个异常并禁止克隆。
但在已被覆盖的clone()
方法中,假若程序员不遵守调用super.clone()
的“正确”方法,又会出现什么情况呢?在BackOn
中,大家可看到实际会发生什么。这个类用一个独立的方法duplicate()
制作当前对象的一个副本,并在clone()
内部调用这个方法,而不是调用super.clone()
。异常永远不会产生,而且新类是可以克隆的。因此,我们不能依赖“抛”出一个异常的方法来防止产生一个可克隆的类。唯一安全的方法在ReallyNoMore
中得到了演示,它设为final
,所以不可继承。这意味着假如clone(
)在final
类中抛出了一个异常,便不能通过继承来进行修改,并可有效地禁止克隆(不能从一个拥有任意继承级数的类中明确调用Object.clone()
;只能调用super.clone()
,它只可访问直接基类)。因此,只要制作一些涉及安全问题的对象,就最好把那些类设为final
。
在类CheckCloneable
中,我们看到的第一个类是tryToClone()
,它能接纳任何Ordinary
对象,并用instanceof
检查它是否能够克隆。若答案是肯定的,就将对象转换成为一个IsCloneable
,调用clone()
,并将结果转换回Ordinary
,最后捕获有可能产生的任何异常。请注意用运行期类型识别(见第11章)打印出类名,使自己看到发生的一切情况。
在main()
中,我们创建了不同类型的Ordinary
对象,并在数组定义中向上转换成为Ordinary
。在这之后的头两行代码创建了一个纯粹的Ordinary
对象,并试图对其克隆。然而,这些代码不会得到编译,因为clone()
是Object
中的一个protected
(受到保护的)方法。代码剩余的部分将遍历数组,并试着克隆每个对象,分别报告它们的成功或失败。输出如下:
总之,如果希望一个类能够克隆,那么:
(1) 实现Cloneable
接口
(2) 覆盖clone()
(3) 在自己的clone()
中调用super.clone()
(4) 在自己的clone()
中捕获异常
这一系列步骤能达到最理想的效果。
# 副本构造器
克隆看起来要求进行非常复杂的设置,似乎还该有另一种替代方案。一个办法是制作特殊的构造器,令其负责复制一个对象。在C++中,这叫作“副本构造器”。刚开始的时候,这好象是一种非常显然的解决方案(如果你是C++程序员,这个方法就更显亲切)。下面是一个实际的例子:
// references/CopyConstructor.java
// (c)2021 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// A constructor to copy an object of the same
// type, as an attempt to create a local copy
import java.lang.reflect.*;
class FruitQualities {
private int weight;
private int color;
private int firmness;
private int ripeness;
private int smell;
// etc.
// Zero-argument constructor:
FruitQualities() {
// Do something meaningful...
}
// Other constructors:
// ...
// Copy constructor:
FruitQualities(FruitQualities f) {
weight = f.weight;
color = f.color;
firmness = f.firmness;
ripeness = f.ripeness;
smell = f.smell;
// etc.
}
}
class Seed {
// Members...
Seed() { /* Zero-argument constructor */ }
Seed(Seed s) { /* Copy constructor */ }
}
class Fruit {
private FruitQualities fq;
private int seeds;
private Seed[] s;
Fruit(FruitQualities q, int seedCount) {
fq = q;
seeds = seedCount;
s = new Seed[seeds];
for(int i = 0; i < seeds; i++)
s[i] = new Seed();
}
// Other constructors:
// ...
// Copy constructor:
Fruit(Fruit f) {
fq = new FruitQualities(f.fq);
seeds = f.seeds;
s = new Seed[seeds];
// Call all Seed copy-constructors:
for(int i = 0; i < seeds; i++)
s[i] = new Seed(f.s[i]);
// Other copy-construction activities...
}
// This allows derived constructors (or other
// methods) to put in different qualities:
protected void addQualities(FruitQualities q) {
fq = q;
}
protected FruitQualities getQualities() {
return fq;
}
}
class Tomato extends Fruit {
Tomato() {
super(new FruitQualities(), 100);
}
Tomato(Tomato t) { // Copy-constructor
super(t); // Upcast to base copy-constructor
// Other copy-construction activities...
}
}
class ZebraQualities extends FruitQualities {
private int stripedness;
// Zero-argument constructor:
ZebraQualities() {
super();
// do something meaningful...
}
ZebraQualities(ZebraQualities z) {
super(z);
stripedness = z.stripedness;
}
}
class GreenZebra extends Tomato {
GreenZebra() {
addQualities(new ZebraQualities());
}
GreenZebra(GreenZebra g) {
super(g); // Calls Tomato(Tomato)
// Restore the right qualities:
addQualities(new ZebraQualities());
}
public void evaluate() {
ZebraQualities zq =
(ZebraQualities)getQualities();
// Do something with the qualities
// ...
}
}
public class CopyConstructor {
public static void ripen(Tomato t) {
// Use the "copy constructor":
t = new Tomato(t); // [1]
System.out.println("In ripen, t is a " +
t.getClass().getName());
}
public static void slice(Fruit f) {
f = new Fruit(f); // Hmm... will this work? // [2]
System.out.println("In slice, f is a " +
f.getClass().getName());
}
@SuppressWarnings("unchecked")
public static void ripen2(Tomato t) {
try {
Class c = t.getClass();
// Use the "copy constructor":
Constructor ct =
c.getConstructor(new Class[] { c });
Object obj =
ct.newInstance(new Object[] { t });
System.out.println("In ripen2, t is a " +
obj.getClass().getName());
} catch(NoSuchMethodException |
SecurityException |
InstantiationException |
IllegalAccessException |
IllegalArgumentException |
InvocationTargetException e) {
System.out.println(e);
}
}
@SuppressWarnings("unchecked")
public static void slice2(Fruit f) {
try {
Class c = f.getClass();
Constructor ct =
c.getConstructor(new Class[] { c });
Object obj =
ct.newInstance(new Object[] { f });
System.out.println("In slice2, f is a " +
obj.getClass().getName());
} catch(NoSuchMethodException |
SecurityException |
InstantiationException |
IllegalAccessException |
IllegalArgumentException |
InvocationTargetException e) {
System.out.println(e);
}
}
public static void main(String[] args) {
Tomato tomato = new Tomato();
ripen(tomato); // OK
slice(tomato); // OOPS!
ripen2(tomato); // OK
slice2(tomato); // OK
GreenZebra g = new GreenZebra();
ripen(g); // OOPS!
slice(g); // OOPS!
ripen2(g); // OK
slice2(g); // OK
g.evaluate();
}
}
/* Output:
In ripen, t is a Tomato
In slice, f is a Fruit
java.lang.NoSuchMethodException: Tomato.<init>(Tomato)
java.lang.NoSuchMethodException: Tomato.<init>(Tomato)
In ripen, t is a Tomato
In slice, f is a Fruit
java.lang.NoSuchMethodException:
GreenZebra.<init>(GreenZebra)
java.lang.NoSuchMethodException:
GreenZebra.<init>(GreenZebra)
*/
这个例子第一眼看上去显得有点奇怪。不同水果的质量肯定有所区别,但为什么只是把代表那些质量的数据成员直接置入Fruit
(水果)类?有两方面可能的原因。第一个是我们可能想简便地插入或修改质量。注意Fruit
有一个protected
(受到保护的)addQualities()
方法,它允许派生类来进行这些插入或修改操作(大家或许会认为最合乎逻辑的做法是在Fruit
中使用一个protected
构造器,用它获取FruitQualities
参数,但构造器不能继承,所以不可在第二级或级数更深的类中使用它)。通过将水果的质量置入一个独立的类,可以得到更大的灵活性,其中包括可以在特定Fruit
对象的存在期间中途更改质量。
之所以将FruitQualities
设为一个独立的对象,另一个原因是考虑到我们有时希望添加新的质量,或者通过继承与多态性改变行为。注意对GreenZebra
来说(这实际是西红柿的一类——我已栽种成功,它们简直令人难以置信),构造器会调用addQualities()
,并为其传递一个ZebraQualities
对象。该对象是从FruitQualities
派生出来的,所以能与基类中的FruitQualities
引用联系在一起。当然,一旦GreenZebr
a使用FruitQualities
,就必须将其向下转换成为正确的类型(就象evaluate()
中展示的那样),但它肯定知道类型是ZebraQualities
。
大家也看到有一个Seed
(种子)类,Fruit
(大家都知道,水果含有自己的种子)包含了一个Seed
数组。
最后,注意每个类都有一个副本构造器,而且每个副本构造器都必须关心为基类和成员对象调用副本构造器的问题,从而获得“深层复制”的效果。对副本构造器的测试是在CopyConstructor
类内进行的。方法ripen()
需要获取一个Tomato
参数,并对其执行副本构建工作,以便复制对象:
t = new Tomato(t);
而slice()
需要获取一个更常规的Fruit
对象,而且对它进行复制:
f = new Fruit(f);
它们都在main()
中伴随不同种类的Fruit
进行测试。下面是输出结果:
In ripen, t is a Tomato
In slice, f is a Fruit
In ripen, t is a Tomato
In slice, f is a Fruit
从中可以看出一个问题。在slice()
内部对Tomato
进行了副本构建工作以后,结果便不再是一个Tomato
对象,而只是一个Fruit
。它已丢失了作为一个Tomato
(西红柿)的所有特征。此外,如果采用一个GreenZebra
,ripen()
和slice()
会把它分别转换成一个Tomato
和一个Fruit
。所以非常不幸,假如想制作对象的一个本地副本,Java中的副本构造器便不是特别适合我们。
(1) 为什么在C++的作用比在Java中大?
副本构造器是C++的一个基本构成部分,因为它能自动产生对象的一个本地副本。但前面的例子确实证明了它不适合在Java中使用,为什么呢?在Java中,我们操控的一切东西都是引用,而在C++中,却可以使用类似于引用的东西,也能直接传递对象。这时便要用到C++的副本构造器:只要想获得一个对象,并按值传递它,就可以复制对象。所以它在C++里能很好地工作,但应注意这套机制在Java里是很不通的,所以不要用它。
# 只读类
尽管在一些特定的场合,由clone()
产生的本地副本能够获得我们希望的结果,但程序员(方法的作者)不得不亲自禁止别名处理的副作用。假如想制作一个库,令其具有常规用途,但却不能担保它肯定能在正确的类中得以克隆,这时又该怎么办呢?更有可能的一种情况是,假如我们想让别名发挥积极的作用——禁止不必要的对象复制——但却不希望看到由此造成的副作用,那么又该如何处理呢?
一个办法是创建“不变对象”,令其从属于只读类。可定义一个特殊的类,使其中没有任何方法能造成对象内部状态的改变。在这样的一个类中,别名处理是没有问题的。因为我们只能读取内部状态,所以当多处代码都读取相同的对象时,不会出现任何副作用。
作为“不变对象”一个简单例子,Java的标准库包含了“包装器”(wrapper)类,可用于所有基本数据类型。大家可能已发现了这一点,如果想在一个象ArrayList
(只采用Object
引用)这样的集合里保存一个int
数值,可以将这个int
封装到标准库的Integer
类内部。如下所示:
// references/ImmutableInteger.java
// The Integer class cannot be changed
import java.util.*;
import java.util.stream.*;
public class ImmutableInteger {
public static void main(String[] args) {
List<Integer> v = IntStream.range(0, 10)
.mapToObj(Integer::new)
.collect(Collectors.toList());
System.out.println(v);
// But how do you change the int
// inside the Integer?
}
}
/* Output:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
*/
Integer
类(以及基本的“包装器”类)用简单的形式实现了“不变性”:它们没有提供可以修改对象的方法。
若确实需要一个容纳了基本数据类型的对象,并想对基本数据类型进行修改,就必须亲自创建它们。幸运的是,操作非常简单:
// references/MutableInteger.java
// A changeable wrapper class
import java.util.*;
import java.util.stream.*;
class IntValue {
private int n;
IntValue(int x) {
n = x;
}
public int getValue() {
return n;
}
public void setValue(int n) {
this.n = n;
}
public void increment() {
n++;
}
@Override
public String toString() {
return Integer.toString(n);
}
}
public class MutableInteger {
public static void main(String[] args) {
List<IntValue> v = IntStream.range(0, 10)
.mapToObj(IntValue::new)
.collect(Collectors.toList());
System.out.println(v);
v.forEach(IntValue::increment);
System.out.println(v);
}
}
/* Output:
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
*/
IntValue can be even simpler if privacy is not an issue: Directly selecting the n member is a bit awkward, however.
// references/SimplerMutableInteger.java
// A trivial wrapper class
import java.util.*;
import java.util.stream.*;
class IntValue2 {
public int n;
IntValue2(int n) {
this.n = n;
}
}
public class SimplerMutableInteger {
public static void main(String[] args) {
List<IntValue2> v = IntStream.range(0, 10)
.mapToObj(IntValue2::new)
.collect(Collectors.toList());
v.forEach(iv2 -> System.out.print(iv2.n + " "));
System.out.println();
v.forEach(iv2 -> iv2.n += 1);
v.forEach(iv2 -> System.out.print(iv2.n + " "));
}
}
/* Output:
0 1 2 3 4 5 6 7 8 9
1 2 3 4 5 6 7 8 9 10
*/
# 创建只读类
完全可以创建自己的只读类,下面是个简单的例子:
// references/Immutable1.java
// Immutable objects are immune to aliasing
public class Immutable1 {
private int data;
public Immutable1(int initVal) {
data = initVal;
}
public int read() {
return data;
}
public boolean nonzero() {
return data != 0;
}
public Immutable1 multiply(int multiplier) {
return new Immutable1(data * multiplier);
}
public static void f(Immutable1 i1) {
Immutable1 quad = i1.multiply(4);
System.out.println("i1 = " + i1.read());
System.out.println("quad = " + quad.read());
}
public static void main(String[] args) {
Immutable1 x = new Immutable1(47);
System.out.println("x = " + x.read());
f(x);
System.out.println("x = " + x.read());
}
}
/* Output:
x = 47
i1 = 47
quad = 188
x = 47
*/
所有数据都设为private
,可以看到没有任何public
方法对数据作出修改。事实上,确实需要修改一个对象的方法是quadruple()
,但它的作用是新建一个Immutable1
对象,初始对象则是原封未动的。
方法f()
需要取得一个Immutable1
对象,并对其采取不同的操作,而main()
的输出显示出没有对x作任何修改。因此,x
对象可别名处理许多次,不会造成任何伤害,因为根据Immutable1
类的设计,它能保证对象不被改动。
# “一成不变”的弊端
从表面看,不变类的建立似乎是一个好方案。但是,一旦真的需要那种新类型的一个修改的对象,就必须辛苦地进行新对象的创建工作,同时还有可能涉及更频繁的垃圾收集。对有些类来说,这个问题并不是很大。但对其他类来说(比如String
类),这一方案的代价显得太高了。
为解决这个问题,我们可以创建一个“同志”类,并使其能够修改。以后只要涉及大量的修改工作,就可换为使用能修改的同志类。完事以后,再切换回不可变的类。
因此,上例可改成下面这个样子:
// references/Immutable2.java
// A companion class to modify immutable objects
class Mutable {
private int data;
Mutable(int initVal) {
data = initVal;
}
public Mutable add(int x) {
data += x;
return this;
}
public Mutable multiply(int x) {
data *= x;
return this;
}
public Immutable2 makeImmutable2() {
return new Immutable2(data);
}
}
public class Immutable2 {
private int data;
public Immutable2(int initVal) {
data = initVal;
}
public int read() {
return data;
}
public boolean nonzero() {
return data != 0;
}
public Immutable2 add(int x) {
return new Immutable2(data + x);
}
public Immutable2 multiply(int x) {
return new Immutable2(data * x);
}
public Mutable makeMutable() {
return new Mutable(data);
}
public static Immutable2 modify1(Immutable2 y) {
Immutable2 val = y.add(12);
val = val.multiply(3);
val = val.add(11);
val = val.multiply(2);
return val;
}
// This produces the same result:
public static Immutable2 modify2(Immutable2 y) {
Mutable m = y.makeMutable();
m.add(12).multiply(3).add(11).multiply(2);
return m.makeImmutable2();
}
public static void main(String[] args) {
Immutable2 i2 = new Immutable2(47);
Immutable2 r1 = modify1(i2);
Immutable2 r2 = modify2(i2);
System.out.println("i2 = " + i2.read());
System.out.println("r1 = " + r1.read());
System.out.println("r2 = " + r2.read());
}
}
/* Output:
i2 = 47
r1 = 376
r2 = 376
*/
和往常一样,Immutable2
包含的方法保留了对象不可变的特征,只要涉及修改,就创建新的对象。完成这些操作的是add()
和multiply()
方法。同志类叫作Mutable
,它也含有add()
和multiply()
方法。但这些方法能够修改Mutable
对象,而不是新建一个。除此以外,Mutable
的一个方法可用它的数据产生一个Immutable2
对象,反之亦然。
两个静态方法modify1()
和modify2()
揭示出获得同样结果的两种不同方法。在modify1()
中,所有工作都是在Immutable2
类中完成的,我们可看到在进程中创建了四个新的Immutable2
对象(而且每次重新分配了val
,前一个对象就成为垃圾)。
在方法modify2()
中,可看到它的第一个行动是获取Immutable2 y
,然后从中生成一个Mutable
(类似于前面对clone()
的调用,但这一次创建了一个不同类型的对象)。随后,用Mutable
对象进行大量修改操作,同时用不着新建许多对象。最后,它切换回Immutable2
。在这里,我们只创建了两个新对象(Mutable
和Immutable2
的结果),而不是四个。
这一方法特别适合在下述场合应用:
(1) 需要不可变的对象,而且
(2) 经常需要进行大量修改,或者
(3) 创建新的不变对象代价太高
# 不变字符串
请观察下述代码:
// references/Stringer.java
public class Stringer {
public static String upcase(String s) {
return s.toUpperCase();
}
public static void main(String[] args) {
String q = new String("howdy");
System.out.println(q); // howdy
String qq = upcase(q);
System.out.println(qq); // HOWDY
System.out.println(q); // howdy
}
}
/* Output:
howdy
HOWDY
howdy
*/
q
传递进入upcase()
时,它实际是q
的引用的一个副本。该引用连接的对象实际只在一个统一的物理位置处。引用四处传递的时候,它的引用会得到复制。
若观察对upcase()
的定义,会发现传递进入的引用有一个名字s
,而且该名字只有在upcase()
执行期间才会存在。upcase()
完成后,本地引用s
便会消失,而upcase()
返回结果——还是原来那个字符串,只是所有字符都变成了大写。当然,它返回的实际是结果的一个引用。但它返回的引用最终是为一个新对象的,同时原来的q并未发生变化。所有这些是如何发生的呢?
(1) 隐式常数
若使用下述语句:
String s = "asdf";
String x = Stringer.upcase(s);
那么真的希望upcase()
方法改变参数或者参数吗?我们通常是不愿意的,因为作为提供给方法的一种信息,参数一般是拿给代码的读者看的,而不是让他们修改。这是一个相当重要的保证,因为它使代码更易编写和理解。
为了在C++中实现这一保证,需要一个特殊关键字的帮助:const
。利用这个关键字,程序员可以保证一个引用(C++叫“指针”或者“引用”)不会被用来修改原始的对象。但这样一来,C++程序员需要用心记住在所有地方都使用const
。这显然易使人混淆,也不容易记住。
(2) 重载+
和StringBuffer
利用前面提到的技术,String
类的对象被设计成“不可变”。若查阅联机文档中关于String
类的内容(本章稍后还要总结它),就会发现类中能够修改String
的每个方法实际都创建和返回了一个崭新的String
对象,新对象里包含了修改过的信息——原来的String
是原封未动的。因此,Java里没有与C++的const
对应的特性可用来让编译器支持对象的不可变能力。若想获得这一能力,可以自行设置,就象String
那样。
由于String
对象是不可变的,所以能够根据情况对一个特定的String
进行多次别名处理。因为它是只读的,所以一个引用不可能会改变一些会影响其他引用的东西。因此,只读对象可以很好地解决别名问题。
通过修改产生对象的一个崭新版本,似乎可以解决修改对象时的所有问题,就象String
那样。但对某些操作来讲,这种方法的效率并不高。一个典型的例子便是为String
对象重载的运算符+
。“重载”意味着在与一个特定的类使用时,它的含义已发生了变化(用于String
的+
和+=
是Java中能被重载的唯一运算符,Java不允许程序员重载其他任何运算符——注释④)。
④:C++允许程序员随意重载运算符。由于这通常是一个复杂的过程(参见《Thinking in C++》,Prentice-Hall于1995年出版),所以Java的设计者认定它是一种“糟糕”的特性,决定不在Java中采用。但具有讽剌意味的是,运算符的重载在Java中要比在C++中容易得多。
针对String
对象使用时,+
允许我们将不同的字符串连接起来:
String s = "abc" + foo + "def" + Integer.toString(47);
可以想象出它“可能”是如何工作的:字符串"abc"
可以有一个方法append()
,它新建了一个字符串,其中包含"abc"
以及foo
的内容;这个新字符串然后再创建另一个新字符串,在其中添加"def"
;以此类推。
这一设想是行得通的,但它要求创建大量字符串对象。尽管最终的目的只是获得包含了所有内容的一个新字符串,但中间却要用到大量字符串对象,而且要不断地进行垃圾收集。我怀疑Java的设计者是否先试过种方法(这是软件开发的一个教训——除非自己试试代码,并让某些东西运行起来,否则不可能真正了解系统)。我还怀疑他们是否早就发现这样做获得的性能是不能接受的。
解决的方法是象前面介绍的那样制作一个可变的同志类。对字符串来说,这个同志类叫作StringBuffer
,编译器可以自动创建一个StringBuffer
,以便计算特定的表达式,特别是面向String
对象应用重载过的运算符+
和+=
时。下面这个例子可以解决这个问题:
// references/ImmutableStrings.java
// (c)2017 MindView LLC: see Copyright.txt
// We make no guarantees that this code is fit for any purpose.
// Visit http://OnJava8.com for more book information.
// Demonstrating StringBuilder
public class ImmutableStrings {
public static void main(String[] args) {
String foo = "foo";
String s = "abc" + foo + "def"
+ Integer.toString(47);
System.out.println(s);
// The "equivalent" using StringBuilder:
StringBuilder sb =
new StringBuilder("abc"); // Creates String
sb.append(foo);
sb.append("def"); // Creates String
sb.append(Integer.toString(47));
System.out.println(sb);
}
}
/* Output:
abcfoodef47
abcfoodef47
*/
创建字符串s
时,编译器做的工作大致等价于后面使用sb
的代码——创建一个StringBuffer
,并用append()
将新字符直接加入StringBuffer
对象(而不是每次都产生新对象)。尽管这样做更有效,但不值得每次都创建象"abc"
和"def"
这样的引号字符串,编译器会把它们都转换成String
对象。所以尽管StringBuffer
提供了更高的效率,但会产生比我们希望的多得多的对象。
# String
和StringBuffer
类
这里总结一下同时适用于String
和StringBuffer
的方法,以便对它们相互间的沟通方式有一个印象。这些表格并未把每个单独的方法都包括进去,而是包含了与本次讨论有重要关系的方法。那些已被重载的方法用单独一行总结。
首先总结String
类的各种方法:
方法 | 参数,重载 | 用途 |
---|---|---|
构造器 | 已被重载 默认,String ,StringBuffer ,char 数组,byte 数组 | 创建String 对象 |
length() | 无 | String 中的字符数量 |
charAt() | int Index | 位于String 内某个位置的char |
getChars() ,getBytes | 开始复制的起点和终点,要向其中复制内容的数组,对目标数组的一个索引 | 将char 或byte 复制到外部数组内部 |
toCharArray() | 无 | 产生一个char[] ,其中包含了String 内部的字符 |
equals() ,equalsIgnoreCase() | 用于对比的一个String | 对两个字符串的内容进行等价性检查 |
compareTo() | 用于对比的一个String | 结果为负、零或正,具体取决于String 和参数的字典顺序。注意大写和小写不是相等的! |
regionMatches() | 这个String 以及其他String 的位置偏移,以及要比较的区域长度。重载加入了“忽略大小写”的特性 | 一个布尔结果,指出要对比的区域是否相同 |
startsWith() | 可能以它开头的String 。重载在参数里加入了偏移 | 一个布尔结果,指出String 是否以那个参数开头 |
endsWith() | 可能是这个String 后缀的一个String | 一个布尔结果,指出参数是不是一个后缀 |
indexOf() ,lastIndexOf() | 已重载:char ,char 和起始索引,String ,String 和起始索引 | |
substring() | 已重载:起始索引,起始索引和结束索引 | 返回一个新的String 对象,其中包含了指定的字符子集 |
concat() | 想连结的String | 返回一个新String 对象,其中包含了原始String 的字符,并在后面加上由参数提供的字符 |
relpace() | 要查找的老字符,要用它替换的新字符 | 返回一个新String 对象,其中已完成了替换工作。若没有找到相符的搜索项,就沿用老字符串 |
toLowerCase() ,toUpperCase() | 无 | 返回一个新String 对象,其中所有字符的大小写形式都进行了统一。若不必修改,则沿用老字符串 |
trim() | 无 | 返回一个新的String 对象,头尾空白均已删除。若毋需改动,则沿用老字符串 |
valueOf() | 已重载:object ,char[] ,char[] 和偏移以及计数,boolean ,char ,int ,long ,float ,double | 返回一个String ,其中包含参数的一个字符表现形式 |
Intern() | 无 | 为每个独一无二的字符顺序都产生一个(而且只有一个)String 引用 |
可以看到,一旦有必要改变原来的内容,每个String
方法都小心地返回了一个新的String
对象。另外要注意的一个问题是,若内容不需要改变,则方法只返回指向原来那个String
的一个引用。这样做可以节省存储空间和系统开销。
下面列出有关StringBuffer
(字符串缓冲)类的方法:
方法 | 参数,重载 | 用途 |
---|---|---|
构造器 | 已重载:默认,要创建的缓冲区长度,要根据它创建的String | 新建一个StringBuffer 对象 |
toString() | 无 | 根据这个StringBuffer 创建一个String |
length() | 无 | StringBuffer 中的字符数量 |
capacity() | 无 | 返回目前分配的空间大小 |
ensureCapacity() | 用于表示希望容量的一个整数 | 使StringBuffer 容纳至少希望的空间大小 |
setLength() | 用于指示缓冲区内字符串新长度的一个整数 | 缩短或扩充前一个字符串。如果是扩充,则用null 值填充空隙 |
charAt() | 表示目标元素所在位置的一个整数 | 返回位于缓冲区指定位置处的char |
setCharAt() | 代表目标元素位置的一个整数以及元素的一个新char 值 | 修改指定位置处的值 |
getChars() | 复制的起点和终点,要在其中复制的数组以及目标数组的一个索引 | 将char 复制到一个外部数组。和String 不同,这里没有getBytes() 可供使用 |
append() | 已重载:Object ,String ,char[] ,特定偏移和长度的char[] ,boolean ,char ,int ,long ,float ,double | 将参数转换成一个字符串,并将其追加到当前缓冲区的末尾。若有必要,同时增大缓冲区的长度 |
insert() | 已重载,第一个参数代表开始插入的位置:Object ,String ,char[] ,boolean ,char ,int ,long ,float ,double | 第二个参数转换成一个字符串,并插入当前缓冲区。插入位置在偏移区域的起点处。若有必要,同时会增大缓冲区的长度 |
reverse() | 无 | 反转缓冲内的字符顺序 |
最常用的一个方法是append()
。在计算包含了+
和+=
运算符的String
表达式时,编译器便会用到这个方法。insert()
方法采用类似的形式。这两个方法都能对缓冲区进行重要的操作,不需要另建新对象。
# 字符串的特殊性
现在,大家已知道String
类并非仅仅是Java提供的另一个类。String
里含有大量特殊的类。通过编译器和特殊的重载或重载运算符+
和+=
,可将引号字符串转换成一个String
。在本章中,大家已见识了剩下的一种特殊情况:用同志StringBuffer
精心构造的“不可变”能力,以及编译器中出现的一些有趣现象。
# 总结
由于Java中的所有东西都是引用,而且由于每个对象都是在内存堆中创建的——只有不再需要的时候,才会当作垃圾收集掉,所以对象的操作方式发生了变化,特别是在传递和返回对象的时候。举个例子来说,在C和C++中,如果想在一个方法里初始化一些存储空间,可能需要请求用户将那片存储区域的地址传递进入方法。否则就必须考虑由谁负责清除那片区域。因此,这些方法的接口和对它们的理解就显得要复杂一些。但在Java中,根本不必关心由谁负责清除,也不必关心在需要一个对象的时候它是否仍然存在。因为系统会为我们照料一切。我们的程序可在需要的时候创建一个对象。而且更进一步地,根本不必担心那个对象的传输机制的细节:只需简单地传递引用即可。有些时候,这种简化非常有价值,但另一些时候却显得有些多余。
可从两个方面认识这一机制的缺点:
(1) 肯定要为额外的内存管理付出效率上的损失(尽管损失不大),而且对于运行所需的时间,总是存在一丝不确定的因素(因为在内存不够时,垃圾收集器可能会被强制采取行动)。对大多数应用来说,优点显得比缺点重要,而且部分对时间要求非常苛刻的段落可以用native
方法写成(参见附录A)。
(2) 别名处理:有时会不慎获得指向同一个对象的两个引用。只有在这两个引用都假定指向一个“明确”的对象时,才有可能产生问题。对这个问题,必须加以足够的重视。而且应该尽可能地“克隆”一个对象,以防止另一个引用被不希望的改动影响。除此以外,可考虑创建“不可变”对象,使它的操作能返回同种类型或不同种类型的一个新对象,从而提高程序的执行效率。但千万不要改变原始对象,使对那个对象别名的其他任何方面都感觉不出变化。
有些人认为Java的克隆是一个笨拙的家伙,所以他们实现了自己的克隆方案(注释⑤),永远杜绝调用Object.clone()
方法,从而消除了实现Cloneable
和捕获CloneNotSupportException
异常的需要。这一做法是合理的,而且由于clone()
在Java标准库中很少得以支持,所以这显然也是一种“安全”的方法。只要不调用Object.clone()
,就不必实现Cloneable
或者捕获异常,所以那看起来也是能够接受的。
Doug Lea对解决这个问题很有帮助,他向我建议了这一点,他说他只是为每个类创建一个名为duplicate()的函数。通过这种方式,您永远不会调用Object.clone(),从而消除 需要实现Cloneable并捕获CloneNotSupportedException。这当然是一种合理的方法,而且由于标准Java库中很少支持clone(),因此它显然也是一种安全的方法。 与其编写自己的克隆支持,不如考虑Apache Commons序列化实用程序类或深度克隆库。
1.在通常处理小数据位的C中,默认为按值传递。C++必须遵循这种形式,但对于对象,传递值通常不是最有效的方法。此外,在C++中编码类以支持传递值是一个令人头疼的问题。↩ 2.这不是这个词的字典拼写,但它是使用的在Java库中,所以我也在这里使用了它,希望能减少混乱。↩ 3.C++允许程序员随意重载运算符。因为 这通常是一个复杂的过程((see Chapter 10 of Thinking in C++, 2nd edition, Prentice Hall, 2000),Java 设计人员认为这是一个“糟糕”的特性,不应该包含在Java中。这并没有那么糟糕,他们最终并没有自己做,具有讽刺意味的是,运算符重载在Java中比在C++中更容易使用。这可以在Python(参见www.Python.org)中看到,它具有垃圾收集和简单的运算符重载。↩