(ZZ)蔡学镛先生的一片好文章-OO的特性解析

OO [/size]


Java 是物件導向的(object-oriented)語言。物件導向近年來成為顯學,全新的程式語言幾乎都具備物件導向的特色,而舊的程式語言也紛紛在新版本中開始支援物件導向的特色。

所謂「物件導向的特色」,指的是物件導向的三個機制,三者缺一不可,此三個機制分別為封裝(encapsulation)、繼承(inheritance)、多型(polymorphism)。我也喜歡把這三個機制稱為三個階段,以突顯出它們之間的次序性:沒有封裝就不可能會有繼承,沒有繼承就不可能會有多型。為了稱呼上的方便,我們常常將物件導向簡稱為 OO。

有一些程式語言常被誤認為是 OO 的語言,但其實不然。例如:Visual Basic 6 以及更早的版本只支援封裝,不支援繼承和多型,所以不可以被視為 OO 的語言。這種只支援封裝 的語言,只能被稱為 object-based 程式語言。但是 Visual Basic 7(也就是 Visual Basic.NET)開始支援繼承和多型,所以可以被視為 OO 的語言,因此威力大增,語言的複雜度也會大增,因為繼承和多型會帶來許多複雜的特質。

[size=medium]封裝

關於封裝,你可以扮演兩種角色:

    * 封裝者:封裝出一個類別(class),以供他人使用。
    * 類別使用者:使用別人封裝出來的類別

程式員都是在這兩種角色之間不斷地轉換:一會兒封裝出一個類別,一會兒使用別人封裝好的類別。 當一個類別使用者顯然比當一個封裝者簡單許多,只要透過 new 這個關鍵字來產生類別的 instance,即可使用。如果欲使用的是 class method/field,甚至連 new 都可以省了。既然當類別使用者比當封裝者來得簡單,我們自然是盡量使用既有的類別, 這樣就可以達到很高的再用性(reusability)。Java 平台提供了數千個已經封裝好的類別,我們可以直接拿來使用, 這可以為我們省下許多時間。這些類別所形成的集合,就稱為 API。

查詢 Java API

J2SE 平台所具備的 API,就稱為 Core API(核心 API)。你可以在你的 Java 2 SDK 路徑下找到 docs/api/index.html,這就是 API 文件,從這裡,你就可以找到每個類別的用法(例如我們在 Hello 程式中使用的 System,就是一個類別)。畫面如下所示:



我很喜歡把程式設計比喻成設計一個主機板。主機板的設計者不用一切從頭來,因為他們有許多 IC chip(晶片)可以使用(其中有一個 chip 為 CPU)。從市面上的各種 chip 中(SIS、Intel、VIA…), 挑選出各種適合的 chip,再把這些 chip 全都放到主機板上適當的位置固定好,再把這些 chip 的接腳之間互相 連接起來就可以了。這麼一來,設計主機板的工作包含:

   1. 挑選 chip
   2. 應用這些 chip 來進行電路佈局(circuit layout)

寫程式很類似設計主機板,差異在於主機板是硬件,程式是軟件。寫 Java 程式不用一切從頭來,因為我們有許多類別可以使用。從 Java Core API 的類別中, 挑選出各種你需要的類別,把這些類別用在你的程式中,程式中利用 Java 的關鍵字(keyword) 和算符(operator)將這些類別銜接起來就可以了。這麼一來,寫程式的工作包含:

   1. 挑選適當的類別
   2. 應用這些類別來完成程式

繼承[size=medium][/size]

既有的 Java API 已經可以滿足我們大多數時候的需求,但還是有些時候,我們希望有一個類別能提供特定的功能,但是 Java API 不提供這樣的類別。我們通常會找協力廠商(thirty party)購買這樣的類別。有許多軟體公司專門提供類別庫(class library),收納了許多好用的類別,例如繪製統計圖表(chart)的類別、產生報表(report)的類別、複雜科學計算的類別……等。你可以在 WWW 上找到許多這樣的產品。

如果沒有任何廠商提供完全符合你需求的類別,或者你根本不想增加成本購買 third party 的類別(這些類別通常都不單獨出售, 而是許多類別被包在一起成為類別庫,且售價不便宜),那麼你只好自己寫一個這樣的類別。

