天天看點

Java程式設計思想之傳遞和傳回對象

 對象的“傳遞”實際傳遞的隻是一個句柄。一般都會問到:“Java有指針嗎?”有些人認為指針的操作很困難,而且十分危險,是以一廂情願地認為它沒有好處。同時由于Java有如此好的口碑,是以應該很輕易地免除自己以前程式設計中的麻煩,其中不可能夾帶有指針這樣的“危險品”。然而準确地說,Java是有指針的!事實上,Java中每個對象(除基本資料類型以外)的辨別符都屬于指針的一種。但它們的使用受到了嚴格的限制和防範,不僅編譯器對它們有“戒心”,運作期系統也不例外。或者換從另一個角度說,Java有指針,但沒有傳統指針的麻煩。我曾一度将這種指針叫做“句柄”,但你可以把它想像成“安全指針”。

1、傳遞句柄

将句柄傳遞進入一個方法時,指向的仍然是相同的對象。一個簡單的實驗可以證明這一點:

//: 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(): [email protected]

h inside f() : [email protected]

可以看到,無論p還是h引用的都是同一個對象。這比複制一個新的PassHandles對象有效多了,使我們能将一個參數發給一個方法。但這樣做也帶來了另一個重要的問題。

  1.1 别名問題

“别名”意味着多個句柄都試圖指向同一個對象,就象前面的例子展示的那樣。若有人向那個對象裡寫入一點什麼東西,就會産生别名問題。若其他句柄的所有者不希望那個對象改變,恐怕就要失望了。這可用下面這個簡單的例子說明:

//: 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)。是以倘若建立一個會修改自己參數的方法,必須向使用者明确地指出這一情況,并警告使用那個方法可能會有的後果以及它的潛在威脅。由于存在這些混淆和缺陷,是以應該盡量避免改變參數。

若需在一個方法調用期間修改一個參數,且不打算修改外部參數,就應在自己的方法内部制作一個副本,進而保護那個參數。

2、制作本地副本

稍微總結一下:Java中的所有自變量或參數傳遞都是通過傳遞句柄進行的。也就是說,當我們傳遞“一個對象”時,實際傳遞的隻是指向位于方法外部的那個對象的“一個句柄”。是以一旦要對那個句柄進行任何修改,便相當于修改外部對象。此外:

■參數傳遞過程中會自動産生别名問題

■ 不存在本地對象,隻有本地句柄

■句柄有自己的作用域,而對象沒有

■對象的“存在時間”在Java裡不是個問題

■沒有語言上的支援(如常量)可防止對象被修改(以避免别名的副作用)

若隻是從對象中讀取資訊,而不修改它,傳遞句柄便是自變量傳遞中最有效的一種形式。這種做非常恰當;預設的方法一般也是最有效的方法。然而,有時仍需将對象當作“本地的”對待,使我們作出的改變隻影響一個本地副本,不會對外面的對象造成影響。許多程式設計語言都支援在方法内自動生成外部對象的一個本地副本(注釋 ①)。盡管Java不具備這種能力,但允許我們達到同樣的效果。

①:在C語言中,通常控制的是少量資料位,預設操作是按值傳遞。C++也必須遵照這一形式,但按值傳遞對象并非肯定是一種有效的方式。此外,在C++中用于支援按值傳遞的代碼也較難編寫,是件讓人頭痛的事情。

  2.1 按值傳遞

首先要解決術語的問題,最适合“按值傳遞”的看起來是自變量。“按值傳遞”以及它的含義取決于如何了解程式的運作方式。最常見的意思是獲得要傳遞的任何東西的一個本地副本,但這裡真正的問題是如何看待自己準備傳遞的東西。對于“按值傳遞”的含義,目前存在兩種存在明顯差別的見解:

(1) Java按值傳遞任何東西。若将基本資料類型傳遞進入一個方法,會明确得到基本資料類型的一個副本。但若将一個句柄傳遞進入方法,得到的是句柄的副本。是以人們認為“一切”都按值傳遞。當然,這種說法也有一個前提:句柄肯定也會被傳遞。但Java的設計方案似乎有些超前,允許我們忽略(大多數時候)自己處理的是一個句柄。也就是說,它允許我們将句柄假想成“對象”,因為在發出方法調用時,系統會自動照管兩者間的差異。

(2) Java主要按值傳遞(無自變量),但對象卻是按引用傳遞的。得到這個結論的前提是句柄隻是對象的一個“别名”,是以不考慮傳遞句柄的問題,而是直接指出“我準備傳遞對象”。由于将其傳遞進入一個方法時沒有獲得對象的一個本地副本,是以對象顯然不是按值傳遞的。Sun公司似乎在某種程度上支援這一見解,因為它“保留但未實作”的關鍵字之一便是byvalue(按值)。但沒人知道那個關鍵字什麼時候可以發揮作用。

   2.2 克隆對象

若需修改一個對象,同時不想改變調用者的對象,就要制作該對象的一個本地副本。這也是本地副本最常見的一種用途。若決定制作一個本地副本,隻需簡單地使用clone()方法即可。Clone是“克隆”的意思,即制作完全一模一樣的副本。這個方法在基礎類Object中定義成“protected”(受保護)模式。但在希望克隆的任何衍生類中,必須将其覆寫為“public”模式。

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庫中專門保留了這樣的一個關鍵字。

    2.3 用Vector進行深層複制

下面讓我們複習一下本章早些時候提出的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(散清單)進行深層複制,也必須采取類似的處理。

這個例子剩餘的部分顯示出克隆已實際進行——證據就是在克隆了對象以後,可以自由改變它,而原來那個對象不受任何影響。

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并未發生變化。所有這些是如何發生的呢?

  3.1 隐式常數

若使用下述語句:

String s = "asdf";

String x = Stringer.upcase(s);

那麼真的希望upcase()方法改變自變量或者參數嗎?我們通常是不願意的,因為作為提供給方法的一種資訊,自變量一般是拿給代碼的讀者看的,而不是讓他們修改。這是一個相當重要的保證,因為它使代碼更易編寫和了解。

為了在C++中實作這一保證,需要一個特殊關鍵字的幫助:const。利用這個關鍵字,程式員可以保證一個句柄(C++叫“指針”或者“引用”)不會被用來修改原始的對象。但這樣一來,C++程式員需要用心記住在所有地方都使用const。這顯然易使人混淆,也不容易記住。

  3.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";以此類推。

這一設想是行得通的,但它要求建立大量字串對象。盡管最終的目的隻是獲得包含了所有内容的一個新字串,但中間卻要用到大量字串對象,而且要不斷地進行垃圾收集。

解決的方法是象前面介紹的那樣制作一個可變的同志類。對字串來說,這個同志類叫作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提供了更高的效率,但會産生比我們希望的多得多的對象。

4、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和起始索引 若自變量未在這個String裡找到,則傳回-1;否則傳回自變量開始處的位置索引。lastIndexOf()可從終點開始回溯搜尋

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()方法采用類似的形式。這兩個方法都能對緩沖區進行重要的操作,不需要另建新對象。

5、 字串的特殊性

現在,大家已知道String類并非僅僅是Java提供的另一個類。String裡含有大量特殊的類。通過編譯器和特殊的覆寫或過載運算符+和+=,可将引号字元串轉換成一個String。在本章中,大家已見識了剩下的一種特殊情況:用同志StringBuffer精心構造的“不可變”能力,以及編譯器中出現的一些有趣現象。