1. 程式人生 > >Java課堂筆記(二):面向物件

Java課堂筆記(二):面向物件

        幾乎每一本介紹Java語言的書中都會提到“面向物件”的這個概念,然而博主初學Java時看到這方面的內容一般都是草草地看一看,甚至是直接略過。原因很簡單:考試基本不考,而且初學階段寫程式碼也很少用上。但事實上面向物件時Java中一個非常重要的內容,而且與程式碼這整體設計關係很大。越是具有豐富的程式設計經驗,就越能體會到這個思想在實際程式碼結構設計中的重要性。在《Java程式設計思想》中,作者用了很大的篇幅來介紹面向物件的相關內容,其中不乏一些關於如何運用面向物件來優化程式結構,提高程式碼的可讀性和可維護性的內容。這一章就來整理一下關於面向物件的相關內容。

什麼是面向物件

《Java程式設計思想》一書,對於面向物件程式設計總結了一下五個特徵:

  • 萬物皆為物件。將物件視為奇特的變數,它可以儲存資料,除此之外,你還可以要求它在自身上執行操作。理論上講,你可以出去帶求解問題的任何概念化構件(狗、建築物、服務等),將其表示為程式中的物件。
  • 程式是物件的合集,它們通過傳送訊息來告知彼此所要做的。要想請求一個物件,就必須對該物件傳送一條訊息。更具體地說,可以把訊息想象為對某個特定物件的方法呼叫請求。
  • 每個物件都有自己的由其他物件所構成的儲存。換句話說,可以通過建立包含現有物件的包的方式來建立新型別的物件。因此,可以在程式中構建複雜的體系,同時將其複雜性隱藏在物件的簡單性背後。
  • 每個物件都擁有其型別。按照通用的說法,“每個物件都是某個類(class)的一個例項(instance)”,這裡“類”就是“型別”的同義詞。每個類最重要的區別於其他類的特徵就是“可以傳送什麼樣的訊息給它”。
  • 某些特定型別的所有物件都可以接受相同的訊息。“圓形”型別的物件同時也是“幾何形”型別的物件,所以一個“圓形”物件必定能夠接受傳送給“幾何形”物件的訊息。這意味著可以編寫與“幾何形”互動並自動處理所有與幾何形性質相關的事物的程式碼。

為什麼要面向物件

  討論這個問題前要先說說“抽象”機制。所有的程式語言都提供抽象機制,所謂抽象就是用程式語言的元素來表達某些內容。不同的程式語言所能夠抽象的物件是不一樣的。組合語言是對底層機器的輕微抽象,能夠表達計算機底層結構以及操作。還有一些"命令式"的程式語言(例如C、FORTRAN等),這些語言是對組合語言的抽象。這些語言能夠更易於閱讀,更方便地被人所理解。但這些語言依然是在基於計算機的底層結構進行抽象的,因此在解決問題時依然會受限制與計算機的機構。程式設計師在解決某個特定問題的時候,需要構建該問題與計算機機構模型之間的對映,這使得編寫程式變得很困難。

  如果程式語言能夠直接對所需要解決的問題進行抽象,能夠直接表達問題中的元素,那麼就能夠省去計算機結構和問題之間的對映的工作。面向物件就是能夠為程式設計師提供面向問題空間元素的一個工具。如上文所述,面向物件的語言中,萬物皆為物件,且每個物件都有其型別,也就是類(class)。因此,程式設計師就可以自定義適用於問題空間的類來解決問題。

  面向物件程式設計能夠降低各元件之間的耦合性,增加了程式碼的可維護性,複用性和可擴充套件性。

封裝、繼承和多型

  封裝、繼承和多型是面向物件程式設計的三大特性。在具體介紹這三者前,先對Java中的類進行簡單介紹。類是一組具有相同特性(資料元素)和行為(功能)的物件的集合,一個類可以有很多個物件。Java的類包含兩部分元素:屬性(也叫欄位)和方法(也叫成員函式)。屬性可以使任何型別的物件,包括基本型別和引用型別;方法是該類的物件能夠進行的操作,用OOP中的說法,方法決定了一個物件能夠接受什麼樣的訊息。方法的組成包括名稱,引數,返回值和方法體幾部分。類的基本形式如下:

 

class ClassTypeA{
    int dataA;
    double dataB;
    ClassTypeB classTypeB;

    ReturnType methodName(/* Argument list */) {
        /* Method body */
    }
    ...
}

 