假設我們現在就有這樣的需求,需要設計一個類別。在下面的程式碼中,我們封裝出一個名為 ClassB 類別:

// code-1
class ClassB
{
    int doThis() {
       // ...
               // ...很多、很多程式碼...
               // ...
   }
    int doThat() {
        // ...
                // ...很多、很多程式碼...
               // ...
   }
}


這時候,你發現有一個叫做 ClassA 的類別,也提供了 doThis() 和 doThat(),其中 ClassA 的 doThis() 完全就是 ClassB 的 doThis() 所需要的功能,但是 ClassA 的 doThat() 卻和 ClassB 的 doThat() 所需要的功能有一些小差異,你希望 ClassB 的 doThat() 傳出值一定是大於等於零的整數。 你可以把 ClassA 拿來運用在 ClassB 中,將 code-1程式改寫如下:

// code-2
class ClassB {
    ClassA a = new ClassA();
    int doThis() {
       return a.doThis();
    }
    int doThat() {
        return Math.abs(a.doThat());
                 // Math.abs() 是取絕對值(正數)的意思
   
}
}

code-2 的版本因為運用 ClassA,所以整個程式比 code-1 更精簡。我們稱 ClassA 被嵌入(embed)到 ClassB 中,ClassB 把 method 遞送給 ClassA 的 method 來處理。

如果 ClassA 除了具有 doThis() 和 doThat() 之外,還具有100 個 method, 分別叫做 method1() method2()、……、method100(),而且這100 個 method 每個都是 ClassB 想要具備的功能,那麼我們的 code-2 程式可以改寫如下:

// code-3
class ClassB {
    ClassA a = new ClassA();
    int doThis() {
        return a.doThis();
   }
    int doThat() {
        return Math.abs(a.doThat());
    }
    int method1() {
        return a.method1();
    }
    int method2() {
        return a.method2();
    }
   // ...一直重複類似的程式碼
   
int method100() {
       return a.method100();
    }
}


這根本就是一場惡夢, 因為我們不斷地把 ClassB 的 methodX() 轉給 ClassA 的 methodX()。這個程式糟透了, 我們應該把它改成下面的版本:

// code-4
class ClassB extends ClassA{
    int doThat() {
        return Math.abs(a.doThat());
    }
}


code-4 的版本用到繼承(inheritance)的技巧。繼承也是封裝的一種,所以語法和封裝很類似,唯一的差異是多了 extends 以及後面的 ClassA。extends 是 Java 的關鍵字,後面接著一個類別的名稱,用來表示繼承的來源。以此例來說,我們稱 ClassA 是 ClassB 的父類別(parent class),或超類別(super class),或基底類別(base class);我們稱 ClassB 是 ClassA 的子類別(child class),或次類別(sub-class),或衍生類別(derived class)。

code-4 的寫法之所以比 code-3 簡潔,是因為 code-4 使用繼承。在 code-4 中,雖然 ClassB 本身只定義了一個 method,也就是 doThat() ,但是 ClassA 中的 method 也會被自動繼承過來。 換句話說,我們可以拿 ClassB 的對象來呼叫 ClassA 中定義的 method,如下例所示:

ClassB b = new ClassB();
b.doThis();
b.method1();


這就是繼承的目的「之一」:達到程式碼的再用性(reusability)。ClassB 輕易地運用了 ClassA 的程式碼。 (繼承的另一個目的是:為多型做準備。)我主張繼承應該被視為一種特殊的封裝,因為繼承只是一種「過程」,繼承的結果仍是封裝,一個全新的封裝。

在繼承的時候,如果子類別定義了和父類別完全相同名稱(包括參數、傳出值、修飾字)的 method,我們就說子類別的此 method 推翻(override)了父類別的同名 method。從 code-4 的例子來看,ClassB 的 doThat() 推翻了 ClassA 的 doThat()。

請注意,在繼承的時候,不只 instance method 會被繼承,連 instance field、class field、class method 都會被繼承。

如果你不希望你所設計出來的類別被其他類別繼承,那麼你可以利用 final 來修飾此 class,如下所示:

// code-5
final class MyClass {
    // ...
}


如果你不希望你所設計出來的某個 method 被子類別推翻(override),那麼你可以利用 final 來修飾此 method,如下所示:

