Java IAQ:很少被回答的問題

jopen 9年前發布 | 33K 次閱讀 Java

Q什么是很少被回答的問題?

一個問題如果被回答地很少,有可能是因為知道答案的人很少,亦或是因為問題本身模糊不清、微不足道(但對你來講可能很關鍵)。我似乎發明了一個術語,但是它在一個信息量很大的叫做About.com Urban legends 網站里也被提到了。Java相關的常見問題非常之多,但接下來我要講的是Java不常問到的問題(不常見問題列表就沒那么多了,其中包括了一些對C語言的冷嘲熱諷。)

Qfinally 語句內的代碼一定會被執行,對吧?

嗯,大部分時候是的。但也存在一些特例,比如:不管choice的值是什么,下面代碼finally中的語句就不會被執行。

try 
{
    if (choice) 
        while (true) ;
    else
      System.exit(1);
}
finally
{
    code.to.cleanup();
}

Q:在類C的一個方法m中調用this.getClass()是不是永遠返回C

不。有時候對象x可以是一個c的子類c1,要么c1.m()這個方法不存在,要么x中某些方法調用了super.m()。無論上述那種情況,this.getClass()都會返回c1,而不是C.m()中的c。不過如果C是被final修飾的,那每次都會返回c是成立的。

Q:我自定義了一個equals方法,但是Hashtable忽略了它,為什么?

想要完全理解equals函數實際上是很難的。首先看下面幾方面:

1、你定義了一個錯誤的equals方法。比如你這樣寫:

public class C 
{
    public boolean equals(C that) 
    { 
        return id(this) == id(that); 
    }
}

但為了讓table.get(c)能正常工作,你需要為equals方法設置一個Object類型參數,而不是C類型的參數:

public class C 
{
    public boolean equals(Object that) 
    {
        return (that instanceof C) && id(this) == id((C)that);
    }
}

為什么?Hashtable.get方法大概這樣:

public class Hashtable 
{
    public Object get(Object key) 
    {
        Object entry;

    //...

    if (entry.equals(key)) //...
}

}</pre>

現在,entry.equals(key) 觸發的方法取決于實際運行時的對象引用entry,以及聲明的編譯時變量key的類型。所以,當你調用table.get(new C(…))時,this會在C類中尋找參數為Object的equals方法。如果恰巧你有一個參數定義為為C的equals方法,那并沒有任何關系。它會忽略,并繼續尋找函數簽名為equals(Object)的函數,最終找到equals(Object)。如果你想重寫一個方法,你需要將它們的參數類型也匹配上。有些情況下,你可能想要兩種方法,這樣可以在類型已知的情況下避免由類型轉換帶來的額外開銷:

public class C
{
    public boolean equals(Object that) 
    {
        return (this == that) || ((that instanceof C) && this.equals((C)that));
    }

public boolean equals(C that)
{
    return id(this) == id(that); // Or whatever is appropriate for class C
}

}</pre>

2、你實現的equals方法并不是絕對等價的:equals方法必須是對稱的、傳遞的和自反的。對稱性是指a.equals(b)的值必須與b.equals(a)一致。(大多數人會把這一點搞混。)傳遞性是指如果a.equals(b)為真且b.equals(c)也為真那么a.equals(c)必須為真。自反性是指a.equals(a)必須為真,并且這也是為什么要有上述(this == that)這個條件測試(這是比較好的做法,因為這會提高效率:利用==測試要比跟蹤一個對象進行測試快很多,并且一定程度上屏蔽了循環指針鏈的遞歸問題)。

3、你忘記了hashCode方法。任何時候你定義了一個equals方法,那么就應該同時定義一個hashCode方法。你必須保證兩個相等的對象有著同樣的hashCode,并且如果你想追求更好的hashtable性能,應該嘗試著把最不相等的對象設置成不同的hashCodes。一些類將hashCodes進行了緩存,所以它們僅被計算一次。如果是這樣的話,你在equals方法中加一句if (this.hashSlot != that.hashSlot) return false會節省不少時間。

4、你沒有處理好繼承。首先,考慮到如果來自兩個不同類的對象可以相等的話。在你說“不!一定不會!”之前,想想下面這種情況:一個Rectangle類中有width和height兩個字段,另一個Box類除了上述兩個字段外還有一個depth字段。那么,如果depth==0,這時的Box是否與Rectangle等價呢?你也許會贊成這個觀點。如果你所處理的類不是被final修飾的,那么它有可能成為其它類的父類,此時作為一個良民,你會想要善待你的子類。特別的情況下,你可能想允許C類的子類利用super調用C.equals(),就像這樣:

public class C2 extends C 
{
    int newField = 0;
    public boolean equals(Object that) 
    {
        if (this == that) 
            return true;
        else if (!(that instanceof C2)) 
            return false;
        else 
            return this.newField == ((C2)that).newField && super.equals(that);
    }
}

為了能實現上述功能,你需要在C.euqals的定義中對類謹慎地處理。例如,檢查類型時用that instanceof C而不是that.getClass() == C.class。具體原因參看前面IAQ。如果你確定兩個對象的父類一樣的時候是相等的,那就可以使用this.getClass() == that.getClass() 。

5、你沒有處理好循環引用,比如像這樣:

public class LinkedList 
{
    Object contents;
    LinkedList next = null;

public boolean equals(Object that) 
{
    return (this == that) || ((that instanceof LinkedList) && this.equals((LinkedList)that));
}

public boolean equals(LinkedList that) 
{
    // Buggy!
    return  Util.equals(this.contents, that.contents) &&
            Util.equals(this.next, that.next);
}

}</pre>

