成都專業(yè)網(wǎng)站推廣公司網(wǎng)絡營銷的特點有幾個
文章目錄
- 一、day1
- 1. 什么是面向?qū)ο?/li>
- 2. 面向?qū)ο蟮娜?#xff1a;繼承、封裝和多態(tài)
- 2.1 封裝
- **2.1.1 封裝的概念**
- **2.1.2 如何實現(xiàn)封裝**
- **2.1.3 封裝的底層實現(xiàn)**
- 2.1.4 為什么使用封裝?(好處)
- **2.1.5 封裝只有類能做嗎?結構體如何封裝?命名空間能實現(xiàn)封裝嗎?**
- 2.2 繼承
- **2.2.1 繼承的概念**
- **2.2.2 繼承的主要作用**
- **2.2.3 如何實現(xiàn)繼承**
- **2.2.4 構造函數(shù)和析構函數(shù)總結**
- **2.2.5 派生類和基類之間的特殊關系**
- **2.2.6 繼承的底層實現(xiàn)**
- 2.2.7 **繼承的類型**
- 2.2.8 **繼承的優(yōu)缺點**
- 2.3 多態(tài)
- **2.3.1 多態(tài)的概念**
- **2.3.2 多態(tài)的類型**
- **2.3.3 存在類繼承的情況下,為何需要虛析構函數(shù)**
- **2.3.4 多態(tài)的底層實現(xiàn)**(虛函數(shù)表的實現(xiàn))
- 2.3.5 使用虛方法時需注意的一些點
- 2.3.6 純虛函數(shù)
- 2.3.7 動態(tài)聯(lián)編和靜態(tài)聯(lián)編
一、day1
本節(jié)學習設計模式的前置知識,面向?qū)ο缶幊毯兔嫦蜻^程編程的區(qū)別,以及面向?qū)ο缶幊痰娜筇卣?#xff1a;封裝、繼承和多態(tài)。
參考:
設計模式 | 愛編程的大丙
封裝、繼承與多態(tài)究極詳解(面試必問) - Further_Step - 博客園
C++ 動態(tài)聯(lián)編和靜態(tài)聯(lián)編 - scyq - 博客園
1. 什么是面向?qū)ο?/h2>
要學習設計模式,首先需要了解什么是面向?qū)ο?#xff0c;并掌握其三大要素:封裝、繼承和多態(tài)。我們可以通過一個簡單的例子來說明:
假設我們想要把一頭大象放進冰箱,這個過程可以分為三個步驟:1)打開冰箱門;2)把大象放進去;3)關上冰箱門。在面向過程的編程中,這三個步驟通常被抽象為三個函數(shù),并在調(diào)用時按需提供參數(shù)。而在面向?qū)ο蟮木幊讨?#xff0c;需要圍繞具體的對象進行設計。這里有兩個關鍵對象:冰箱和大象。冰箱需要具備開門和關門的功能;大象則需要具備進入冰箱和離開冰箱的功能。
對象是類的實例。以大象為例,它的耳朵、鼻子、嘴巴等是屬性,而“進入冰箱”“走出冰箱”或“跳起來”是行為。通過設計冰箱類和大象類,使它們具備相應的功能,就可以實現(xiàn)讓大象進入冰箱的目標。從面向?qū)ο蟮慕嵌葋砜?#xff0c;這個過程需要先調(diào)用冰箱對象的開門功能,再調(diào)用大象對象的進入功能,最后調(diào)用冰箱對象的關門功能。
B站up愛編程的大丙舉了一個很形象的例子說明了面向過程和面向?qū)ο蟮膮^(qū)別:
假設現(xiàn)在有三個人:織女、牛郎和紅娘。紅娘想撮合牛郎和織女,她可以采用兩種編程思路:面向過程和面向?qū)ο蟆?/p>
面向過程編程:
- 紅娘把牛郎的牛牽到河邊。
- 紅娘把織女的紡車放到牛郎的牛車上。
- 紅娘告訴牛郎去找牛。
- 紅娘告訴織女去找紡車。
- 牛郎和織女在河邊相遇,一見鐘情。
- 兩人過上了幸福的生活。
在這個場景中,前四步是由紅娘主導完成的,后兩步則是牛郎和織女的互動。如果用代碼實現(xiàn),每一步都會對應一個函數(shù),函數(shù)需要傳入必要的參數(shù)。例如,在第一個函數(shù)中,我們忽略了紅娘這個主語,僅僅實現(xiàn)了“將牛牽到河邊”的功能。
面向?qū)ο缶幊?#xff1a;
- 紅娘:牛郎,能借你的牛用一下嗎?
牛郎:好的,我去牽牛。 - 紅娘:織女,能借你的紡車用一下嗎?
織女:沒問題,我去搬紡車。
隨后發(fā)生了意外:
- 牛郎:呀!牛丟了,我得趕緊去找牛。
- 織女:呀!紡車丟了,我得趕緊去找紡車。
最終,牛郎和織女相遇并交流:
- 牛郎:織女,我知道108種牛肉做法,要不要嘗嘗?
- 織女:我會做很多漂亮的衣服,你想不想試試?
- 牛郎:那我們結婚吧!
- 織女:好的!
在面向?qū)ο蟮乃悸分?#xff0c;我們會將場景中的對象抽象出來。例如:
- 牛和牛車是牛郎的屬性,牽牛、找牛、說話、結婚是牛郎的行為。
- 紡車是織女的屬性,搬紡車、找紡車、說話、結婚是織女的行為。
- 紅娘負責協(xié)調(diào)和推動整個事件的發(fā)生,這是她的行為。
面向?qū)ο缶幊痰谋举|(zhì)
面向?qū)ο缶幊痰暮诵氖?strong>將屬性和行為解耦,明確屬性和行為分別屬于哪個對象。基于這些屬性和行為,定義相應的類,例如牛郎類和織女類。類是模板,實例化類就會生成具體的對象(如具體的牛郎和織女)。通過對象,我們可以調(diào)用類中定義的屬性和行為。
相比之下,面向過程編程沒有定義牛郎、織女和紅娘的類,所有的步驟都通過函數(shù)一步步實現(xiàn)。雖然這種方式簡單直觀,但隨著功能復雜度的增加,函數(shù)體會變得冗長且難以維護,增加了出錯的可能性。而在面向?qū)ο笾?#xff1a;
- 織女類只處理與自己相關的行為,例如搬紡車、找紡車、說話和結婚。
- 牛郎類同樣專注于自己的行為,例如牽牛、找牛、說話和結婚。
這種分工明確的設計,讓代碼更加模塊化、可維護,也更貼近真實場景的邏輯。
總結
- 面向過程編程(POP):是一種依賴于函數(shù)調(diào)用和過程的編程范式。在POP中,程序通過執(zhí)行一系列步驟(函數(shù)調(diào)用)來達到目標。數(shù)據(jù)和操作這些數(shù)據(jù)的功能是分開的。程序的核心是通過操作全局數(shù)據(jù)來進行的。
- 面向?qū)ο缶幊?#xff08;OOP):將數(shù)據(jù)和操作這些數(shù)據(jù)的功能封裝在一起,構成一個“對象”。面向?qū)ο蟮某绦蚴怯蓪ο蠼M成的,這些對象通過消息(方法調(diào)用)與其他對象交互。
2. 面向?qū)ο蟮娜?#xff1a;繼承、封裝和多態(tài)
面向?qū)ο缶幊?/strong>有三大特征:封裝、繼承和多態(tài)。
- 封裝(Encapsulation):封裝確保對象中的數(shù)據(jù)安全,通過將數(shù)據(jù)和操作數(shù)據(jù)的方法封裝在一個對象中,避免外部直接訪問對象的數(shù)據(jù)。
- 繼承(Inheritance):繼承保證了對象的可擴展性,子類可以繼承父類的屬性和方法,并且可以在此基礎上進行擴展。
- 多態(tài)(Polymorphism):多態(tài)保證了程序的靈活性,允許不同類型的對象對于相同的消息作出不同的響應。
封裝是類的一個天然特性,就像一個盒子天生可以用來裝東西。類通過封裝,將數(shù)據(jù)和方法保護起來,對外只提供必要的接口,從而提高了代碼的安全性和可維護性。
繼承是類之間的一種重要關系。盡管類之間還可以有其他關系,例如關聯(lián)、依賴、實現(xiàn)、聚合和組合,但我們常強調(diào)繼承。這是因為繼承不僅是一種特殊的關系,還為類之間的代碼復用提供了基礎。事實上,實現(xiàn)可以看作是繼承的一種特例,而其他關系更像是根據(jù)需求將類放在不同位置靈活組合。需要注意的是,這些關系在 C 的結構體中也可以實現(xiàn),結構體并不是 C++ 的獨創(chuàng)。但繼承不同,它是一種全新的機制,需要在設計時明確約定規(guī)則。
繼承的一個重要作用是引入多態(tài)性。通過繼承,不同的子類可以在運行時根據(jù)相同的消息動態(tài)決定使用哪個方法,這使得資源分配更加靈活。這種多態(tài)性是繼承的延伸,是面向?qū)ο缶幊痰囊淮蠛诵奶攸c。
總結來說,封裝是類的內(nèi)在特性,繼承是類之間的一種新型關系,而多態(tài)則是繼承帶來的資源分配新規(guī)則。這三者正是 C++ 相較于 C 的主要創(chuàng)新點,也為從面向過程編程轉(zhuǎn)向面向?qū)ο缶幊烫峁┝藦娪辛Φ闹С帧?/strong>
2.1 封裝
2.1.1 封裝的概念
在面向?qū)ο缶幊讨?#xff0c;封裝是將數(shù)據(jù)和方法綁定到一個對象中,并通過控制數(shù)據(jù)的訪問來保證對象內(nèi)部的一致性和安全性。
封裝的基本思想是隱藏內(nèi)部實現(xiàn)細節(jié),暴露必要的接口。封裝有兩個主要方面:
- 數(shù)據(jù)隱藏:只允許通過公開的接口(方法)訪問和修改數(shù)據(jù)。這樣可以避免外部代碼直接修改對象的內(nèi)部狀態(tài),減少錯誤的發(fā)生。
- 接口與實現(xiàn)分離:對象暴露的是一組操作數(shù)據(jù)的接口,而不是數(shù)據(jù)本身。外部只關心如何使用這個對象提供的功能,而不需要了解它的內(nèi)部實現(xiàn)。
2.1.2 如何實現(xiàn)封裝
在C++中,封裝是通過類和訪問修飾符(如public
、private
、protected
)來實現(xiàn)的。
- public:類的公共部分,外部可以訪問和修改。
- private:類的私有部分,外部無法直接訪問,只能通過類提供的公有方法來間接訪問。
- protected:類似于private,但允許派生類(子類)訪問。
2.1.3 封裝的底層實現(xiàn)
從底層的角度看,封裝的實現(xiàn)通常依賴于內(nèi)存布局和訪問控制機制。在C++中,類的成員變量通常會在對象實例化時分配內(nèi)存。通過訪問控制(private
、public
)和get
、set
方法,編譯器幫助開發(fā)者實現(xiàn)了對數(shù)據(jù)訪問的精細控制。
- 內(nèi)存分配:每個對象都有獨立的內(nèi)存區(qū)域來存儲成員變量。當對象被創(chuàng)建時,內(nèi)存會分配給它的所有成員變量。
private
和public
只是影響這些成員在外部代碼中的訪問方式,實際的內(nèi)存布局不會變化。 - 訪問控制:
private
、public
和protected
是由編譯器支持的訪問權限控制機制,確保類的私有數(shù)據(jù)只能通過特定的公有方法來修改。編譯器會在編譯時檢查是否有非法訪問的代碼,防止程序出現(xiàn)不可預期的行為。
2.1.4 為什么使用封裝?(好處)
- 數(shù)據(jù)保護:封裝隱藏了數(shù)據(jù)的實現(xiàn),外部無法直接改變對象的內(nèi)部狀態(tài),防止了誤操作或非法操作。
- 提高代碼可維護性:通過暴露清晰的接口和隱藏復雜的內(nèi)部實現(xiàn),程序更加模塊化。如果需要改變實現(xiàn)細節(jié),只需要修改類的內(nèi)部代碼,不會影響到其他依賴這個類的代碼。
- 提高安全性:封裝可以確保對象的一致性和有效性。比如,
withdraw
方法中檢查提款金額是否合理,確保余額不被非法提取。
2.1.5 封裝只有類能做嗎?結構體如何封裝?命名空間能實現(xiàn)封裝嗎?
除了類之外,結構體和命名空間也可以實現(xiàn)一定程度的封裝:
-
在類中,編譯器通過訪問修飾符(如
public
、private
、protected
)來實現(xiàn)封裝。 -
struct
和class
本質(zhì)上是相似的,唯一區(qū)別是:class
的成員默認是private
struct
的成員默認是public
-
命名空間(
namespace
)主要用于邏輯上的分組和避免名字沖突,但它不能像類一樣提供訪問控制。通過命名空間,也可以實現(xiàn)一種“偽封裝”,但沒有訪問權限控制。namespace MyNamespace {namespace Detail { // 內(nèi)部命名空間,相當于隱藏的實現(xiàn)int hiddenFunction(int x) {return x * x;}}int publicFunction(int x) {return Detail::hiddenFunction(x) + 10;} }
雖然
Detail::hiddenFunction
仍然可以被訪問,但在設計上約定為只在MyNamespace
內(nèi)部使用
2.2 繼承
2.2.1 繼承的概念
繼承是面向?qū)ο缶幊讨械囊环N機制,它允許我們創(chuàng)建一個新的類,該類可以繼承自一個或多個已存在的類。被繼承的類稱為父類(或基類),新創(chuàng)建的類稱為子類(或派生類)。子類繼承了父類的屬性和方法,并可以在此基礎上進行擴展和修改。
派生類和基類的關系是一種 is-a
關系(公有繼承),即派生類對象也是一個基類對象,可以對基類對象執(zhí)行的任何操作,也可以對派生類對象執(zhí)行。但不是 has-a
、is-like-a
、uses-a
和is-implemented-as-a
關系。
2.2.2 繼承的主要作用
- 代碼復用:子類無需重新定義父類已經(jīng)實現(xiàn)的方法和屬性,可以直接使用它們。
- 擴展性:子類可以在繼承的基礎上擴展功能,添加特有的行為。
- 層次化設計:繼承允許程序員通過類層次結構來組織和簡化代碼。例如,
Dog
和Cat
都可以繼承自Animal
,然后你可以根據(jù)需要為Dog
和Cat
添加各自的特殊行為。
2.2.3 如何實現(xiàn)繼承
在C++中,繼承通過class
和public
、protected
、private
修飾符來實現(xiàn),不同的修飾符會影響父類成員在子類中的訪問權限。
1.Public 繼承
- 子類會繼承父類的 公有成員 和 保護成員。
- 在子類中,父類的 公有成員 仍然是 公有的,可以直接訪問。
- 父類的 保護成員 在子類中仍然是 保護的。
- 私有成員 雖然不能直接被子類訪問,但仍然是子類的一部分,可以通過父類的 公有或保護方法 進行間接訪問。
2.Protected 繼承
- 子類會繼承父類的 公有成員 和 保護成員。
- 在子類中,父類的 公有成員 會變成 保護的。
- 父類的 保護成員 保持不變,仍然是 保護的。
- 私有成員 和 Public 繼承一樣,不能直接訪問,但仍然可以通過父類的相關方法間接訪問。
3.Private 繼承
- 子類會繼承父類的 公有成員 和 保護成員。
- 在子類中,父類的 公有成員 和 保護成員 都變成了 私有的,只能在子類的內(nèi)部訪問。
- 私有成員 和前兩種繼承方式一樣,不能直接訪問,但仍然是子類的一部分,可以通過父類的方法間接訪問。
因為派生類不能直接訪問基類的私有成員,而必須通過基類的公有方法進行訪問,因此基類的構造函數(shù)不能直接設置繼承的私有成員,所以派生類構造函數(shù)必須使用基類構造函數(shù)。派生類構造函數(shù)的流程如下:
- 首先創(chuàng)建基類對象
- 派生類構造函數(shù)應通過成員初始化列表將基類信息傳遞給基類構造函數(shù)
- 派生類構造函數(shù)應初始化派生類新增的數(shù)據(jù)成員
2.2.4 構造函數(shù)和析構函數(shù)總結
- 創(chuàng)建派生類對象時,程序首先調(diào)用基類構造函數(shù),然后再調(diào)用派生類構造函數(shù),派生類的構造函數(shù)總是調(diào)用一個基類構造函數(shù)。
- 派生類對象過期時,程序?qū)⑹紫日{(diào)用派生類析構函數(shù),然后再調(diào)用基類析構函數(shù)。
2.2.5 派生類和基類之間的特殊關系
- 派生類對象可以使用基類的方法,條件是方法不是私有的(只能是公有或保護的)。
- 基類指針可以在不進行顯示類型轉(zhuǎn)換的情況下指向派生類對象。
- 基類引用可以在不進行顯示類型轉(zhuǎn)換的情況下引用派生類對象。
class TableTennisPlayer{// .......
}
class RatedPlayer : public TableTennisPlayer{// .......
}
假設有上述繼承關系,那么基類的指針和引用可以在不進行顯示類型轉(zhuǎn)換的情況下指向或引用派生類對象:
TableTennisPlayer* pt = &RatedPlayer;
TableTennisPlayer& rt = RatedPlayer;
但注意,基類指針或引用只能用于調(diào)用基類方法或成員,不能使用 rt
或 pt 來
調(diào)用派生類的方法。通常,C++要求引用和指針類型與賦給的類型匹配,但這一規(guī)則對繼承來說是例外。
可以說基類的指針和引用可以在不進行顯示類型轉(zhuǎn)換的情況下指向或引用派生類對象,派生類指針或引用不能指向或引用基類對象;也可以說派生類對象可以復制或賦值給基類對象(只針對二者共有的成員),但不能說不能將基類對象賦值或復制給派生類對象(雖然系統(tǒng)沒有默認函數(shù)支持,但我們可以定義重載函數(shù)實現(xiàn),不過一般情況下是不允許將基類對象賦值或復制給派生類對象的)。
基類和派生類還可以進行轉(zhuǎn)換:
- 將派生類引用或指針轉(zhuǎn)換為基類引用或指針被稱為向上強制轉(zhuǎn)換(upcasting),這使得公有繼承不需要進行顯式類型轉(zhuǎn)換,該規(guī)則是
is-a
關系的一部分。 - 將基類指針或引用轉(zhuǎn)換為派生類指針或引用稱為向下強制轉(zhuǎn)換(downcasting)。如果不使用顯式類型轉(zhuǎn)換,則向下強制轉(zhuǎn)換是不被允許的,原因是
is-a
關系通常是不可逆的。
但我們可以通過顯式強制轉(zhuǎn)換將基類指針或引用轉(zhuǎn)換為派生類指針或引用,但這可能會帶來不安全的操作,因為派生類的一些方法在基類中可能不存在。如下代碼:
Base t1; // 基類
Baseplus* t2 = (Baseplus*)&t1; // 將基類強制轉(zhuǎn)換為派生類
t2->print();
如果 print()
是 虛函數(shù),此時調(diào)用的是 基類的版本,并不會因為強制轉(zhuǎn)換調(diào)用派生類的 print
函數(shù),而是因為 t1
是一個 基類對象,它的虛函數(shù)表(vtable)指向的是基類的虛函數(shù)表。即使通過強制轉(zhuǎn)換獲得了一個派生類指針,虛函數(shù)調(diào)用依然由對象的動態(tài)類型(這里是 Base
)決定,而不是指針的靜態(tài)類型。
如果 print()
不是虛函數(shù),則調(diào)用的是指針類型(即 Baseplus*
)對應的函數(shù)版本。在這種情況下,結果是未定義行為,因為 t1
是基類對象,但你嘗試通過派生類指針調(diào)用派生類的方法,可能會訪問未初始化的派生類成員。
2.2.6 繼承的底層實現(xiàn)
在底層,繼承通過對象布局和指針偏移來實現(xiàn)。每個對象都有一個虛函數(shù)表(vtable),用于支持多態(tài)(如果使用了虛函數(shù))。當你創(chuàng)建一個子類對象時,它不僅包含自己的數(shù)據(jù)成員,還會包含父類的數(shù)據(jù)成員(如果父類有數(shù)據(jù)成員的話)。
內(nèi)存布局:
- 對象的內(nèi)存布局包含了父類部分和子類部分。父類的成員變量和成員函數(shù)會先存儲在內(nèi)存中,子類會在父類的基礎上添加額外的成員。
- 如果有虛函數(shù),編譯器會為類創(chuàng)建一個虛函數(shù)表,虛函數(shù)表包含所有虛函數(shù)的指針,確保子類能夠重寫(覆蓋)父類的虛函數(shù)。
示例內(nèi)存布局:
假設有以下類繼承關系:
A
是基類,B
是從A
繼承的子類,C
是從B
繼承的子類。
內(nèi)存布局 | 說明 |
---|---|
A 類的成員 | 基類 A 中的成員數(shù)據(jù)存儲在內(nèi)存中 |
B 類的成員 | 子類 B 擴展的成員數(shù)據(jù)存儲在內(nèi)存中 |
C 類的成員 | 子類 C 擴展的成員數(shù)據(jù)存儲在內(nèi)存中 |
2.2.7 繼承的類型
繼承可以分為不同類型,常見的包括:
- 單繼承:子類只繼承一個父類。
- 多重繼承:子類可以繼承多個父類。
- 多級繼承:子類繼承自父類,孫類繼承自子類等。
2.2.8 繼承的優(yōu)缺點
優(yōu)點:
- 代碼重用:子類繼承父類的行為,可以減少代碼重復,提升代碼復用性。
- 模塊化設計:通過繼承可以構建層次結構,使得代碼更具組織性。
- 擴展性:子類可以繼承父類的功能,并在此基礎上擴展或重寫,滿足更多需求。
缺點:
- 緊密耦合:繼承會導致類之間的緊密耦合,子類對父類的依賴較強,修改父類可能影響子類的行為。
- 繼承層次復雜:多層繼承可能導致類關系復雜,尤其是多重繼承時,可能出現(xiàn)二義性(例如“菱形繼承問題”)。
- 不利于靈活性:過度使用繼承可能導致代碼不易擴展或維護,過度繼承會使類層次過于復雜。
2.3 多態(tài)
2.3.1 多態(tài)的概念
多態(tài)(Polymorphism)是面向?qū)ο缶幊?#xff08;OOP)中的一個核心概念,它允許不同類的對象通過相同的接口(方法名)來調(diào)用不同的實現(xiàn)。簡單來說,多態(tài)使得不同類型的對象可以通過相同的接口執(zhí)行不同的操作。多態(tài)性使得程序更加靈活和可擴展。
有兩種機制可用于實現(xiàn)多態(tài)公有繼承:
- 在派生類中重新定義基類的方法。這種方式不需要額外的語法支持,但只有當通過子類對象直接調(diào)用方法時,才能體現(xiàn)多態(tài)性。通過基類的指針或引用調(diào)用時,仍然會調(diào)用基類的方法。
- 使用虛方法,基類中將函數(shù)聲明為
virtual
,派生類可以重寫該函數(shù)。當通過基類的指針或引用調(diào)用時,會根據(jù)對象的實際類型調(diào)用重寫后的函數(shù),而不是基類的版本。
但注意:
- 虛函數(shù)必須通過基類的指針或引用調(diào)用,才能實現(xiàn)動態(tài)綁定,即調(diào)用派生類中重寫后的方法。
- 如果直接通過對象調(diào)用,不管有沒有使用虛函數(shù),無論基類還是派生類對象,調(diào)用的都是對象所屬類的版本。
- 沒有被重寫的虛函數(shù),調(diào)用時會使用基類的默認實現(xiàn)。
- 如果需要在派生類中調(diào)用基類的版本,必須顯式指定
Base::
,否則會調(diào)用派生類重寫的方法。
#include <iostream>
using namespace std;class Animal {
public:virtual void makeSound() { // 虛函數(shù)cout << "Animal makes a sound" << endl;}
};class Dog : public Animal {
public:void makeSound() override { // 重寫虛函數(shù)cout << "Dog barks" << endl;}
};class Cat : public Animal {
public:void makeSound() override { // 重寫虛函數(shù)cout << "Cat meows" << endl;}
};
class Bird : public Animal {
public:void makeSound() override { // 重寫虛函數(shù)cout << "Bird meows" << endl;// 規(guī)則4Animal::makeSound(); // 顯式調(diào)用基類的 makeSound() 方法}
};int main() {// 規(guī)則1Animal* animal1 = new Dog();Animal* animal2 = new Cat();animal1->makeSound(); // 輸出: Dog barksanimal2->makeSound(); // 輸出: Cat meowsdelete animal1;delete animal2;// 規(guī)則2Dog dog();Cat cat();dog().makeSound(); // 輸出: Dog barkscat().makeSound(); // 輸出: Cat meowsreturn 0;
}
上段代碼中分別對規(guī)則1 和規(guī)則 2進行的描述,如果我們通過基類的引用或指針調(diào)用,則程序?qū)⒏鶕?jù)引用或指針指向的對象類型來選擇方法(使用了虛函數(shù));如果直接通過派生類對象調(diào)用,即使沒有使用虛函數(shù),也會調(diào)用派生類的方法。
2.3.2 多態(tài)的類型
- 編譯時多態(tài)(靜態(tài)多態(tài)):在編譯時決定調(diào)用哪個函數(shù),常見的實現(xiàn)方式是方法重載(Overloading)和運算符重載(Operator Overloading)。
- 運行時多態(tài)(動態(tài)多態(tài)):在程序運行時決定調(diào)用哪個函數(shù),常通過虛函數(shù)和繼承實現(xiàn)。運行時多態(tài)通常通過虛函數(shù)來實現(xiàn)。虛函數(shù)是基類中聲明為
virtual
的函數(shù),子類可以重寫這個函數(shù)。當通過基類指針或引用調(diào)用該函數(shù)時,程序會根據(jù)對象的實際類型(而不是指針或引用的類型)來決定調(diào)用哪個函數(shù)實現(xiàn)。
2.3.3 存在類繼承的情況下,為何需要虛析構函數(shù)
使用虛析構函數(shù)是為了確保析構函數(shù)序列被正確調(diào)用。如果基類的析構函數(shù)不是虛函數(shù),通過基類指針刪除派生類對象時,只會調(diào)用基類的析構函數(shù),而不會調(diào)用派生類的析構函數(shù)。這樣可能導致派生類中動態(tài)分配的資源沒有正確釋放,進而產(chǎn)生資源泄漏。如下:
#include <iostream>
using namespace std;class Base {
public:~Base() { // 非虛析構函數(shù)cout << "Base destructor called" << endl;}
};class Derived : public Base {
public:~Derived() {cout << "Derived destructor called" << endl;}
};int main() {Base* ptr = new Derived(); // 基類指針指向派生類對象delete ptr; // 只調(diào)用了基類的析構函數(shù)return 0;
}
在上段代碼中,Derived
類的析構函數(shù)沒有被調(diào)用,因此派生類持有的資源無法正確釋放。輸出如下:
Base destructor called
將基類析構函數(shù)設為虛函數(shù),可確保先調(diào)用派生類析構函數(shù),再調(diào)用基類析構函數(shù),結果如下:
class Base {
public:virtual ~Base() { // 虛析構函數(shù)cout << "Base destructor called" << endl;}
};// 輸出
Derived destructor called
Base destructor called
2.3.4 多態(tài)的底層實現(xiàn)(虛函數(shù)表的實現(xiàn))
多態(tài)的底層實現(xiàn)依賴于虛函數(shù)表(vtable)。每個包含虛函數(shù)的類,在編譯時會生成一個虛函數(shù)表,其中存儲著類的所有虛函數(shù)指針。當通過父類指針調(diào)用虛函數(shù)時,程序會查找虛函數(shù)表,找到對應的子類實現(xiàn)并調(diào)用。
虛函數(shù)表的工作原理:
我們一般利用虛表和虛表指針來實現(xiàn)動態(tài)綁定,那么具體是如何實現(xiàn)的?
通常,編譯器處理虛函數(shù)的方法是:給每個對象添加一個隱藏成員。隱藏成員中保存了一個指向函數(shù)地址數(shù)組的指針,這種數(shù)組稱為虛函數(shù)表。虛函數(shù)表中存儲了該類所有虛函數(shù)的地址。例如,基類對象包含一個指針,該指針指向基類中所有虛函數(shù)的地址表。派生類對象將包含一個指向獨立地址表的指針。如果派生類重新定義了基類的虛函數(shù),虛函數(shù)表會更新該函數(shù)的地址,指向派生類的新定義;如果派生類沒有重寫基類的虛函數(shù),虛函數(shù)表會保留基類的虛函數(shù)地址。如果派生類新增了虛函數(shù),這些虛函數(shù)的地址會被添加到虛函數(shù)表中。
注意,無論類中包含的虛函數(shù)是1個還是10個,對象中的隱藏指針始終只有一個,占用固定的內(nèi)存,只是指向表的大小不同而已。虛表是屬于類的,而不是屬于某個具體的對象,一個類只需要一個虛表即可。同一個類的所有對象都使用同一個虛表。
如上圖,我們定義了基類 Scientist
,并聲明了兩個虛函數(shù) show_name()
和 show_all()
。同時定義了一個繼承自 Scientist
的子類 Physicist
,子類中重定義了 show_all()
并新增了虛函數(shù) show_field()
。
基類 Scientist
和派生類 Physicist
的虛函數(shù)表分別如下圖所示:
基類 Scientist
中聲明了兩個虛函數(shù),所以它的虛函數(shù)表存在兩個徐函數(shù)地址 4064 和 6400,且虛函數(shù)表的地址為 2008;派生類 Physicist
中將虛函數(shù) show_all()
重新定義,并聲明了新的虛函數(shù) show_field()
,所以它的虛函數(shù)表中更新 show_all()
的地址為 6820,并新增了對應 show_field()
的地址 7280,且它的虛函數(shù)表地址為 2096。
并且二者都有一個隱藏的指針成員用于指向各自的虛函數(shù)表,如下圖所示:
基類 Scientist
的內(nèi)存空間如上圖所示,其私有成員 name
的地址存儲內(nèi)容為 Sopjoe Fant
,但它還有一個隱藏指針成員 vptr
用于指向它的虛函數(shù)表;同樣,派生類 Physicist
中也有一個隱藏指針成員 vptr
用于指向它的虛函數(shù)表,同樣它的私有成員 field
內(nèi)容為 Nuclear Structure
。
那么調(diào)用虛函數(shù)時,虛函數(shù)表是如何作用的呢?
調(diào)用虛函數(shù)時,程序?qū)⒉榭创鎯υ趯ο笾械奶摵瘮?shù)表地址,然后轉(zhuǎn)向相應的函數(shù)地址表。如果使用類聲明中定義的第一個虛函數(shù),則程序?qū)⑹褂脭?shù)組中的第一個函數(shù)地址,并執(zhí)行具有該地址的函數(shù)。如果使用類聲明中的第三個虛函數(shù),程序?qū)⑹褂玫刂窞閿?shù)組中第三個元素的函數(shù)。如下圖所示:
當我們調(diào)用派生類 Physicist
的虛函數(shù) show_all()
時,我們首先獲取派生類 Physicist
的隱藏指針成員 vtpr
指向的地址 2096,并前往該處獲取對應的虛函數(shù)表,然后我們依據(jù)順序獲悉表中對應函數(shù)的地址 6820(由于虛表在編譯階段就可以構造出來了,所以可以根據(jù)所調(diào)用的函數(shù)定位到虛表中的對應條目),編譯器前往 6820 處執(zhí)行這里的虛函數(shù)。
注意:非虛函數(shù)的調(diào)用不用經(jīng)過虛表,故不需要虛表中的指針指向這些函數(shù)。而且虛函數(shù)需要消費一定的資源,所以無繼承以及無虛函數(shù)的情況下,虛函數(shù)表不會生成。
什么時候會執(zhí)行函數(shù)的動態(tài)綁定?這需要符合以下三個條件。
- 通過指針來調(diào)用函數(shù)
- 指針
upcast
向上轉(zhuǎn)型(繼承類向基類的轉(zhuǎn)換稱為upcast
) - 調(diào)用的是虛函數(shù)
2.3.5 使用虛方法時需注意的一些點
- 構造函數(shù)不能是虛函數(shù)。創(chuàng)建派生類對象時,將調(diào)用派生類的構造函數(shù),而不是基類的構造函數(shù),然后,派生類的構造函數(shù)將使用基類的一個構造函數(shù),這種順序不同于繼承機制。因此,派生類不繼承基類的構造函數(shù),所以將類構造函數(shù)聲明為虛函數(shù)沒有意義。
- 析構函數(shù)應當是虛函數(shù),除非類不用做基類。
- 友元不能是虛函數(shù),因為友元不是類成員,而只有成員才能是虛函數(shù)。
- 如果派生類沒有重新定義函數(shù),將使用該函數(shù)的基類版本。
- 派生類重新定義函數(shù)會隱藏基類方法。
前面四條很淺顯易懂,這里詳細說一下第五條。第五條有以下兩個個規(guī)則:
- 如果基類的函數(shù)被聲明為
virtual
,而派生類定義了一個函數(shù)名、參數(shù)列表和返回類型完全相同的函數(shù),那么派生類的函數(shù)將覆蓋基類的函數(shù)。 - 如果基類和派生類的函數(shù)名相同,但參數(shù)列表不同,則派生類的函數(shù)會隱藏基類的同名函數(shù),無論基類的函數(shù)是否是
virtual
。
隱藏、覆蓋和重載是三個不同的概念。重載發(fā)生在同一個類內(nèi),通過定義參數(shù)列表不同的同名函數(shù)實現(xiàn)。隱藏和覆蓋則出現(xiàn)在基類與派生類之間。
當派生類重新定義基類中的虛函數(shù)時:
- 如果參數(shù)列表(特征標)相同,派生類的函數(shù)會覆蓋基類的虛函數(shù)。
- 如果參數(shù)列表不同,派生類的函數(shù)會隱藏基類的虛函數(shù)。
如果基類的函數(shù)被隱藏或覆蓋了,但仍需要調(diào)用,使用基類類名加作用域運算符::
,顯式調(diào)用基類的函數(shù)。
2.3.6 純虛函數(shù)
純虛函數(shù)(Pure Virtual Function)是C++中的一種特殊成員函數(shù),通常用于定義抽象類,為派生類提供一個必須實現(xiàn)的接口。抽象類不能實例化。它的定義形式在基類中包含= 0
的語法,例如:
virtual void display() = 0;
- 不能在基類中實現(xiàn):純虛函數(shù)不包含函數(shù)體,只定義接口,具體實現(xiàn)必須由派生類完成。
- 定義抽象類:包含純虛函數(shù)的類稱為抽象類,不能直接實例化。
- 派生類的義務:派生類必須重寫所有繼承的純虛函數(shù),否則派生類本身也會變成抽象類。
在原型中使用 =0
指出類是一個抽象基類,在類中不可以定義該函數(shù),應在派生類中定義。
純虛函數(shù)的主要作用是定義接口規(guī)范,強制要求派生類必須實現(xiàn)這些函數(shù),從而實現(xiàn)接口的統(tǒng)一和標準化。
舉個例子說明:
假設我們要設計一個繪圖系統(tǒng),可以繪制不同的形狀,如圓形、矩形等。每種形狀都有一個draw()
函數(shù)負責繪圖,但每種形狀的繪圖方式不同。我們可以用純虛函數(shù)實現(xiàn):
#include <iostream>
#include <vector>
using namespace std;// 抽象類:Shape
class Shape {
public:virtual void draw() = 0; // 純虛函數(shù),強制派生類實現(xiàn)virtual double area() = 0; // 純虛函數(shù),計算面積virtual ~Shape() {} // 虛析構函數(shù),確保按正確順序釋放資源
};// 派生類:Circle
class Circle : public Shape {
private:double radius;
public:Circle(double r) : radius(r) {}void draw() override {cout << "Drawing a Circle with radius: " << radius << endl;}double area() override {return 3.14159 * radius * radius;}
};// 派生類:Rectangle
class Rectangle : public Shape {
private:double length, width;
public:Rectangle(double l, double w) : length(l), width(w) {}void draw() override {cout << "Drawing a Rectangle with length: " << length << ", width: " << width << endl;}double area() override {return length * width;}
};int main() {// 用基類指針管理不同的形狀對象vector<Shape*> shapes;shapes.push_back(new Circle(5.0)); // 添加一個圓shapes.push_back(new Rectangle(4.0, 6.0)); // 添加一個矩形// 使用多態(tài)調(diào)用派生類實現(xiàn)for (Shape* shape : shapes) {shape->draw();cout << "Area: " << shape->area() << endl;}// 釋放資源for (Shape* shape : shapes) {delete shape;}return 0;
}
輸出為:
Drawing a Circle with radius: 5
Area: 78.53975
Drawing a Rectangle with length: 4, width: 6
Area: 24
這樣僅僅把抽象類 Shape
當作一個接口規(guī)范類,我們在每一個繼承它的子類中都定義了專屬于自身的實現(xiàn)(多態(tài)),而且因為抽象類中有一些共用的屬性,所以相比單獨的定義 Circle
、Rectangle
類,通過抽象類衍生派生類更加方便。
2.3.7 動態(tài)聯(lián)編和靜態(tài)聯(lián)編
當我們在程序中寫下一個函數(shù)并調(diào)用它時,編譯器會決定如何執(zhí)行這個函數(shù)。這一過程不僅僅是簡單地“代碼怎么寫,編譯器就怎么執(zhí)行”。特別是在C++中,由于引入了函數(shù)重載、重寫(虛函數(shù))等機制,同一個函數(shù)名可能對應多個實現(xiàn),因此編譯器需要進一步確定到底調(diào)用哪個具體的函數(shù)實現(xiàn)。
什么是聯(lián)編?
聯(lián)編就是將程序中的函數(shù)調(diào)用與具體的函數(shù)實現(xiàn)關聯(lián)起來的過程。通俗來說,聯(lián)編相當于讓程序知道“這個函數(shù)名對應的具體操作在哪里”。
在C語言中,聯(lián)編相對簡單:每個函數(shù)名唯一地對應一個函數(shù)實現(xiàn),因此函數(shù)調(diào)用和具體實現(xiàn)之間的關系在編譯時就能完全確定。但在C++中,函數(shù)重載(同名函數(shù)參數(shù)不同)和虛函數(shù)(子類覆蓋父類方法)等特性增加了聯(lián)編的復雜性,編譯器需要更多信息來決定調(diào)用哪一個具體的函數(shù)實現(xiàn)。
聯(lián)編的類型:
靜態(tài)聯(lián)編是在程序的編譯階段完成的,也叫早期聯(lián)編。它在編譯時確定函數(shù)調(diào)用與具體實現(xiàn)之間的關系,運行時無需再做額外的判斷,效率較高。通常用于普通函數(shù)調(diào)用,包括非虛函數(shù)的調(diào)用和函數(shù)重載。編譯器會根據(jù)函數(shù)名和參數(shù)列表,直接找到匹配的函數(shù)實現(xiàn)。代碼執(zhí)行時,已經(jīng)明確知道調(diào)用的是哪段代碼。
動態(tài)聯(lián)編是在程序的運行階段完成的,也叫晚期聯(lián)編。它允許程序在運行時,根據(jù)實際的對象類型或上下文,動態(tài)選擇函數(shù)的實現(xiàn)。動態(tài)聯(lián)編通常用于虛函數(shù)的調(diào)用,因為在多態(tài)場景中,編譯器無法在編譯階段確定具體調(diào)用的是哪個函數(shù)。編譯器會為每個類生成一個虛函數(shù)表(vtable),運行時根據(jù)對象類型從虛函數(shù)表中查找并調(diào)用正確的函數(shù)。
雖然動態(tài)聯(lián)編的靈活性很高,但是因為虛函數(shù)表的生成、調(diào)用需要消耗一定的資源,所以靜態(tài)聯(lián)編被用作C++的默認選擇,因為靜態(tài)聯(lián)編在編譯時完成,效率高于動態(tài)聯(lián)編。