// code-6
class MyClass {
    final int myMethod() {
        // ...
    }
}

java.lang.Object

在 Java 中,每個類別都會有父類別。如果你沒有用 extends 關鍵字來指定父類別,那麼它的父類別就是 java.lang.Object, 例如:code-6 的程式其實等同於下面的程式:

// code-7
class MyClass extends java.lang.Object {
    final int myMethod() {
        // ...
    }
}


java.lang.Object 是一個很特殊的類別,因為:

    * 它是唯一沒有父類別的類別
    * Java 所有的類別都直接或間接地繼承了 java.lang.Object

請牢記下面三點:

   1. 每個類別(java.lang.Object 除外)一定有一個父類別
   2. 一個父類別可以有許多子類別
   3. 所有的類別都源自於 java.lang.Object

繼承的使用時機

認清繼承的使用時機很重要。如果存在某個類別(比方說 ClassA), 其功能和我們的需求「差不多」,但有一些不同之處,那麼我們就「可以」 使用繼承的方式來設計一個類別(比方說 ClassB),以將 Class 的功能抄襲過來, 並進行「擴充」或「修改」。

「可以」使用繼承,不代表「一定要」使用繼承。 繼承也不見得全然都只有好處,沒有任何缺點。有些時候,兩個 class 之間的關係,用 association 的關係比用繼承的關係好。也就是說, 有些時候我們不用 code-4 的版本,反而使用 code-2 的版本,因為這樣做有下列的好處:

    * 在 code-2 中,a 可以被其他類使用;但在 code-4 中,則無法這麼做。
    * 在 code-2 中,a 可以隨時被設定成別的物件;但在 code-4 中,則無法改變。
    * ClassB 可以不提供部份的 method,例如:code-2 就不提供 method1()~method100();但在 ode-4 中, ClassB 無法決定哪些 method/field 要繼承或不繼承。

特別注意:繼承一定需要「擴充」或「修改」,如果不擴充也不修改, 這樣的繼承就毫無意義可言。所謂的擴充,指的是:子類別新定義父類別沒有的 method,以擴充功能;所謂的修改,指的是子類別提供 method 推翻父類別的同名 method。

多型[color=red][/color]

關於多型(Polymorphism),一言以蔽之:「當某變數的實際型態(actual type)和形式型態(formal type)不一致時,呼叫此變數的 method,一定會呼叫到「正確」的版本, 也就是實際型態的版本。

從這句話,我們可以思索下面的問題:

   1. 為什麼實際型態和形式型態會不一致?
   2. 多型帶來時麼好處?

如果你能夠瞭解這兩點,你就能徹底瞭解多型。

學會多型之後,你不可以對多型有「錯誤的期待」。千萬要小心下面這兩點:

   1. 多型的機制只用在 method 上,不用在 field 上。
   2. 多型的機制只用在 instance method上,不用在 class method 上。

在本文章中,我也會對上述兩點提出明確的解釋。

為什麼實際型態和形式型態會不一致?

請看下面的例子:

int a = 3;
long b = a;


因為 a 和 b 是基本型態(primitive type),而非 reference 型態,所以 a 的值會被複製一份到 b。在複製的過程中,a 的值原本是 32 bit 的 int,會被自動地擴充成(widen)64 bit 的 long,再指定(assign)給 b。正因為有擴充和複製,所以對於 b 來說,其實際型態(值的型態)和形式型態(宣告時的型態)都一樣是 long。

所有屬於基本型態的變數都類似如此,在設定其值的時候,會經歷擴充(或縮減)以及複製,製造出實際型態和形式型態一致的值。

但是 reference 型態可就不一樣了。請看下面的例子:

class ClassA {
  void method1() {
    System.out.println("method1() in ClassA");
 }
void method2() {
    System.out.println("method2() in ClassA");
 }
}
class ClassB extends ClassA {
  void method1() {
    System.out.println("method1() in ClassB");
  }
  void method3() {
    System.out.println("method3() in ClassB");
  }
}
public class ClassC {
  public static void main(String[] args) {
    ClassA a = new ClassB();
    a.method1();   // method1() in ClassB
    a.method2();  // method2() in ClassA
    a.method3();  // compile-time error
  }
}