這里我假設有一個Util類可以做如下工作:

public static boolean equals(Object x, Object y)
{
    return (x == y) || (x != null && x.equals(y));
}

我想把這個方法放到Object內部;如果沒有它,那你不得不在測試的時候拋出null指針的異常。總之,LinkedList.equals 這個方法如果用來檢測兩個循環引用的鏈表,那它永遠不會返回(鏈表中一個元素指向另一個元素)。至于如何在線性時間內,僅使用兩個字的額外存儲空間完成這件事,請參看Common Lisp的list-length函數的描述。(我怕你們想自己搞清楚它,所以這里就不劇透答案了。)

Q我嘗試向super傳一個方法,但有時候它不正常工作。為什么?

下面是針對上述問題的一段簡化后的代碼示例:

/** A version of Hashtable that lets you do

  • table.put("dog", "canine");, and then have
  • table.get("dogs") return "canine". / public class HashtableWithPlurals extends Hashtable { / Make the table map both key and key + "s" to value. **/ public Object put(Object key, Object value) {
     super.put(key + "s", value);
     return super.put(key, value);
    
    } }</pre>

    你需要在調用super的時候非常小心,并且一定要清楚super的方法究竟會做什么。在這個例子中,Hashtable.put 的職責是將key和value的映射關系記錄到表中。然而,如果hashtable太滿了,那Hashtable.put 會為表分配一個更大的數組,將所有舊的對象拷貝過去,然后再次遞歸調用table.put(key, value)。因為Java是根據目標運行時的類別解析方法的,在這個例子中,代碼中Hashtable遞歸調用將會調用HashtableWithPlurals.put(key, value)。最終的結果就是:有時候(當table的容量在錯誤的時間溢出時),你在得到“dogs”和“dog”的同時,也得到一個“dogss”。任何文檔提到過put遞歸調用這種現象有時會發生么?沒有。在這種情況下,查看JDK的源碼是非常有幫助的。

    Q為什么在我使用get時,Properties對象總是忽略默認值?

    你不應該對Properties對象調用get方法;而應該調用getProperty方法。許多人認為二者的區別是getProperty聲明了返回值為String類型,而get聲明的返回值類型為Object。但實際上二者之間有更大的區別:getProperty會查看默認值。get是繼承自Hashtable的方法,它會忽視默認值,所以get的職責就像Hashtable文檔中描述的一樣,但是這種方式可能會跟你想象中的不一樣。其它繼承自Hashtable的方法也會忽略默認值(如isEmpty和toString方法),舉個例子:

    Properties defaults = new Properties();
    defaults.put("color", "black");
    Properties props = new Properties(defaults);
    System.out.println(props.get("color") + ", " +
    props.getProperty(color));
    // This prints "null, black"

    這點在文檔中有描述么?可能吧。Hashtable的文檔中提到了table的實體,同時提到了如果你認為默認值不是表中實體的話,那么Properties的行為是與Hashtable一致的。如果出于某些原因,你認為默認值是表中的實體(正如你會以為能得到與getProperty一樣的效果)那你就暈了。

    Q承看起來很容易出錯。有什么辦法能防止犯錯么?

    前兩個問題都表示出了一個觀點,那就是程序員需要在繼承類的時候特別小心,并且在使用其它類的子類時也同樣要小心。上述兩個問題讓John Outsterhout發表了如下言論“實現繼承導致了代碼之間糾纏不清,變得更為脆弱,這正如goto語句被濫用時發現的問題一樣。最終,這導致面向對象系統經常飽受復雜度和缺乏代碼重用的困擾” (Scripting, IEEE Computer, March 1998)。與此同時,據說Edsger Dijkstra說過“面向對象編程有時也并不盡如人意,這極有可能起源于加利福尼亞”(來自一些簽名的文件)。

    我認為沒有可以保證一定安全的方法,但是下面是一些可以加以考慮的事情:

    • 繼承一些沒有源碼的類是很有風險的;在你不能預見的某些情況下,文檔可能是不完整的
    • 調用super方法一般會導致不可預料的問題。
    • 對于不需要重寫的方法,你需要花與重寫方法同樣多的精力來處理。這是利用面向對象的繼承機制的一大缺點。繼承的確可以讓你少些一些代碼,但你為此也不得不考慮一下那些沒有用到的代碼。
    • 如果你在子類中違背了父類中的任何方法的約定,亦或是違背了整個父類的約定,那你就是自討苦吃了。約定何時被改變很難說,因為契約是非正式的(正式的部分包括了類型簽名,但是剩下的部分僅在注釋里體現而已)。在Properties例子中,很難說契約到底有沒有被打破,因為并沒有明確指定默認值是否被考慮為table的實體。

    Q除了繼承,還有其它類似的做法

    委托是繼承的一種替代品。委托的意思就是可以將其它類的實例以實例變量的方式添加到一個類中,并將參數傳遞給這個實例變量。通常來講,這要比繼承更加安全,因為由于實例變量是一個已知類,而不是一個新類,所以這么做的話會迫使你深思熟慮每次要傳遞的參數與此同時,這么做也不會強制你接受父類的所有方法:你可以僅使用其中一些需要的方法。另一方面,這會使你寫更多的代碼,也就導致了其很難復用(因為它不是一個子類)。

    在HashtableWithPlurals例子中,利用代理的方式可以這樣寫注意:在JDK1.2版本中,Dictionary是不推薦使用的;可以使用Map替代):

    /** A version of Hashtable that lets you do
  • table.put("dog", "canine");, and then have
  • table.get("dogs") return "canine". / public class HashtableWithPlurals extends Dictionary { Hashtable table = new Hashtable(); / Make the table map both key and key + "s" to value. **/ public Object put(Object key, Object value) {
     table.put(key + "s", value);
     return table.put(key, value);
    
    } //... Need to implement other methods as well }</pre>

    在Properties例子中,如果你想強調默認值是實體這種解釋的話,那最好使用代理。為什么Properties還用繼承處理呢?因為Java的實現團隊追求簡潔的代碼,而且他們太匆忙了。

    Q為什么Java里沒有全局變量?

    由于一些原因,并不推薦大家使用全局變量:

    • 添加全局變量打破了引用透明的原則(你永遠不再可能通過單一語句或表達式明白它們各自的含義了:你需要要結合它們在上下文中設置的各種全局變量來進行理解)。
    • 全局變量會使程序變得低內聚:你需要了解更多的信息來理解代碼是怎么運行的。面向對象編程的一大主要特點就是將全局的變量打散,使其變成更容易理解的局部變量
    • 當你添加一個全局變量時,你的程序就被限制成只能運行一個實例了。你眼中的全局別人看來可能認為是局部的:他們可能想同時運行兩個程序。

    出于上述原因,Java決定廢棄全局變量。

    Q我還是很懷念全局變量。我能做點什么?

    那要看你想做什么了。無論哪種情況,你都需要確定以下兩件事:認清這個所謂的全局變量一共需要多少個副本?以及放在哪里比較合適?以下是一些常見的解決方案:

    如果你真的只是想在用戶首次啟動JVM的時候,在程序中保留一個副本的話,那你也許可以使用一個靜態實例變量。比如,在你的應用中有一個叫做MainWindow的類,并且你想記錄下用戶打開窗口的數量,并在用戶關閉最后一個窗口時初始化“真要退出嗎?”這個對話框。如此一來,你可以這樣做:

    // One variable per class (per JVM)