封裝

  封裝是指將類的內部實現隱藏起來,僅暴露出必要的介面給使用者。隱藏實現的第一個優點是可以避免類的使用者接觸和操作到他們不應該接觸的部分,避免由於使用者的粗心或錯誤操作產生程式bug。第二,隱藏具體實現,僅暴露介面給使用者,那麼設計者在修改類的實現的時候就不用顧忌對使用者的影響,只需要保持對外的介面不變就可以。第三點,使用介面呼叫來連線設計者和使用者,可以降低系統的耦合性。

  在具體的開發過程中,設計者對於類中的不同元素的可訪問情況會有不同的要求:對於類內部的關鍵性欄位,設計者會希望其完全被隱藏,不希望被任何使用者訪問或修改;而對於提供給外部使用的介面,一定是希望其能夠被所有使用者訪問;還有一些元素,設計者希望該類的子類能夠訪問和使用,而不會被其他的使用者接觸到。Java用訪問許可權關鍵字來區別這種不同的可訪問性。

  Java中一共有3中訪問許可權關鍵字:

  • public表示緊隨其後的元素對任何人都是可用的;
  • private關鍵字表示除了型別的建立者和型別內部的方法以外,任何人都不能訪問該元素。如果有人試圖訪問private成員,就會在編譯時收到錯誤資訊提示;
  • protected關鍵字與private相比,區別在於繼承的類可以訪問protected修飾的元素,而不能訪問private修飾的元素。

  除了以上三種訪問許可權以外,Java還有一種預設的訪問許可權,在沒有使用任何訪問許可權關鍵字的情況下,預設制定為這種控制權限,也被稱為包控制權限,因為被其修飾的元素可以被同一個包中的其他類所訪問。

  訪問許可權關鍵字的使用方式如下:

class accessDemo {
    private int privateData;
    protected int protectedData;

    public int publicMethod() {...}

    int defaultMethod() {...}
}

 繼承

   繼承是面向物件程式設計中必不可少的組成部分。在Java中,使用extends關鍵字來表示類的繼承關係。繼承關係中,將已有的類成為父類(基類),由父類生成的新類稱為子類(匯出類)。如下面一段程式碼中,Animal類為父類,Dog類為它的子類。

class Animal {
    public Animal(){

    }
}

class Dog extends Animal{
    public Dog(){

    }
}

  事實上在Java中建立一個類時,總是在繼承,如果新建類時沒有用extends指定繼承自那個類,則就隱式地從Java的標準根類Object類進行繼承。也就是說所有的類都繼承自Object類。讓所有類都繼承自Object可以使所有的類都具有一些相同的特性,或者可以都可以進行某些操作。例如Java中所有的類都具有hashcode()方法,可以計算該類物件的雜湊值。這是Java中HashMap等重要資料結構實現的基礎,也是判斷物件間是否相同重要依據。

  1、子類中的元素

  • 子類繼承父類的成員變數和方法。當一個子類繼承一個父類時,子類便可以具有父類中的一些成員變數和方法,但子類只能繼承父類中public和protected修飾的成員變數和方法。
  • 子類可以定義自己的成員變數和方法。在定義自己的成員變數和父類的中的變數名一致時,就會發生隱藏的現象。即子類中的變數會掩蓋父類的變數。同樣的,在定義方法時,如果子類的方法和父類的方法的方法名、引數列表和返回值都相同,則子類的方法就會覆蓋父類的方法。隱藏和覆蓋是有差別的,簡單來說。隱藏適用於成員變臉和靜態方法,是指在子類中不顯示父類的成員變數和方法,如果將子類轉換為父類,呼叫的還是父類的成員變數和方法;覆蓋針對的是普通方法,如果將子類轉換成父類,訪問的還是子類的具體方法。

  2、構造器

  子類不會繼承父類的構造器,但是既然子類能夠繼承父類中的成員變數,那麼自然也需要對其成員變數進行必要的初始化,初始化的方法就是呼叫父類的構造器。

  • 無引數構造器。如果父類的構造器沒有引數,那麼在呼叫子類的構造器時,編譯器會預設呼叫父類的構造器,完成相關初始化工作。如下面這段程式碼:
public class Animal {
    public Animal(){
        System.out.println("animal constructor");
    }
    public static void main(String[] args) {
        Dog dog = new Dog();
    }
}

class Dog extends Animal{
    public Dog(){
        System.out.println("dog constructor");
    }
}
/**
 * 執行結果:
 * animal constructor
 * dog constructor
 */
  • 有引數構造器。如果父類只有有引數的構造器,那麼在子類的構造器中必須顯式地呼叫父類的構造器,並且要位於子類構造器的最開始。否則在編譯時就會報錯。原因是在沒有呼叫父類構造器的情況下,編譯器會預設呼叫父類的無引數構造器。但此時編譯器會找不到父類的無引數構造器,從而報錯。如下面程式碼所示:
public class Animal {
    public Animal(int i){
        System.out.println("animal constructor " + i);
    }
    public static void main(String[] args) {
        Dog dog = new Dog(0);
    }
}

