計算機程式的思維邏輯 (17) - 繼承實現的基本原理

swiftma發表於2016-08-24

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (17) - 繼承實現的基本原理

第15節我們介紹了繼承和多型的基本概念,而上節我們進一步介紹了繼承的一些細節,本節我們通過一個例子,來介紹繼承實現的基本原理。需要說明的是,本節主要從概念上來介紹原理,實際實現細節可能與此不同。

例子

這是基類程式碼:

public class Base {
    public static int s;
    private int a;
    
    static {
        System.out.println("基類靜態程式碼塊, s: "+s);
        s = 1;
    }
    
    {
        System.out.println("基類例項程式碼塊, a: "+a);
        a = 1;
    }
    
    public Base(){
        System.out.println("基類構造方法, a: "+a);
        a = 2;
    }
    
    protected void step(){
        System.out.println("base s: " + s +", a: "+a);
    }
    
    public void action(){
        System.out.println("start");
        step();
        System.out.println("end");
    }
}
複製程式碼

Base包括一個靜態變數s,一個例項變數a,一段靜態初始化程式碼塊,一段例項初始化程式碼塊,一個構造方法,兩個方法step和action。

這是子類程式碼:

public class Child extends Base {
    public static int s;
    private int a;
    
    static {
        System.out.println("子類靜態程式碼塊, s: "+s);
        s = 10;
    }
    
    {
        System.out.println("子類例項程式碼塊, a: "+a);
        a = 10;
    }
    
    public Child(){
        System.out.println("子類構造方法, a: "+a);
        a = 20;
    }
    
    protected void step(){
        System.out.println("child s: " + s +", a: "+a);
    }
}
複製程式碼

Child繼承了Base,也定義了和基類同名的靜態變數s和例項變數a,靜態初始化程式碼塊,例項初始化程式碼塊,構造方法,重寫了方法step。

這是使用的程式碼:

public static void main(String[] args) {
    System.out.println("---- new Child()");
    Child c = new Child();
    
    System.out.println("\n---- c.action()");
    c.action();
    
    Base b = c;
    System.out.println("\n---- b.action()");
    b.action();
    
    
    System.out.println("\n---- b.s: " + b.s); 
    System.out.println("\n---- c.s: " + c.s); 
}
複製程式碼

建立了Child型別的物件,賦值給了Child型別的引用變數c,通過c呼叫action方法,又賦值給了Base型別的引用變數b,通過b也呼叫了action,最後通過b和c訪問靜態變數s並輸出。這是螢幕的輸出結果:

---- new Child()
基類靜態程式碼塊, s: 0
子類靜態程式碼塊, s: 0
基類例項程式碼塊, a: 0
基類構造方法, a: 1
子類例項程式碼塊, a: 0
子類構造方法, a: 10

---- c.action()
start
child s: 10, a: 20
end

---- b.action()
start
child s: 10, a: 20
end

---- b.s: 1

---- c.s: 10
複製程式碼

下面我們來解釋一下背後都發生了一些什麼事情,從類的載入開始。

類的載入

在Java中,所謂類的載入是指將類的相關資訊載入到記憶體。在Java中,類是動態載入的,當第一次使用這個類的時候才會載入,載入一個類時,會檢視其父類是否已載入,如果沒有,則會載入其父類。

一個類的資訊主要包括以下部分:

  • 類變數(靜態變數)
  • 類初始化程式碼
  • 類方法(靜態方法)
  • 例項變數
  • 例項初始化程式碼
  • 例項方法
  • 父類資訊引用

類初始化程式碼包括:

  1. 定義靜態變數時的賦值語句
  2. 靜態初始化程式碼塊

例項初始化程式碼包括:

  1. 定義例項變數時的賦值語句
  2. 例項初始化程式碼塊
  3. 構造方法

類載入過程包括:

  1. 分配記憶體儲存類的資訊
  2. 給類變數賦預設值
  3. 載入父類
  4. 設定父子關係
  5. 執行類初始化程式碼

需要說明的是,關於類初始化程式碼,是先執行父類的,再執行子類的,不過,父類執行時,子類靜態變數的值也是有的,是預設值。對於預設值,我們之前說過,數字型變數都是0,boolean是false,char是'\u0000',引用型變數是null。

之前我們說過,記憶體分為棧和堆,棧存放函式的區域性變數,而堆存放動態分配的物件,還有一個記憶體區,存放類的資訊,這個區在Java中稱之為方法區。

載入後,對於每一個類,在Java方法區就有了一份這個類的資訊,以我們的例子來說,有三份類資訊,分別是Child,Base,Object,記憶體示意圖如下:

計算機程式的思維邏輯 (17) - 繼承實現的基本原理

我們用class_init()來表示類初始化程式碼,用instance_init()表示例項初始化程式碼,例項初始化程式碼包括了例項初始化程式碼塊和構造方法。例子中只有一個構造方法,實際中可能有多個例項初始化方法。

本例中,類的載入大概就是在記憶體中形成了類似上面的佈局,然後分別執行了Base和Child的類初始化程式碼。接下來,我們看物件建立的過程。

建立物件

在類載入之後,new Child()就是建立Child物件,建立物件過程包括:

  1. 分配記憶體
  2. 對所有例項變數賦預設值
  3. 執行例項初始化程式碼

分配的記憶體包括本類和所有父類的例項變數,但不包括任何靜態變數。例項初始化程式碼的執行從父類開始,先執行父類的,再執行子類的。但在任何類執行初始化程式碼之前,所有例項變數都已設定完預設值。

每個物件除了儲存類的例項變數之外,還儲存著實際類資訊的引用。

Child c = new Child();會將新建立的Child物件引用賦給變數c,而Base b = c;會讓b也引用這個Child物件。建立和賦值後,記憶體佈局大概如下圖所示:

計算機程式的思維邏輯 (17) - 繼承實現的基本原理

引用型變數c和b分配在棧中,它們指向相同的堆中的Child物件,Child物件儲存著方法區中Child型別的地址,還有Base中的例項變數a和Child中的例項變數a。建立了物件,接下來,來看方法呼叫的過程。

方法呼叫

我們先來看c.action();這句程式碼的執行過程是:

  1. 檢視c的物件型別,找到Child型別,在Child型別中找action方法,發現沒有,到父類中尋找
  2. 在父類Base中找到了方法action,開始執行action方法
  3. action先輸出了start,然後發現需要呼叫step()方法,就從Child型別開始尋找step方法
  4. 在Child型別中找到了step()方法,執行Child中的step()方法,執行完後返回action方法
  5. 繼續執行action方法,輸出end

尋找要執行的例項方法的時候,是從物件的實際型別資訊開始查詢的,找不到的時候,再查詢父類型別資訊。

我們來看b.action();,這句程式碼的輸出和c.action是一樣的,這稱之為動態繫結,而動態繫結實現的機制,就是根據物件的實際型別查詢要執行的方法,子型別中找不到的時候再查詢父類。這裡,因為b和c指向相同的物件,所以執行結果是一樣的。

如果繼承的層次比較深,要呼叫的方法位於比較上層的父類,則呼叫的效率是比較低的,因為每次呼叫都要進行很多次查詢。大多數系統使用一種稱為虛方法表的方法來優化呼叫的效率。

虛方法表

所謂虛方法表,就是在類載入的時候,為每個類建立一個表,這個表包括該類的物件所有動態繫結的方法及其地址,包括父類的方法,但一個方法只有一條記錄,子類重寫了父類方法後只會保留子類的。

對於本例來說,Child和Base的虛方法表如下所示:

計算機程式的思維邏輯 (17) - 繼承實現的基本原理

對Child型別來說,action方法指向Base中的程式碼,toString方法指向Object中的程式碼,而step()指向本類中的程式碼。

這個表在類載入的時候生成,當通過物件動態繫結方法的時候,只需要查詢這個表就可以了,而不需要挨個查詢每個父類。

接下來,我們看對變數的訪問。

變數訪問

對變數的訪問是靜態繫結的,無論是類變數還是例項變數。程式碼中演示的是類變數:b.s和c.s,通過物件訪問類變數,系統會轉換為直接訪問類變數Base.s和Child.s。

例子中的例項變數都是private的,不能直接訪問,如果是public的,則b.a訪問的是物件中Base類定義的例項變數a,而c.a訪問的是物件中Child類定義的例項變數a。

小結

本節,我們通過一個例子,介紹了類的載入、物件建立、方法呼叫以及變數訪問的內部過程。現在,我們應該對繼承的實現有了一個比較清楚的理解。

之前我們提到過,繼承其實是把雙刃劍,為什麼這麼說呢?讓我們下節來探討。


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (17) - 繼承實現的基本原理

相關文章