【C++】繼承與多型
物件模型: 物件中成員變數在記憶體中的佈局形式。
面向物件程式設計的核心思想是封裝(資料抽象)、繼承(程式碼複用)和多型(動態繫結)。 1.通過使用資料抽象,我們可以將類的介面與實現分離; 2.使用繼承,可以定義相似的型別並對其相似關係建模; 3.使用動態繫結,可以在一定程度上忽略相似型別的區別,而用統一方式使用它們的物件。
protected 將相關的資料成員宣告成保護成員,保護成員可以被本類的成員函式訪問,也可以被本類的派生類的成員函式訪問, 而類以外的任何訪問都是非法的,也就是說它是半隱蔽的。 eg: class Student {};
class UStudent : public student {};
上述繼承方式是公有繼承。 宣告一個派生類的一般格式為: class 派生類名:[繼承方式] 基類名 { 派生類新增的資料成員和成員函式 }; 注意:如果沒有顯示地給出繼承方式,預設是私有繼承。
繼承方式 基類的public成員 基類的protected成員 基類的private成員 ----------------------------------------------------------------------------------------------------- public繼承 仍為public成員 仍為protected成員 不可見(被繼承),用不了它 protected繼承 變為protected成員 變為protected成員 不可見(被繼承) private繼承 變為private成員 變為private成員 不可見(被繼承)
public > protected > private public:保持原樣 protected:比其許可權高的通通改變為protected private:比其許可權高的通通改變為private public繼承(is-a原則) 物件模型:類中的成員變數在記憶體中的佈局形式(非靜態成員變數) 基類在上,子類在下 Base: Derived: _priB _priB _proB _proB _pubB _pubB _priD _proD _pubD 1.物件模型上來看 2.使用角度 在所有用到基類物件的位置都可以用子類物件來替換。 注意:基類物件不能賦值給派生類物件。(派生類物件中存在與基類物件不同的成員變數) protected/private繼承(has-a原則) 子類中內嵌(組合、包含)了一個基類物件(包含關係) 總結: 1.基類使用系統合成的建構函式(只有在需要時系統才會合成),這時的派生類也可以使用系統合成的建構函式 (也是在需要時才會合成)。當基類顯式定義了預設的建構函式,這時的派生類可以使用系統合成的(系統會在此時合成派生類的建構函式, 在派生類的初始化列表會去調基類的建構函式)。 2.當基類中定義了非預設的建構函式(需要引數),這時派生類中必須顯式定義建構函式,且在派生類建構函式的初始化列表 需要呼叫基類的建構函式(傳參),這裡應該很容易明白,因為系統並不知道該給基類的建構函式傳什麼引數,所以你必須自己進行傳參。 3.基類通常都應該定義一個虛解構函式,即使該函式不執行任何實際操作也是如此。 4.基類的私有成員在派生類中是不可訪問的,如果基類不想在類外直接訪問,但又想在派生類中可以訪問時,需要定義為protected。 可以看出,protected這個訪問許可權是為了繼承才出現的。 5.派生類會繼承基類中的所有成員,只是訪問許可權有時會發生改變,導致基類的某些成員在派生類中不可見。 6.每個類控制他自己的成員初始化過程。 7.防止繼承的發生:c++11新標準提供了一種防止繼承發生的方法,即在類名後加上一個關鍵字final; 8.使用關鍵字struct時—>預設公有繼承 使用關鍵字class時—–>預設私有繼承。 9.public繼承是一個介面繼承,保持is-a原則(是一個的關係),每個父類可用的成員對子類也可用,因為每個子類物件也都是一個父類物件。 子類物件可以看成是一個基類的物件(在所有用到基類物件的位置都可以用子類物件來替換) 10.protected/private繼承是一個實現繼承,基類的部分成員並非完全成為子類介面的一部分,是 has-a 的關係原則,所以非特殊情況下 不會使用這兩種繼承關係,在絕大多數的場景下使用的都是公有繼承。私有繼承意味著is-implemented-in-terms-of(是根據……實現的)。 通常比組合(composition)更低階,但當一個派生類需要訪問基類保護成員或需要重定義基類的虛擬函式時它就是合理的。 11.在實際運用中一般使用都是public繼承,極少場景下才會使用protetced/private繼承。
賦值相容規則 在public繼承許可權下,子類和派生類物件之間有以下4種關係: 1.子類物件可以賦值給父類物件(切割/切片) 型別截斷 子類物件可以看成是一個父類物件(實際相當於內容多的可以賦值給內容少的) 2.父類物件不能賦值給子類物件 由於子類物件中存在父類物件所沒有的內容,那麼這個獨有的空間就沒法給子類物件賦值,實際上是虛擬出來的一個空間。 3.父類的指標/引用可以指向子類物件 Base *pb; // 只能訪問派生類物件中Base大小的記憶體空間。 Base& rb = d; // 只能訪問派生類物件中Base大小的記憶體空間。 4.子類的指標/引用不能指向父類物件(可以通過強制型別轉換完成) 這是不安全的,因為派生類物件記憶體空間大,訪問基類物件時就會把基類物件的記憶體空間當做子類物件去看待, 比如訪問完基類物件後 再去訪問就會發生越界訪問。 如果強轉的話,有可能會記憶體覆蓋,並且不能呼叫成員函式,否則會崩潰。
繼承體系中的作用域 1.在繼承體系中基類和派生類是兩個不同作用域。 2.子類和父類中有同名成員,子類成員將遮蔽父類對成員的直接訪問(在子類成員函式中,可以使用 基類::基類成員 訪問) 同名隱藏(重定義):在基類和派生類中,具有相同名稱的成員(成員函式或者成員變數),如果用派生類物件去訪問繼承體系中的同名成員, 只能訪問到派生類自己的,基類的成員由於被隱藏則無法訪問,但是可以通過基類::基類成員的方式去訪問相同名稱的基類。 3.注意在實際的繼承體系裡面最好不要定義同名的成員。
這裡需注意:當父類和子類成員函式同名時,這時只需關注函式名,與引數列表和函式的返回值都無關;當引數列表不同時,可能會有人 認為它們可以構成過載,切記:它們不能構成過載,可以構成過載函式的首要條件是必須在同一作用域內,這裡的父類和子類很明顯是兩個作用域。
繼承體系下派生類和基類建構函式的呼叫次序: 先呼叫派生類的建構函式,再去派生類的初始化列表中去呼叫基類的建構函式; (在進入派生類函式體之前,先要在初始化列表中完成派生類中成員的初始化,先初始化基類中成員,再初始化派生類物件, 若想要構造成功,在派生類的初始化列表中呼叫基類的建構函式。) 函式體的執行順序:先構造基類的物件,再構造子類的物件。 繼承體系下派生類和基類解構函式的呼叫次序 先呼叫派生類的解構函式,將派生類的解構函式體中的內容執行完畢,在將要結束派生類的解構函式時去呼叫基類的解構函式 (相當於加了一句呼叫基類的解構函式),將基類的解構函式呼叫完成後會返回到派生類解構函式體的右花括號之前,繼而結束派生類的解構函式。
說明: 1.基類中沒有定義預設建構函式,派生類必須要在初始化列表中顯式給出基類名和引數列表 2.基類中沒有定義建構函式,則派生類也可以不用定義,全部使用預設建構函式 3.基類定義了帶有形參表建構函式,派生類就一定要定義建構函式。 如果不定義的話,就不知道該傳什麼型別引數。
設計一個類,該類不能繼承 設定一個公有的靜態成員函式,建構函式設定成私有的。 基類中的建構函式由於私有那麼在子類中不可見,子類的建構函式去呼叫父類的建構函式時由於私有會失敗。 私有原先的建構函式+靜態方法例項化(構造)與釋放。 class Base { public: static Base* GetObj() { return new Base; } static void Release(Base *pb) { if (pb) { delete pb; } } private: Base() { cout<<"Base()"<<endl; } }; C++ 11中提供了final關鍵字,防止繼承的發生,即就是在類名後加上final。 eg: class C final { }; 友元與繼承 友元函式不能被繼承,因為友元函式不屬於類,它不受類中訪問許可權的限制。 靜態成員變數可以繼承,而且整個繼承體系中只有一個靜態成員變數,是所有類物件所共享的。
繼承體系下派生類的物件模型 單繼承 一個子類只有一個直接父類時稱這個繼承關係為單繼承 基類在上,派生類在下。 多繼承 一個子類有兩個或以上直接父類時稱這個繼承關係為多繼承 派生類物件在這片記憶體中的最下面,基類物件在記憶體中的佈局取決於多繼承中出現的順序。 菱形繼承(鑽石繼承) 單繼承和多繼承的結合 sizeof(D) = 20 菱形繼承中存在二義性問題(訪問某個成員變數不明確,加上類的作用域) 菱形虛擬繼承 為了解決菱形繼承中的二義性問題。在繼承方式前加上virtual關鍵字。 (儲存了每個派生類物件指向偏移量表格的指標) 虛繼承的特點是,在任何派生類中的virtual基類總用同一個(共享)物件表示。 物件模型 偏移量表格(虛基表) 指標(偏移量表格的地址) -------------------> 0 (相對於自己的偏移量) 派生類成員變數 8 (相對於基類的偏移量) 基類成員變數 虛表指標存在於物件模型中的前四個位元組。 為了防止重複,把_b存在最下面。 00A413C8 mov eax,dword ptr [d] 00A413CB mov ecx,dword ptr [eax+4] 00A413CE mov dword ptr d[ecx],1 D d; d._b = 1; // 1.取物件前4個位元組中的內容,也就是地址d賦給eax; 2.d + 4位元組----------> 取該空間中的內容 3.d[ecx] = 1; 將1賦值到物件向後偏移8位元組的位置---->基類繼承下來的_b位置 虛擬繼承和單繼承 1.多出了4個位元組,儲存了偏移量表格的地址; 2.派生類的物件模型:基類在下,派生類在上 3.派生類物件訪問基類成員---->通過偏移量表格地址 4.合成建構函式,並且多傳遞了一個1(檢測是否為虛擬繼承) 00A413BE push 1 00A413C0 lea ecx,[d] this指標 00A413C3 call D::D (0A410DCh) 在構造物件期間,將偏移量表格的地址放在物件的前4個位元組 00EF1470 mov dword ptr [ebp-8],ecx 00EF1473 cmp dword ptr [ebp+8],0 這裡的[ebp+8]實際上就是1,彙編:je相等的話跳轉 如果是1的話,就附上偏移量表格,反之則不需要附上表格。 注意:虛繼承和一般的繼承在呼叫建構函式時有一點差別,虛擬繼承會先將1壓棧,然後呼叫建構函式, 而一般的繼承直接呼叫建構函式,可以看出將1壓棧只是為了區分虛擬繼承和一般的繼承。 多型
談談對多型的理解 1.多型是什麼?敘述概念,並舉例說明(大自然界中的水) 2.多型的分類,引出來靜態多型和動態多型 3.實現動態多型的條件 4.多型的重要意義(設計模式的基礎,框架的基石) 可以說也可以不說 5.多型的呼叫原理(虛表指標) 意思是同一事物在不同場景下表現出的多種形態,在C++語言中多型有著更廣泛的含義。 會說話的人:見人說人話,見鬼說鬼話。 舉例: *p 與 a*b 函式過載() 靜態多型:在編譯期間就確定了其行為,(確定具體呼叫了哪個函式)也叫作靜態聯編,靜態決議,早繫結。 函式過載:通過傳遞的引數來確定呼叫哪個函式,會發生隱式型別轉換。如果可以轉換,那就轉換,如果不可以的話,就會編譯報錯。 泛型程式設計:不區分資料型別。 動態多型:在程式執行期間,才能確定程式的行為,也叫作晚繫結。
靜態多型: 編譯器在編譯期間完成,編譯器根據實參的型別(可能會發生隱式型別轉化),可推斷出要呼叫哪個函式, 如果沒有對應的函式,則會出現編譯錯誤。 動態多型: 在程式執行期間判斷所引用物件的實際型別,根據實際型別呼叫相應的函式。 動態多型的繫結:(多型的條件) 1.基類中必須要有虛擬函式—->在派生類中必須重寫基類中的虛擬函式;(加上virtual) 重寫(覆蓋):在不同作用域中,函式原型相同。 1>在派生類中要重寫的函式在基類中必須是虛擬函式; 2>派生類中的虛擬函式必須與基類中的虛擬函式原型保持一致;(引數,返回值,函式名) 協變除外(返回值型別不同) 解構函式除外(解構函式名字不同) 協變:基類虛擬函式返回基類的引用或指標,並且派生類中重寫的虛擬函式返回派生類的引用或指標。 eg: virtual Base& GetObj() { return *this; } 3>基類與派生類中虛擬函式的訪問限定符可以不同,基類中的虛擬函式的訪問形式為public。 2.必須使用基類的指標或引用去指向派生類的物件(去呼叫虛擬函式)。
注意: 1.不要在建構函式和解構函式內部呼叫虛擬函式,在構造和解構函式中,物件是不完整的,可能會出現未定義的情況。 例如:在建構函式中,類中有3個成員變數需要初始化,假如在初始化了一個變數後就呼叫了虛擬函式,則可能會出現未定義情況。 在解構函式中,假如釋放了一塊空間後,呼叫虛擬函式,也會導致未定義的情況。 2.在基類中定義了虛擬函式,則在派生類中該函式始終保持虛擬函式的特性。 在派生類中重寫虛擬函式時也可以不顯示寫出virtual關鍵字,這時編譯器會預設該函式為虛擬函式, 為了程式看起來更加清晰,則最好加上virtual關鍵字。 3.如果在類外定義虛擬函式,則只在類中宣告時加virtual關鍵字,在類外定義是不能加virtual關鍵字。(類似於static成員函式) 4.基類與派生類虛擬函式的訪問許可權可以不同,注意:基類中虛擬函式訪問形式必須是公有的。 5.建構函式為什麼不能定義為虛擬函式? 因為呼叫虛擬函式的前提是:物件一定構建成功,獲取虛表地址必須通過物件的地址,如果物件還沒有構建成功,那麼無法獲取虛表地址, 所以建構函式不能定義為虛擬函式。建構函式未執行完時物件是不完整的。雖然可以將operator=定義為虛擬函式,但最好不要這麼做,使用時容易混淆。 拷貝建構函式也不可以定義為虛擬函式。 賦值 同類型物件間賦值 rb引用基類物件: 基類 = 基類; 基類 = 派生類;(賦值相容規則) 會建立臨時基類物件。 rb引用派生類物件: 派生類 = 基類;(有了虛擬函式之後,就可以這樣用了。賦值不是完整的,因此這是不安全的) 派生類 = 派生類; 注意:賦值運算子過載最好不要使用虛擬函式。 eg: Base *pb = (Base *)&d; // 只是改變了之前的呼叫型別 pb->TestFunc1(); // 呼叫派生類
6.靜態成員函式不能定義為虛擬函式。 因為靜態成員函式是該類的所有物件所共享的,可以通過類名和作用域限定符呼叫,也就是可以不構建物件, 但是虛表地址必須要通過物件的地址才能獲取。 7.在類的六個預設成員函式中,只有解構函式需要定義為虛擬函式。 8.不要在建構函式和解構函式中呼叫虛擬函式,在建構函式和解構函式中,物件是不完整的,可能會出現未定義的行為。 建構函式物件不完整(虛表指標、資源還沒準備好) 解構函式物件不完整(資源有可能已經被釋放掉了) 9.最好將基類的解構函式宣告為虛擬函式。 場景:如果派生類解構函式中需要釋放資源,一定要把基類的解構函式宣告為虛擬函式。 (解構函式比較特殊,因為派生類的解構函式跟基類的解構函式名稱不一樣,但是構成覆蓋,這裡編譯器做了特殊處理)。 場景:派生類的解構函式釋放資源時就會造成記憶體洩漏,因為沒有呼叫派生類的解構函式。 Person* p = new Student; // 這裡new了Student,但是解構函式中沒有釋放資源。 call 建構函式,去呼叫父類的建構函式 delete p; // call 解構函式 p->~Person(); 解構函式構成多型,能保證正確呼叫對應的虛擬函式。 虛解構函式的作用 總的來說虛解構函式是為了避免記憶體洩露,而且是當子類中會有指標成員變數時才會使用得到的。 也就說虛解構函式使得在刪除指向子類物件的基類指標時可以呼叫子類的解構函式達到釋放子類中堆記憶體的目的,而防止記憶體洩露。 10.虛表是所有類物件例項共用的。(多個同類型的物件共享一張虛表) 11.行內函數由於編譯時被展開,並不知道該執行什麼具體的行為,因此也不能定義為虛擬函式。 12.友元函式和普通函式不屬於繼承體系,因此也不能定義為虛擬函式。 虛表指標呼叫虛擬函式是在程式執行時進行的,因此需要進行定址操作才能確定真正呼叫的函式,而普通函式是在編譯時就確定了 呼叫的函式,在效率上,虛擬函式要低很多。 普通函式和虛擬函式呼叫區別 普通函式:直接呼叫 虛擬函式:通過虛表查詢虛擬函式的地址 因此呼叫虛擬函式的效率比較低,至少需要查詢兩次。 注意: 什麼函式不能被宣告為虛擬函式? 普通函式(非成員函式)、靜態成員函式、友元函式、建構函式、拷貝建構函式、內聯成員函式。 普通呼叫與物件型別有關,多型呼叫與具體物件有關。 怎麼體現重寫? 構建虛表----虛表的建立時機:程式編譯期間 (單繼承)同一個類最多隻有一張虛表 虛擬函式的作用就是為了實現多型。 多型呼叫->虛表->虛表地址->物件的前4個位元組->必須要有物件 重寫和同名隱藏的區別 1.同名隱藏:繼承體系下,基類與派生類中具有相同名稱的成員(成員變數或者成員函式),當通過派生類物件呼叫相同名稱的成員時, 優先呼叫派生類。 特殊:加作用域,就可以呼叫基類。 2.重寫:基類中函式必須為虛擬函式,派生類虛擬函式原型必須與基類中虛擬函式的原型保持一致。 抽象類 在成員函式(必須為虛擬函式)的形參列表後面寫上=0,則成員函式為純虛擬函式。包含純虛擬函式的類叫做抽象類(也叫介面類),抽象 類不能例項化出物件。純虛擬函式在派生類中重新定義以後,派生類才能例項化出物件。 純虛擬函式是一個在基類中說明的虛擬函式,在基類中沒有定義,要求派生類實現自己的版本。 介面類只是一個功能說明,而不是功能實現。 (指標是可以定義的,通過基類的指標,指向派生類中實現純虛擬函式的方法,這就是框架的思維。) 假如在派生類中仍然沒有實現抽象類的方法,那麼該派生類仍為一個抽象類。 抽象類中是可以定義成員變數的,只是不能夠例項化物件。 有了虛擬函式後,會加上4個位元組,並且在物件模型中的最上面。 幾個虛擬函式就有幾個虛擬函式地址,遇到0結束(取決於編譯器編譯的時間戳) eg: *(int *)&b typedef void(*PVFT)(); PVFT *p = (PVFT *)(*(int *)&b); (*p)(); //找到虛擬函式表格 虛表指標:函式指標陣列的指標。 基類的虛表 虛擬函式表格->虛表 按照虛擬函式在基類中的宣告次序新增到虛擬函式表中。 注意: 虛表的建立時機在程式的編譯期間。 在單繼承中,同一個類中最多隻有一張虛表。(如果沒有虛擬函式就沒有虛表) 虛擬函式表vtable在Linux/Unix中存放在可執行檔案的只讀資料段中(.rodata)。 ecx暫存器事實上儲存了this指標。 this->_b = 0; // 區分是哪個物件中的_b 實際上最後呼叫的eax暫存器中儲存的虛擬函式地址。(多個虛擬函式會呼叫edx暫存器,也就是虛擬函式地址(虛表地址+偏移量)) 1.取虛表地址; eax儲存物件地址(4個位元組),再將eax中的內容賦給edx 2.傳遞this指標;(區分是哪一個物件) ecx 3.取虛擬函式地址(虛表地址+偏移量); 取 [edx] [edx + 4] [edx + 8] 賦給eax 4.呼叫該虛擬函式。 call eax 單繼承 派生類: 1.虛表指標 拷貝一份基類的虛表 (虛表的地址不一樣) 若派生類重寫了基類中的某個虛擬函式,那麼虛表會發生變化(將虛表中相同偏移量位置的基類虛擬函式替換成派生類自己的虛擬函式)。 若派生類添加了新的虛擬函式,那麼按照其在類中宣告的先後次序放在虛表的最後位置。 (考慮基類中的記憶體佈局,然後派生類的存放順序應該在拷貝的基類的下面,並且按照宣告的次序排放) 2.基類 3.派生類自己的特有成員 函式必須是虛擬函式,通過基類物件的指標或者引用來呼叫虛擬函式。 通過查詢對應類物件的虛擬函式查詢虛擬函式的地址。 基類中的函式不是虛擬函式,或者基類中的虛擬函式不是通過基類物件的指標或引用來呼叫, 直接呼叫根基類對應函式的地址。 多繼承 派生類自己的虛擬函式按照其在類中的宣告次序分佈,並且派生類中特有的虛擬函式加在第一個繼承的類的虛表的後面。 菱形繼承 單繼承 + 多繼承
虛擬函式的缺點 1.效率太差,因為呼叫一個函式時得去查詢兩次,第一次得到虛表的地址,然後在虛表中檢視虛擬函式的地址,會造成效能降低。 2.存在安全隱患,例如程式不允許你去訪問一個虛擬函式,但是你自己仍可以在虛表中檢視虛擬函式。