0x01. C++ 多態的實現方式,虛函數的底層實現細節
C++中的多態分爲靜態多態和動態多態,也稱爲編譯時多態和運行時多態。
靜態多態包括:函數重載、模板; 動態多態包括:虛函數。
C++通過虛函數實現動態多態:
在基類的成員函數前加上virtual
關鍵字,在派生類中重寫該函數,運行時根據對象的實際類型調用相應的函數。
原理:
對於每個定義了虛函數的類和它的派生類,都會產生一個虛函數表,這個虛函數表是類共享的。每個對象裏都有一個虛表指針vf_ptr
,指向該類型的虛函數表vf_table
(虛表存放在只讀數據段.rodata
)。動態聯編在基類指針或引用指向不同的派生類對象時發生,若派生類對象重寫了虛函數,則虛表對應項將被覆蓋,實際調用中根據對象的虛表指針vf_ptr
找到該類的虛表,調用對應的函數。
早綁定/晚綁定、靜態綁定/動態綁定:
早綁定又稱靜態綁定:在程序編譯期發生,即編譯期就確定將要調用的函數地址。
晚綁定又稱動態綁定:在程序運行期發生,即在程序運行中確定要調用的函數地址。
p->Show(); # 靜態綁定
0x0139B5D8 push 0Ah # 參數壓棧
0x0139B5DA mov ecx,dword ptr [p]
0x0139B5DD call Base::Show (013916BDh) # 調用函數
p->Show(); # 動態綁定
0x009AA858 mov esi,esp
0x009AA85A push 0Ah # 參數壓棧
0x009AA85C mov eax,dword ptr [p] # 將對象地址放入eax
0x009AA85F mov edx,dword ptr [eax] # 將對象中的虛表指針放入寄存器
0x009AA861 mov ecx,dword ptr [p]
0x009AA864 mov eax,dword ptr [edx] # 查虛表得到虛函數地址
0x009AA866 call eax # 調用相應的虛函數
0x009AA868 cmp esi,esp
0x009AA86A call __RTC_CheckEsp (09A147Eh) # 調整棧平衡
0x02. C++內存泄露/資源泄露
- 調用malloc/new 未free/delete 或 在執行free/delete 之前拋出異常
- 發生淺拷貝對象默認賦值
- 基類指針指向堆區資源,而基類析構函數非虛,則派生類無法析構
- new Test[100] -> delete Test,使用new一次生成多個對象,而沒有使用delete[],多個對象只會調用一次析構函數。
- 構造函數中拋出異常(new -> bad_alloc),退出時未調用析構函數,申請的內存將無法釋放。
- socket/fd未close(fd進程上限:2^16=65535,即epoll可操作fd上限)
- 產生殭屍進程,進程內核棧內存泄露(8K)
0x03. volatile關鍵字的作用
volatile
關鍵字提醒編譯器它後面所定義的變量隨時都有可能改變,因此編譯後的程序每次需要存儲或讀取這個變量的時候,都會直接從變量地址中讀取數據。如果沒有volatile
關鍵字,則編譯器可能優化讀取和存儲,可能暫時使用寄存器中的值,如果這個變量由別的程序更新了,將出現不一致。
一般情況下,volatile
用在如下幾個地方:
1. 中斷服務程序中修改的供其它程序檢測的變量需要加volatile
。
1. 多任務環境下,各任務間共享的標誌應該加volatile
。
1. 存儲器映射的硬件寄存器通常要加volatile
說明,因爲每次對它的讀寫都可能有不同的意義。
多線程下的volatile
(防止多線程對共享變量進行緩存):
當兩個線程都要用到某個變量且該變量的值會被改變時,應該用volatile
聲明,防止編譯器優化把變量從內存裝入CPU寄存器中。如果變量被裝入寄存器,那麼兩個線程有可能一個使用內存中的變量,一個使用寄存器中的變量,會造成程序的錯誤執行。
0x04. C++的函數重載
函數重載: 是指在同一作用域內,可以有一組具有相同函數名,不同參數列表的函數,這組函數被稱爲重載函數。
優點: 減少了函數名的數量,避免了名字空間的污染。
編譯器實現: 應用源碼編譯之後,編譯器對函數進行簽名(作用域+返回類型+函數名+參數列表),從而重載函數的名字變了。例如
void print(int i) // 全局函數編譯後函數名:_Z5printi
class test{
public:
void print(int i); // 編譯後函數名: _ZN4test5printEi
void print(char c); // 編譯後函數名: _ZN4test5printEc
}
函數名調用解析:
爲了估計哪個重載函數最合適,需要依次按照規則來判斷:
精確匹配: 參數匹配而不做轉換,或者只做微小的轉換,如數組名到指針、函數名到指向函數的指針。
提升匹配: 即整數提升(bool到int,char到int,short到int)。
使用標準轉換匹配:
使用用戶自定義匹配:
使用省略號匹配:
0x05. 指針和引用的區別
相同點:
都是地址的概念: 指針指向一塊內存,內容是所指內存的地址;引用是某塊內存的別名。
區別:
- 指針是一個實體,而引用僅是個別名;
- 引用使用時無需解引用(*),指針需要解引用;
- 引用只能在定義時被初始化一次,之後不可變;指針可以多次賦值;
- 引用沒有
const
,指針有const
,const
指針不可變; - 引用不能爲空,指針可以爲空;
sizeof 引用
得到的是引用所指對象的大小;而sizof 指針
得到的是指針本身的大小。- 指針和引用的自增運算意義不一樣;
聯繫:
- 引用在C++語言內部用指針實現(底層彙編操作一樣)
- 對一般應用而言,把引用理解爲指針,不會犯嚴重語義錯誤。引用是操作受限的指針(僅允許讀取內容操作)
0x06. C++內存對齊
爲什麼需要內存對齊?
- 平臺原因: 不是所有的硬件平臺都能訪問任意地址上的任意數據的;某些硬件平臺只能在某些地址處取某些特定類型的數據,否則拋出硬件異常。
- 性能原因: 數據結構應該儘可能地在自然邊界上對齊。原因在於,爲了訪問未對齊的內存,處理器需要作兩次內存訪問;而對齊的內存訪問僅需要一次訪問。
內存對齊規則
- 對於類(結構或聯合)的各個成員,第一個成員位於偏移爲0的位置,以後每個數據成員的偏移量必須是
min(#pragma pack (n) 指定的數n, 該數據成員的自身長度)
的整數倍數; - 在數據成員完成各自對齊之後,類(結構或聯合)本身也要對齊,對齊按照
min(#pragma pack (n) 指定的數n, 結構或聯合最大數據成員長度)
進行。 - 如果類中的成員爲類,則類成員要從
min(#pragma pack (n) 指定的數n, 結構或聯合最大數據成員長度)
開始存儲。
VC、VS
等編譯器默認是 #pragma pack(8) (可選1,2,4,8,16)
,g++、clang++
編譯器默認是#pragma pack(4) (可選1,2,4)
0x07. 內聯函數與宏
- 內聯函數在調用的地方被展開,通過編譯器控制來實現的;宏是由預處理器在預處理階段進行替換。
- 編譯器會對內聯函數的參數類型進行安全檢查或自動類型轉換,而宏定義則不會。
- 內聯函數在運行時可調試,而宏定義不可以。
- 內聯函數可以訪問類的成員變量,而宏定義不能。
注意:
1. 遞歸函數不能定義爲內聯函數;
2. 內聯函數一般適合於不存在循環等複雜的結構且只有1-5條語句的小函數上,否則編譯系統將該函數視爲普通函數;
3. 內聯函數只能先定義後使用;
4. 對內聯函數不能進行異常的接口聲明。
0x08. new與malloc的區別,delete和free的區別
- new是C++運算符;malloc是標準庫函數,需要包含庫文件。
- new自動計算需要分配的內存空間;malloc需要手工計算分配多大空間。
- new建立的是一個對象;而malloc分配的是一塊內存。
- new會調用構造函數,而malloc不能;delete會調用析構函數,而free不能
- new是類型安全的,而malloc不是,比如:
int* p = new float[2]; //編譯時指出錯誤
,int* p = malloc(2*sizeof(float)); //編譯時無法指出錯誤
- new一般由兩部構成,分別是new操作和構造。new操作對應malloc,且new操作可以重載、可以自定義內存分配策略、甚至不做內存分配。而malloc不能。
- new操作符從自由存儲區上爲對象動態分配內存空間,而malloc函數從堆上動態分配內存。自由存儲區是C++基於new操作符的一個抽象概念,自由存儲區可以是堆,也可以是靜態存儲區,取決於new操作符在實現細節。
- 有專門的
new[]
與delete[]
處理數組類型,而malloc沒有。
0x09. C++中的強制類型轉換
1. reinterpret_cast
僅僅重新解釋類型,但沒有進行二進制的轉換:
1. 轉換的類型必須是一個指針、引用、算術類型、函數指針或成員指針;
2. 在比特位級別上進行轉換。可以把一個指針轉換成一個整數,也可以把一個整數轉換成一個指針;
3. 最普通的用途就是在函數指針類型之間進行轉換;
4. 很難保持移植性。
2. static_cast
類似於C風格的強制轉換。無條件轉換、靜態類型轉換。用於:
1. 基類和子類之間轉換:其中子類指針轉換成父類指針是安全的;但父類指針轉換成子類指針是不安全的。
2. 基本數據類型轉換。
3. 把空指針轉換成目標類型的空指針。
4. 把任何類型的表達式轉換成void
類型。
5. static_cast
不能去掉類型的const
、volatile
屬性。
3. dynamic_cast
有條件轉換、動態類型轉換,運行時類型安全檢查(轉換失敗返回NULL);
1. 安全的基類和子類之間轉換
2. 必須要有虛函數
3. 相同基類不同子類之間的交叉轉換,但結果是NULL。
4. const_cast
設置或去掉類型的const或volatile屬性。
總結:
去const屬性用const_cast;
基本類型轉換用static_cast;
多態類之間的類型轉換用dynamic_cast;
不同類型的指針類型轉換用reinterpret_cast;
0x10. const修飾指針
對於指針變量有以下四種情況:
1. 指向非const對象的指針
將非const對象的指針指向一個常量對象將引起編譯錯誤
2. 指向const對象的指針
不能通過指針修改常量的值,但是指針本身可以修改
3. const指針
聲明const指針時,必須同時對其進行初始化
4. 指向const對象的const指針
聲明時必須初始化,指針指向的對象以及指針本身都不能修改
0x11. typedef和#define的區別
typedef
是用來聲明自定義數據類型,配合各種原有數據類型來達到簡化編程的目的;
#define
是預處理指令
1. 首先,兩者指向時間不同
typedef
在編譯階段有效,有類型檢查的功能。
#define
是宏定義,發生在預處理階段,不進行任何檢查
2. 功能不同
typedef
用來定義類型的別名,類型不僅包含內部類型,還包含自定義類型。定義機器無關的類型。
#define
可以爲類型取別名,還可以定義常量、變量、編譯開關等。
作用域不同
typedef
有自己的作用域
#define
沒有作用域的限制,只要是之前預定義過的宏,在以後的程序中都可以使用。對指針的操作
二者修飾指針類型時,作用不同
0x12. 鏈接指示:extern “C”
編寫C++程序有時需要調用其他語言編寫的函數,比如C, Fortran等。C++使用鏈接指示(linkage directive)標註這種混合編程編寫的函數。
- 聲明一個非C++函數
鏈接指示可以有兩種形式:單個或複合。鏈接指示不能出現在類定義或函數定義的內部。
單語句:extern "C" size_t strlen(const char*);
複合語句:
extern "C"{
int strcmp(const char*, const char*);
char *strcat(char*, const char*);
}
鏈接指示與頭文件
複合語句:extern "C" { #include <string.h> }
指向extern “C”函數的指針
extern "C" void (*pf)(int);
當使用pf調用函數時,編譯器認定當前調用的是一個C函數。鏈接指示對整個聲明都有效
當使用鏈接指示時,不僅對函數有效,而且對作爲返回類型或形參類型的函數指針也有效。導出C++函數到其他語言
通過使用鏈接指示對函數進行定義,可以令一個C++函數在其他語言編寫的程序中可用。
對鏈接到C的預處理器的支持:
有時需要在C和C++中編譯同一個源文件,爲了實現這一目的,在編譯C++版本的程序時預處理器定義__cplusplus
(兩個下劃線)。利用這個變量,我們可以在編譯C++程序的時候有條件地包含進來一些代碼:
#ifndef __cplusplus
//正確:我們在編譯C++程序
extern "C"
#endif
int strcmp( const char*, const char* );
- 重載函數與鏈接指示
鏈接指示與重載依賴於目標語言,目標語言支持則支持,C不支持重載,所以不能extern “C”用於相同函數名類型。如果在一組重載函數中有一個是C函數,其餘的必然都是C++函數。
0x13. 如何定義一個只能在堆上(棧上)生成對象的類
在C++中,類的對象建立分爲兩種,一種是靜態建立,如A a
;另一種是動態建立,如A *ptr = new A
;這兩種方式有區別:
靜態建立類對象: 是由編譯器爲對象在棧空間中分配內存,然後在這片棧內存空間上調用構造函數形成一個棧對象。使用這種方法,直接調用類的構造函數。
動態建立類對象: 使用new運算符將對象建立在堆空間中,這個過程分爲兩步:第一步是指針operator new()函數,在堆空間中搜索合適的內存並進行分配;第二步是調用構造函數,初始化這片堆內存空間。使用這種方法,間接調用類的構造函數。
1. 類對象只能建立在堆上
類對象只能建立在堆上,就不能靜態建立類對象,即不能直接調用類的構造函數。
編譯器在爲類對象分配棧空間時,會先檢查類的析構函數的訪問性以及非靜態函數。如果類的析構函數是私有的,則編譯器不會在棧空間上爲類對象分配內存。因此,將析構函數設爲私有,類對象就無法建立在棧上。例如:
class A{
public:
A(){}
void destory() {delete this;}
private:
~A(){}
};
缺點:
1)無法解決繼承問題,如果A作爲其他類的基類,則析構函數通常設爲virtual,然後在子類中重寫,以實現多態。因此,析構函數不能設爲private。可以將析構函數設爲protected。
2)類的使用很不方便,使用new建立對象,卻使用delete釋放對象,而不是使用delete。爲了統一,可以將構造函數設爲protected,然後提供一個public的靜態函數來完成構造,這樣不使用new。
class A{
protected:
A() {}
~A() {}
public:
static A* create(){
return new A();
}
void destory(){
delete this;
}
};
int main(int argc, char* argv[]){
A *aptr = A::create();
return 0;
}
2. 類對象只能建立在棧上
只有使用new運算符,對象纔會建立在堆上。因此,只要禁用new運算符就可以實現類對象只能建立在棧上。將operator new()設爲私有即可。
class A{
private:
void *operator new(size_t) {}
void operator delete(void *ptr) {}
public:
A(){}
~A(){}
};
0x14. 必須在構造函數初始化式裏進行初始化的數據成員
- const常量:常量只能初始化,不能賦值
- 引用類型:只能在定義時初始化,並且不能被重新賦值
- 沒有默認構造函數的類類型:初始化列表可以不必調用默認構造函數來初始化,而是直接調用拷貝構造函數。
static 對象屬於類,不屬於具體的對象;
static const對象不能在初始化列表中初始化。
0x15. 類的構造函數和析構函數不要調用虛函數
構造函數不要調用虛函數
基類構造函數是在派生類之前執行的,在基類構造函數運行的時候對象的派生類的數據成員部分還沒有初始化。如果在基類的構造過程中,對虛函數的調用傳遞到派生類,派生類對象可以參照引用局部的數據成員。但是數據成員此時未初始化,這會導致無休止的未定義行爲。
析構函數不要調用虛函數
如果派生類的對象進行析構,首先調用派生類的析構函數,然後在調用基類的析構時,遇到一個虛函數,由兩種選擇:1)調用虛函數的基類版本,那麼虛函數則失去了運行時調用正確版本的意義;2)調用虛函數的派生類版本,此時對象的派生類部分已經完成析構,函數調用會導致未定義行爲。
實際情況使用基類版本,如果虛函數的基類版本不是純虛實現,不會有嚴重錯誤發生。
0x16. sizeof和strlen區別
- sizeof操作符的結果類型size_t,在頭文件中typedef爲unsigned int類型;
- sizeof是算符,strlen是函數;
- sizeof可以用類型、函數做參數;strlen只能是char* 做參數,且必須是以”\0”結尾的;
- 數據做sizeof的參數不退化,傳遞給strlen就退化爲指針;
- 大部分編譯程序,在編譯的時候就把sizeof計算過了,strlen的結果在運行的時候才能計算出來