對於 reference 型態來說,使用指定算符(assignment operator)時, 只有記憶體位址會被複製,對象本身不會被複製,所以實際值並不會受到變寬或變窄的破壞。

我們在 ClassC 中,產生一個 ClassB 的對象,並將它設定給 a。但是 a 被宣告成 ClassA 型態,所以 a 的實際型態(ClassB)和形式型態(ClassA)並不一致。 在這樣的情況下,呼叫 a 的 method1() 會 執行到哪一個版本?實際型態的版本或形式型態的版本?正確答案是實際型態的版本。這就是多型!

讓我們再看一次文章一開始對多型所下的定義:「當某變數的實際型態和形式型態不一致時,呼叫此變數的 method,一定會呼叫到正確的版本,也就是實際型態的版本」。現在,你是不是比較能體會了。

如果我們嘗試著呼叫 a 的 method2() 時,結果會呼叫到 ClassA 的 method2(),這是因為 ClassB 沒有定義 method2(),其 method2() 乃是繼承 ClassA 而來的。

如果我們嘗試著呼叫 a 的 method3() 時,則無法通過編譯。 這是因為 Java 是強制型態檢查的語言(strongly-typed language),這意味著 編譯時會檢查形式型態是否有提供該 method。a 的形式型態是 ClassA,不提供 method3(),雖然 a 的實際型態有提供 method3(),但編譯只管形式型態, 可不管實際型態,所以形式型態也稱為編譯期型態(compile-time type), 實際型態也稱為執行期型態(runtime type)。

或許你會覺得「ClassA a = new ClassB();」的寫法很怪,為什麼要把 ClassB 的物件宣告成 ClassA 型態?的確,通常我們不會這麼做,但形式型態和實際型態互異的情況卻往往是在不知不覺中發生,請看下面的例子:

class ClassD {
  static void method4(ClassA a) {
    // a 的形式型態是 ClassA,實際型態則不一定是 ClassA
  a.method1();   //呼叫實際型態的 method1();
  }
}
public classE {
  public static void main(String[] args) {
    ClassB b = new ClassB();
    ClassD.method4(b);
  // ClassD.method4() 需要一個 ClassA的參數,
                 // 因為 ClassB 繼承自 ClassA,
                 // 所以 ClassB 相容於 ClassA,
                 // 也因此我們可以傳入 ClassB 的對象當參數
  }
}


因為我們傳進 ClassD.method4() 的參數其實際型態是 ClassB,但是在 method4() 的定義中將它宣告為 ClassA,造成了形式型態和實際型態不一致。這類似於「ClassA a = new ClassB();」的寫法,只不過一切都是在暗中進行,從程式上來看並不明顯。大部分的多型,都是導因於此。

從上面的例子來加以歸納,我們可以知道多型的發生必須是:

   1. 有直接或間接繼承關係的兩個類別,子類別的 method 將父類別的 method 予以override。
   2. 實際型態為子類別的物件,被當成父類別來使用,呼叫其 overrided method。

多型帶來什麼好處?

多型是建立在繼承的基礎之上的,沒有繼承,就不會有多型。繼承的目的有兩種:

    * 繼承程式碼:達到程式碼再用性(reuse)
    * 繼承介面(interface)。達到介面再用性(reuse),為多型預作準備

更明白地說:多型是仰賴介面的繼承。至於程式碼的繼承,完全無涉於多型。

讓我打個比喻:電腦主機板(Main Board)上面能夠插上 Intel 的 CPU,也能插上 AMD 的 CPU,一切運作順利,那是因為 Intel 的 CPU 和 AMD 的 CPU 都遵守該主機板要求的介面(interface)。 這麼一來,任何廠商只要能符合此介面,就能和該主機板搭配使用,主機板不需要做出任何修改。

同樣地,ClassD 的 method4() 需要 ClassA 的介面,不管是 ClassA 的 instance 和 ClassB 的 instance 都能符合這樣的要求(ClassB 繼承了 ClassA 的介面), 所以都能當作 method4() 的參數,和 ClassD.method4() 合作無間。任何對象只要能符合 ClassA 的介面,就能和 ClassD 合作無間,ClassD 的程式碼不需要進行任何修改。 多型的機制,讓程式具備了動態的擴充性。