class Dog extends Animal{
    public Dog(int i){
        super(i);//呼叫父類構造器
        System.out.println("dog constructor " + i);
    }
}
/**
 * 執行結果:
 * animal constructor 0
 * dog constructor 0
 */

 多型

  多型的定義是允許不同的物件對同一訊息進行相應,即同一訊息可以根據物件的不同而進行不同的行為,這裡的行為就是指方法的效用。多型的意義在於分離“做什麼”和“怎麼做”,從而消除型別之間的耦合性,改善程式碼的組織結構和可讀性,創造可擴充套件的程式。我們通過以下這個程式來具體說明:

 1 public class Animal {
 2     public static void takeFood(Animal animal){
 3         System.out.println("===start eat food===");
 4         animal.eat();
 5         System.out.println("====end eat food====");
 6     }
 7 
 8     public void eat() {
 9     }
10     
11     public static void main(String[] args) {
12         Animal animal = new Dog();
13         takeFood(animal);
14         animal = new Cat();
15         takeFood(animal);
16     }
17 
18 }
19 
20 class Dog extends Animal{
21     public void eat() {
22         System.out.println("dog eat food");
23     }
24 }
25 
26 class Cat extends Animal{
27     public void eat() {
28         System.out.println("cat eat food");
29     }
30 }
31 /**
32  * 執行結果:
33  * ===start eat food===
34  * dog eat food
35  * ====end eat food====
36  * ===start eat food===
37  * cat eat food
38  * ====end eat food====
39  */

  在上述程式碼中,Dog類和Cat類都繼承自Animal類,並且各自重寫了eat()方法。在程式碼的12行中建立了一個Dog類物件,但是卻把它付給了一個Dog類的父類引用,第14行同樣這麼做。takeFood()方法中的傳入引數為一個父類物件,但是這並不影響第13行和15行中,將一個指向子類物件的Animal引用作為引數傳入。並且在takeFood()方法中呼叫傳入物件內部的方法時,實際呼叫的是子類中的方法。

  通過以上程式碼我們可以總結,多型存在的三個必要條件是:1)繼承;2)重寫;3)父類引用指向子類物件。

  在上述程式碼中,takeFood()方法體中的內容決定了“做什麼”,具體“怎麼做”卻決定於參入物件的eat()方法。而傳入的物件中如何定義eat()方法與takeFood()的內容並不相關,因此便實現了兩者的解耦。當我們將傳入takeFood()的引數由Dog類物件改為Cat類物件時,只需要修改引用的指向即可。這也就增強了程式碼的可替換性;當需要新增加一個新的動物Pig並進行相同的操作時,我們只需要新建一個Pig類,重寫其中eat()方法,然後將Pig類物件傳入takeFood()方法即可,這就是增強了程式碼的可擴充套件性。

動態繫結

   你可能會想知道,在takeFood()方法中,呼叫的是Animal類的eat()方法,而具體執行的方法主體卻是子類中的方法。那麼編譯器是怎麼知道呼叫一個方法時具體應該執行哪個方法主體呢?

  我們把方法呼叫和一個方法主體的關聯起來成為繫結。如果方法呼叫和方法主體在程式執行前就能關聯起來,則稱為前期繫結(靜態繫結),反之,如果必須到程式執行時才能知道方法呼叫具體應該執行哪一個方法,則稱為後期繫結(動態繫結)。如果類B是類A的子類,A中定義了方法func(String s),B中重寫了方法func(String s),那麼此方法就需要使用動態繫結。如果x是B的一個例項,通過x.func(str)呼叫方法時,Java虛擬機器會先在B中尋找此方法,如果B類中有對應方法,則直接呼叫它,否則就在B的父類A中尋找此方法。

 

   在Java中,除了用static方法、final方法、private方法和構造方法以外,其他方法均採用動態繫結。這四種方法中:  

  • private方法無法被繼承,那麼自然無法被重寫,所以在編譯時就可以確定具體呼叫的方法
  • static方法可以被繼承,可以被子類隱藏,但是不能被子類重寫。所以也可以在編譯時確定
  • final方法可以被繼承,但是不能被子類重寫
  • 構造方法也不能被子類繼承。子類的構造方法有兩種:使用系統自動生成的無引數構造方法;呼叫父類的構造方法(包括自己定義構造方法並在其中呼叫父類的構造方法)

  由以上分析我們可以看出,上述四種方法可以使用靜態繫結的最終原因都是:不會出現方法重寫,不會產生子類與父類具有資訊(方法名,引數個數,引數型別,返回型別等)完全相同的方法。

 參考文獻

[1]. https://blog.csdn.net/u012340794/article/details/73194674

[2].Java程式設計思想 第四版