or
or
By clicking below, you agree to our terms of service.
New to HackMD? Sign up
Syntax | Example | Reference | |
---|---|---|---|
# Header | Header | 基本排版 | |
- Unordered List |
|
||
1. Ordered List |
|
||
- [ ] Todo List |
|
||
> Blockquote | Blockquote |
||
**Bold font** | Bold font | ||
*Italics font* | Italics font | ||
~~Strikethrough~~ | |||
19^th^ | 19th | ||
H~2~O | H2O | ||
++Inserted text++ | Inserted text | ||
==Marked text== | Marked text | ||
[link text](https:// "title") | Link | ||
 | Image | ||
`Code` | Code |
在筆記中貼入程式碼 | |
```javascript var i = 0; ``` |
|
||
:smile: | ![]() |
Emoji list | |
{%youtube youtube_id %} | Externals | ||
$L^aT_eX$ | LaTeX | ||
:::info This is a alert area. ::: |
This is a alert area. |
On a scale of 0-10, how likely is it that you would recommend HackMD to your friends, family or business associates?
Please give us some advice and help us improve HackMD.
Syncing
xxxxxxxxxx
tags:
C++
點此回到 C++筆記 目錄
繼承共同行為
子類別繼承父類別,可用來避免重複的行為,不過並非為了避免重複定義行為就使用繼承,濫用繼承而導致程式維護上的問題時有所聞,如何正確判斷使用繼承的時機,以及繼承之後如何活用多型,才是學習繼承時的重點。
無論如何,先來看看行為重複是怎麼一回事,假設你在正開發一款 RPG(Role-playing game)遊戲,一開始設定的角色有劍士與魔法師。首先你定義了劍士類別:
接著你為魔法師定義類別:
你注意到什麼呢?因為只要是遊戲中的角色,都會具有角色名稱、等級與血量,類別中也都為名稱、等級與血量定義了取值方法與設值方法,Magician 與 SwordsMan 有許多程式碼重複了。
重複在程式設計上,就是不好的味道。舉個例子來說,如果要將 name、level、blood 改為其他名稱,那就要修改 SwordsMan 與 Magician 兩個類別,如果有更多類別具有重複的程式碼,那就要修改更多類別,造成維護上的不便。
如果要改進,可以把相同的程式碼提昇(Pull up)為父類別:
這個類別在定義上沒什麼特別的新語法,只不過是將 SwordsMan 與 Magician 中重複的程式碼複製過來。接著 SwordsMan 可以如下繼承 Role:
在定義 SwordsMan 類別時,
:
指出 SwordsMan 會擴充 Role 的行為,:
右邊的 public 表示,會以公開的方式繼承 Role,這表示繼承而來的 Role 成員,權限控制最大是 public,也就是 Role 繼承而來的相關成員維持既有的權限控制。在繼承類別時,還可以在 : 右邊指定 protected 或 private,表示繼承而來的 Role 成員權限控制最大是 protected 或 private,例如若
:
右邊指定 private,Role 的 protected 或 public 成員在子類中,權限就會被限縮為 private。繼承時設定的權限預設會套用至各個成員,然而,可以使用 using 指出哪些成員要維持父類中設定之權限。例如,若父類 P 中有 public 的 publicMember 及 protected 的 protectedMember:
如果繼承時沒有指定 public、protected、private,且子類別定義時使用 struct,那預設就是 public 繼承,反之,若沒有指定且子類別定義時使用 class,那預設就是 private 繼承。
定義類別時,protected 成員,表示只能被子類存取。
在剛剛的程式碼中,SwordsMan 定義了建構式,建構時指定的 name、level、blood 指定給 Role 的建構式,SwordsMan 也定義了自己的 fight 方法。
類似地,Magician 可以如下繼承 Role 類別:
如何看出確實有繼承了呢?以下簡單的程式可以看出:
雖然 SwordsMan 與 Magician 並沒有定義 to_string 方法,但從 Role 繼承了,所以可以直接使用,執行的結果如下:
繼承的好處之一,就是若要將 name、level、blood 等值域改名為其他名稱,那就只要修改 Role 就可以了,繼承 Role 的子類別無需修改。
在 SwordsMan、Magician 中定義了建構式,並呼叫了父類 Role 建構式,實際上建構式本體沒寫什麼,在這種情況下,你可能會想直接繼承 Role 定義的建構流程,這可以透過 using 指定父類名稱來達到,例如:
這麼一來,SwordsMan("Justin", 1, 1000)、Magician("Magician", 1, 800) 的建構流程,就直接走 Role 中相同簽署的建構流程了,不過,就繼承意義而言,這才是實質地繼承了建構式,不過這種方式,不能繼承預設、複製與移動建構式,若需要這些建構式,子類必須自行定義。
在物件導向中,繼承是個雙面刃,想判斷繼承的運用是否正確,有許多角度可以探討,最基本的,就是看看父子類別是否為「是一種(is-a)」的關係,就上例來說,SwordsMan 是一種 Role,Magician 是一種 Role,符合最低限度的關係。
就這邊的範例說,建構子類實例時,會先執行父類建構式,接著是子類建構式,而解構的時候相反,會先執行子類解構式,接著才是父類解構式。
遮蔽父類方法
在〈繼承共同行為〉中,Role 的 to_string 被繼承了,然而,你也許會想要 SwordsMan、Magician 各自的 to_string,可以有類別名稱作為前置,這個需求可以藉由在各自的類別中定義 to_string 來達成。例如:
在範例中,to_string 方法取得 Role::to_string 的呼叫結果,並加上各自的前置名稱後傳回,Role::to_string 這樣的呼叫,會隱含地傳入目前的 this,作為 Role::to_string 中的 this。
這一次雖然同樣是 swordsMan.to_string()、magician.to_string() 呼叫,然而使用了子類各自的定義,父類的 to_string 定義被遮蔽(hide),因此執行結果會是:
在子類別中若要呼叫父類建構式或者是父類方法,在其他語言中,會有 super 之類的關鍵字可以用,然而 C++ 必須使用父類名稱,在簡單的情境中,寫死父類名稱或許不是什麼問題,然而,在更複雜的情況,多個方法都得呼叫父類方法時,寫死一大堆父類名稱,可能就是個問題,如果父類名稱在撰寫時又比較複雜,問題可能就更大。
一個緩解的方式是以 using 定義別名。例如:
這麼一來,未來若真的要修改父類名稱,可以只在一個地方修改。
實際上就以上的需求,你也可以在 SwordsMan 或 Magician 中定義一個 desc 來完成相同的任務,那麼以相同名稱遮敝父類方法的意義何在呢?
就這邊來說,在還沒遮敝同名方法方法前,swordsMan.to_string()、magician.to_string() 在編譯時期,就綁定了呼叫的方法會是 Role 中定義的 to_string 方法,在遮敝同名方法之後,編譯時綁定的版本,就是各自類別中定義的 to_string 方法。
也就是就這邊的範例來說,遮敝同名方法之目的,是要在編譯時期,視實例的型態來綁定對應的方法版本。
如果遮敝了 to_string,然後你這麼呼叫呢?
首先,因為繼承會有 is-a 的關係,也就是 SwordsMan 是一種 Role,當 = 左邊型態是一種右邊型態時,編譯器允許隱含的型態轉換,因此 Role role = swordsMan 可以通過編譯。
接下來 role.to_string() 呼叫時,由於編譯器在編譯時期只知道 role 的型態是 Role,雖然 role 實際上參考了 swordsMan,然而編譯時期能綁定的就是 Role 的 to_string 定義,因此執行的結果會是來自 Role 的 to_string 定義,而不是 SwordsMan 的 to_string 定義。
如果想在編譯時期,不管實例實際上是哪種型態,一律視呼叫方法時的變數型態來決定呼叫的版本,這個行為就會是你要的,在這種情況下,若有個函式或方法,想要操作實例繼承而來,或者是本身定義的方法,會透過模版來達成。例如:
因為是模版,實際上會依呼叫時指定的實例型態,重載出對應型態的版本,該呼叫哪個版本,是編譯時期就決定的事,就程式碼本身而言,是以父類定義的行為來看待實例的操作,是一種多型(polymorphism)的實現,由於這種多型實現是編譯時期就可以達成方法綁定,亦被稱為編譯時期多型(compile-time polymorphism)。
當然,printInfo 模版可以適用任何具有 to_string 方法的實例,不用是 Role 或其子類別的實例,因而可用來實現結構型態系統(structural type system)。
如果想在執行時期,看看實際上實例是何種型態,並採用各自型態的 to_string 定義,該方法必須設定為 virtual,這之後再來說明了。
虛擬函式
虛擬函式 (virtual function)
在〈遮敝父類方法〉中看到,在繼承關係下,基於 is-a,子類實例可以指定給父類型態,如果你這麼做,多數情況下想要的效果是,想以一般化的方式來操作實例,無論該實例是父類或子類實例。
例如,Role、SwordsMan 都具有 to_string 方法,執行時期若透過 Role 來操作 SwordsMan,是因為 SwordsMan 是一種 Role,我們想要的就是操作Role的 to_string,但如果 SwordsMan 有定義了 to_string,多數情況下,我們希望執行的會是實例重新定義後的版本,也就是 SwordsMan 的版本。
對於父類的方法,你預期它的執行時期行為會被重新定義,也就是希望在執行時期,依照實例的型態綁定對應的方法版本,可以在父類定義方法時加上 virtual,例如:
被定義為 virtual 的函式,若程式碼中透過父類型態參考或指標操作,會在執行時期才綁定要執行的版本,因此 printInfo 會依指定的實例,呼叫各自重新定義後的 to_string 方法,執行結果如下:
如果定義類別時,預期會在執行時期,以父類型態操作子類實例重新定義的方法,那麼該方法要設定為 virtual。
而在試圖想重新定義父類的 virtual 方法時,很容易因為不符合方法簽署,造成實際上定義了新方法而不是重新定義方法,若想避免這種情況,C++ 11 以後最好在後方標註 override,編譯器就會檢查,目前定義的方法是否真的是重新定義了父類別的 virtual 方法。
父類中的方法若被標示 virtual,子類重新定義方法時自然就會是 virtual,因此重新定義時可以基於閱讀上的方便性,自行選擇是否標註 virtual。
若 Class 中有方法被標示為 virtual,編譯器會隱含地在 Class 內加入虛擬方法表(virtual method table),表中的指標用來指向被標示為 virtual 的方法,只要一個 class 中有一個以上的 virtual 函式,那麼每一個由該 class 產生的 object 都會包含一個 virtual table 與一個指向 virtual table 的指標 virtual table pointer。我們可以將 virtual table 想像成陣列,而陣列中的每個元素都是個指向 virtual 方法的 implementation 的指標。如果子類重新定義了 virtual 方法,子類的虛擬方法表中該方法的指標,會指向重新定義的方法,繼承下來而沒有被重新定義的 virtual 方法,該方法的指標會指向父類定義的 virtual 方法。
- The image file may be corrupted
- The server hosting the image is unavailable
- The image path is incorrect
- The image format is not supported
Learn More →圖片來源:C++中關於 virtual 的兩三事
看到這裡你可能會想說,什麼是 implementation 啊? 這要談到函式的細節。 首先,函式有分「宣告」與「定義」,像這樣就是宣告,它會告訴編譯器這個函式的名字、回傳類型和傳入的參數等等,但並沒有「定義」這個函式要做什麼,像是:
而函式「定義」則告訴這個函式實際上要做些什麼,像是:
而我們在繼承的時候,也分了這兩種:
inheritance of function interfaces( 函數介面(接口)的繼承 )
與inheritance of function implementations (函數實現的繼承)
,前者就是指繼承了函式的「宣告」,後者則是繼承了函式的「定義」。 因此我們可以知道,所謂的 interface 介面(接口) 就是指函式宣告的部分,而 implementation 實現 就是指函式定義的部分。註:文章參考連結
那麼回來一開始的程式,當中 printInfo,限定必須得是 Role 的子類實例,因為是以父類觀點來操作子類實例,被稱為子型態多型(subtype polymorphism),因為是執行時期才有 virtual 方法的位址,也就是執行時期才能決定綁定的方法,又稱為執行時期多型(runtime polymorphism)。
乍看之下,〈遮敝父類方法〉中談到的編譯時期多型,與這邊談到的執行時期多型,似乎有很大的重疊性,區別就只是編譯時期或執行時期綁定?例如,單就「顯示角色資訊」來說,這邊的 printInfo 與〈遮敝父類方法〉中的 printInfo,似乎都可以解決需求?
不過,你要再釐清需求,「顯示角色資訊」表示你要接受的對象是「角色」,而不是具有 to_string 的任何物件,如果需求是「顯示具有 to_string 物件的資訊」,你要使用的是模版。
另一個用來釐清需求的方式是,定義 virtual 方法時可以完全不實作,也就是執行時期,這類方法在虛擬方法表中的指標,可以指向 nullptr,這類方法稱為純虛擬方法,也被稱為抽象方法,這之後再來談。
在範例中 Role 的解構式也被定義為 virtual 了,這表示執行時期才會決定使用哪個版本的解構器,這影響的會是動態建立 Role 的子類實例後,以 delete 刪除該實例,會執行的是哪個版本的解構式。例如:
如果 Role 的解構式不是 virtual,那麼 role 會在編譯時期就綁定 Role 定義的解構式,delete role 執行的就只會是 Role 定義的解構式,這通常不會是我們想要的結果,如果 Role 的解構式是 virtual,role 是在執行時期,依實例類型綁定解構式,就這邊就是 SwordsMan 的解構式,因此 delete role 執行的就會是 SwordsMan 定義的解構式,接著是 Role 的解構式。
絕大多數情況下,子類實例解決時,當然也想要執行子類的解構式,解構式預設並不是 virtual,因此若定義的類別,是會被用來繼承的基礎類別,應該定義解構式為 virtual。 簡單來說,子類 Class 可能會有不同的解構方法,所以我們需要加上 virtual ,否則解構子會被寫死。。
如果不希望方法被子類重新定義,可以定義方法為 final 的 virtual,例如:
這麼一來,子類就不能定義 foo 方法了,如果類別不希望有子類,可以定義類別為 final:
如果不是透過父類型態參考或指標操作,那就只是透過複製建構式建構了父類實例罷了。例如:
這邊的 role 實際上是建立了 Role 實例,而不是參考了 SwordsMan 實例。以下這樣也不是:
也就是說,如果想使用執行時期多型(子型態多型),必須透過參考或指標來操作。
父類型態可以參考子類型態實例,但不能反過來,例如:
道理很簡單,SwordsMan 一定是一種 Role,然而 Role 未必是 SwordsMan,當然,就上例來說,role 參考的確實是 SwordsMan 實例,雖然不鼓勵,不過還是可以明確地轉換型態:
類似地,指標也可以明確地轉換型態:
dynamic_cast 用於告知編譯器,你就是要將父類別的參考或指標向下轉型為子類型態,這不單只是要編譯器住嘴,繼承體系中必須有 virtual 函式的存在,實際的型態轉換會在執行時期進行,確定轉換目標與來源是否有類別階層關係,如果是個指標,轉換成功時傳回位址,失敗的話會傳回 nullptr,如果是參考的話,轉換失敗會丟出 bad_cast 例外,這令執行時期的轉換失敗,會有機會進行處理,如果你使用 static_cast,雖然可以令編譯器住嘴,然而錯誤的轉換會有什麼結果就無法預期了。
純虛擬函式
在〈虛擬函式〉中,將 to_string 設成 virtual 了,然而你可能會發現,Role 的子類別都有 fight 方法,為什麼不將它們提昇至父類別並設為 virtual?可以是可以,不過提昇之後,在 Role 中的方法該寫什麼呢?空的方法本體?如果是這樣的話,不如將它設為純虛擬函式(pure virtual function),也就是沒有任何實作,只有宣告的方法:
這麼一來,就可以如下寫個 doFight 了:
被設為 0 的 virtual 函式,沒有任何實作,是個抽象方法,而擁有抽象方法的類別,我們稱它為抽象類別(abstract class),不能用來實例化,這很正常,因為它裡面存在沒有定義的函式:
因此,也不能如下指定:
也就是說,因為抽象類別不是個完整的類別定義,只用來宣告參考或指標,而繼承抽象類別的子類,一定要重新定義抽象方法,否則該子類也會是抽象類別,無法用來實例化。
來看看抽象方法與抽象類別的另一個應用,如果要你開發一個猜數字遊戲,會隨機產生 0 到 9 的數字,使用者輸入的數字與隨機產生的數字相比,如果相同就顯示「猜中了」,如果不同就繼續讓使用者輸入數字,直到猜中為止。
這程式有什麼難的?相信現在的你也可以實作出來:
不過,需求中並沒有說要在文字模式下執行這個遊戲,也就是說取得使用者輸入、顯示結果的環境未定,但你負責的這部份還是要先實作,怎麼辦呢?可以先設計個抽象類別:
對於文字模式中的遊戲,可以繼承 GuessGame:
在定義類別時,可以完全只有純虛擬函式,完全不提供實作,也沒有任何狀態定義,將 Class 當成是一種設計藍圖。
來個簡單的需求演變情境,以說明為什麼要有這種類別。老闆今天想開發一個海洋樂園遊戲,當中所有東西都會游泳。你想了一下,談到會游的東西,第一個想到的就是魚,你也許會定義 Fish 類別有個 swim 的行為:
由於實際上每種魚游泳方式不同,所以將 swim 定義為純虛擬函式,因此 Fish 是抽象類別。接著定義各種魚繼承魚:
老闆說話了,為什麼都是魚?人也會游泳啊!怎麼沒寫?於是你就再定義 Human 類別繼承 Fish…等一下!Human 繼承 Fish? 不會覺得很奇怪嗎?人是一種魚嗎?既然如此,不如將 Fish 改名為 Swimmer,讓 Human 繼承 Swimmer,這樣好像說得過去,魚是一種會游泳的生物,人也是一種會游泳的生物嘛!
不過,如果 Human 要有 firstName、lastName 兩個值域呢?也就是說就算你將 Fish 改名為 Swimmer,原本的狀態定義,並不適合 Human 來繼承,怎麼辦呢?
既然都想要抽象的 swim,而狀態不同是個問題,不如就定義個沒有狀態的 Swimmer 吧!
然後原本的 Fish 繼承 Swimmer:
Fish 剛剛的子類不用修改,也就是維持既有的繼承體系,接著 Human 也可以繼承 Swimmer:
那再做個潛水艇吧!
現在,大家可以快快樂樂地一起游泳了:
模板與繼承
C++ 可以定義類別模版,在繼承時對象也可以使用模版,不過並不鼓勵這種做法,例如,你也許會想要量測某個方法的執行時間,為了可以重用量測用的程式碼,或許會採用這樣的設計:
Timing 繼承的對象,必須具有 execute 方法,雖說這樣可以達成目的,然而,模版實例化的 Timing<EmptyStringOutputter> 實際上會像是:
這就有點問題了,TimingXXX 是一種 EmptyStringOutputter 嗎?或者 Timing<EmptyStringOutputter> 是一種 EmptyStringOutputter 嗎?就閱讀上 Timing<EmptyStringOutputter> ,應該是有(has-a)一個 EmptyStringOutputter 吧!實際上這個需求,可以用組合(composite)來達成,而不是使用繼承:
這麼一來,Timing<EmptyStringOutputter> 就實作與閱讀上,就都是有(has-a)一個 EmptyStringOutputter 了。
結合模版與繼承時比較合理的一個例子,是在想共用某個相同實作之時,例如:
在這個例子中,Comparable 實作了部份用於比較的方法,實際上如何比較物件的狀態並不知道,畢竟不同類別的定義不同,因此以模版參數 T 代表物件類型,並規範了 compareTo 必須由子類實作。
Ball 類別繼承時將模版實例化為 Comparable<Ball>,Ball 只要重新定義 compareTo,就可以使用事先實作的 lessThan 等方法了,在這種情況下,Ball 是一種 Comparable<Ball>,也就是這球是一種可比較的球,關係上也比較合理。
RTTI
RTTI 全名為 Run-Time Type Information,也有人寫作 Run-Time Type Identification,代表著執行時期取得物件的型態資訊,在 C++ 中,可以使用定義於 type_info 的 typeid 來實作。
typeid 接受物件,傳回 type_info 實例,具有以下的方法可以操作:
來看個簡單的範例:
執行結果如下:
如果你需要基於型態來排序,type_info 的 before 方法,是唯一提供順序的方式,可用來定義比較器(comparator)。例如:
繼承 ── 複習與補充
在講多重繼承前我們再複習一下繼承,並補充一些東西。作為一個 Class 的設計者,有的時候我們會想要 派生Class 只繼承一個member function 的interface (declaration)。有的時候你想要 派生Class 既繼承interface(介面)也繼承implementation(實現),但你要允許它們替換他們繼承到的implementation。還有的時候你想要 派生Class 繼承一個函數的 interface(介面)和 implementation(實現),但不允許它們替換任何東西。
為了更好地感覺這些選擇之間的不同之處,我們來看看下面這個例子:
Shape 是一個 Abstract Class,因為它有 pure virtual function(純虛擬函數),因此我們不能創建 Shape class 的實例,只能創建它的 派生Class 的實例。
Shape class 內宣告了三個函數。第一個是 draw,用途是在一個畫出當前對象。 第二個是 error,如果 member functions 需要報錯,就呼叫它。第三個是 objectID,回傳一個整數,並且不能修改資料成員。每一個函數都用不同的方式宣告:draw 是一個 pure virtual function(純虛擬函數);error 是一個 virtual function(虛擬函數);而objectID 是一個 non-virtual function(非虛擬函數),那我們來複習一下他們的特性。
首先是 pure virtual function(純虛擬函式) draw:
pure virtual functions(純虛擬函數)有兩個最顯著的特性是它們必須被任何繼承它們的 Class 重新宣告,父類中通常沒有它們的定義。把這兩個特性加在一起,我們就知道了
以上例來說,一個長方形肯定要可以被畫出來,但要怎麼畫需要自己定義,因為畫長方形的方法跟畫橢圓形的方法肯定不一樣嘛! 也就是說,draw 這個純虛擬函式的宣告告訴了長方形這個 派生Class 它必須要有畫出來的方法,但要怎麼畫要自己寫。
這邊要提醒一個常有的迷思,就是許多人認為 pure virtual functions(純虛擬函式) 是「不能」有定義的,這是不對的,純虛擬函式仍然可以有定義,但通常我們不會去定義一個預設的純虛擬函式,因為使用了純虛擬函式就代表說 派生Class 需要定義一個自己的函式,這樣的話根本就不會用到預設的定義了,可以看看下面這個例子:
輸出結果:
可以看見我們的確呼叫了預設的純虛擬函式,那這樣有什麼用呢? 痾…通常沒用,上面也說了我們通常不會定義預設的純虛擬函式。 但有一個特殊的用途,後面我們會談到要如何為 virtual function(虛擬函式) 提供一個比較安全的繼承機制,就是透過切斷實現與介面聯繫來做,並利用提供預設定義給純虛擬函式來改善的,另外,這是一個把實現和介面分清楚的關鍵。
回到前面的話題,我們接著看 virtual function(虛擬函式):
例子中,父類有 virtual function(虛擬函式) 代表要求每個 派生Class 需要有一個 error 函式,並且告訴她如果不需要有什麼特別的功能,可以用父類中預設寫好的功能。但這種的結果有時會導致危險,因為我們並不需要特別註明要繼承這個函式,所以可能會忘記有這個函式,或是忘記重寫,導致一些錯誤。 舉個例子,現在有兩台民航機,A 和 B,它們的飛行方法是一樣的:
這樣的話 A 和 B 就能夠順利起飛,然後今天多了一台戰鬥機 C:
這樣是很危險的,我們讓一台戰鬥機用民航機的飛行方法在飛,理論上,不同機型需要有不同的
fly
定義,顯然戰鬥機和民航機的飛行方法不一樣,但這裡的問題不是 fly 有預設的定義,這是我們想要的,而是 C 「不需要明確地說出他要做什麼就可以繼承」。 幸運的是,「除非 派生Class 明確的提出需求,否則不讓它們繼承」 這件事是很容易做的,這個訣竅就是切斷介面和實現之間的聯繫。以下用的就是這個方法:這樣我們就切斷介面和實現之間的聯繫了。 這一方案並非十分安全,我們還是有可能因為複製貼上等等的原因導致出錯,但至少比一開始好了,這樣的設計讓我們不需要每個 派生Class 都重新定義一次 fly(),但又避免了忘記重新定義的問題。
而還有一個重點,那就是 Airplane::defaultFly 是一個非虛擬函式,這一點也很重要,這是因為 派生Class 不應該重新定義這個函式,如果把她設為虛擬的,那我們可能會在某些地方忘記要重新定義他,陷入一個循環,這樣就失去意義了。
有些人反對為介面和實現分別提供函式,就像上面的 fly 和 defaultFly 那樣。 它們注意到這樣做會導致類似的相關函式名去汙染到 Class namespace (類名字空間) 。 但它們仍同意介面和實現應該分開,這聽起來很矛盾對不對? 那它們是怎樣解決的呢? 通過下面這個方法: 純虛擬函式必須在 concrete derived classes(具體子類) 中被 redeclared (重新宣告),但它們也可以有自己的實現,換句話說,就是提供純虛擬函式預設的定義,並讓 派生Class 在實現的時候直接利用這個預設的定義,下面給個例子:
同樣的原理,利用了提供預設的定義給純虛擬函式來切斷介面與實現,畢竟這也算是純虛擬函式的特性,那何必大費周章的再定義一個 defaultFly 呢?
最後,我們看看 Shape 裡的non-virtual function(非虛擬函數),objectID:
當一個 member function 是非虛擬的時,不應該指望他在 派生Class 中的行為會有所不同。實際上,一個非虛擬成員函式指定了一個 invariant over specialization(超越特殊化的不變量), 因為不論一個 派生Class 變得多麼特殊,他都把它看成是不允許變化的行為,簡單來說
這邊我們假設
1objectID()
是為了生成一個特殊的辨識碼而做的函式,那麼他的 派生Class 被要求不能更改這個函式就很合理了對吧!最後做個總結,我們在設計Class時,最好要能精確的設計純虛擬函式、虛擬函式和非虛擬函式這三個,何時要加上 virtual 是一項很重要的議題,因為這三個宣告代表著完全不同的意義,在宣告時最好要仔細的考量,缺乏經驗的設計者常常犯兩個常見的錯誤:
宣告所有的成員函式都為非虛擬函式。
這樣的話他的 派生Class 就沒有任何特殊化的空間了,那就沒有繼承的意義了,除非這個Class你並不希望她有繼承者,否則不要這樣設計。
宣告所有的成員函式為虛擬函式。
這樣的話有些不應該被重新定義的函式就有危險了,除非你希望你的 父Class 內全部的函式都能夠被重新定義否則不要這樣寫。
此篇文章來源:Effective C++之Item 34 區分inheritance of interface(接口繼承)和inheritance of implementation(實現繼承)
我改了很多字,幾乎重寫了XD 希望幫助大家能看懂。
多重繼承
繼承本身就具有複雜性,在設計上並不鼓勵多重繼承,在可以使用其他設計方式替代的場合,例如合成(composite),往往建議別使用繼承;C++ 可以多重繼承,也就是子類可以同時繼承多個父類,既然單一繼承已經有複雜性了,可想而知地,多重繼承更會急劇地增加複雜度。
限制複雜度的方式之一,是限制只能繼承一個具有狀態定義的父類,因為狀態本身就是複雜的根源,同時繼承多個具有狀態定義的父類,只會令狀態的管理更複雜。
來看看從〈純虛擬函式〉衍生出來的簡單情境,如果今天老闆突發奇想,想把海洋樂園變為海空樂園,有的東西會游泳,有的東西會飛,有的東西會游也會飛,那麼現有的程式可以應付這個需求嗎?
仔細想想,有的東西會飛,但這些東西的狀態定義不一定是相同的,有了〈純虛擬函式〉的經驗,你使用定義了 Flyer:
Flyer 定義了 fly 方法,程式中想要飛的東西,可以繼承 Flyer,,而有的東西會飛也會遊,例如飛魚,它是一種魚,可以繼承 Fish,而它也可以飛,因此同時繼承了 Flyer:
在這邊運用了多重繼承,若要繼承多個父類,只要用逗號區隔就好了,接著你想,來個超人吧!
雖然叫超人,不過電影裡的超人往往不是人,就不繼承 Human 了,而是繼承 Flyer 與 Swimmer;接下來,能游的就游,能飛的就飛吧!
這是多重繼承的一個簡單運用:為了不同狀態定義的類別實例能夠多型。因為繼承的來源沒有狀態定義,只有行為規範,才令多重繼承時的複雜度不致於一下難以控制。
多重繼承的建構
如〈繼承共同行為〉中看過的,在單一繼承時,情況比較單純,建構子類實例時,會先執行父類建構式,接著是子類建構式,而解構的時候相反,會先執行子類解構式,接著才是父類解構式。
多重繼承時,若繼承來源之一有狀態定義,另一個沒有狀態定義,就如〈純虛擬函式〉、〈模版與繼承〉中的範例,因為另一來源沒有狀態定義,也就不用考慮該來源的初始或銷毀問題,這時只要考量有狀態定義的繼承來源的建構與解構,如同單一繼承,問題就可以單純化。
在進一步看到多重繼承的建構與解構之前,先來看個單一繼承時 this 實際位址在哪的示範:
顯示的結果會是同一位址:
如果是多重繼承的話呢?
多重繼承時,建構式的執行順序會與繼承的順序有關(而不是呼叫父類建構式的順序),C 因為繼承時的順序是 P1、P2,建構式執行順序會是 P1、P2、C,至於解構式的執行順序,會是與建構式執行相反的順序,從執行結果中,可以發現 this 的位址會是不同:
多重繼承時,C 實例的起始位址是 0x61feb8,而 P1 位址的偏移量是 0,P2 位址的偏移量是 4,因此 P1、P2 中雖然都定義了 x 成員,若在個別的類別中寫 this->x,因為 this 位址不同,取得就會是各自不同的 x,因為是各自不同的位址,建構時也是個別地初始化在不同的位址,從執行結果中也可以看到,衍生類別實例的位址會用來初始第一個繼承的父類。
多重繼承時,個別類別中的 this 位址不同的事實,也會反應在以父類型態參考子類實例之時:
執行時有關 p1、p2 位址的顯示結果會是不同的:
取址的時候也是,在以下的範例中,都是 &c,然而 p1 與 p2 儲存的位址並不同,知道這個事實後,就會知道將 p1 指標的位址指定給 p2 是不可行的,會造成編譯錯誤:
如果強硬地使用 C 風格轉型,也就是 p2 = (P2*) p1 的話,雖然可以通過編譯,結果是造成 p2 儲存了 p1 的位址,雖然 p2 的型態是 P2*,若透過 p2 操作 P2 定義的方法,會造成方法中的 this 指向的是 p1 的位址,結果會是不可預期的,當然,P1 與 P2 本來就是不同繼承體系、不同型態,試圖在兩者之間轉換,本來就是錯誤的。
虛擬繼承
類別若繼承兩個以上的抽象類別,而兩個抽象類別都定義了相同方法,那麼子類別會怎樣嗎?程式面上來說,並不會有錯誤,照樣通過編譯:
但在設計上,你要思考一下:Task 與 Command 定義的 execute 是否表示不同的行為?
如果表示不同的行為,那麼 Service 在實作時,應該會有不同的方法實作,那麼 Task 與 Command 的 execute 方法就得在名稱上有所不同,Service 在實作時才可以有兩個不同的方法實作。
如果表示相同的行為,那可以定義一個父類別,在當中定義純虛擬 execute 方法,而 Task 與 Command 繼承該類別,各自定義純虛擬的 doSome 與 doOther 方法:
這個程式可以編譯成功也可以執行,不過從〈多重繼承的建構〉可以知道,task 與 command 的位址是不同的,建構 service 的過程中,Task、Command 的建構式中 this 會是不同位址,而它們又會以各自的 this 來執行 Action 的建構式。
也就是就上例來說,Action 的建構流程會跑兩次,一次是以 task 的位址,一次是以 command 的位址,這意謂著,如果 Action 定義了值域,task 與 command 會各自擁有一份。
另外要知道的是,目前為止的繼承方式,都是編譯時期就決定了子類從父類繼承而來的定義,例如,單看 Task,編譯時期就決定了從 Action 繼承而來的定義,而單看 Command,編譯時期就決定了從 Action 繼承而來的定義。
結果就是,由於 Task、Command 各自有一份編譯時期繼承而來的 Action 定義,如果 Service 同時繼承了 Task、Command,那它會有兩份 Action 定義,各來自 Task、Command,藉由 this 的實際位址來決定該使用哪個定義。
這就有了個問題,如果是用 Action 型態來參考 service 呢?
由於 Service 有兩份 Action 定義,作為父型態的 Action 要參考 service 時,編譯器不知道你想採用哪份 Action 定義,如果想在編譯時期就決定這件事,就得明確告訴編譯器:
如果不想使用 static_cast 呢?根源在於 Task、Command 在編譯時期就決定了從 Action 繼承而來的定義,才造成 Service 中有兩份 Action 定義,那能不能在執行時期才決定 Task、Command 繼承的定義,就類似 virtual 函式,執行時期才決定實際的函式位址?
這可以透過虛擬繼承,也就是在繼承時加上 virtual 關鍵字來達到:
現在 Task、Command 編譯過後,不會各自包含 Action 的定義了,只會各自有個可用來指向 Action 的指標,在執行時期才指向同一個 Action 類別,因此 Service 繼承而來的 Action 類別也就是 Task、Command 共享的那一個,因此 Action 型態就可以直接參考 Service 實例了:
在虛繼承下,Action 的建構式只會以 Service 實例的位址執行一次。
當然,這些都是編譯器的細節,若要從語義上理解,實際上 Service 才真的實作 execute,Task、Command 不用真的包含 Action 定義,virtual 繼承時,Task、Command 就像是轉接 Action,Service 發現這兩個類別轉接的對象是同一個 Action,最後就會像是 Service 直接繼承 Action,若要做個比喻,就會像 class Service : public Action, public Task, public Command。
另一種語義上的理解方式是,虛繼承的 Task、Command 表明,若以 Action 型態參考實例來操作時,Task、Command 的 this 願意共用相同的位址,而這個位址就會是同時繼承了 Task、Command 的子類位址,也就是 Service 實例的位址。
多重繼承的複雜
繼承本身就會令事情複雜化,多重繼承更是會令複雜度加劇,〈虛擬繼承〉中看到的不過是部份情況。
同名的方法或值域若在子類中可見,就必須處理名稱重疊時的相關議題(在子類中不可見的值域或方法,程式碼撰寫上本來就不能存取,也就不會有名稱重疊的判斷問題)。例如,如果繼承的父類有實作方法,而另一父類有同名的純 virtual 函式,從父類繼承的實作方法並不被視為實作了純 virtual 函式。例如:
C 仍被視為抽象類別,C 必須重新定義 foo,才可用來建立實例。
如果繼承的父類具有同名的實作方法,會造成實例呼叫的方法版本模棱兩可:
如果 C 只想保留其中一個版本,那就在 C 中重新定義 foo,並以 :: 指明會呼叫哪個父類的版本,如果不想重新定義 foo,那就必須明確指定給 P1 或 P2 型態:
若從兩個父類繼承了同名且可見的值域,也會有類似問題:
C 實例其實會有兩份 x,藉由 this 位址的不同來存取,上例若要能存取,必須明確指定給 P1 或 P2 型態: