📄 chapter12.htm
字号:
<br>
567-568页程序<br>
<br>
总之,如果希望一个类能够克隆,那么:<br>
(1) 实现Cloneable接口<br>
(2) 覆盖clone()<br>
(3) 在自己的clone()中调用super.clone()<br>
(4) 在自己的clone()中捕获违例<br>
这一系列步骤能达到最理想的效果。<br>
<br>
12.3.1 副本构建器<br>
克隆看起来要求进行非常复杂的设置,似乎还该有另一种替代方案。一个办法是制作特殊的构建器,令其负责复制一个对象。在C++中,这叫作“副本构建器”。刚开始的时候,这好象是一种非常显然的解决方案(如果你是C++程序员,这个方法就更显亲切)。下面是一个实际的例子:<br>
<br>
568-571页程序<br>
<br>
这个例子第一眼看上去显得有点奇怪。不同水果的质量肯定有所区别,但为什么只是把代表那些质量的数据成员直接置入Fruit(水果)类?有两方面可能的原因。第一个是我们可能想简便地插入或修改质量。注意Fruit有一个protected(受到保护的)addQualities()方法,它允许衍生类来进行这些插入或修改操作(大家或许会认为最合乎逻辑的做法是在Fruit中使用一个protected构建器,用它获取FruitQualities参数,但构建器不能继承,所以不可在第二级或级数更深的类中使用它)。通过将水果的质量置入一个独立的类,可以得到更大的灵活性,其中包括可以在特定Fruit对象的存在期间中途更改质量。<br>
之所以将FruitQualities设为一个独立的对象,另一个原因是考虑到我们有时希望添加新的质量,或者通过继承与多形性改变行为。注意对GreenZebra来说(这实际是西红柿的一类——我已栽种成功,它们简直令人难以置信),构建器会调用addQualities(),并为其传递一个ZebraQualities对象。该对象是从FruitQualities衍生出来的,所以能与基础类中的FruitQualities句柄联系在一起。当然,一旦GreenZebra使用FruitQualities,就必须将其下溯造型成为正确的类型(就象evaluate()中展示的那样),但它肯定知道类型是ZebraQualities。<br>
大家也看到有一个Seed(种子)类,Fruit(大家都知道,水果含有自己的种子)包含了一个Seed数组。<br>
最后,注意每个类都有一个副本构建器,而且每个副本构建器都必须关心为基础类和成员对象调用副本构建器的问题,从而获得“深层复制”的效果。对副本构建器的测试是在CopyConstructor类内进行的。方法ripen()需要获取一个Tomato参数,并对其执行副本构建工作,以便复制对象:<br>
t = new Tomato(t);<br>
而slice()需要获取一个更常规的Fruit对象,而且对它进行复制:<br>
f = new Fruit(f);<br>
它们都在main()中伴随不同种类的Fruit进行测试。下面是输出结果:<br>
<br>
572页上程序<br>
<br>
从中可以看出一个问题。在slice()内部对Tomato进行了副本构建工作以后,结果便不再是一个Tomato对象,而只是一个Fruit。它已丢失了作为一个Tomato(西红柿)的所有特征。此外,如果采用一个GreenZebra,ripen()和slice()会把它分别转换成一个Tomato和一个Fruit。所以非常不幸,假如想制作对象的一个本地副本,Java中的副本构建器便不是特别适合我们。<br>
<br>
1. 为什么在C++的作用比在Java中大?<br>
副本构建器是C++的一个基本构成部分,因为它能自动产生对象的一个本地副本。但前面的例子确实证明了它不适合在Java中使用,为什么呢?在Java中,我们操控的一切东西都是句柄,而在C++中,却可以使用类似于句柄的东西,也能直接传递对象。这时便要用到C++的副本构建器:只要想获得一个对象,并按值传递它,就可以复制对象。所以它在C++里能很好地工作,但应注意这套机制在Java里是很不通的,所以不要用它。<br>
<br>
12.4 只读类<br>
尽管在一些特定的场合,由clone()产生的本地副本能够获得我们希望的结果,但程序员(方法的作者)不得不亲自禁止别名处理的副作用。假如想制作一个库,令其具有常规用途,但却不能担保它肯定能在正确的类中得以克隆,这时又该怎么办呢?更有可能的一种情况是,假如我们想让别名发挥积极的作用——禁止不必要的对象复制——但却不希望看到由此造成的副作用,那么又该如何处理呢?<br>
一个办法是创建“不变对象”,令其从属于只读类。可定义一个特殊的类,使其中没有任何方法能造成对象内部状态的改变。在这样的一个类中,别名处理是没有问题的。因为我们只能读取内部状态,所以当多处代码都读取相同的对象时,不会出现任何副作用。<br>
作为“不变对象”一个简单例子,Java的标准库包含了“封装器”(wrapper)类,可用于所有基本数据类型。大家可能已发现了这一点,如果想在一个象Vector(只采用Object句柄)这样的集合里保存一个int数值,可以将这个int封装到标准库的Integer类内部。如下所示:<br>
<br>
573页中程序<br>
<br>
Integer类(以及基本的“封装器”类)用简单的形式实现了“不变性”:它们没有提供可以修改对象的方法。<br>
若确实需要一个容纳了基本数据类型的对象,并想对基本数据类型进行修改,就必须亲自创建它们。幸运的是,操作非常简单:<br>
<br>
573-574页程序<br>
<br>
注意n在这里简化了我们的编码。<br>
若默认的初始化为零已经足够(便不需要构建器),而且不用考虑把它打印出来(便不需要toString),那么IntValue甚至还能更加简单。如下所示:<br>
class IntValue { int n; }<br>
将元素取出来,再对其进行造型,这多少显得有些笨拙,但那是Vector的问题,不是IntValue的错。<br>
<br>
12.4.1 创建只读类<br>
完全可以创建自己的只读类,下面是个简单的例子:<br>
<br>
574-575页程序<br>
<br>
所有数据都设为private,可以看到没有任何public方法对数据作出修改。事实上,确实需要修改一个对象的方法是quadruple(),但它的作用是新建一个Immutable1对象,初始对象则是原封未动的。<br>
方法f()需要取得一个Immutable1对象,并对其采取不同的操作,而main()的输出显示出没有对x作任何修改。因此,x对象可别名处理许多次,不会造成任何伤害,因为根据Immutable1类的设计,它能保证对象不被改动。<br>
<br>
12.4.2 “一成不变”的弊端<br>
从表面看,不变类的建立似乎是一个好方案。但是,一旦真的需要那种新类型的一个修改的对象,就必须辛苦地进行新对象的创建工作,同时还有可能涉及更频繁的垃圾收集。对有些类来说,这个问题并不是很大。但对其他类来说(比如String类),这一方案的代价显得太高了。<br>
为解决这个问题,我们可以创建一个“同志”类,并使其能够修改。以后只要涉及大量的修改工作,就可换为使用能修改的同志类。完事以后,再切换回不可变的类。<br>
因此,上例可改成下面这个样子:<br>
<br>
575-577页程序<br>
<br>
和往常一样,Immutable2包含的方法保留了对象不可变的特征,只要涉及修改,就创建新的对象。完成这些操作的是add()和multiply()方法。同志类叫作Mutable,它也含有add()和multiply()方法。但这些方法能够修改Mutable对象,而不是新建一个。除此以外,Mutable的一个方法可用它的数据产生一个Immutable2对象,反之亦然。<br>
两个静态方法modify1()和modify2()揭示出获得同样结果的两种不同方法。在modify1()中,所有工作都是在Immutable2类中完成的,我们可看到在进程中创建了四个新的Immutable2对象(而且每次重新分配了val,前一个对象就成为垃圾)。<br>
在方法modify2()中,可看到它的第一个行动是获取Immutable2 y,然后从中生成一个Mutable(类似于前面对clone()的调用,但这一次创建了一个不同类型的对象)。随后,用Mutable对象进行大量修改操作,同时用不着新建许多对象。最后,它切换回Immutable2。在这里,我们只创建了两个新对象(Mutable和Immutable2的结果),而不是四个。<br>
这一方法特别适合在下述场合应用:<br>
(1) 需要不可变的对象,而且<br>
(2) 经常需要进行大量修改,或者<br>
(3) 创建新的不变对象代价太高<br>
<br>
12.4.3 不变字串<br>
请观察下述代码:<br>
<br>
577-578页程序<br>
<br>
q传递进入upcase()时,它实际是q的句柄的一个副本。该句柄连接的对象实际只在一个统一的物理位置处。句柄四处传递的时候,它的句柄会得到复制。<br>
若观察对upcase()的定义,会发现传递进入的句柄有一个名字s,而且该名字只有在upcase()执行期间才会存在。upcase()完成后,本地句柄s便会消失,而upcase()返回结果——还是原来那个字串,只是所有字符都变成了大写。当然,它返回的实际是结果的一个句柄。但它返回的句柄最终是为一个新对象的,同时原来的q并未发生变化。所有这些是如何发生的呢?<br>
<br>
1. 隐式常数<br>
若使用下述语句:<br>
String s = "asdf";<br>
String x = Stringer.upcase(s);<br>
那么真的希望upcase()方法改变自变量或者参数吗?我们通常是不愿意的,因为作为提供给方法的一种信息,自变量一般是拿给代码的读者看的,而不是让他们修改。这是一个相当重要的保证,因为它使代码更易编写和理解。<br>
为了在C++中实现这一保证,需要一个特殊关键字的帮助:const。利用这个关键字,程序员可以保证一个句柄(C++叫“指针”或者“引用”)不会被用来修改原始的对象。但这样一来,C++程序员需要用心记住在所有地方都使用const。这显然易使人混淆,也不容易记住。<br>
<br>
2. 覆盖"+"和StringBuffer<br>
利用前面提到的技术,String类的对象被设计成“不可变”。若查阅联机文档中关于String类的内容(本章稍后还要总结它),就会发现类中能够修改String的每个方法实际都创建和返回了一个崭新的String对象,新对象里包含了修改过的信息——原来的String是原封未动的。因此,Java里没有与C++的const对应的特性可用来让编译器支持对象的不可变能力。若想获得这一能力,可以自行设置,就象String那样。<br>
由于String对象是不可变的,所以能够根据情况对一个特定的String进行多次别名处理。因为它是只读的,所以一个句柄不可能会改变一些会影响其他句柄的东西。因此,只读对象可以很好地解决别名问题。<br>
通过修改产生对象的一个崭新版本,似乎可以解决修改对象时的所有问题,就象String那样。但对某些操作来讲,这种方法的效率并不高。一个典型的例子便是为String对象覆盖的运算符“+”。“覆盖”意味着在与一个特定的类使用时,它的含义已发生了变化(用于String的“+”和“+=”是Java中能被覆盖的唯一运算符,Java不允许程序员覆盖其他任何运算符——注释④)。<br>
<br>
④:C++允许程序员随意覆盖运算符。由于这通常是一个复杂的过程(参见《Thinking
in C++》,Prentice-Hall于1995年出版),所以Java的设计者认定它是一种“糟糕”的特性,决定不在Java中采用。但具有讽剌意味的是,运算符的覆盖在Java中要比在C++中容易得多。<br>
<br>
针对String对象使用时,“+”允许我们将不同的字串连接起来:<br>
<br>
579页中程序<br>
<br>
可以想象出它“可能”是如何工作的:字串"abc"可以有一个方法append(),它新建了一个字串,其中包含"abc"以及foo的内容;这个新字串然后再创建另一个新字串,在其中添加"def";以此类推。<br>
这一设想是行得通的,但它要求创建大量字串对象。尽管最终的目的只是获得包含了所有内容的一个新字串,但中间却要用到大量字串对象,而且要不断地进行垃圾收集。我怀疑Java的设计者是否先试过种方法(这是软件开发的一个教训——除非自己试试代码,并让某些东西运行起来,否则不可能真正了解系统)。我还怀疑他们是否早就发现这样做获得的性能是不能接受的。<br>
解决的方法是象前面介绍的那样制作一个可变的同志类。对字串来说,这个同志类叫作StringBuffer,编译器可以自动创建一个StringBuffer,以便计算特定的表达式,特别是面向String对象应用覆盖过的运算符+和+=时。下面这个例子可以解决这个问题:<br>
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -