標籤:
第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()startchild s: 10, a: 20end---- b.action()startchild s: 10, a: 20end---- b.s: 1---- c.s: 10
下面我們來解釋一下背後都發生了一些什麼事情,從類的載入開始。
類的載入
在Java中,所謂類的載入是指將類的相關資訊載入到記憶體。在Java中,類是動態載入的,當第一次使用這個類的時候才會載入,載入一個類時,會查看其父類是否已載入,如果沒有,則會載入其父類。
一個類的資訊主要包括以下部分:
- 類變數(靜態變數)
- 類初始化代碼
- 類方法(靜態方法)
- 執行個體變數
- 執行個體初始化代碼
- 執行個體方法
- 父類資訊引用
類初始化程式碼封裝括:
- 定義靜態變數時的指派陳述式
- 靜態初始化代碼塊
執行個體初始化程式碼封裝括:
- 定義執行個體變數時的指派陳述式
- 執行個體初始化代碼塊
- 構造方法
類載入過程包括:
- 分配記憶體儲存類的資訊
- 給類變數賦預設值
- 載入父類
- 設定父子關係
- 執行類初始化代碼
需要說明的是,關於類初始化代碼,是先執行父類的,再執行子類的,不過,父類執行時,子類靜態變數的值也是有的,是預設值。對於預設值,我們之前說過,數字型變數都是0,boolean是false,char是‘\u0000‘,引用型變數是null。
之前我們說過,記憶體分為棧和堆,棧存放函數的局部變數,而堆存放動態分配的對象,還有一個記憶體區,存放類的資訊,這個區在Java中稱之為方法區。
載入後,對於每一個類,在Java方法區就有了一份這個類的資訊,以我們的例子來說,有三份類資訊,分別是Child,Base,Object,記憶體如下:
我們用class_init()來表示類初始化代碼,用instance_init()表示執行個體初始化代碼,執行個體初始化程式碼封裝括了執行個體初始化代碼塊和構造方法。例子中只有一個構造方法,實際中可能有多個執行個體初始化方法。
本例中,類的載入大概就是在記憶體中形成了類似上面的布局,然後分別執行了Base和Child的類初始化代碼。接下來,我們看對象建立的過程。
建立對象
在類載入之後,new Child()就是建立Child對象,建立對象過程包括:
- 分配記憶體
- 對所有執行個體變數賦預設值
- 執行執行個體初始化代碼
分配的記憶體包括本類和所有父類的執行個體變數,但不包括任何靜態變數。執行個體初始化代碼的執行從父類開始,先執行父類的,再執行子類的。但在任何類執行初始化代碼之前,所有執行個體變數都已設定完預設值。
每個對象除了儲存類的執行個體變數之外,還儲存著實際類資訊的引用。
Child c = new Child();會將新建立的Child對象引用賦給變數c,而Base b = c;會讓b也引用這個Child對象。建立和賦值後,記憶體布局大概如所示:
引用型變數c和b分配在棧中,它們指向相同的堆中的Child對象,ChildObject Storage Service著方法區中Child類型的地址,還有Base中的執行個體變數a和Child中的執行個體變數a。建立了對象,接下來,來看方法調用的過程。
方法調用
我們先來看c.action();這句代碼的執行過程是:
- 查看c的物件類型,找到Child類型,在Child類型中找action方法,發現沒有,到父類中尋找
- 在父類Base中找到了方法action,開始執行action方法
- action先輸出了start,然後發現需要調用step()方法,就從Child類型開始尋找step方法
- 在Child類型中找到了step()方法,執行Child中的step()方法,執行完後返回action方法
- 繼續執行action方法,輸出end
尋找要執行的執行個體方法的時候,是從對象的實際類型資訊開始尋找的,找不到的時候,再尋找父類類型資訊。
我們來看b.action();,這句代碼的輸出和c.action是一樣的,這稱之為動態綁定,而動態綁定實現的機制,就是根據對象的實際類型尋找要執行的方法,子類型中找不到的時候再尋找父類。這裡,因為b和c指向相同的對象,所以執行結果是一樣的。
如果繼承的層次比較深,要調用的方法位於比較上層的父類,則調用的效率是比較低的,因為每次調用都要進行很多次尋找。大多數系統使用一種稱為虛方法表的方法來最佳化調用的效率。
虛方法表
所謂虛方法表,就是在類載入的時候,為每個類建立一個表,這個表包括該類的對象所有動態綁定的方法及其地址,包括父類的方法,但一個方法只有一條記錄,子類重寫了父類方法後只會保留子類的。
對於本例來說,Child和Base的虛方法表如下所示:
對Child類型來說,action方法指向Base中的代碼,toString方法指向Object中的代碼,而step()指向本類中的代碼。
這個表在類載入的時候產生,當通過對象動態Binder 方法的時候,只需要尋找這個表就可以了,而不需要挨個尋找每個父類。
接下來,我們看對變數的訪問。
變數訪問
對變數的訪問是靜態繫結的,無論是類變數還是執行個體變數。代碼中示範的是類變數:b.s和c.s,通過對象訪問類變數,系統會轉換為直接存取類變數Base.s和Child.s。
例子中的執行個體變數都是private的,不能直接存取,如果是public的,則b.a訪問的是對象中Base類定義的執行個體變數a,而c.a訪問的是對象中Child類定義的執行個體變數a。
小結
本節,我們通過一個例子,介紹了類的載入、對象建立、方法調用以及變數訪問的內部過程。現在,我們應該對繼承的實現有了一個比較清楚的理解。
之前我們提到過,繼承其實是把雙刃劍,為什麼這麼說呢?讓我們下節來探討。
----------------
未完待續,查看最新文章,敬請關注公眾號“老馬說編程”(掃描下方二維碼),從入門到進階,深入淺出,老馬和你一起探索Java編程及電腦技術的本質。原創文章,保留所有著作權。
電腦程式的思維邏輯 (17) - 繼承實現的基本原理