到目前為止,讀者應對對象的“傳遞”有了一個較為深刻的認識,記住實際傳遞的只是一個引用。
在許多程序設計語言中,我們可用語言的“普通”方式到處傳遞對象,而且大多數時候都不會遇到問題。但有些時候卻不得不採取一些非常做法,使得情況突然變得稍微複雜起來(在C++中則是變得非常複雜)。Java亦不例外,我們十分有必要準確認識在對象傳遞和賦值時所發生的一切。這正是本章的宗旨。
若讀者是從某些特殊的程序設計環境中轉移過來的,那麼一般都會問到:“Java有指針嗎?”有些人認為指針的操作很困難,而且十分危險,所以一廂情願地認為它沒有好處。同時由於Java有如此好的口碑,所以應該很輕易地免除自己以前編程中的麻煩,其中不可能夾帶有指針這樣的“危險品”。然而準確地說,Java是有指針的!事實上,Java中每個對象(除基本數據類型以外)的標識符都屬於指針的一種。但它們的使用受到了嚴格的限制和防範,不僅編譯器對它們有“戒心”,運行期系統也不例外。或者換從另一個角度說,Java有指針,但沒有傳統指針的麻煩。我曾一度將這種指針叫做“引用”,但你可以把它想像成“安全指針”。和預備學校為學生提供的安全剪刀類似——除非特別有意,否則不會傷著自己,只不過有時要慢慢來,要習慣一些沉悶的工作。
將引用傳遞進入一個方法時,指向的仍然是相同的對象。一個簡單的實驗可以證明這一點(若執行這個程序時有麻煩,請參考第3章3.1.2小節“賦值”):
//: PassHandles.java
// Passing handles around
package c12;
public class PassHandles {
static void f(PassHandles h) {
System.out.println("h inside f(): " + h);
}
public static void main(String[] args) {
PassHandles p = new PassHandles();
System.out.println("p inside main(): " + p);
f(p);
}
} ///:~
toString
方法會在打印語句裡自動調用,而PassHandles
直接從Object
繼承,沒有toString
的重新定義。因此,這裡會採用toString
的Object
版本,打印出對象的類,接著是那個對象所在的位置(不是引用,而是對象的實際存儲位置)。輸出結果如下:
p inside main(): PassHandles@1653748
h inside f() : PassHandles@1653748
可以看到,無論p
還是h
引用的都是同一個對象。這比複製一個新的PassHandles
對象有效多了,使我們能將一個參數發給一個方法。但這樣做也帶來了另一個重要的問題。
“別名”意味著多個引用都試圖指向同一個對象,就象前面的例子展示的那樣。若有人向那個對象裡寫入一點什麼東西,就會產生別名問題。若其他引用的所有者不希望那個對象改變,恐怕就要失望了。這可用下面這個簡單的例子說明:
//: Alias1.java
// Aliasing two handles to one object
public class Alias1 {
int i;
Alias1(int ii) { i = ii; }
public static void main(String[] args) {
Alias1 x = new Alias1(7);
Alias1 y = x; // Assign the handle
System.out.println("x: " + x.i);
System.out.println("y: " + y.i);
System.out.println("Incrementing x");
x.i++;
System.out.println("x: " + x.i);
System.out.println("y: " + y.i);
}
} ///:~
對下面這行:
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設想的正常方法——別名問題就會自動出現,因為創建的本地引用可能修改“外部對象”(在方法作用域之外創建的對象)。下面是一個例子:
//: Alias2.java
// Method calls implicitly alias their
// arguments.
public class Alias2 {
int i;
Alias2(int ii) { i = ii; }
static void f(Alias2 handle) {
handle.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);
}
} ///:~
輸出如下:
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
模式。例如,標準庫類Vector
覆蓋了clone()
,所以能為Vector
調用clone()
,如下所示:
//: Cloning.java
// The clone() operation works for only a few
// items in the standard Java library.
import java.util.*;
class Int {
private int i;
public Int(int ii) { i = ii; }
public void increment() { i++; }
public String toString() {
return Integer.toString(i);
}
}
public class Cloning {
public static void main(String[] args) {
Vector v = new Vector();
for(int i = 0; i < 10; i++ )
v.addElement(new Int(i));
System.out.println("v: " + v);
Vector v2 = (Vector)v.clone();
// Increment all v2's elements:
for(Enumeration e = v2.elements();
e.hasMoreElements(); )
((Int)e.nextElement()).increment();
// See if it changed v's elements:
System.out.println("v: " + v);
}
} ///:~
clone()
方法產生了一個Object
,後者必須立即重新轉換為正確類型。這個例子指出Vector
的clone()
方法不能自動嘗試克隆Vector
內包含的每個對象——由於別名問題,老的Vector
和克隆的Vector
都包含了相同的對象。我們通常把這種情況叫作“簡單複製”或者“淺層複製”,因為它只複製了一個對象的“表面”部分。實際對象除包含這個“表面”以外,還包括引用指向的所有對象,以及那些對象又指向的其他所有對象,由此類推。這便是“對象網”或“對象關係網”的由來。若能複製下所有這張網,便叫作“全面複製”或者“深層複製”。
在輸出中可看到淺層複製的結果,注意對v2
採取的行動也會影響到v
:
v: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
v: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
一般來說,由於不敢保證Vector
裡包含的對象是“可以克隆”(註釋②)的,所以最好不要試圖克隆那些對象。
②:“可以克隆”用英語講是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()
方法背後的所有細節後,便可創建出能方便複製的類,以便提供了一個本地副本:
//: LocalCopy.java
// Creating local copies with clone()
import java.util.*;
class MyObject implements Cloneable {
int i;
MyObject(int ii) { i = ii; }
public Object clone() {
Object o = null;
try {
o = super.clone();
} catch (CloneNotSupportedException e) {
System.out.println("MyObject can't clone");
}
return o;
}
public String toString() {
return Integer.toString(i);
}
}
public class LocalCopy {
static MyObject g(MyObject v) {
// Passing a handle, modifies outside object:
v.i++;
return v;
}
static MyObject f(MyObject v) {
v = (MyObject)v.clone(); // Local copy
v.i++;
return v;
}
public static void main(String[] args) {
MyObject a = new MyObject(11);
MyObject b = g(a);
// Testing handle equivalence,
// not object equivalence:
if(a == b)
System.out.println("a == b");
else
System.out.println("a != b");
System.out.println("a = " + a);
System.out.println("b = " + b);
MyObject c = new MyObject(47);
MyObject d = f(c);
if(c == d)
System.out.println("c == d");
else
System.out.println("c != d");
System.out.println("c = " + c);
System.out.println("d = " + d);
}
} ///:~
不管怎樣,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()
時,實際發生的是什麼事情呢?當我們在自己的類裡覆蓋clone()
時,什麼東西對於super.clone()
來說是最關鍵的呢?根類中的clone()
方法負責建立正確的存儲容量,並通過“按位複製”將二進制位從原始對象中複製到新對象的存儲空間。也就是說,它並不只是預留存儲空間以及複製一個對象——實際需要調查出欲複製之對象的準確大小,然後複製那個對象。由於所有這些工作都是在由根類定義之clone()
方法的內部代碼中進行的(根類並不知道要從自己這裡繼承出去什麼),所以大家或許已經猜到,這個過程需要用RTTI判斷欲克隆的對象的實際大小。採取這種方式,clone()
方法便可建立起正確數量的存儲空間,並對那個類型進行正確的按位複製。
不管我們要做什麼,克隆過程的第一個部分通常都應該是調用super.clone()
。通過進行一次準確的複製,這樣做可為後續的克隆進程建立起一個良好的基礎。隨後,可採取另一些必要的操作,以完成最終的克隆。
為確切瞭解其他操作是什麼,首先要正確理解Object.clone()
為我們帶來了什麼。特別地,它會自動克隆所有引用指向的目標嗎?下面這個例子可完成這種形式的檢測:
//: Snake.java
// Tests cloning to see if destination of
// handles are also cloned.
public class Snake implements Cloneable {
private Snake next;
private char c;
// Value of i == number of segments
Snake(int i, char x) {
c = x;
if(--i > 0)
next = new Snake(i, (char)(x + 1));
}
void increment() {
c++;
if(next != null)
next.increment();
}
public String toString() {
String s = ":" + c;
if(next != null)
s += next.toString();
return s;
}
public Object clone() {
Object o = null;
try {
o = super.clone();
} catch (CloneNotSupportedException e) {}
return o;
}
public static void main(String[] args) {
Snake s = new Snake(5, 'a');
System.out.println("s = " + s);
Snake s2 = (Snake)s.clone();
System.out.println("s2 = " + s2);
s.increment();
System.out.println(
"after s.increment, s2 = " + s2);
}
} ///:~
一條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()
方法也能依次對自己的引用進行深層複製,以此類推。這使我們的操作變得複雜。為了能正常實現深層複製,必須對所有類中的代碼進行控制,或者至少全面掌握深層複製中需要涉及的類,確保它們自己的深層複製能正確進行。
下面這個例子總結了面對一個組合對象進行深層複製時需要做哪些事情:
//: DeepCopy.java
// Cloning a composed object
class DepthReading implements Cloneable {
private double depth;
public DepthReading(double depth) {
this.depth = depth;
}
public Object clone() {
Object o = null;
try {
o = super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return o;
}
}
class TemperatureReading implements Cloneable {
private long time;
private double temperature;
public TemperatureReading(double temperature) {
time = System.currentTimeMillis();
this.temperature = temperature;
}
public Object clone() {
Object o = null;
try {
o = super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
return o;
}
}
class OceanReading implements Cloneable {
private DepthReading depth;
private TemperatureReading temperature;
public OceanReading(double tdata, double ddata){
temperature = new TemperatureReading(tdata);
depth = new DepthReading(ddata);
}
public Object clone() {
OceanReading o = null;
try {
o = (OceanReading)super.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}
// Must clone handles:
o.depth = (DepthReading)o.depth.clone();
o.temperature =
(TemperatureReading)o.temperature.clone();
return o; // Upcasts back to Object
}
}
public class DeepCopy {
public static void main(String[] args) {
OceanReading reading =
new OceanReading(33.9, 100.5);
// Now clone it:
OceanReading r =
(OceanReading)reading.clone();
}
} ///:~
DepthReading
和TemperatureReading
非常相似;它們都只包含了基本數據類型。所以clone()
方法能夠非常簡單:調用super.clone()
並返回結果即可。注意兩個類使用的clone()
代碼是完全一致的。
OceanReading
是由DepthReading
和TemperatureReading
對象合併而成的。為了對其進行深層複製,clone()
必須同時克隆OceanReading
內的引用。為達到這個目標,super.clone()
的結果必須轉換成一個OceanReading
對象(以便訪問depth
和temperature
引用)。
下面讓我們複習一下本章早些時候提出的Vector
例子。這一次Int2
類是可以克隆的,所以能對Vector
進行深層複製:
//: AddingClone.java
// You must go through a few gyrations to
// add cloning to your own class.
import java.util.*;
class Int2 implements Cloneable {
private int i;
public Int2(int ii) { i = ii; }
public void increment() { i++; }
public String toString() {
return Integer.toString(i);
}
public Object clone() {
Object o = null;
try {
o = super.clone();
} catch (CloneNotSupportedException e) {
System.out.println("Int2 can't clone");
}
return o;
}
}
// Once it's cloneable, inheritance
// doesn't remove cloneability:
class Int3 extends Int2 {
private int j; // Automatically duplicated
public Int3(int i) { super(i); }
}
public class AddingClone {
public static void main(String[] args) {
Int2 x = new Int2(10);
Int2 x2 = (Int2)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();
Vector v = new Vector();
for(int i = 0; i < 10; i++ )
v.addElement(new Int2(i));
System.out.println("v: " + v);
Vector v2 = (Vector)v.clone();
// Now clone each element:
for(int i = 0; i < v.size(); i++)
v2.setElementAt(
((Int2)v2.elementAt(i)).clone(), i);
// Increment all v2's elements:
for(Enumeration e = v2.elements();
e.hasMoreElements(); )
((Int2)e.nextElement()).increment();
// See if it changed v's elements:
System.out.println("v: " + v);
System.out.println("v2: " + v2);
}
} ///:~
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對象序列化示例,可能發現若在一個對象序列化以後再撤消對它的序列化,或者說進行裝配,那麼實際經歷的正是一個“克隆”的過程。
那麼為什麼不用序列化進行深層複製呢?下面這個例子通過計算執行時間對比了這兩種方法:
//: Compete.java
import java.io.*;
class Thing1 implements Serializable {}
class Thing2 implements Serializable {
Thing1 o1 = new Thing1();
}
class Thing3 implements Cloneable {
public Object clone() {
Object o = null;
try {
o = super.clone();
} catch (CloneNotSupportedException e) {
System.out.println("Thing3 can't clone");
}
return o;
}
}
class Thing4 implements Cloneable {
Thing3 o3 = new Thing3();
public Object clone() {
Thing4 o = null;
try {
o = (Thing4)super.clone();
} catch (CloneNotSupportedException e) {
System.out.println("Thing4 can't clone");
}
// Clone the field, too:
o.o3 = (Thing3)o3.clone();
return o;
}
}
public class Compete {
static final int SIZE = 5000;
public static void main(String[] args) {
Thing2[] a = new Thing2[SIZE];
for(int i = 0; i < a.length; i++)
a[i] = new Thing2();
Thing4[] b = new Thing4[SIZE];
for(int i = 0; i < b.length; i++)
b[i] = new Thing4();
try {
long t1 = System.currentTimeMillis();
ByteArrayOutputStream buf =
new ByteArrayOutputStream();
ObjectOutputStream o =
new ObjectOutputStream(buf);
for(int i = 0; i < a.length; i++)
o.writeObject(a[i]);
// Now get copies:
ObjectInputStream in =
new ObjectInputStream(
new ByteArrayInputStream(
buf.toByteArray()));
Thing2[] c = new Thing2[SIZE];
for(int i = 0; i < c.length; i++)
c[i] = (Thing2)in.readObject();
long t2 = System.currentTimeMillis();
System.out.println(
"Duplication via serialization: " +
(t2 - t1) + " Milliseconds");
// Now try cloning:
t1 = System.currentTimeMillis();
Thing4[] d = new Thing4[SIZE];
for(int i = 0; i < d.length; i++)
d[i] = (Thing4)b[i].clone();
t2 = System.currentTimeMillis();
System.out.println(
"Duplication via cloning: " +
(t2 - t1) + " Milliseconds");
} catch(Exception e) {
e.printStackTrace();
}
}
} ///:~
其中,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
,並默認為不具備克隆能力(就象在下一節會看到的那樣)。只要不明確地添加克隆能力,這種能力便不會自動產生。但我們可以在任何層添加它,然後便可從那個層開始向下具有克隆能力。如下所示:
//: HorrorFlick.java
// You can insert Cloneability at any
// level of inheritance.
import java.util.*;
class Person {}
class Hero extends Person {}
class Scientist extends Person
implements Cloneable {
public Object clone() {
try {
return super.clone();
} catch (CloneNotSupportedException e) {
// this should never happen:
// It's Cloneable already!
throw new InternalError();
}
}
}
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 = (Scientist)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) 有條件地支持克隆。若類容納了其他對象的引用,而那些對象也許能夠克隆(集合類便是這樣的一個例子),就可試著克隆擁有對方引用的所有對象;如果它們“拋”出了異常,只需讓這些異常通過即可。舉個例子來說,假設有一個特殊的Vector
,它試圖克隆自己容納的所有對象。編寫這樣的一個Vector
時,並不知道客戶程序員會把什麼形式的對象置入這個Vector
中,所以並不知道它們是否真的能夠克隆。
(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(單例)方案。
下面這個例子總結了克隆的各種實現方法,然後在層次結構中將其“關閉”:
//: CheckCloneable.java
// Checking to see if a handle can be cloned
// Can't clone this because it doesn't
// override clone():
class Ordinary {}
// Overrides clone, but doesn't implement
// Cloneable:
class WrongClone extends Ordinary {
public Object clone()
throws CloneNotSupportedException {
return super.clone(); // Throws exception
}
}
// Does all the right things for cloning:
class IsCloneable extends Ordinary
implements Cloneable {
public Object clone()
throws CloneNotSupportedException {
return super.clone();
}
}
// Turn off cloning by throwing the exception:
class NoMore extends IsCloneable {
public Object clone()
throws CloneNotSupportedException {
throw new CloneNotSupportedException();
}
}
class TryMore extends NoMore {
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. This is a dummy
// copy, just to make the point:
return new BackOn();
}
public Object clone() {
// Doesn't call NoMore.clone():
return duplicate(this);
}
}
// Can't inherit from this, so can't override
// the clone method like in BackOn:
final class ReallyNoMore extends NoMore {}
public class CheckCloneable {
static Ordinary tryToClone(Ordinary ord) {
String id = ord.getClass().getName();
Ordinary x = null;
if(ord instanceof Cloneable) {
try {
System.out.println("Attempting " + id);
x = (Ordinary)((IsCloneable)ord).clone();
System.out.println("Cloned " + id);
} catch(CloneNotSupportedException e) {
System.out.println(
"Could not clone " + id);
}
}
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, since clone() is
// protected in Object:
//! x = (Ordinary)x.clone();
// tryToClone() checks first to see if
// a class implements Cloneable:
for(int i = 0; i < ord.length; i++)
tryToClone(ord[i]);
}
} ///:~
第一個類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
(受到保護的)方法。代碼剩餘的部分將遍歷數組,並試著克隆每個對象,分別報告它們的成功或失敗。輸出如下:
Attempting IsCloneable
Cloned IsCloneable
Attempting NoMore
Could not clone NoMore
Attempting TryMore
Could not clone TryMore
Attempting BackOn
Cloned BackOn
Attempting ReallyNoMore
Could not clone ReallyNoMore
總之,如果希望一個類能夠克隆,那麼:
(1) 實現Cloneable
接口
(2) 覆蓋clone()
(3) 在自己的clone()
中調用super.clone()
(4) 在自己的clone()
中捕獲異常
這一系列步驟能達到最理想的效果。
克隆看起來要求進行非常複雜的設置,似乎還該有另一種替代方案。一個辦法是製作特殊的構造器,令其負責複製一個對象。在C++中,這叫作“副本構造器”。剛開始的時候,這好象是一種非常顯然的解決方案(如果你是C++程序員,這個方法就更顯親切)。下面是一個實際的例子:
//: CopyConstructor.java
// A constructor for copying an object
// of the same type, as an attempt to create
// a local copy.
class FruitQualities {
private int weight;
private int color;
private int firmness;
private int ripeness;
private int smell;
// etc.
FruitQualities() { // Default constructor
// 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() { /* Default 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;
// Call all Seed copy-constructors:
for(int i = 0; i < seeds; i++)
s[i] = new Seed(f.s[i]);
// Other copy-construction activities...
}
// To allow 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 for base copy-constructor
// Other copy-construction activities...
}
}
class ZebraQualities extends FruitQualities {
private int stripedness;
ZebraQualities() { // Default constructor
// 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());
}
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);
System.out.println("In ripen, t is a " +
t.getClass().getName());
}
public static void slice(Fruit f) {
f = new Fruit(f); // Hmmm... will this work?
System.out.println("In slice, f is a " +
f.getClass().getName());
}
public static void main(String[] args) {
Tomato tomato = new Tomato();
ripen(tomato); // OK
slice(tomato); // OOPS!
GreenZebra g = new GreenZebra();
ripen(g); // OOPS!
slice(g); // OOPS!
g.evaluate();
}
} ///:~
這個例子第一眼看上去顯得有點奇怪。不同水果的質量肯定有所區別,但為什麼只是把代表那些質量的數據成員直接置入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)類,可用於所有基本數據類型。大家可能已發現了這一點,如果想在一個象Vector
(只採用Object
引用)這樣的集合裡保存一個int
數值,可以將這個int
封裝到標準庫的Integer
類內部。如下所示:
//: ImmutableInteger.java
// The Integer class cannot be changed
import java.util.*;
public class ImmutableInteger {
public static void main(String[] args) {
Vector v = new Vector();
for(int i = 0; i < 10; i++)
v.addElement(new Integer(i));
// But how do you change the int
// inside the Integer?
}
} ///:~
Integer
類(以及基本的“包裝器”類)用簡單的形式實現了“不變性”:它們沒有提供可以修改對象的方法。
若確實需要一個容納了基本數據類型的對象,並想對基本數據類型進行修改,就必須親自創建它們。幸運的是,操作非常簡單:
//: MutableInteger.java
// A changeable wrapper class
import java.util.*;
class IntValue {
int n;
IntValue(int x) { n = x; }
public String toString() {
return Integer.toString(n);
}
}
public class MutableInteger {
public static void main(String[] args) {
Vector v = new Vector();
for(int i = 0; i < 10; i++)
v.addElement(new IntValue(i));
System.out.println(v);
for(int i = 0; i < v.size(); i++)
((IntValue)v.elementAt(i)).n++;
System.out.println(v);
}
} ///:~
注意n
在這裡簡化了我們的編碼。
若默認的初始化為零已經足夠(便不需要構造器),而且不用考慮把它打印出來(便不需要toString
),那麼IntValue
甚至還能更加簡單。如下所示:
class IntValue { int n; }
將元素取出來,再對其進行轉換,這多少顯得有些笨拙,但那是Vector
的問題,不是IntValue
的錯。
完全可以創建自己的只讀類,下面是個簡單的例子:
//: Immutable1.java
// Objects that cannot be modified
// 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 quadruple() {
return new Immutable1(data * 4);
}
static void f(Immutable1 i1) {
Immutable1 quad = i1.quadruple();
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());
}
} ///:~
所有數據都設為private
,可以看到沒有任何public
方法對數據作出修改。事實上,確實需要修改一個對象的方法是quadruple()
,但它的作用是新建一個Immutable1
對象,初始對象則是原封未動的。
方法f()
需要取得一個Immutable1
對象,並對其採取不同的操作,而main()
的輸出顯示出沒有對x作任何修改。因此,x
對象可別名處理許多次,不會造成任何傷害,因為根據Immutable1
類的設計,它能保證對象不被改動。
從表面看,不變類的建立似乎是一個好方案。但是,一旦真的需要那種新類型的一個修改的對象,就必須辛苦地進行新對象的創建工作,同時還有可能涉及更頻繁的垃圾收集。對有些類來說,這個問題並不是很大。但對其他類來說(比如String
類),這一方案的代價顯得太高了。
為解決這個問題,我們可以創建一個“同志”類,並使其能夠修改。以後只要涉及大量的修改工作,就可換為使用能修改的同志類。完事以後,再切換回不可變的類。
因此,上例可改成下面這個樣子:
//: Immutable2.java
// A companion class for making changes
// to immutable objects.
class Mutable {
private int data;
public 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());
}
} ///:~
和往常一樣,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) 創建新的不變對象代價太高
請觀察下述代碼:
//: Stringer.java
public class Stringer {
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
}
} ///:~
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
對象應用重載過的運算符+
和+=
時。下面這個例子可以解決這個問題:
//: ImmutableStrings.java
// Demonstrating StringBuffer
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 StringBuffer:
StringBuffer sb =
new StringBuffer("abc"); // Creates String!
sb.append(foo);
sb.append("def"); // Creates String!
sb.append(Integer.toString(47));
System.out.println(sb);
}
} ///:~
創建字符串s
時,編譯器做的工作大致等價於後面使用sb
的代碼——創建一個StringBuffer
,並用append()
將新字符直接加入StringBuffer
對象(而不是每次都產生新對象)。儘管這樣做更有效,但不值得每次都創建象"abc"
和"def"
這樣的引號字符串,編譯器會把它們都轉換成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()
的函數即可。
Java中一個有趣的關鍵字是byvalue
(按值),它屬於那些“保留但未實現”的關鍵字之一。在理解了別名和克隆問題以後,大家可以想象byvalue
最終有一天會在Java中用於實現一種自動化的本地副本。這樣做可以解決更多複雜的克隆問題,並使這種情況下的編寫的代碼變得更加簡單和健壯。
(1) 創建一個myString
類,在其中包含了一個String
對象,以便用在構造器中用構造器的參數對其進行初始化。添加一個toString()
方法以及一個concatenate()
方法,令其將一個String
對象追加到我們的內部字符串。在myString
中實現clone()
。創建兩個static
方法,每個都取得一個myString x
引用作為自己的參數,並調用x.concatenate("test")
。但在第二個方法中,請首先調用clone()
。測試這兩個方法,觀察它們不同的結果。
(2) 創建一個名為Battery
(電池)的類,在其中包含一個int
,用它表示電池的編號(採用獨一無二的標識符的形式)。接下來,創建一個名為Toy
的類,其中包含了一個Battery
數組以及一個toString
,用於打印出所有電池。為Toy
寫一個clone()
方法,令其自動關閉所有Battery
對象。克隆Toy
並打印出結果,完成對它的測試。
(3) 修改CheckCloneable.java
,使所有clone()
方法都能捕獲CloneNotSupportException
異常,而不是把它直接傳遞給調用者。
(4) 修改Compete.java
,為Thing2
和Thing4
類添加更多的成員對象,看看自己是否能判斷計時隨複雜性變化的規律——是一種簡單的線性關係,還是看起來更加複雜。
(5) 從Snake.java
開始,創建Snake
的一個深層複製版本。