public Class MainWindow {

static int numWindows = 0;

...

// when opening: MainWindow.numWindows++;

// when closing: MainWindow.numWindows--;

}</pre>

大多數情況下,你需要的只是一個類的實例變量。比如,假設你在寫一個網頁瀏覽器并且想將訪問歷史記錄當做全局變量那么在Java中,如果將其設置成一個Browser類內的實例變量會更好。這樣的話,用戶完全可以在同一個JVM中同時運行兩個瀏覽器,之間也不會相互影響。

// One variable per instance

public class Browser {

HistoryList history = new HistoryList();

...

// Make entries in this.history

}</pre>

現在假設你完成了瀏覽器的大部分設計與實現,這時候你發現想要在Http類內的Cookies類里面打印出一些錯誤信息但是不知道在哪里展示這些信息。你可以簡單在Browser類中添加一個實例變量,用它來記錄待輸出的流或幀,但目前你還沒有將當前的Browser對象中的實例傳遞給Cookies類的方法。你并不希望在傳遞Browser對象的時候修改大部分的函數簽名。你也不能用一個靜態變量來解決,因為可能有多個Browser對象同時運行。然而,如果你可以保證每個線程中只有一個Browser對象(盡管每個Browser對象可能會有多個線程),那么有一種比較好的解決方法:在Browser類中存儲一個靜態表,保存線程與Browser對象之間的映射關系,然后根據當前所在線程查找正確的Browser對象(這里就是要找到待顯示錯誤信息的Browser對象)。

// One "variable" per thread

public class Browser {

static Hashtable browsers = new Hashtable();

public Browser() { // Constructor

browsers.put(Thread.currentThread(), this);

}

...

public void reportError(String message) {

Thread t = Thread.currentThread();

((Browser)Browser.browsers.get(t))

  .show(message)

}

}</pre>

最后,如果你想要一個全局變量在JVM期間一直存在,亦或是想讓其在多個JVM之間通過網絡互相共享。那么你大概需要一個通過JDBC訪問的數據庫,或者將數據序列化,然后將它存成文件的形式。

Q我可以將Math.sin(x)寫成sin(x)嗎?

長話短說:Java1.5之前的版本不可以。Java1.5之后的版本可以通過引用static imports實現;你現在可以這樣寫:import static java.lang.Math.*然后直接可以調用sin(x)。但是要注意來自Sun的警告“:你什么時候應該使用靜態導入?一定要謹慎!”

下面是一些針對Java1.5之前版本的解決方案:

 

</tr>

如果你只是想用Math中的一小部分方法,那你可以將它們封裝到你自己的類中:

</td>

public static double sin(double x) 
{
    return Math.sin(x); 
}

public static double cos(double x) { return Math.cos(x); }

//...

sin(x)</pre></td> </tr>


靜態方法需要一個目標(就是點符號左面的東西),這個目標要么是一個類的名字,要么是一個與具體取值無關的某個類的對象,但一定要聲明成正確的類。所以你可以為每次調用少敲三個字符,就像這樣:

</td>

// Can't instantiate Math, so it must be null.
Math m = null;
//...
m.sin(x)


java.lang.Math是被final修飾的類,所以不能被繼承,但如果你有一些自己的靜態函數,并且想在自己的各個類之間互相共享使用的話,那你可以把它們打包起來,然后再使用的時候繼承它們

</td>

public abstract class MyStaticMethods
{
    public static double mysin(double x) 
    {
        //...
    }
}

