C++語法復習
1. C++入門基礎
缺省參數
- 半缺省參數必須從右往左依次來給出,不能間隔著給
- 缺省參數不能在函數聲明和定義中同時出現
- 缺省值必須是常量或者全局變量
- C語言不支持(編譯器不支持)
函數重載
函數重載是函數的一種特殊情況,C++允許在同一作用域中聲明幾個功能類似的同名函數,這 些同名函數的形參列表**(參數個數 或 類型 或 類型順序)不同**,常用來處理實現功能類似數據類型不同的問題。
原理:函數名的修飾規則 鏈接時需要通過函數名找到函數實現
gcc的函數修飾后名字不變。而g++的函數修飾后變成【_Z+函數長度 +函數名+類型首字母】
通過這里就理解了C語言沒辦法支持重載,因為同名函數沒辦法區分。而C++是通過函數修 飾規則來區分,只要參數不同,修飾出來的名字就不一樣,就支持了重載
引用和指針
引用概念:引用不是新定義一個變量,而是給已存在變量取了一個別名,編譯器不會為引用變量開辟內存空 間,它和它引用的變量共用同一塊內存空間。
引用和指針的區別:
從語法的角度來說:引用是給已有的變量取別名,不開空間;指針是開空間存儲對象的地址,指向對象
從使用角度來說:
- 引用定義的時候需要初始化,指針可以不初始化
- 上面推論:沒有NULL引用,但有NULL指針, 引用比指針使用起來相對更安全
- 指針可以改變指向,引用不可以改變
- 有多級指針,但是沒有多級引用
- sizeof指針和引用含義不一樣
- 引用++和指針++表達的含義不一樣
- 訪問實體的方式不一樣,指針需要顯示解引用,引用由編譯器處理
從底層實現的角度來說(最后):引用在底層實現上實際是有空間的,因為引用是按照指針方式來實現的
引用的使用場景:
1.做參數
2.做返回值(注意使用安全,不要返回棧上的臨時對象)
內聯inline
C++建議使用 const enum inline 代替 宏, 內聯/宏 和 函數對應
再來說說宏的缺點:
- 容易出錯,符號優先級問題
- 不能調試,預處理階段已經替換,看不到宏的具體處理邏輯
- 不夠嚴謹,與類型無關,缺少類型檢查
宏的優點:對于各種類型都適配,可以增加代碼的復用性,當處理小型計算的任務時宏比函數調用的消耗更小
內聯:以inline修飾的函數叫做內聯函數,編譯時C++編譯器會在調用內聯函數的地方展開,沒有函數調用建立棧幀的開銷,內聯函數提升程序運行的效率。
inline是一種以空間換時間的做法,如果編譯器將函數當成內聯函數處理,在編譯階段,會 用函數體替換函數調用,缺陷:可能會使目標文件變大(這一點和宏一樣),優勢:少了調用開銷,提高程序運 行效率
inline對于編譯器而言只是一個建議,不同編譯器關于inline實現機制可能不同
inline不建議聲明和定義分離,分離會導致鏈接錯誤。因為inline被展開,就沒有函數地址 了,鏈接就會找不到
nullptr
因為NULL在C++中可能是0也可能是((void*)0)
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
- 在使用nullptr表示指針空值時,不需要包含頭文件,因為nullptr是C++11作為新關鍵字引入 的。
- 在C++11中,sizeof(nullptr) 與 sizeof((void*)0)所占的字節數相同。
- 為了提高代碼的健壯性,在后續表示指針空值時建議最好使用nullptr。
2. 類和對象
面向對象和面向過程的區別?
面向對象更加注重:類和類之間的關系(如:棧的實現,容器適配器、迭代器統一(反向迭代器),算法通過迭代器獲取容器的數據)
面向對象更加注重實現過程
**面向對象(Object-Oriented Programming, OOP)**是一種編程范式,它基于“對象”的概念,將數據和操作數據的方法組織在一起。在面向對象編程中,對象是類的實例,類定義了對象的屬性(數據成員)和行為(方法)。對象可以互相通信,通過調用彼此的方法來完成任務。面向對象的四個核心原則是封裝、繼承、多態和抽象。
面向對象的主要特點:
- 封裝:隱藏對象的內部細節,只對外提供接口進行交互,保護數據的安全性。
- 繼承:允許創建一個新類(子類)作為現有類(父類)的擴展,繼承其屬性和方法。
- 多態:同一方法可以根據調用它的對象類型表現出不同的行為。
- 抽象:通過抽象類或接口來定義通用行為,實現代碼的重用和模塊化。
面向過程(Procedural Programming): 面向過程編程更側重于步驟和函數的組合來解決問題。程序被設計為一系列有序的步驟,每個步驟對應一個函數或子程序,這些函數直接操作數據。面向過程編程不強調對象的概念,而是以數據為中心,通過函數來處理數據。
面向對象與面向過程的區別:
- 編程思路:面向對象是基于類和對象,通過對象之間的交互實現功能;面向過程是通過函數調用來完成任務序列。
- 封裝性:面向對象封裝的是數據和操作數據的方法,而面向過程主要封裝的是功能邏輯。
- 結構與復用:面向對象支持繼承和多態,使得代碼更容易復用和擴展;面向過程的復用主要依賴函數和模塊。
- 復雜性管理:面向對象更適合處理復雜的系統,因為它能更好地模擬現實世界中的實體和關系;面向過程則適用于簡單的、線性的任務。
類大小的計算
內存對齊
和結構體的內存對齊規則一樣
- 首元素位于0偏移量地址
- 對齊數 = 編譯器的默認對齊數 和 對象大小 (取小)
- 整體大小等于最大對齊數的整數倍
空類大小(占位)
注意空類的大小,空類比較特殊,編譯器給了空類一個字節來唯一標識這個類的對象。
成員函數代碼位于代碼區,成員變量在實例化位置定義
struct 和 class 的區別
訪問限定符、繼承關系、兼容C語言
struct訪問限定符默認public,class訪問限定符默認private
struct默認public繼承,class默認private繼承
struct兼容C語言,在C語言中是結構體,在C++中可以定義成員函數方法,class不兼容C語言
this指針
C++編譯器給每個“非靜態的成員函數“增加了一個隱藏 的指針參數,讓該指針指向當前對象(函數運行時調用該函數的對象),在函數體中所有“成員變量” 的操作,都是通過該指針去訪問。只不過所有的操作對用戶是透明的,即用戶不需要來傳遞,編 譯器自動完成
特性:
- this指針的類型:類類型 const*,即成員函數中,不能給this指針賦值
- 只能在“成員函數”的內部使用
- this指針本質上是“成員函數”的形參,當對象調用成員函數時,將對象地址作為實參傳遞給 this形參。所以對象中不存儲this指針
- this指針是“成員函數”第一個隱含的指針形參,一般情況由編譯器通過ecx寄存器自動傳遞,不需要用戶傳遞
this指針儲存位置(寄存器ecx或調用位置的棧空間)
從概念上講,當你調用一個非靜態成員函數時,編譯器會自動將調用該函數的對象的地址作為第一個隱式參數傳遞給該函數,這個地址就是this指針的值。在函數內部,你可以通過this指針來訪問或修改對象的成員。
然而,this指針并不是存儲在對象的內存布局中的一部分。它更像是函數調用的一個“附加”參數,由編譯器在函數調用時自動處理。
在大多數實現中,當你調用一個成員函數時,編譯器會生成一些額外的代碼來設置this指針。這個指針通常會被存放在一個特殊的寄存器中,或者如果寄存器不可用,它可能會被壓入調用棧中。但是,這些都是實現細節,并且在不同的編譯器和平臺上可能會有所不同
this指針可以為空嗎,當你調用的成員函數內部不訪問成員變量(就不會解引用報錯)- 使用方式類似于靜態成員函數
八個默認成員函數
- 構造和析構
- 拷貝構造和賦值
- 移動構造和移動賦值
- 取地址重載和const取地址重載(基本不用)
一般情況下,構造都是要自己實現的
深拷貝的類,需要提供拷貝構造、賦值重載和析構
移動構造和移動賦值,當上面三個是編譯器默認生成的,編譯器才會自己在生成
也就是說一般深拷貝的類需要自己提供移動構造和移動賦值
默認生成的,會對內置類型淺拷貝,對自定義類型調用其拷貝構造或賦值
對于默認移動構造和移動賦值,會對內置類型拷貝,自定義類型調用其對應的(自己實現的yes)移動構造,如果沒有調用拷貝構造
初始化列表
特性:所有的構造初始化都會經過初始化列表
哪些成員必須要在初始化列表初始化?
- 引用成員變量
- const成員變量
- 沒有默認構造的自定義成員變量
無參構造函數、全缺省構造函數、我們沒寫編譯器默認生成的構造函數,都可以認為是默認構造函數
初始化列表的初始化順序是成員對象的聲明順序
運算符重載
目的是讓自定義類型用運算符,增強可讀性
哪些運算符不能重載 (.* .)
class Person
{
public:Person(double name = 1.1, int age = 0):_name(name), _age(age){}void* operator new(size_t size) // new重載{}void operator delete(void* p) // delete重載{}
private:Test2 _name;int _age;
};
友元(了解)
友元提供了一種突破封裝的方式,有時提供了便利。但是友元會增加耦合度,破壞了封裝,所以 友元不宜多用
友元函數
友元函數可以直接訪問類的私有成員,它是定義在類外部的普通函數,不屬于任何類,但需要在 類的內部聲明,聲明時需要加friend關鍵字
- 友元函數可訪問類的私有和保護成員,但不是類的成員函數
- 友元函數不能用const修飾
- 友元函數可以在類定義的任何地方聲明,不受類訪問限定符限制
- 一個函數可以是多個類的友元函數
- 友元函數的調用與普通函數的調用原理相同
友元類
友元類的所有成員函數都可以是另一個類的友元函數,都可以訪問另一個類中的非公有成員
- 友元關系是單向的,不具有交換性
- 友元關系不能傳遞
- 友元關系不能繼承
explicit關鍵字
構造函數對于單個參數或者除第一個參數無默認值其余均有默認值 的構造函數,還具有類型轉換的作用。
使用用explicit修飾構造函數,將會禁止構造函數的隱式轉換
static成員
不屬于類的成員大小
可以當作靜態全局變量,只是受類域的限制
需要在類外定義
所有類實例化的對象共用一個靜態成員變量
- 靜態成員函數可以調用非靜態成員函數嗎?(參數傳了this就可以)
- 非靜態成員函數可以調用類的靜態成員函數嗎?可以
3. 內存管理¥
內存分布
malloc/calloc/realloc 區別
。。。
new/delete 和 malloc/free 區別(重點)
- 性質 操作符運算符 庫函數
- 使用 需不需要大小/返回值類型強轉與否/出錯返回空或拋異常
- 功能 對于類型的初始化,和對自定義類型的析構
new和new[]的底層實現原理
new和delete是用戶進行動態內存申請和釋放的操作符,operator new 和operator delete是系統提供的全局函數,new在底層調用operator new全局函數來申請空間,delete在底層通過 operator delete全局函數來釋放空間。
operator new (全局)+ 構造函數 operator new封裝malloc
為什么?解決拋異常的問題,operator new失敗內部拋異常
operator new:該函數實際通過malloc來申請空間,當malloc申請空間成功時直接返回;申請空間失敗,嘗試執行空 間不足應對措施,如果改應對措施用戶設置了,則繼續申請,否則拋異常
定位new/顯示調用構造函數 - new(地址)類型
顯示調用析構 - 地址->~類型()
內存泄漏
堆內存泄漏(Heap leak) 和 系統資源泄漏(套接字、文件描述符、管道)
什么是內存泄漏:內存泄漏指因為疏忽或錯誤造成程序未能釋放已經不再使用的內存的情況。內 存泄漏并不是指內存在物理上的消失,而是應用程序分配某段內存后,因為設計錯誤,失去了對 該段內存的控制,因而造成了內存的浪費。 內存泄漏的危害:長期運行的程序出現內存泄漏,影響很大,如操作系統、后臺服務等等,出現 內存泄漏會導致響應越來越慢,最終卡死。
如何避免內存泄漏
- 工程前期良好的設計規范,養成良好的編碼規范,申請的內存空間記著匹配的去釋放。ps: 這個理想狀態。但是如果碰上異常時,就算注意釋放了,還是可能會出問題。需要下一條智 能指針來管理才有保證。
- 采用RAII思想或者智能指針來管理資源。
- 有些公司內部規范使用內部實現的私有內存管理庫。這套庫自帶內存泄漏檢測的功能選項。
- 出問題了使用內存泄漏工具檢測。ps:不過很多工具都不夠靠譜,或者收費昂貴
4. 模板
非類型模板參數,就是用一個常量作為類(函數)模板的一個參數,在類(函數)模板中可將該參數當成常量來使用(整形)
使用模板可以實現一些與類型無關的代碼
語法格式
template<class T>bool Less(T left, T right){return left < right;}
在模板函數使用的時候一般是傳參,然后編譯器推導
對類模板使用使用時,一般是顯示指定類型—類類型<模板參數類型> 對象名
但對于一些特殊類型的可能會得到一些錯誤的結果
例如:對比指針類型,比較的是指針的地址,而不是指向的對象大小關系
此時,就需要對模板進行特化。即:在原模板類的基礎上,針對特殊類型所進行特殊化的實現方式。模板特化中分為函數模板特化與類模板特化。
模板特化
函數模板特化(函數模板特化必須要指定特定的類型,沒有偏特化的說法)
- 必須要先有一個基礎的函數模板
- 關鍵字template后面接一對空的尖括號<>
- 函數名后跟一對尖括號,尖括號中指定需要特化的類型
- 函數形參表: 必須要和模板函數的基礎參數類型完全相同,如果不同編譯器可能會報一些奇怪的錯誤
namespace kele
{template<class T> // 基礎的函數模板bool less(T a, T b){return a < b;}template<> // 特化的函數模板bool less<int*>(int* a, int* b){return *a < *b;}bool less(int* left, int* right) // 函數 優先級最高{return *left < *right;}
}
注意:一般情況下如果函數模板遇到不能處理或者處理有誤的類型,為了實現簡單通常都是將該函數直接給出
類模板特化
全特化:全特化即是將模板參數列表中所有的參數都確定化。
偏特化:任何針對模版參數進一步進行條件限制設計的特化版本。
- 部分特化
- 參數更進一步的限制(指針/引用)
要注意:參數限制還有const限制要考慮
namespace kele // 反向迭代器利用萃取類(模板偏特化)
{ template <class Iterator>struct iterator_traits {typedef typename Iterator::value_type value_type;typedef typename Iterator::pointer pointer;typedef typename Iterator::reference reference;};template <class T>struct iterator_traits<T*> {typedef T value_type;typedef T* pointer;typedef T& reference;};template <class T>struct iterator_traits<const T*> {typedef T value_type;typedef const T* pointer;typedef const T& reference;};template<class Iteartor>struct Reverse_Iterator{typedef typename iterator_traits<Iteartor>::value_type value_type;typedef typename iterator_traits<Iteartor>::reference reference;typedef typename iterator_traits<Iteartor>::pointer pointer;typedef Reverse_Iterator<Iteartor> self;Iteartor rit;Reverse_Iterator(Iteartor x):rit(x){}template<class iter>Reverse_Iterator(const Reverse_Iterator<iter>& x):rit(x.rit){}reference operator*(){Iteartor tmp = rit;return *(--tmp);}pointer operator->(){return &(operator*());}self operator++(){--rit;return *this;}self operator++(int){self tmp = *this;--rit;return tmp;}self operator--(){++rit;return *this;}self operator--(int){self tmp = *this;++rit;return tmp;}bool operator!=(const self& x) const{return rit != x.rit;}bool operator==(const self& x) const{return rit == x.rit;}};
}
模板分離編譯
模板不能分離編譯:模板在沒有實例化的時候是不會被編譯的,在兩個文件中,一個有實例化的聲明,一個沒有函數,就會鏈接報錯,解決方法:
- 將聲明和定義放到一個文件 “xxx.hpp” 里面或者xxx.h其實也是可以的。推薦使用這種。
- 模板定義的位置顯式實例化。這種方法不實用,不推薦使用。
模板優點和缺點
優點:
- 模板復用了代碼,節省資源,更快的迭代開發,C++的標準模板庫(STL)因此而產生
- 增強了代碼的靈活性
缺點:
- 模板會導致代碼膨脹問題,也會導致編譯時間變長
- 出現模板編譯錯誤時,錯誤信息非常凌亂,不易定位錯誤
5. 繼承(重點)
什么是繼承?繼承的意義
繼承(inheritance)機制是面向對象程序設計使代碼可以復用的最重要的手段,它允許程序員在保持原有類特性的基礎上進行擴展,增加功能,這樣產生新的類,稱派生類。繼承呈現了面向對象程序設計的層次結構,體現了由簡單到復雜的認知過程。以前我們接觸的復用都是函數復用,繼承是類設計層次的復用
類成員/繼承方式 | public繼承 | protected繼承 | private繼承 |
---|---|---|---|
基類的public成員 | 派生類的public成員 | 派生類的protected 成員 | 派生類的private 成員 |
基類的protected 成員 | 派生類的protected成員 | 派生類的protected 成員 | 派生類的private 成員 |
基類的private成員 | 在派生類中不可見 | 在派生類中不可見 | 在派生類中不可見 |
不可見是指基類的私有成員還是被繼承到了派生類對象中,但是語法上限制派生類對象不管在類里面還是類外面都不能去訪問它。
使用關鍵字class時默認的繼承方式是private,使用struct時默認的繼承方式是public
在實際運用中一般使用都是public繼承,幾乎很少使用protetced/private繼承
賦值兼容 - 切片
派生類對象 可以賦值給 基類的對象 / 基類的指針 / 基類的引用。這里有個形象的說法叫切片或者切割。寓意把派生類中父類那部分切來賦值過去。
基類對象不能賦值給派生類對象
基類的指針或者引用可以通過強制類型轉換賦值給派生類的指針或者引用。但是必須是基類的指針是指向派生類對象時才是安全的
隱藏(重定義)
- 在繼承體系中基類和派生類都有獨立的作用域
- 子類和父類中有同名成員,子類成員將屏蔽父類對同名成員的直接訪問,這種情況叫隱藏, 也叫重定義。(在子類成員函數中,可以使用 基類::基類成員 顯示訪問)
- 需要注意的是如果是成員函數的隱藏,只需要函數名相同就構成隱藏
- 注意在實際中在繼承體系里面最好不要定義同名的成員
類繼承
class Person
{
public:void print(){cout << _name << _age << endl;}
protected:string _name = "kele";int _age = 21;
};class Student :public Person
{
public:void print(){cout << _name << _age << _stdid << endl;}
protected:string _name = "lihua";int _stdid = 1111;
};int main()
{Student s;s.print();s.Person::print();//顯示訪問基類print函數return 0;
}
結果:
lihua211111
kele21
說明理解:成員也可以隱藏,這種隱藏更加像是在不同類域的顯示優先級,例如同名的局部變量和全局變量
派生類的默認成員函數行為
- 構造函數:派生類的構造函數必須調用基類的構造函數 初始化基類的那一部分成員。如果基類沒有默認的構造函數,則必須在派生類構造函數的初始化列表階段顯示調用
- 拷貝構造:派生類的拷貝構造函數必須調用基類的拷貝構造 完成基類的拷貝初始化
- 賦值重載:派生類的operator=必須要調用基類的operator=完成基類的復制
- 析構函數:派生類的析構函數會在被調用完成后自動調用基類的析構函數清理基類成員。因為這樣才能保證派生類對象先清理派生類成員再清理基類成員的順序
- 派生類對象初始化先調用基類構造再調派生類構造。派生類對象析構清理先調用派生類析構再調基類的析構
class Person
{
public:Person(string name, int age):_name(name),_age(age){cout << "Person(string name, int age) 基類構造" << endl;}Person(const Person& t): _name(t._name),_age(t._age){cout << "Person(const Person & t) 基類拷貝構造" << endl;}Person& operator==(const Person& t){if (this != &t){_name = t._name;_age = t._age;}cout << "Person& operator==(const Person& t) 基類賦值" << endl;return *this;}Person(Person&& t):_name(std::forward<std::string>(t._name)),_age(t._age){cout << "Person(Person&& t) 基類移動構造" << endl;}Person& operator==(Person&& t){if (this != &t){_name = std::forward<std::string>(t._name);_age = t._age;}cout << "Person& operator==(const Person& t) 基類移動賦值" << endl;return *this;}~Person(){cout << "~Person() 基類析構" << endl;}void print(){cout << _name << endl << _age << endl;}
protected:string _name;int _age;
};class Student :public Person
{
public:Student(string name, int age, int stdid):Person(name, age),_stdid(stdid){cout << "Student(string name, int age, int stdid) 派生類構造" << endl;}Student(const Student& s):Person(s), // 利用&引用切片_stdid(s._stdid){cout << "Student(const Student & s) 派生類拷貝構造" << endl;}Student& operator==(const Student& s){if (this != &s){Person::operator==(s); // 利用&引用切片_stdid = s._stdid;}cout << "Student& operator==(const Student& s) 派生類賦值" << endl;return *this;}Student(Student&& s):Person(std::forward<Person>(s)), _stdid(s._stdid){cout << "Student(Student&& s) 派生類移動構造" << endl;}Student& operator==(Student&& s){if (this != &s){Person::operator==(std::forward<Person>(s)); // 利用&引用切片_stdid = s._stdid;}cout << "Student& operator==(Student&& s) 派生類移動賦值" << endl;return *this;}~Student(){cout << "~Student() 派生類析構" << endl;}void print(){cout << _name << _age << _stdid << endl;}
protected:int _stdid;
};int main()
{Student s("coke", 22, 21);cout << "-------------------------------------" << endl;Student s1 = s; // 拷貝構造cout << "-------------------------------------" << endl;s == s1; // 賦值cout << "-------------------------------------" << endl;Student s2(move(s));cout << "-------------------------------------" << endl;s1 == move(s2);cout << "-------------------------------------" << endl;return 0;
}
結果:
Person(string name, int age) 基類構造
Student(string name, int age, int stdid) 派生類構造
Person(const Person & t) 基類拷貝構造
Student(const Student & s) 派生類拷貝構造
Person& operator==(const Person& t) 基類賦值
Student& operator==(const Student& s) 派生類賦值
Person(Person&& t) 基類移動構造
Student(Student&& s) 派生類移動構造
Person& operator==(const Person& t) 基類移動賦值
Student& operator==(Student&& s) 派生類移動賦值
~Student() 派生類析構
~Person() 基類析構
~Student() 派生類析構
~Person() 基類析構
~Student() 派生類析構
~Person() 基類析構
總結:只有析構是先析構派生類再析構基類,其他默認成員函數的調用順序都一樣相反
原因:對于構造類,規則是初始化順序由聲明的順序決定,繼承的聲明在類最開始的時候;對于析構,由于派生類的成員變量使用了基類的成員(例如指針引用),可能造成析構錯誤,或者二次析構
網上的解答:
在面向對象編程中,一個對象可能擁有需要手動釋放的資源,如動態分配的內存、文件句柄、網絡連接等。派生類可能在其構造函數中分配這些資源,并在其析構函數中釋放它們。如果基類的析構函數在派生類的析構函數之前被調用,那么派生類釋放資源的代碼將無法訪問這些資源,因為它們可能已經被基類析構函數中的代碼釋放或改變了。因此,先調用派生類的析構函數可以確保派生類在基類之前釋放其擁有的資源。
繼承與友元和靜態成員
友元關系不能繼承,也就是說基類友元不能訪問子類私有和保護成員
基類定義了static靜態成員,則整個繼承體系里面只有一個這樣的成員。無論派生出多少個子類,都只有一個static成員實例
菱形繼承
單繼承:一個子類只有一個直接父類時稱這個繼承關系為單繼承
多繼承:一個子類有兩個或以上直接父類時稱這個繼承關系為多繼承
菱形繼承:
菱形繼承的問題:從下面的對象成員模型構造,可以看出菱形繼承有數據冗余和二義性的問題。
_name在Assistant的對象中Person成員會有兩份
class Person
{
public:string _name; // 姓名
};
class Student : public Person
{
protected:int _num; //學號
};
class Teacher : public Person
{
protected:int _id; // 職工編號
};
class Assistant : public Student, public Teacher
{
protected:string _majorCourse; // 主修課程
};
void Test()
{// 這樣會有二義性無法明確知道訪問的是哪一個Assistant a;//a._name = "peter";// 需要顯示指定訪問哪個父類的成員可以解決二義性問題,但是數據冗余問題無法解決a.Student::_name = "xxx";a.Teacher::_name = "yyy";
}int main()
{Test();return 0;
}
虛擬繼承可以解決菱形繼承的二義性和數據冗余的問題。
如上面的繼承關系,在Student和 Teacher的繼承Person時使用虛擬繼承,即可解決問題。需要注意的是,虛擬繼承不要在其他地 方去使用。
在基礎重復的基類時 virtual 繼承
虛基表包含:
- 偏移量(Offset):虛基表中存放的是偏移量,這些偏移量用于確定從派生類對象的起始地址到虛基類成員的實際內存地址的偏移量。在菱形繼承中
- 以NULL結尾(編譯器不同情況下不一樣,適用于VS):與虛函數表(virtual function table,簡稱虛表或V-Table)類似,虛基表也是以NULL結尾的。這標識了表的結束,方便程序在遍歷或訪問表時知道何時停止。
在g++編譯器下,一般采用在虛函數表中放置虛基類的偏移量的方式
繼承和組合
- public繼承是一種is-a的關系。也就是說每個派生類對象都是一個基類對象。
- 組合是一種has-a的關系。假設B組合了A,每個B對象中都有一個A對象。
- 優先使用對象組合,而不是類繼承 。
- 繼承允許你根據基類的實現來定義派生類的實現。這種通過生成派生類的復用通常被稱 為白箱復用(white-box reuse)。術語“白箱”是相對可視性而言:在繼承方式中,基類的內部細節對子類可見 。繼承一定程度破壞了基類的封裝,基類的改變,對派生類有很 大的影響。派生類和基類間的依賴關系很強,耦合度高。
- 對象組合是類繼承之外的另一種復用選擇。新的更復雜的功能可以通過組裝或組合對象 來獲得。對象組合要求被組合的對象具有良好定義的接口。這種復用風格被稱為黑箱復 用(black-box reuse),因為對象的內部細節是不可見的。對象只以“黑箱”的形式出現。 組合類之間沒有很強的依賴關系,耦合度低。優先使用對象組合有助于你保持每個類被 封裝。
- 實際盡量多去用組合。組合的耦合度低,代碼維護性好。不過繼承也有用武之地的,有些關系就適合繼承那就用繼承,另外要實現多態,也必須要繼承。類之間的關系可以用繼承,可以用組合,就用組合。
6. 多態(重點)
什么是多態?
概念:通俗來說,就是多種形態,具體點就是去完成某個行為,當不同的對象去完成時會 產生出不同的狀態
靜態的多態(編譯時):
- 函數重載,利用參數匹配和函數名修飾規則
- 模板,利用模板根據參數實例化
動態的多態(運行時)
和指向對象有關
構成條件
-
父子類,繼承關系
-
虛函數重寫
虛函數:父類虛函數,子類的虛函數可以不加virtual
重寫:三同,特例協變和析構,具體看下面
-
父類指針或者引用調用虛函數
結果:指向父類調用父類虛函數,指向子類調用子類虛函數
重寫(覆蓋)
首先,必須要是虛函數,重寫叫做虛函數的重寫
派生類中有一個跟基類完全相同的虛函數(即派生類虛函數與基類虛函數的 返回值類型、函數名字、參數列表完全相同),稱子類的虛函數重寫了基類的虛函數
class Person
{
public:virtual void BuyTicket() { cout << "買票-全價" << endl; }
};
class Student : public Person
{
public:void BuyTicket() { cout << "買票-半價" << endl; } // 可以不加virtual但是不建議
};void Func(Person& p) // 切片
{p.BuyTicket();
}int main()
{Person ps;Student st;Func(ps);Func(st);return 0;
}
重寫的特例:
- 協變(基類與派生類虛函數返回值類型不同)
派生類重寫基類虛函數時,與基類虛函數返回值類型不同。即基類虛函數返回基類對象的指針或者引用,派生類虛函數返回派生類對象的指針或者引用時,稱為協變
常見的使用場景:operator==重寫,返回值必須是該對象的引用,所以存在使用場景,規則做出的妥協
- 析構函數的重寫(基類與派生類析構函數的名字不同)
編譯器對析構函數的名稱做了特殊處理,編譯后析構函數的名稱統一處理成destructor,這樣函數名就相同了
析構函數建議是虛函數
純虛函數
在虛函數的后面寫上 =0 ,則這個函數為純虛函數
包含純虛函數的類叫做抽象類(也叫接口類),抽象類不能實例化出對象,只有重寫純虛函數,派生類才能實例化出對象
純虛函數規范了派生類必須重寫,另外純虛函數更體現出了接口繼承
抽象類的作用:
-
定義接口
純虛函數常用于定義接口類。接口類是一種只包含純虛函數的類,它定義了一組操作,但具體的實現由派生類提供。這樣,不同的派生類可以根據需要實現不同的行為,而接口類則提供了一個統一的訪問方式
-
實現多態(可以使用抽象類指針類型)
-
間接強制派生類的重寫實現(不重寫就還是抽象類)
class Car
{
public:virtual void Drive() = 0;
};
class Benz :public Car
{
public:virtual void Drive() {cout << "Benz-舒適" << endl;}
};
class BMW :public Car
{
public:virtual void Drive() {cout << "BMW-操控" << endl;}
};void func(Car* car)
{car->Drive();
}void Test()
{Benz* pBenz = new Benz;BMW* pBMW = new BMW;func(pBenz);func(pBMW);
}int main()
{Test();return 0;
}
普通函數的繼承是一種實現繼承,派生類繼承了基類函數,可以使用函數,繼承的是函數的實現。
虛函數的繼承是一種接口繼承,派生類繼承的是基類虛函數的接口,目的是為了重寫,達成多態,繼承的是接口
重載、覆蓋(重寫)、隱藏(重定義)的對比
override/final
從上面可以看出,C++對函數重寫的要求比較嚴格,但是有些情況下由于疏忽,可能會導致函數名字母次序寫反而無法構成重載,而這種錯誤在編譯期間是不會報出的,只有在程序運行時沒有 得到預期結果才來debug會得不償失,因此:C++11提供了override和final兩個關鍵字,可以幫助用戶檢測是否重寫
1. final:修飾虛函數,表示該虛函數不能再被重寫,若重寫編譯報錯(在基類虛函數修飾)
class Person {
public:virtual ~Person() final { cout << "~Person()" << endl; }
};class Student : public Person {
public:virtual ~Student() { cout << "~Student()" << endl; }
};
報錯:“Person::~Person”: 聲明為 “final” 的函數不能由 “Student::~Student” 重寫
2. override(重寫覆蓋) 檢查派生類虛函數是否重寫了基類某個虛函數,如果沒有重寫編譯報錯。(在派生類虛函數修飾)
class Person {
public:~Person() { cout << "~Person()" << endl; }
};class Student : public Person {
public:virtual ~Student() override { cout << "~Student()" << endl; }
};
報錯:“Student::~Student”: 包含重寫說明符“override”的方法沒有重寫任何基類方法
多態原理
虛函數表
// 在32位平臺下,sizeof(Base)等于多少?
class Base
{
public:virtual void Func1(){cout << "Func1()" << endl;}
private:int _b = 1;
};int main()
{Base b;cout << sizeof(Base) << endl;return 0;
}
通過觀察測試我們發現b對象是8bytes,除了_b成員,還多一個__vfptr放在對象的前面(注意有些平臺可能會放到對象的最后面,這個跟平臺有關),對象中的這個指針我們叫做虛函數表指針(v代 表virtual,f代表function)。一個含有虛函數的類中都至少都有一個虛函數表指針,因為虛函數的地址要被放到虛函數表中,虛函數表也簡稱虛表
在多繼承的情況下,如果多個基類都包含虛函數指針,那么這個派生類就需要多個虛函數指針
虛函數表本質是一個存虛函數指針的指針數組,在vs下這個數組最后面放了一個nullptr
總結一下派生類的虛表生成(編譯階段):
- 先將基類中的虛表內容拷貝一份到派生類虛表中
- 如果派生類重寫了基類中某個虛函數,用派生類自己的虛函數覆蓋虛表中基類的虛函數
- 派生類自己新增加的虛函數按其在派生類中的聲明次序增加到派生類虛表的最后
虛函數指針生成在構造函數的初始化列表(編譯器自動)
虛函數存在代碼段,虛表存在代碼段(常量區)
// 虛函數表打印
class Person
{
public:virtual void BuyTicket() {cout << "買票全價" << endl;}virtual void Func() {cout << _id << endl;BuyTicket();}
protected:int _id = 1;
};class Student :public Person
{
public:void BuyTicket() {cout << "買票半價" << endl;}protected:int _id = 2;
};typedef void(*VFPtr) ();void PrintVFTable(VFPtr* p)
{for (int i = 0; p[i] != nullptr; ++i){printf("[%d] -> %p\n", i, p[i]);}cout << endl;
}int main()
{Person p;Student s;PrintVFTable(*(VFPtr**)&p);PrintVFTable(*(VFPtr**)&s);return 0;
}
動態綁定與靜態綁定
-
靜態綁定又稱為前期綁定(早綁定),在程序編譯期間確定了程序的行為,也稱為靜態多態, 比如:函數重載
-
動態綁定又稱后期綁定(晚綁定),是在程序運行期間,根據具體拿到的類型確定程序的具體 行為,調用具體的函數,也稱為動態多態
多繼承關系的虛函數表(拓展)
多繼承派生類的虛表個數與繼承基類個數相關
多繼承派生類的虛表生成順序與繼承聲明順序有關
多繼承派生類的未重寫的虛函數 放在 第一個繼承基類部分的虛函數表(第一個虛表) 的最后
面試題
以下關于純虛函數的說法,正確的是( )
- 聲明純虛函數的類不能實例化對象
- 子類必須實現基類的純虛函數
- 聲明純虛函數的類是虛基類
- 純虛函數必須是空函數
關于虛表說法正確的是( )
- 一個類只能有一張虛表
- 基類中有虛函數,如果子類中沒有重寫基類的虛函數,此時子類與基類共用同一張虛表
- 虛表是在運行期間動態生成的
- 一個類的不同對象共享該類的虛表
關于虛函數的描述正確的是( )
- 派生類的虛函數與基類的虛函數具有不同的參數個數和類型
- 內聯函數不能是虛函數
- 派生類必須重新定義基類的虛函數
- 虛函數可以是一個static型的函數
A D B
程序結果
菱形繼承初始化順序問題
class A {
public:A(char* s) { cout << s << endl; }~A() {}
};
class B :virtual public A
{
public:B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class C :virtual public A
{
public:C(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};
class D :public B, public C
{
public:D(char* s1, char* s2, char* s3, char* s4) :B(s2, s2), C(s3, s3), A(s1){cout << s4 << endl;}
};
int main() {char a[] = "class A";char b[] = "class B";char c[] = "class C";char d[] = "class D";D* p = new D(a, b, c, d);delete p;return 0;
}
class A class B class C class D
多繼承的指針偏移問題
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main() {Derive d;Base1* p1 = &d;Base2* p2 = &d;Derive* p3 = &d;cout << p1 << endl;cout << p2 << endl;cout << p3 << endl;return 0;
}
p1 == p3 < p2
接口繼承參數問題
class A
{
public:virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }virtual void test() { func(); }
};class B : public A
{
public:void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};int main()
{B* p = new B;p->test();return 0;
}
B->1
問答:
- 什么是多態?
多種形態,為了完成某種行為,不同的對象調用會產生不同的結果
- 什么是重載、重寫(覆蓋)、重定義(隱藏)?
重載是在同一類域中,函數名相同,參數不同,本質上是利用C++函數的修飾規則實現的調用
重定義體現在繼承關系中,分別在基類和派生類的兩個函數,且函數名相同就構成重定義,基類的函數被隱藏,可以顯示調用
重寫是在重定義的基礎上,兩個函數都是虛函數,且函數名,返回值類型和參數類型都相同就構成重寫
- 多態的實現原理?
在包含虛函數的類,在編譯過程中,會在代碼段(常量區)生成一張虛函數表,里面包含虛函數的指針。
通過這個類定義的對象,被實例化的過程中,編譯器會通過初始化列表定義一個虛表指針指向這個虛表。
基類對象的指針或引用調用虛函數時,編譯器就會通過對象的虛表指針進一步找到虛函數的地址
- 虛函數可以加inlin嗎?
可以,加inline可以,但加了也沒用
要變成內聯函數,由于內聯函數是直接展開代碼,并不存在函數調用,即沒有函數地址,但如果要是虛函數就必須要有虛表,虛表就是存虛函數地址的
是內聯函數就不是虛函數,是虛函數就不是內聯函數
- 靜態成員函數可以是虛函數嗎
不行,靜態成員函數不可以是虛函數。靜態函數是屬于類的,不屬于對象本身,靜態成員函數沒有this指針,就找不到虛函數表指針
- 構造函數可以是虛函數嗎?
不行,因為對象中的虛函數表指針是在構造函數初始化列表階段才初始化的。實例化對象需要構造函數,調用構造函數需要虛表指針,虛表指針還沒有被實例化出來
- 析構函數可以是虛函數嗎?
可以,并且最好把基類的析構函數定義成虛函數
- 對象訪問普通函數快還是虛函數更快?
首先如果是普通對象,是一樣快的。如果是指針對象或者是引用對象,則調用的普通函數快,因為多態調用,運行時調用虛函數需要到虛函數表中去查找
- 虛函數表是在什么階段生成的,存在哪的?
虛函數表是在編譯階段就生成的,一般情況下存在代碼段(常量區)的。
10.C++菱形繼承的問題?虛繼承的原理?
數據冗余和二義性
在虛繼承的基類成員統一開辟一塊區域存儲,用虛基表記錄偏移量,通過虛基表指針找到偏移量進而找到
11.什么是抽象類?抽象類的作用?
在虛函數的后面寫上 =0 ,則這個函數為純虛函數。包含純虛函數的類叫做抽象類(也叫接口類)
抽象類不能實例化出對象。派生類繼承后也不能實例化出對象,只有重寫純虛函數,派生類才能實例化出對象。
純虛函數規范了派生類必須重寫,另外純虛函數更體現出了接口繼承。
- 定義接口
- 實現多態
- 間接強制子類實現虛函數重寫