Reflection 也可以讓程式具備動態的擴充性,甚至比多型更動態,所以有些場合可以使用 reflection 來取代多型,但 reflection 通常效率較差,而且有的語言不提供 Reflection 機制。 Java 早在 1.1 以後就開始支援 Reflection API 了 ,而 Java1.4 版的 Reflection API 速度也大幅地提升了。 如果你對於 Java Reflection API 感興趣的話,可以參考 java.lang.reflect.*。

多型的機制只用在 method 上,不用在 field 上

簡單來說,只要符合下兩點,就可能會有多型:

 
 1. 有直接或間接繼承關係的兩個類別,子類別的 method 將父類別的 method 予以 override。
   2. 實際型態為子類別的對象,被當成父類別來使用,呼叫其 overrided method。


請注意:上述兩點指的是 method,而非 field。對於 field 來說,根本沒有多型這一回事。請看下面的例子:

class ClassA {
  String field1 = 'A1';
  String field2 = 'A2';
}

class ClassB extends ClassA {
  String field1 = 'B1';
  String field3 = 'B3';
  }

public class ClassC {
  public static void main(String[] args) {
    ClassA a = new ClassB();
    System.out.println(a.field1());  // "A1"
    System.out.println(a.field2());  // "A2"
    System.out.println(a.field3());  // compile-time error
  }
}


對於 field 來說,形式型態決定了一切,完全和實際型態無關。 雖然 a 的實際型態是 ClassB,但是形式型態是 ClassA,所以 a.field1 得到的結果是”A1”,a.field2 得到的結果是”A2”。

如果子類別提供了和父類別一樣名稱與參數的 method, 我們說子類別的 method 把父類別的同名 method 給 override 了。如果子類 別提供了和父類別一樣名稱的 field,我們說子類別的 field 把父類別的同名 field 給 shadow 了。

針對 method 和 field 的作法居然如此大的不同,這絕對不是 Java 語言的設計者故意想折磨你。事實上,這樣的設計有其巧妙之處。在 Java 的思維中:介面是對象之間溝通的管道,而介面只包含了類別的名稱和 method 的集合。至於 field,則是不屬於介面的一部份。既然如此,而多型又是完全是 依靠介面,所以當然多型會把 field 排除在外。

多型的機制只用在 instance method 上,不用在 class method 上

被 static 修飾的 field/method 是屬於 class field/method。繼承的時候,依然可以繼承這些 static field/method。封裝和繼承的效果可以同時用在 class 或 instance 上。換句話說:封裝不只封裝 instance field/method,也可以封裝 class field/method;繼承不只繼承父類的 instance field/method,也繼承父類別的 class field/method。

但是, 切記,多型只能對 instance 產生作用,不能對 class 產生作用,這可以從我對多型的定義看出端倪,定義如下:

當某變數的實際型態和形式型態不一致時,呼叫(invoke)此變數的 method,一定會呼叫到正確的版本,也就是實際型態的版本。

class 是形式型態,沒有實際型態,根本就和多型無關。只有 instance 才可能會造成形式型態和實際型態不一致。

請看下面的例子:

class ClassA {
  static void method1() {
    System.out.println("method1() in ClassA");
 }
     static void method2() {
    System.out.println("method2() in ClassA");
  }
}
class ClassB extends ClassA {
  static void method1() {
     System.out.println("method1() in ClassB");
   }
  static void method3() {
    System.out.println("method3() in ClassB");
   }
}
public class ClassC {
  public static void main(String[] args) {
    ClassA a = new ClassB();
    a.method1();  // method1() in ClassA
    a.method2();  // method2() in ClassA
    a.method3();  // compile-time error
   }
}


因為這裡的 method1()、method2()、method3() 都是 static,所以和多型無關,編譯器直接使用靜態連結(static link),而不會留到執行期才動態地決定真正該呼叫的 method 為何。因為編譯器在編譯期就做必須決定 method, 所以編譯器完全依賴形式型態,這麼一來「a.method1()」等於是「ClassA.method1()」, 「a.method2()」等於是「ClassA.method2()」。

多型的英文是 polymorphism,poly 是「多」的意思,morph 是「型」的意思,這個「型」可做「型態」解。 也就是說,只有當一件事物有多種型態時,才會有的一種機制。

你可能感兴趣的:(J2SE,OO)