public class MyClass1 extends MyStaticMethods { //... mysin(x) }</pre></td> </tr> </tbody> </table>

Just Java的作者Peter van der Linden在他的FAQ中反對上述最后兩種做法。大多數情況下,我也認為Math m = null 是一種糟糕的做法,但我不認同MyStaticMethods 的例子是一種“為了使用可有可無的縮寫(不如直接用類別層級的方式進行表示)而導致缺乏面向對象風格的繼承做法”。首先,說縮寫不重要是一種旁觀者的想法;縮寫可能是極其重要的(參看這個例子來了解我是如何利用這種做法來達到理想效果的)。其次,倒不如他自以為是的說這是一種糟糕的面向對象風格。對于Java來講,你可以說這事一種糟糕的風格,但是對于具有多繼承機制的語言來講,我這種用法更容易被接受。

另一種考慮這個問題的點是:Java的某些特性(對每個語言來講)會有一些無可避免的權衡,并且其中還混雜著各種問題。我同意MyClass1繼承MyStaticMethods這種做法會誤導用戶以為MyClass1繼承了一些來自MyStaticMethods的方法,并且我也贊同這樣做會無法繼承真正需要的類,這也是不好的。但對Java而言,類一般是封裝和編譯(大部分時候)和一些命名空間的單元。MyStaticMethods這種方法在繼承機制面前有負面效果,但是在命名空間這方面有正面作用。如果你認為繼承更重要,那我不會與你爭論了。但你真的認為一個類同時做多件事要比只做一件事好嗎?你真的認為風格的規定一定比權衡更重要嗎?

Qnull是Object類型么?

當然不是。我這里的否定是指null instanceof Object 會返回false。

下面是一些你需要了解的與null相關的事情:

1、你不能對null調用方法:當x是null且m是非靜態方法時,調用x.m()是錯誤的。(當m時靜態方法時候是合法的,但那是跟x的類相關,與x這個對象本身的值并無關系。)

2、null只有一個,并不是每個類都有一個自己的null。例如,((String) null == (Hashtable) null)這樣會返回true。

3、可以將null當做參數傳給一個方法,前提是這個方法支持這種做法。要注意的是,有些方法支持這樣做,有些方法不支持。比如,System.out.println(null)這樣寫沒有問題,但是string.compareTo(null)這樣就不行了。所以除非參數本身是顯而易見的,否則你寫方法的時候應該在javadoc里說明null這種參數是否是合法的。

4、JDK1.1到1.1.5版本中,將null當做參數直接傳給匿名內部類的構造函數如new SomeClass(null){…})會導致編譯錯誤。但傳入一個結果是null的表達式是沒有問題的,或者傳入強制類型轉換的null也可以,如new SomeClass( (String)null){…}

5、Null通常來講至少有三種不同的含義:

  • 未初始化。變量或內存地址尚未被賦值。
  • 不存在/不可用。比如,在二叉樹中,一般會將普通節點的子節點的指針設為null,以此來表示一個葉節點。
  • 空。比如,你可能會用null表示一棵空樹。注意,雖然有些人會混淆,但這與上一點有些許的不同。不同之處在于null是否為一個可以接受的樹節點,還是一個表示不是樹節點的特殊值。對比下列三種不同的二叉樹中序周游的實現:
  • </ul>

  • // null means not applicable

    // There is no empty tree.

    class Node {

    Object data;

    Node left, right;

    void print() {

    if (left != null)
    
      left.print();
    
    System.out.println(data);
    
    if (right != null)
    
      right.print();
    
    

    }

    }</pre></td>

    // null means empty tree

    // Note static, non-static methods

    class Node {

    Object data;

    Node left, right;

    void static print(Node node) {

    if (node != null) node.print();
    
    

    }

    void print() {

    print(left);
    
    System.out.println(data);
    
    print(right);
    
    

    }

    }</pre></td>

    // Separate class for Empty

    // null is never used

    interface Node { void print(); }

    class DataNode implements Node{

    Object data;

    Node left, right;

    void print() {

    left.print();
    
    System.out.println(data);
    
    right.print();
    
    

    }

    }

    class EmptyNode implements Node {

    void print() { }

    }</pre></td> </tr> </tbody> </table>

    QObject究竟有多大?為什么沒有sizeof?

    C語言有sizeof運算符,這是必須要有的,因為用戶需要管理malloc的調用,同時也是因為一些原生的類型(如long)的大小并沒有 一個 統一的標準。Java并不需要sizeof,但是如果有這個運算符的話當然會方便很多。如果想在Java里得到類似sizeof的效果,你可以這樣做:

    static Runtime runtime = Runtime.getRuntime();

    ...

    long start, end;

    Object obj;

    runtime.gc();

    start = runtime.freememory();

    obj = new Object(); // Or whatever you want to look at

    end = runtime.freememory();

    System.out.println("That took " + (start-end) + "

    bytes.");</pre>

    這個方法并不總是奏效,因為垃圾回收可能發生在你代碼正在進行檢測的時候,那樣就會丟掉字節的計數。并且,如果你使用的是JIT類的編譯器,那么生成代碼也會產生一些額外的字節。

    在Sun 的JDK VM中,你也許會感到很吃驚,一個Object會占用16字節,或是4字大小。其中的內容是這樣的:頭信息占用了兩個字大小,一個字指向了對象所屬的類,另一個字指向了實例的變量。即使Object沒有實例變量,Java也會為其分配一字大小的空間。最后,還有一個“handle”,這是一個指向兩字大小的頭信息的指針。Sun聲稱這一額外的間接層使垃圾回收過程變得更為簡單。(而近15年以來,高性能Lisp和Smalltalk兩種語言卻不使用間接層的垃圾回收器。我也聽說微軟的JVM并沒有這種額外的間接層,這點尚未被我證實。)

    一個空的new String()占用40字節,或是10字:3個字來存儲頭信息,3個字來存儲實例變量(開始索引結束索引以及字符數組),和4個字來存儲空的字符數組。從一個已有的字符串建立一個字串僅需6個字的空間,因為字符數組是共享的。將Integer類型的key/value鍵值對存入Hashtable需要64字節(這其中包含了預先給Hashtable中的數組所分配的4字節):我會讓你明白這是為什么。

    Q初始化代碼的執行順序是怎樣的?我應該怎么安排它們?

    在一個類中,實例變量的初始化代碼可以出現在3個地方:

    </tr>

    </tr>

    </tr> </tbody> </table>

    當你寫下new C()時,初始化的順序是這樣的(不考慮內存不夠的情況):

    1、調用C父類的構造函數(除非C是Object這個類,因為Object沒有父類)。大多數情況都會調用無參數構造函數,除非程序員在構造函數最開始的時候顯式地寫下了super(…)。

    2、一旦父類的構造函數返回了,接下來實例變量初始化器和對象初始化器會按照文字順序(從左到右)執行。不要被javadoc和javap用字母順序所迷惑,在這里并不重要。

    3、現在會執行構造函數中余下的代碼。這里可以設置實例變量,或者做任何其它事情。

    實際上你對上述三種初始化方式有很大的自主選擇權。我推薦的是使用實例變量初始化器,這樣一來,如果這個變量的值與所用的構造函數無關,則可以不必為每個構造函數都寫一遍初始化代碼了。僅在初始化情況非常復雜(比如 需要用到循環)的時候再去使用代碼塊初始化,這樣可以避免在多個構造函數中重復初始化同樣的東西。剩下的就可以讓構造函數去完成了。

    下面是一個例子:

    在類(或父類)的實例變量初始化器中。

    class C 
    {
        String var = "val";}
    </td>


    在類(或父類)的構造函數中。


    public C() { var = "val"; }
    </td>


    類的初始化代碼塊中。這是Java1.1中新加入的功能;這類似于靜態初始化代碼塊,但是不用static關鍵字修飾。


    { var = "val"; }
    </td>


    Program:


    class A {

    String a1 = ABC.echo(" 1: a1");
    
    String a2 = ABC.echo(" 2: a2");
    
    public A() {ABC.echo(" 3: A()");}
    
    

    }

    class B extends A {

    String b1 = ABC.echo(" 4: b1");
    
    String b2;
    
    public B() {
    
        ABC.echo(" 5: B()");
    
        b1 = ABC.echo(" 6: b1 reset");
    
        a2 = ABC.echo(" 7: a2 reset");
    
    }
    
    

    }

    class C extends B {

    String c1;
    
    { c1 = ABC.echo(" 8: c1"); }
    
    String c2;
    
    String c3 = ABC.echo(" 9: c3");
    
    public C() {
    
        ABC.echo("10: C()");
    
        c2 = ABC.echo("11: c2");
    
        b2 = ABC.echo("12: b2");
    
    }
    
    

    }

    public class ABC {

    static String echo(String arg) {
    
        System.out.println(arg);
    
        return arg;
    
    }
    
    public static void main(String[] args) {
    
        new C();
    
    }
    
    

    }</pre></td>


    </td> </tr> </tbody> </table>

    輸出:

    1: a1

    2: a2

    3: b1

    4: B()

    5: b1 reset

    6: a2 reset

    7: c1

    8: c3

    9: C()

    10: c2

    11: b2

    Q談談類的初始化?

    從實例創建中區分出類的初始化是很重要的一點。實例在你利用new來調用構造函數時被創建。一個類C,是在第一次被激活使用的時候初始化的。在這個過程中,這個類的初始化代碼會以文本順序運行。一共有兩種類初始化代碼:靜態初始化代碼塊(static {…})和類的變量初始化(static String var = …)。

    以下是對激活使用(active use)的一些定義,當你第一次進行如下任何一種操作時,就出發了激活使用這個條件:

    1、通過調用構造函數創建了一個C的實例。

    2、調用了C中定義的的靜態方法(不是繼承來的)。

    3、對C中定義的靜態變量(不是繼承來的)進行讀寫。如果靜態變量是被常量表達式(比如一些只用到了原始操作符的表達式(如+或者||)、常量以及被static final所修飾的變量)那么不會算數,因為這些是在編譯的時候被初始化的。

    下面是一個例子:

  • Program:

    class A {

    static String a1 = ABC.echo(" 1: a1");
    
    static String a2 = ABC.echo(" 2: a2");
    
    

    }

    class B extends A {

    static String b1 = ABC.echo(" 3: b1");
    
    static String b2;
    
    static {
    
        ABC.echo(" 4: B()");
    
        b1 = ABC.echo(" 5: b1 reset");
    
        a2 = ABC.echo(" 6: a2 reset");
    
    }
    
    

    }

    class C extends B {

    static String c1;
    
    static { c1 = ABC.echo(" 7: c1"); }
    
    static String c2;
    
    static String c3 = ABC.echo(" 8: c3");
    
    static {
    
        ABC.echo(" 9: C()");
    
        c2 = ABC.echo("10: c2");
    
        b2 = ABC.echo("11: b2");
    
    }
    
    

    }

    public class ABC {

    static String echo(String arg) {
    
        System.out.println(arg);
    
        return arg;
    
    }
    
    public static void main(String[] args) {
    
        new C();
    
    }
    
    

    }</pre></td>

    </tr> </tbody> </table> </td> </tr> </tbody> </table>

    輸出:

    1: a1

    2: a2

    3: b1

    4: B()

    5: b1 reset

    6: a2 reset

    7: c1

    8: c3

    9: C()

    10: c2

    11: b2

    Q我有一個類,內有6個實例變量,每一個變量都可以選擇初始化或者不初始化。那么我是應該寫64個構造函數么?

    你當然不需要寫(26)個構造函數。假設你有一個類叫C,它的定義如下:

    public class C { int a,b,c,d,e,f; }

    你可以為構造函數做如下幾件事:

    1、對極有可能需要的幾種變量組合進行猜測,并且為之提供構造函數。贊成的觀點認為:這是慣用的做法。反對的觀點認為:很難完全猜對;會產生大量冗余代碼。

    2、定義可串聯的setter方法,因為它們會返回this。如此一來,為每個實例變量定義一個setter,然后調用默認構造函數之后調用它們

    public C setA(int val) { a = val; return this; }

    ...

    new C().setA(1).setC(3).setE(5);</pre>

    贊成:這是一種相當簡潔且高效的方法。一些類似的觀點在Bjarne Stroustrop的The Design and Evolution of C++一書中第156頁被討論過了。反對:你需要實現所有的setter,這并不遵從JavaBean規則(因為它們返回this而不是void),并且如果兩個值之間需要交互的話那這種方法也不適用了。

    3、在默認的構造函數中利用非靜態的初始化代碼塊對匿名子類進行初始化:

    new C() {{ a = 1; c = 3; e = 5; }}

    贊成:十分簡潔,沒有使用setter那么凌亂;反對:實例變量不能是私有的,處理子類需要額外的間接成本,而這個對象可能根本就不是C這個類(雖然它是C的一個實例)。這僅在你對實例變量有訪問權限的時候才管用,然而包括經驗豐富的Java程序員在內的大多數人都不會明白。

    實際上很簡單:定義一個新的沒有命名的(匿名的)C的子類,而這個子類沒有新添任何方法或變量,但初始化代碼塊初始化了ac和e。如此定義這個類的話,你就相當于在創建一個實例。當我把這展示給Guy Steele看得時候,他說“哈哈!這太酷了,好吧,但我可能不會提倡這么做……”。和平時一樣,Guy是對的(對了,你還可以用這種方法創建并初始化向量。你要知道能如此創建并初始化是非常給力的一件事兒,想想看,new String[] {“one”, “two”, “three”}就可以初始化一個String數組了。 曾經你以為必須用賦值語句對vector進行初始化的工作,現在也可以用類似的方法解決了new Vector(3) {{add(“one”); add(“two”); add(“three”)}})。

    4、你可以換一種支持選擇性初始化部分變量的語言。比如,C++就支持默認參數。所以你可以這么寫:

    class C {

    public: C(int a=1, int b=2, int c=3, int d=4, int e=5);

    }

    ...

    new C(10); // Construct an instance with defaults for b,c,d,e</pre>

    Common Lisp和Python都有關鍵字參數,也支持默認參數,所以你可以這么寫:

    C(a=10, c=30, e=50)            # Construct an instance; use defaults for b and d.

    Q何時調用構造函數,何時調用其它法呢?

    最直觀的回答就是在你想new一個對象的時候調用構造函數;這是new這個關鍵字的用途。而我的回答是:構造函數往往被濫用了,調用它們和它們所做的工作兩方面都被濫用了。下面是一些需要考慮的問題:

    • Setter方法:正如我們在之前的問題中所看到的,有些人會寫很多構造函數。而通常來講,最好要控制住構造函數的數量,然后提供一些setter方法,讓他們它們做剩余的初始化工作。如果這些方法返回this,那你可以通過一個表達式就完成對象的創建;否則,創建一個對象需要多條語句。善用setter方法是件好事,因為在創建對象時需要修改的變量往往之后也可能要修改,所以為什么要在構造函數和setter方法里寫一樣的代碼呢?
    • 工廠:有時候你想創建某個類或某個接口的實例,但你并不關心到底是那個子類創建的,亦或你想推遲到運行時再做決定。比如,你正在寫一個計算器程序,你可能會想調用new Number(string)如果string是浮點型格式的話希望它返回Double,如果string是整數格式的話 希望它返回Long。但出于以下兩點,你無法實現上述功能:Number是一個抽象類,你不能直接調用它的構造函數,并且每一次調用構造函數都會返回所屬類的實例,而并不是它子類的實例。
    • 一種可以像構造函數一樣返回對象且對如何構造有更大選擇余地(也可以指定其類型)的方法被稱為工廠。Java沒有自帶對工廠模式的支持,但是你仍可以自己動手寫一個工廠模式。
    • 緩存與回收:構造函數一定會創建一個新的對象。但是創建一個新的對象消耗非常大。像現實世界中一樣,你可以以循環利用的方法來降低垃圾回收的代價。比如,new Boolean(x)會創建一個Boolean對象,但你最好優先循環使用已有的值(x ? Boolean.TRUE : Boolean.FALSE),而不是浪費資源去申請一個新的。如果Java提倡使用上述的機制而不是單一的提倡使用構造函數就完美了。Boolean只是一個例子;你應該也考慮其它不可變,諸如CharacterInteger也許還包括一些你自定義的類。下面是一個有關Number的回收工廠的例子。如果我有選擇權的話,我想調用Number.make,但是很顯然我沒法向Number類添加方法,所以我只能用別的方法了:
    • </ul>

      public Number numberFactory(String str) throws NumberFormatException {

      try {
      
        long l = Long.parseLong(str);
      
        if (l >= 0 && l < cachedLongs.length) {
      
          int i = (int)l;
      
          if (cachedLongs[i] != null) return cachedLongs[i];
      
          else return cachedLongs[i] = new Long(str);
      
        } else {
      
          return new Long(l);
      
        }
      
      } catch (NumberFormatException e) {
      
        double d = Double.parseDouble(str);
      
        return d == 0.0 ? ZERO : d == 1.0 ? ONE : new Double(d);
      
      }
      
      

      }

      private Long[] cachedLongs = new Long[100];

      private Double ZERO = new Double(0.0);

      private Double ONE = new Double(1.0);</pre>

      可以看出new的功能很有用,但是工廠的回收機制同樣很有用。Java之所以僅支持new,是因為這是最簡單最有效的方法,并且Java的宗旨也是盡量保持語言自身的簡潔。但這并不意味著你自己的類庫需要按照這一低標準來約束自己。(而且這并不意味著內置的庫也需要這種約束條件,但是很可惜,他們還是這么做了。)

      Q我的代碼會在創建對象或在GC開始之前時被殺掉嗎?

      假設應用程序不得不操縱許多3D幾何點。很明顯,依Java的風格來做就是去寫一個Point類,內含3個double變量x、y、z坐標。但是為大量點進行申請和回收的確會導致性能上的問題。而你可以自己建立資源池對存儲進行管理。你可以在程序運行之初申請一大批Point對象,并將其存入數組中,而不是每次用到時才去申請。得到的數組(封裝在一個類中)就像Point的工廠一樣,但它是上下文感知的(socially-concious)回收工廠。調用pool.point(x,y,z) 時會返回數組中第一個未被使用的Point對象,將其3個變量設置為指定的值,并把它標記為已使用。而作為一個程序員來講,當這些對象不再使用時,將它們放回資源池中便成了你的責任。

      完成這點的方法有很多。如果你確定所申請的Point對象在使用一段時間之后會被丟棄的話,那最簡單的方法就是這樣做:利用int pos = pool.mark() 來標識當前資源池的位置。當你用完了之后,可以調用pool.restore(pos) 將原來位置的標志位重置。如果你想同時使用多個Point對象,那從不同的資源池里申請吧。資源池節省了垃圾回收時的開銷(如果你有一個好的處理對象回收的模型)但是你仍然躲不開初始化對象時候的開銷。你可以選擇用“Fortran式”的方法來解決這個問題:用三個數組來存儲x、y和z坐標,而不是用Point對象。你可以一個管理一批Point的類,而不必為單個點定義Point類。下面是一個資源池類的例子:

      public class PointPool {

      / Allocate a pool of n Points. /

      public PointPool(int n) {

      x = new double[n];
      
      y = new double[n];
      
      z = new double[n];
      
      next = 0;
      
      

      }

      public double x[], y[], z[];

      / Initialize the next point, represented as in integer index. /

      int point(double x1, double y1, double z1) {

      x[next] = x1; y[next] = y1; z[next] = z1;
      
      return next++;
      
      

      }

      / Initialize the next point, initilized to zeros. /

      int point() { return point(0.0, 0.0, 0.0); }

      / Initialize the next point as a copy of a point in some pool. /

      int point(PointPool pool, int p) {

      return point(pool.x[p], pool.y[p], pool.z[p]);
      
      

      }

      public int next;

      }</pre>

      你可以這樣使用它:

      PointPool pool = new PointPool(1000000);

      PointPool results = new PointPool(100);

      ...

      int pos = pool.next;

      doComplexCalculation(...);

      pool.next = pos;

      ...

      void doComplexCalculation(...) {

      ...

      int p1 = pool.point(x, y, z);

      int p2 = pool.point(p, q, r);

      double diff = pool.x[p1] - pool.x[p2];

      ...

      int p_final = results.point(pool,p1);

      ...

      }</pre>

      用PointPool 的方法申請100萬個點花了半秒鐘,而用Point類直接申請100萬個點的方法需要6秒鐘,所以相當于提速了12倍。

      把p1,p2和p_final直接當做Point來聲明遠比當做int來聲明好的多吧?在C/C++中,你可以用typedef int Point命令,但是Java不允許這樣做。如果你想冒險一下,可以自己設置一下makefile,讓文件在Java編譯器運行之前先過一遍C語言的預處理器,然后你就可以這樣寫了:#define Point int.

      Q我在循環中有一個復雜的表達式。為了保證效率,我想讓這個計算僅做一次。但是為了可讀性,我想讓留在循環里被調用的地方。我該怎么辦?

      我們假設有這樣一個例子,match是一個正則表達式的模式匹配函數,compile將一個字符串編譯成一個有限狀態機以供match調用:

      for(;;) {

      ...

      String str = ...

      match(str, compile("abc*"));

      ...

      }</pre>

      由于Java沒有宏定義,隨著時間的推移,你也許會需要一些控制,但你的選擇很有限。其中一種可行的選擇是,使用帶有變量初始化的內部接口,這雖然不優雅但是是一種可行的方法。

      for(;;) {

      ...

      String str = ...

      interface P1 {FSA f = compile("abc*);}

      match(str, P1.f);

      ...

      }</pre>

      P1.f會在第一次使用P1時進行初始化,并且不會再改變,因為接口中的變量是隱式的static final的。如果你不想這么做,那可以換一種可以提供更多控制選擇的語言。在Common Lisp中,字符序列#.表示其緊隨在后的表達式會在讀(編譯)時計算,而不是在運行時。所以你可以這樣寫:

      (loop

      ...

      (match str #.(compile "abc*"))

      ...)</pre>

      Q有哪些其它操作出奇的慢?

      我該從何說起?下面是一些最該知道的東西。我在一個循環里寫了一個計時功能,用來報告每秒鐘千次迭代速度(K/sec)和每次迭代所需微秒數(uSecs)。整個測試在Sparc 20上完成,JDK版本為1.1.4,編譯器為JIT。隨后我注意到了如下信息:

      • 這些實驗是在1998年完成的。編譯器已經有所變化了。
      • </ul>

        • 遞減計數要比遞增計數快兩倍:我的機器可以在一秒鐘內遞減計數1.44億次,但遞增計數只能完成7200萬次。
        • </ul>

          • 調用Math.max(a,b)要比(a > b) ? a : b慢七倍,這是由于函數調用引起的。
          • </ul>

            • 數組要比Vectors快15到30倍。Hashtable要比Vector快2/3倍。
            • </ul>

              • Bitset.get(i)要比bits & 1 << i慢60倍。這大部分時候是因為函數的同步調用造成的。當然,如果超過了64位,這個測試可能就不準了。下面是對一些數據結構進行讀寫操作的時間耗費表:
              • </ul>


    K/sec uSecs Code Operation

    ========= ======= ==================== ===========

    147,058 0.007 a = a & 0×100; get element of int bits

    314 3.180 bitset.get(3); get element of Bitset

    20,000 0.050 obj = objs[1]; get element of Array

    5,263 0.190 str.charAt(5); get element of String

    361 2.770 buf.charAt(5); get element of StringBuffer

    337 2.960 objs2.elementAt(1); get element of Vector

    241 4.140 hash.get(“a”); get element of Hashtable

    336 2.970 bitset.set(3); set element of Bitset

    5,555 0.180 objs[1] = obj; set element of Array

    355 2.810 buf.setCharAt(5,’ ‘) set element of StringBuffer

    308 3.240 objs2.setElementAt(1 set element of Vector

    237 4.210 hash.put(“a”, obj); set element of Hashtable

    </td> </tr> </tbody> </table>

    • Java編譯器在循環中能檢測出常量這件事做得并不好。C/Java的for循環是個比較糟糕的概念,因為它大部分時候會重復計算結束條件。比如,for(int i=0; i<str.length(); i++) 要比int len = str.length(); for(int i=0; i<len; i++)慢三倍。
    • </ul>

      Q我能從書本中獲得一些關于Java的好建議么?

      Java相關的書籍有很多,大概可以分為三個等級:

      糟糕的。大部分Java書籍都是由那些找不到Java相關工作的人寫出來的(因為編程幾乎總是比出書更掙錢;我都干過,所以我敢這么說)。這種書漏洞百出,會有不好的建議以及糟糕的程序。這些書對于初學者來說很危險,但是對其它語言稍有經驗的編程人員是可以很容易認出并拒絕的。

      非常好的。Java類的好書數量并不多。我更偏向于官方說明文檔Arnlod和GoslingMarty HallPeter van der Linden這些作者寫的書。作為參考,我喜歡Java in a Nutshell系列書籍,以及Sun的在線參考(我將javadoc API語言規范以及它們的變更都拷到了本地硬盤中。此外我將它們添加到了我瀏覽器的書簽里,這樣我總是可以快速訪問它們了。)

      不確定的。在上述兩種極端之間存在著一些中等質量的書籍,一般這些書是由不夠了解Java的人寫出來的他們要么沒花時間研究Java究竟是如何工作的,或者是想快速出版太過倉促。舉一個例子,來自Rise and Resurrection of the American Programmer的 Edward Yourdon的Java and the new Internet programming paradigm一書。下面是Yourdon眼中與眾不同的Java:

      • “函數被廢止了”Java中并沒有“function”這種字眼,所以這么說并沒有錯。Java稱之為method (Perl稱之為subroutines, Scheme稱之為procedure, 但你并不能說這些語言中沒有函數)。你盡可以說Java中沒有全局函數,但我認為這樣說更精確:Java中全局作用域的函數是存在的,只不過它們必須被定義到一個類里,并且稱之為“靜態方法C.f”而不是“函數f”。
      • 自動數據類型轉換被廢止了。雖然強制兩類轉換是限制的,但是遠不到被廢止那個份上。你仍然可以寫(1.0 + 2),2會自動被轉換成double類型。或者你也可以直接寫(“one” + 2)2會自動轉為string。
      • 指針和指針相關操作被廢止了。顯示的對指針進行操作被廢止了是沒錯的(可算解脫了)。但是指針還是被保留下來了;事實上,每一個對象的引用都是指針。(這就是為什么我們會遇到NullPointerExceptioin。)對于一個有競爭力的Java程序員來講,這應該是必須知道的內容。每個Java程序員都應該知道,當你這樣做的時候:
      • </ul>

        int[] a = {0, 1, 2};
        int[] b = a;
        b[0] = 99;

        那a[0]也會變成99,因為a和b都是同一個對象的指針(或引用)。