1. C++基礎知識點
1.1 有符號類型和無符號類型
- 當我們賦給無符號類型一個超出它表示范圍的值時,結果是初始值對無符號類型表示數值總數取模之后的余數。當我們賦給帶符號類型一個超出它表示范圍的值時,結果是未定義的;此時,程序可能繼續工作、可能崩潰。也可能生成垃圾數據。
- 如果表達式中既有帶符號類型由于無符號類型那個,當帶符號類型取值為負時會出現異常結果,這是因為帶符號數會自動轉換成無符號數。
int a = 1;unsigned int b = -2;
cout<<a+b<<endl; // 輸出4294967295
int c = a + b; //c=-1;
a = 3;b = -2;
cout<<a+b<<endl; // 輸出1
1.2 引用與指針
引用并非對象,它只是為一個已經存在的對象起的一個別名。在定義引用時,程序把引用和它的初始值綁定在一起,而不是將初始值拷貝給引用。一旦初始化完成,應用將和它的初始值綁定在一起。以為無法令引用重新綁定到另外一個對象,因此引用必須初始化。
指針是指向另外一種類型的符合類型。與引用類似,指針也實現了對其他對象的簡介訪問。然而指針與引用相比又有許多不同點:
- 指針本身就是一個對象,允許對指針賦值和拷貝。而且在指針的生命周期內它可以先后指向幾個不同的對象。引用不是對象,所以也不能定義指向引用的指針。
- 指針無須在定義時賦值。
void*
是一種特殊的指針類型,可以存放任意對象的地址。但我們對該地址中存放的是什么類型的對象并不了解,所以也不能直接操作void*
指針所指的對象。
1.3 static關鍵字
- 申明為static的局部變量,存儲在靜態存儲區,其生存期不再局限于當前作用域,而是整個程序的生存期。
- 對于全局變量而言, 普通的全局變量和函數,其作用域為整個程序或項目,外部文件(其它cpp文件)可以通過extern關鍵字訪問該變量和函數;static全局變量和函數,其作用域為當前cpp文件,其它的cpp文件不能訪問該變量和函數。
- 當使用static修飾成員變量和成員函數時,表示該變量或函數屬于一個類,而不是該類的某個實例化對象。
1.4 const限定符
const的作用
- 在定義常變量時必須同時對它初始化,此后它的值不能再改變。常變量不能出現在賦值號的左邊(不為“左值”);
- 對指針來說,可以指定指針本身為const,也可以指定指針所指的數據為const,或二者同時指定為const;
- 在一個函數聲明中,const可以修飾形參,表明它是一個輸入參數,在函數內部不能改變其值;
- 對于類的成員函數,若指定其為const類型,則表明其是一個常函數,不能修改類的成員變量;
- 對于類的成員函數,有時候必須指定其返回值為const類型,以使得其返回值不為"左值"。例如:
//operator*的返回結果必須是一個const對象,否則下列代碼編譯出錯
const classA operator*(const classA& a1,const classA& a2);
classA a, b, c;
(a*b) = c; //對a*b的結果賦值。操作(a*b) = c顯然不符合編程者的初衷,也沒有任何意義
用const修飾的符號常量的區別:const位于(*)
的左邊,表示被指物是常量;const位于(*)
的右邊,表示指針自身是常量(常量指針)。(口訣:左定值,右定向)
const char *p; //指向const對象的指針,指針可以被修改,但指向的對象不能被修改。
char const *p; //同上
char * const p; //指向char類型的常量指針,指針不能被修改,但指向的對象可以被修改。
const char * const p; //指針及指向對象都不能修改。
const與#define的區別
- const常量有數據類型,而宏常量沒有數據類型。編譯器可以對前者進行類型安全檢查。而對后者只進行字符替換,沒有類型安全檢查,并且在字符替換可能會產生意料不到的錯誤(邊際效應)。
- 有些集成化的調試工具可以對const常量進行調試,但是不能對宏常量進行調試。
- 在C++程序中只使用const常量而不使用宏常量,即const常量完全取代宏常量。
1.5 數組與指針的區別
- 數組要么在靜態存儲區被創建(如全局數組),要么在棧上被創建。指針可以隨時指向任意類型的內存塊。
- 用運算符sizeof可以計算出數組的容量(字節數)。sizeof(p),p為指針得到的是一個指針變量的字節數,而不是p所指的內存容量。C/C++語言沒有辦法知道指針所指的內存容量,除非在申請內存時記住它。
- C++編譯系統將形參數組名一律作為指針變量來處理。實際上在函數調用時并不存在一個占有存儲空間的形參數組,只有指針變量。
實參數組名a代表一個固定的地址,或者說是指針型常量,因此要改變a的值是不可能的。例如:a++;
是錯誤的。形參數組名array是指針變量,并不是一個固定的地址值。它的值是可以改變的。例如:array++;
是合法的。
為了節省內存,C/C++把常量字符串放到單獨的一個內存區域。當幾個指針賦值給相同的常量字符串時,它們實際上會指向相同的內存地址。但用常量內存初始化數組時,情況卻有所不同。
char str1[] = “Hello World”;
char str2[] = “Hello World”;
char *str3[] = “Hello World”;
char *str4[] = “Hello World”;
其中,str1和str2會為它們分配兩個長度為12個字節的空間,并把“Hello World”的內容分別復制到數組中去,這是兩個初始地址不同的數組。str3和str4是兩個指針,我們無須為它們分配內存以存儲字符串的內容,而只需要把它們指向“Hello World”在內存中的地址就可以了。由于“Hello World”是常量字符串,它在內存中只有一個拷貝,因此str3和str4指向的是同一個地址。
1.6 sizeof運算符
sizeof是C語言的一種單目操作符,它并不是函數。操作數可以是一個表達式或類型名。數據類型必須用括號括住,sizeof(int);
變量名可以不用括號括住。
int a[50]; //sizeof(a)=200
int *a=new int[50]; //sizeof(a)=4;
Class Test{int a; static double c}; //sizeof(Test)=4
Test *s; //sizeof(s)=4
Class Test{ }; //sizeof(Test)=1
int func(char s[5]); //sizeof(s)=4;
操作數不同時注意事項:
- 數組類型,其結果是數組的總字節數;指向數組的指針,其結果是該指針的字節數。
- 函數中的數組形參或函數類型的形參,其結果是指針的字節數。
- 聯合類型,其結果采用成員最大長度對齊。
- 結構類型或類類型,其結果是這種類型對象的總字節數,包括任何填充在內。
- 類中的靜態成員不對結果產生影響,因為靜態變量的存儲位置與結構或者類的實例地址無關;
- 沒有成員變量的類的大小為1,因為必須保證類的每一個實例在內存中都有唯一的地址;
- 有虛函數的類都會建立一張虛函數表,表中存放的是虛函數的函數指針,這個表的地址存放在類中,所以不管有幾個虛函數,都只占據一個指針大小。
例題:
1、下列聯合體的sizeof(sampleUnion)的值為多少。
union{
char flag[3];
short value;
} sampleUnion;
答案:4。聯合體占用大小采用成員最大長度的對齊,最大長度是short的2字節。但char flag[3]需要3個字節,所以sizeof(sampleUnion) = 2*(2字節)= 4。注意對齊有兩層含義,一個是按本身的字節大小數對齊,一個是整體按照最大的字節數對齊。
2、在32位系統中:
char arr[] = {4, 3, 9, 9, 2, 0, 1, 5};
char *str = arr;
sizeof(arr) = 8;
sizeof(str) = 4;
strlen(str) = 5;
答案:8,4,5。注意strlen函數求取字符串長度以ASCII值為0為止。
3、定義一個空的類型,里面沒有任何成員變量和成員函數。
問題:對該類型求sizeof,得到的結果是什么?
答案:1。
問題:為什么不是0?
答案:當我們聲明該類型的實例的時候,它必須在內存中占有一定的空間,否則無法使用這些實例。至于占用多少內存,由編譯器決定。Visual Studio中每個空類型的實例占用1字節的空間。
問題:如果在該類型中添加一個構造函數和析構函數,結果又是什么?
答案:還是1。調用構造函數和析構函數只需要知道函數的地址即可,而這些函數的地址只與類型相關,而與類型的實例無關。
問題:那如果把析構函數標記為虛函數呢?
答案:C++的編譯器一旦發現一個類型中有虛函數,就會為該類型生成虛函數表,并在該類型的每一個實例中添加一個指向虛函數表的指針。在32位的機器上,指針占用4字節,因此求sizeof得到4;如果是64位機器,將得到8。
1.7 四個強制類型轉換
C++中有以下四種命名的強制類型轉換:
- static_cast:任何具有明確定義的類型轉換,只要不包含底層const,都可以使用static_cast。
- const_cast:去const屬性,只能改變運算對象的底層const。常用于有函數重載的上下文中。
- reninterpret_cast:通常為運算對象的位模式提供較低層次的重新解釋,本質依賴與機器。
- dynamic_cast:主要用來執行“安全向下轉型”,也就是用來決定某對象是否歸屬繼承體系中的某個類型。主要用于多態類之間的轉換
一般來說,如果編譯器發現一個較大的算術類型試圖賦值給較小的類型,就會給出警告;但是當我們執行了顯式的類型轉換之后,警告信息就被關閉了。
//進行強制類型轉換以便執行浮點數除法
int j = 1,i = 2;
double slope = static_cast<double>(j)/i;
//任何非常量對象的地址都能存入void*,通過static_cast可以將指針轉換會初始的指針類型
void* p = &slope;
double *dp = static_cast<double*>(p);
只有const_cast能夠改變表達式的常量屬性,其他形式的強制類型轉換改變表達式的常量屬性都將引發編譯器錯誤。
//利用const_cast去除底層const
const char c = 'a';
const char *pc = &c;
char* cp = const_cast<char*>(pc);
*cp = 'c';
reinterpret_cast常用于函數指針類型之間進行轉換。
int doSomething(){return0;};
typedef void(*FuncPtr)(); //FuncPtr是一個指向函數的指針,該函數沒有參數,返回值類型為void
FuncPtr funcPtrArray[10]; //假設你希望把一個指向下面函數的指針存入funcPtrArray數組:
funcPtrArray[0] =&doSomething;// 編譯錯誤!類型不匹配
funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething); //不同函數指針類型之間進行轉換
dynamic_cast
有條件轉換,動態類型轉換,運行時類型安全檢查(轉換失敗返回NULL):
- 安全的基類和子類之間轉換。
- 必須要有虛函數。
- 相同基類不同子類之間的交叉轉換。但結果是NULL。
class Base {
public:
int m_iNum;
virtualvoid foo(){}; //基類必須有虛函數。保持多態特性才能使用dynamic_cast
};
class Derive: public Base {
public:
char*m_szName[100];
void bar(){};
};
Base* pb =new Derive();
Derive *pd1 = static_cast<Derive *>(pb); //子類->父類,靜態類型轉換,正確但不推薦
Derive *pd2 = dynamic_cast<Derive *>(pb); //子類->父類,動態類型轉換,正確
Base* pb2 =new Base();
Derive *pd21 = static_cast<Derive *>(pb2); //父類->子類,靜態類型轉換,危險!訪問子類m_szName成員越界
Derive *pd22 = dynamic_cast<Derive *>(pb2); //父類->子類,動態類型轉換,安全的。結果是NULL
1.8 結構體的內存對齊
內存對齊規則
- 每個成員相對于這個結構體變量地址的偏移量正好是該成員類型所占字節的整數倍。為了對齊數據,可能必須在上一個數據結束和下一個數據開始的地方插入一些沒有用處字節。
- 且最終占用字節數為成員類型中最大占用字節數的整數倍。
struct AlignData1
{
char c;
short b;
int i;
char d;
}Node;
這個結構體在編譯以后,為了字節對齊,會被整理成這個樣子:
struct AlignData1
{
char c;
char padding[1];
short b;
int i;
char d;
char padding[3];
}Node;
所以編譯前總的結構體大小為:8個字節。編譯以后字節大小變為:12個字節。
但是,如果調整順序:
struct AlignData2
{
char c;
char d;
short b;
int i;
}Node;
那么這個結構體在編譯前后的大小都是8個字節。
那么編譯后不用填充字節就能保持所有的成員都按各自默認的地址對齊。這樣可以節約不少內存!一般的結構體成員按照默認對齊字節數遞增或是遞減的順序排放,會使總的填充字節數最少。
1.9 malloc/free 與 new/delete的區別
- malloc與free是C++/C語言的標準庫函數,new/delete是C++的運算符。它們都可用于申請和釋放動態內存。
- 對于非內部數據類型的對象而言,用maloc/free無法滿足動態對象的要求。對象在創建的同時要自動執行構造函數,對象在消亡之前要自動執行析構函數。由malloc/free是庫函數而不是運算符,不在編譯器控制權限之內,不能夠把執行構造函數和析構函數的任務強加于malloc/free,因此C++語言需要一個能完成動態內存分配和初始化工作的運算符new,和一個能完成清理與釋放內存工作的運算符delete。
- new可以認為是malloc加構造函數的執行。new出來的指針是直接帶類型信息的。而malloc返回的都是void*指針。newdelete在實現上其實調用了malloc,free函數。
- new建立的是一個對象;malloc分配的是一塊內存。
2. 面對對象編程
2.1 String類的實現
class MyString
{
public:
MyString();
MyString(const MyString &);
MyString(const char *);
MyString(const size_t,const char);
~MyString();
size_t length();// 字符串長度
bool isEmpty();// 返回字符串是否為空
const char* c_str();// 返回c風格的trr的指針
friend ostream& operator<< (ostream&, const MyString&);
friend istream& operator>> (istream&, MyString&);
//add operation
friend MyString operator+(const MyString&,const MyString&);
// compare operations
friend bool operator==(const MyString&,const MyString&);
friend bool operator!=(const MyString&,const MyString&);
friend bool operator<=(const MyString&,const MyString&);
friend bool operator>=(const MyString&,const MyString&);
// 成員函數實現運算符重載,其實一般需要返回自身對象的,成員函數運算符重載會好一些
char& operator[](const size_t);
const char& operator[](const size_t)const;
MyString& operator=(const MyString&);
MyString& operator+=(const MyString&);
// 成員操作函數
MyString substr(size_t pos,const size_t n);
MyString& append(const MyString&);
MyString& insert(size_t,const MyString&);
MyString& erase(size_t,size_t);
int find(const char* str,size_t index=0);
private:
char *p_str;
size_t strLength;
};
2.2 派生類中構造函數與析構函數,調用順序
構造函數的調用順序總是如下:
- 基類構造函數。如果有多個基類,則構造函數的調用順序是某類在類派生表中出現的順序,而不是它們在成員初始化表中的順序。
- 成員類對象構造函數。如果有多個成員類對象則構造函數的調用順序是對象在類中被聲明的順序,而不是它們出現在成員初始化表中的順序。如果有的成員不是類對象,而是基本類型,則初始化順序按照聲明的順序來確定,而不是在初始化列表中的順序。
- 派生類構造函數。
析構函數正好和構造函數相反。
2.3 虛函數的實現原理
虛函數表:
編譯器會為每個有虛函數的類創建一個虛函數表,該虛函數表將被該類的所有對象共享。類的虛函數表是一塊連續的內存,每個內存單元中記錄一個JMP指令的地址。類的每個虛函數占據虛函數表中的一塊,如果類中有N個虛函數,那么其虛函數表將有4N字節的大小。
編譯器在有虛函數的類的實例中創建了一個指向這個表的指針,該指針通常存在于對象實例中最前面的位置(這是為了保證取到虛函數表的有最高的性能)。這意味著可以通過對象實例的地址得到這張虛函數表,然后就可以遍歷其中函數指針,并調用相應的函數。
有虛函數或虛繼承的類實例化后的對象大小至少為4字節(確切的說是一個指針的字節數;說至少是因為還要加上其他非靜態數據成員,還要考慮對齊問題);沒有虛函數和虛繼承的類實例化后的對象大小至少為1字節(沒有非靜態數據成員的情況下也要有1個字節來記錄它的地址)。
哪些函數適合聲明為虛函數,哪些不能?
- 當存在類繼承并且析構函數中有必須要進行的操作時(如需要釋放某些資源,或執行特定的函數)析構函數需要是虛函數,否則若使用父類指針指向子類對象,在delete時只會調用父類的析構函數,而不能調用子類的析構函數,從而造成內存泄露或達不到預期結果;
- 內聯函數不能為虛函數:內聯函數需要在編譯階段展開,而虛函數是運行時動態綁定的,編譯時無法展開;
- 構造函數不能為虛函數:構造函數在進行調用時還不存在父類和子類的概念,父類只會調用父類的構造函數,子類調用子類的,因此不存在動態綁定的概念;但是構造函數中可以調用虛函數,不過并沒有動態效果,只會調用本類中的對應函數;
- 靜態成員函數不能為虛函數:靜態成員函數是以類為單位的函數,與具體對象無關,虛函數是與對象動態綁定的。
2.4 虛繼承的實現原理
為了解決從不同途徑繼承來的同名的數據成員在內存中有不同的拷貝造成數據不一致問題,將共同基類設置為虛基類。這時從不同的路徑繼承過來的同名數據成員在內存中就只有一個拷貝,同一個函數名也只有一個映射。這樣不僅就解決了二義性問題,也節省了內存,避免了數據不一致的問題。
構造函數和析構函數的順序:虛基類總是先于非虛基類構造,與它們在集成體系中的次序和位置無關。如果有多個虛基類,則按它們在派生列表中出現的順序從左到右依次構造。
#include <iostream>
using namespace std;
class zooAnimal
{
public: zooAnimal(){cout<<"zooAnimal construct"<<endl;}
};
class bear : virtual public zooAnimal
{
public: bear(){cout<<"bear construct"<<endl;}
};
class toyAnimal
{
public: toyAnimal(){cout<<"toyAnimal construct"<<endl;}
};
class character
{
public: character(){cout<<"character construct"<<endl;}
};
class bookCharacter : public character
{
public: bookCharacter(){cout<<"bookCharacter construct"<<endl;}
};
class teddyBear : public bookCharacter, public bear, virtual public toyAnimal
{
public: teddyBear(){cout<<"teddyBear construct"<<endl;}
};
int main()
{
teddyBear();
}
編譯器按照直接基類的聲明順序依次檢查,以確定其中是否含有虛基類。如果有,則先構造虛基類,然后按照聲明順序依次構造其他非虛基類。構造函數的順序是:zooAnimal, toyAnimal, character, bookCharacter, bear, teddyBear。析構過程與構造過程正好相反。
3. 內存管理
3.1 程序加載時的內存分布
在多任務操作系統中,每個進程都運行在一個屬于自己的虛擬內存中,而虛擬內存被分為許多頁,并映射到物理內存中,被加載到物理內存中的文件才能夠被執行。這里我們主要關注程序被裝載后的內存布局,其可執行文件包含了代碼段,數據段,BSS段,堆,棧等部分,其分布如下圖所示。
- 代碼段(.text):用來存放可執行文件的機器指令。存放在只讀區域,以防止被修改。
- 只讀數據段(.rodata):用來存放常量存放在只讀區域,如字符串常量、全局const變量等。
- 可讀寫數據段(.data):用來存放可執行文件中已初始化全局變量,即靜態分配的變量和全局變量。
- BSS段(.bss):未初始化的全局變量和局部靜態變量一般放在.bss的段里,以節省內存空間。
- 堆:用來容納應用程序動態分配的內存區域。當程序使用malloc或new分配內存時,得到的內存來自堆。堆通常位于棧的下方。
- 棧:用于維護函數調用的上下文。棧通常分配在用戶空間的最高地址處分配。
- 動態鏈接庫映射區:如果程序調用了動態鏈接庫,則會有這一部分。該區域是用于映射裝載的動態鏈接庫。
- 保留區:內存中受到保護而禁止訪問的內存區域。
3.2 堆與棧的區別
1. 申請管理方式
(1)棧:由編譯器自動管理,無需我們手工控制。
(2)堆:堆的申請和釋放工作由程序員控制,容易產生內存泄漏。
2. 申請后系統的響應
(1)棧:只要棧的剩余空間大于所申請空間,系統將為程序提供內存,否則將報異常提示棧溢出。
(2)堆:首先應該知道操作系統有一個記錄空閑內存地址的鏈表,當系統收到程序的申請時,會遍歷該鏈表,尋找第一個空間大于所申請空間的堆結點,然后將該結點從空閑結點鏈表中刪除,并將該結點的空間分配給程序,另外,對于大多數系統,會在這塊內存空間中的首地址處記錄本次分配的大小,這樣,代碼中的delete語句才能正確的釋放本內存空間。另外,由于找到的堆結點的大小不一定正好等于申請的大小,系統會自動的將多余的那部分重新放入空閑鏈表中。
3、申請大小的限制
(1)棧:在Windows下,棧是向低地址擴展的數據結構,是一塊連續的內存的區域。這句話的意思是棧頂的地址和棧的最大容量是系統預先規定好的,在WINDOWS下,棧的大小是1M(可修改),如果申請的空間超過棧的剩余空間時,將提示overflow。因此,能從棧獲得的空間較小。
(2)堆:堆是向高地址擴展的數據結構,是不連續的內存區域。這是由于系統是用鏈表來存儲的空閑內存地址的,自然是不連續的,而鏈表的遍歷方向是由低地址向高地址。堆的大小受限于計算機系統中有效的虛擬內存。由此可見,堆獲得的空間比較靈活,也比較大。
4、申請效率的比較
(1)棧由系統自動分配,速度較快。但程序員是無法控制的。
(2)堆是由new分配的內存,一般速度比較慢,而且容易產生內存碎片,不過用起來最方便。另外,在WINDOWS下,最好的方式是用VirtualAlloc分配內存,他不是在堆,也不是在棧是直接在進程的地址空間中保留一塊內存,雖然用起來最不方便。但是速度快,也最靈活。
5、棧和堆中的存儲內容
(1)棧:在函數調用時,第一個進棧的是主函數中后的下一條指令(函數調用語句的下一條可執行語句)的地址,然后是函數的各個參數,在大多數的C編譯器中,參數是由右往左入棧的,然后是函數中的局部變量。注意靜態變量是不入棧的。當本次函數調用結束后,局部變量先出棧,然后是參數,最后棧頂指針指向最開始存的地址,也就是主函數中的下一條指令,程序由該點繼續運行。
(2)堆:一般是在堆的頭部用一個字節存放堆的大小。堆中的具體內容由程序員安排。
總結:堆和棧相比,由于大量new/delete的使用,容易造成大量的內存碎片;并且可能引發用戶態和核心態的切換,內存的申請,代價變得更加昂貴。所以棧在程序中是應用最廣泛的,就算是函數的調用也利用棧去完成,函數調用過程中的參數,返回地址,ebp和局部變 量都采用棧的方式存放。所以,推薦大家盡量用棧,而不是用堆。雖然棧有如此眾多的好處,但是向堆申請內存更加靈活,有時候分配大量的內存空間,還是用堆好一些。
3.3 常見的內存錯誤及其對策
內存分配未成功,卻使用了它,因為沒有意識到內存分配會不成功。
解決辦法:在使用內存之前檢查指針是否為NULL。如果指針p是函數的參數,那么在函數的入口處用assert(p!=NULL)進行檢查。如果是用malloc或new來申請內存,應該用if(p==NULL) 或if(p!=NULL)進行防錯處理。內存分配雖然成功,但是尚未初始化就引用它。犯這種錯誤主要有兩個起因:一是沒有初始化的觀念;二是誤以為內存的缺省初值全為零,導致引用初值錯誤(例如數組)。
解決方法:不要忘記為數組和動態內存賦初值,即便是賦零值也不可省略。防止將未被初始化的內存作為右值使用。內存分配成功并且已經初始化,但操作越過了內存的邊界。例如在使用數組時經常發生下標“多1”或者“少1”的操作。特別是在for循環語句中,循環次數很容易搞錯,導致數組操作越界。
解決方法:避免數組或指針的下標越界,特別要當心發生“多1”或者“少1”操作。忘記了釋放內存,造成內存泄露。含有這種錯誤的函數每被調用一次就丟失一塊內存。剛開始時系統的內存充足,你看不到錯誤。終有一次程序突然死掉,系統出現提示:內存耗盡。
解決方法:動態內存的申請與釋放必須配對,程序中malloc與free的使用次數一定要相同,否則肯定有錯誤(new/delete同理)。釋放了內存卻繼續使用它。有三種情況:(1)程序中的對象調用關系過于復雜,實在難以搞清楚某個對象究竟是否已經釋放了內存,此時應該重新設計數據結構,從根本上解決對象管理的混亂局面。(2)函數的return語句寫錯了,注意不要返回指向“棧內存”的“指針”或者“引用”,因為該內存在函數體結束時被自動銷毀。(3)使用free或delete釋放了內存后,沒有將指針設置為NULL。導致產生“野指針”。
解決方法:用free或delete釋放了內存之后,立即將指針設置為NULL,防止產生“野指針”。
3.4 智能指針
智能指針是在 <memory> 標頭文件中的std命名空間中定義的,該指針用于確保程序不存在內存和資源泄漏且是異常安全的。它們對RAII“獲取資源即初始化”編程至關重要,RAII的主要原則是為將任何堆分配資源(如動態分配內存或系統對象句柄)的所有權提供給其析構函數包含用于刪除或釋放資源的代碼以及任何相關清理代碼的堆棧分配對象。大多數情況下,當初始化原始指針或資源句柄以指向實際資源時,會立即將指針傳遞給智能指針。在C++11中,定義了3種智能指針(unique_ptr、shared_ptr、weak_ptr),并刪除了C++98中的auto_ptr。
智能指針的設計思想:將基本類型指針封裝為類對象指針(這個類肯定是個模板,以適應不同基本類型的需求),并在析構函數里編寫delete語句刪除指針指向的內存空間。
unique_ptr 只允許基礎指針的一個所有者。unique_ptr小巧高效;大小等同于一個指針且支持rvalue引用,從而可實現快速插入和對STL集合的檢索。
shared_ptr采用引用計數的智能指針,主要用于要將一個原始指針分配給多個所有者(例如,從容器返回了指針副本又想保留原始指針時)的情況。當所有的shared_ptr所有者超出了范圍或放棄所有權,才會刪除原始指針。大小為兩個指針;一個用于對象,另一個用于包含引用計數的共享控制塊。最安全的分配和使用動態內存的方法是調用make_shared標準庫函數,此函數在動態分配內存中分配一個對象并初始化它,返回對象的shared_ptr。
智能指針支持的操作
- 使用重載的
->
和*
運算符訪問對象。 - 使用get成員函數獲取原始指針,提供對原始指針的直接訪問。你可以使用智能指針管理你自己的代碼中的內存,還能將原始指針傳遞給不支持智能指針的代碼。
- 使用刪除器定義自己的釋放操作。
- 使用release成員函數的作用是放棄智能指針對指針的控制權,將智能指針置空,并返回原始指針。(只支持unique_ptr)
- 使用reset釋放智能指針對對象的所有權。
智能指針的使用示例:
#include <iostream>
#include <string>
#include <memory>
using namespace std;
class base
{
public:
base(int _a): a(_a) {cout<<"構造函數"<<endl;}
~base() {cout<<"析構函數"<<endl;}
int a;
};
int main()
{
unique_ptr<base> up1(new base(2));
// unique_ptr<base> up2 = up1; //編譯器提示未定義
unique_ptr<base> up2 = move(up1); //轉移對象的所有權
// cout<<up1->a<<endl; //運行時錯誤
cout<<up2->a<<endl; //通過解引用運算符獲取封裝的原始指針
up2.reset(); // 顯式釋放內存
shared_ptr<base> sp1(new base(3));
shared_ptr<base> sp2 = sp1; //增加引用計數
cout<<"共享智能指針的數量:"<<sp2.use_count()<<endl; //2
sp1.reset(); //
cout<<"共享智能指針的數量:"<<sp2.use_count()<<endl; //1
cout<<sp2->a<<endl;
auto sp3 = make_shared<base>(4);//利用make_shared函數動態分配內存
}
4. C++對象內存模型
在C++中有兩種類的數據成員:static和nonstatic,以及三種類的成員函數:static、nonstatic和virtual。在C++對象模型中,非靜態數據成員被配置于每一個類的對象之中,靜態數據成員則被存放在所有的類對象之外;靜態及非靜態成員函數也被放在類對象之外,虛函數則通過以下兩個步驟支持:
- 每一個類產生出一堆指向虛函數的指針,放在表格之中,這個表格被稱為虛函數表(virtual table, vtbl)。
- 每一個類對象被添加了一個指針,指向相關的虛函數表,通常這個指針被稱為vptr。vptr的設定和重置都由每一個類的構造函數、析構函數和拷貝賦值運算符自動完成。另外,虛函數表地址的前面設置了一個指向type_info的指針,RTTI(Run Time Type Identification)運行時類型識別是由編譯器在編譯器生成的特殊類型信息,包括對象繼承關系,對象本身的描述,RTTI是為多態而生成的信息,所以只有具有虛函數的對象在會生成。
4.1 繼承下的對象內存模型
C++支持單一繼承、多重繼承和虛繼承。在虛繼承的情況下,虛基類不管在繼承鏈中被派生多少次,永遠只會存在一個實體。
單一繼承,繼承關系為class Derived : public Base
。其對象的內存布局為:虛函數表指針、Base類的非static成員變量、Derived類的非static成員變量。
多重繼承,繼承關系為class Derived : public Base1, public Base2
。其對象的內存布局為:基類Base1子對象和基類Base2子對象及Derived類的非static成員變量組成。基類子對象包括其虛函數表指針和其非static的成員變量。
重復繼承,繼承關系如下。Derived類的對象的內存布局與多繼承相似,但是可以看到基類Base的子對象在Derived類的對象的內存中存在一份拷貝。這樣直接使用Derived中基類Base的相關成員時,就會引發歧義,可使用多重虛擬繼承消除之。
class Base1 : public Base
class Base2: public Base
class Derived : public Base1, public Base2
虛繼承,繼承關系如下。其對象的內存布局與重復繼承的類的對象的內存分布類似,但是基類Base的子對象沒有拷貝一份,在對象的內存中僅存在在一個Base類的子對象。但是它的非static成員變量放置在對象的末尾處。
class Base1 : virtual public Base
class Base2: virtual public Base
class Derived : public Base1, public Base2
5. 常見的設計模式
5.1 單例模式
當僅允許類的一個實例在應用中被創建的時候,我們使用單例模式(Singleton Pattern)。它保護類的創建過程來確保只有一個實例被創建,它通過設置類的構造方法為私有(private)來實現。要獲得類的實例,單例類可以提供一個方法,如GetInstance(),來返回類的實例。該方法是唯一可以訪問類來創建實例的方法。
優點:(1)由于單例模式在內存中只有一個實例,減少了內存開支,特別是一個對象需要頻繁地創建、銷毀時,而且創建或銷毀時性能又無法優化,單例模式的優勢就非常明顯。(2)減少了系統的性能開銷,當一個對象的產生需要比較多的資源時,如讀取配置、產生其他依賴對象時,則可以通過在應用啟動時直接產生一個單例對象,然后永久駐留內存的方式來解決。(3)避免對資源的多重占用。如避免對同一個資源文件的同時寫操作。(4)單例模式可以在系統設置全局的訪問點,優化和共享資源訪問。
缺點:單例模式一般沒有接口,擴展困難。不利于測試。
使用場景:(1)在整個項目中需要一個共享訪問點或共享數據。(2)創建一個對象需要消耗的資源過多,如要訪問IO和數據庫等資源。(3)需要定義大量的靜態常量和靜態方法的環境。
實現:懶漢實現與餓漢實現
懶漢實現,即實例化在對象首次被訪問時進行。可以使用類的私有靜態指針變量指向類的唯一實例,并用一個公有的靜態方法獲取該實例。同時需將默認構造函數聲明為private,防止用戶調用默認構造函數創建對象。
//Singleton.h
class Singleton
{
public:
static Singleton* GetInstance();
private:
Singleton() {}
static Singleton *m_pInstance;
};
//Singleton.cpp
Singleton* Singleton::m_pInstance = NULL;
Singleton* Singleton::GetInstance()
{
if (m_Instance == NULL)
{
Lock();
if (m_Instance == NULL)
{
m_Instance = new Singleton();
}
UnLock();
}
return m_pInstance;
}
該類有以下特征:
- 它的構造函數是私有的,這樣就不能從別處創建該類的實例。
- 它有一個唯一實例的靜態指針m_pInstance,且是私有的。
- 它有一個公有的函數,可以獲取這個唯一的實例,并在需要的時候創建該實例。
此處進行了兩次m_Instance == NULL的判斷,是借鑒了Java的單例模式實現時,使用的所謂的“雙檢鎖”機制。因為進行一次加鎖和解鎖是需要付出對應的代價的,而進行兩次判斷,就可以避免多次加鎖與解鎖操作,同時也保證了線程安全。
上面的實現存在一個問題,就是沒有提供刪除對象的方法。一個妥善的方法是讓這個類自己知道在合適的時候把自己刪除。程序在結束的時候,系統會自動析構所有的全局變量。事實上,系統也會析構所有的類的靜態成員變量,就像這些靜態成員也是全局變量一樣。利用這個特征,我們可以在單例類中定義一個這樣的靜態成員變量,而它的唯一工作就是在析構函數中刪除單例類的實例。如下面的代碼中的CGarbo類(Garbo意為垃圾工人):
class Singleton
{
public:
static Singleton* GetInstance() {}
private:
Singleton() {};
static Singleton *m_pInstance;
//CGarbo類的唯一工作就是在析構函數中刪除CSingleton的實例
class CGarbo
{
public:
~CGarbo()
{
if (Singleton::m_pInstance != NULL)
delete Singleton::m_pInstance;
}
};
//定義一個靜態成員,在程序結束時,系統會調用它的析構函數
static CGarbo Garbo;
};
類CGarbo被定義為Singleton的私有內嵌類,以防該類被在其他地方濫用。程序運行結束時,系統會調用Singleton的靜態成員Garbo的析構函數,該析構函數會刪除單例的唯一實例。
餓漢實現方法:在程序開始時就自行創建實例。如果說懶漢實現是“時間換空間”,那么餓漢實現就是“空間換時間”,因為一開始就創建了實例,所以每次用到的之后直接返回就好了。
//Singleton.h
class Singleton
{
public:
static Singleton* GetInstance();
private:
Singleton() {}
static Singleton *m_pInstance;
class CGarbo
{
public:
~CGarbo()
{
if (Singleton::m_pInstance != NULL)
delete Singleton::m_pInstance;
}
};
static CGarbo garbo;
};
//Singleton.cpp
Singleton* Singleton::m_pInstance = new Singleton();
Singleton* Singleton::GetInstance()
{
return m_pInstance;
}
5.2 簡單工廠模式
簡單工廠模式的主要特點是需要在工廠類中做判斷,從而創造相應的產品。當增加新的產品時,就需要修改工廠類。
例子:有一家生產處理器核的廠家,它只有一個工廠,能夠生產兩種型號的處理器核。客戶需要什么樣的處理器核,一定要顯式地告訴生產工廠。
enum CTYPE {COREA, COREB};
class SingleCore
{
public:
virtual void Show() = 0;
};
//單核A
class SingleCoreA: public SingleCore
{
public:
void Show() { cout<<"SingleCore A"<<endl; }
};
//單核B
class SingleCoreB: public SingleCore
{
public:
void Show() { cout<<"SingleCore B"<<endl; }
};
//唯一的工廠,可以生產兩種型號的處理器核,在內部判斷
class Factory
{
public:
SingleCore* CreateSingleCore(enum CTYPE ctype)
{
if (ctype == COREA) //工廠內部判斷
return new SingleCoreA(); //生產核A
else if (ctype == COREB)
return new SingleCoreB(); //生產核B
else
return NULL;
}
};
這樣設計的主要缺點之前也提到過,就是要增加新的核類型時,就需要修改工廠類。這就違反了開放封閉原則:軟件實體(類、模塊、函數)可以擴展,但是不可修改。于是,工廠方法模式出現了。
5.3 工廠方法模式
工廠方法模式是指定義一個用于創建對象的接口,讓子類決定實例化哪一個類。工廠方法模式使一個類的實例化延遲到其子類。
例子:這家生產處理器核的廠家賺了不少錢,于是決定再開設一個工廠專門用來生產B型號的單核,而原來的工廠專門用來生產A型號的單核。這時,客戶要做的是找好工廠,比如要A型號的核,就找A工廠要;否則找B工廠要,不再需要告訴工廠具體要什么型號的處理器核了。
class SingleCore
{
public:
virtual void Show() = 0;
};
//單核A
class SingleCoreA: public SingleCore
{
public:
void Show() { cout<<"SingleCore A"<<endl; }
};
//單核B
class SingleCoreB: public SingleCore
{
public:
void Show() { cout<<"SingleCore B"<<endl; }
};
class Factory
{
public:
virtual SingleCore* CreateSingleCore() = 0;
};
//生產A核的工廠
class FactoryA: public Factory
{
public:
SingleCoreA* CreateSingleCore() { return new SingleCoreA(); }
};
//生產B核的工廠
class FactoryB: public Factory
{
public:
SingleCoreB* CreateSingleCore() { return new SingleCoreB(); }
};
工廠方法模式也有缺點,每增加一種產品,就需要增加一個對象的工廠。如果這家公司發展迅速,推出了很多新的處理器核,那么就要開設相應的新工廠。在C++實現中,就是要定義一個個的工廠類。顯然,相比簡單工廠模式,工廠方法模式需要更多的類定義。
5.4 抽象工廠模式
抽象工廠模式的定義為提供一個創建一系列相關或相互依賴對象的接口,而無需指定它們具體的類。
例子:這家公司的技術不斷進步,不僅可以生產單核處理器,也能生產多核處理器。現在簡單工廠模式和工廠方法模式都鞭長莫及。這家公司還是開設兩個工廠,一個專門用來生產A型號的單核多核處理器,而另一個工廠專門用來生產B型號的單核多核處理器。
//單核
class SingleCore
{
public:
virtual void Show() = 0;
};
class SingleCoreA: public SingleCore
{
public:
void Show() { cout<<"Single Core A"<<endl; }
};
class SingleCoreB :public SingleCore
{
public:
void Show() { cout<<"Single Core B"<<endl; }
};
//多核
class MultiCore
{
public:
virtual void Show() = 0;
};
class MultiCoreA : public MultiCore
{
public:
void Show() { cout<<"Multi Core A"<<endl; }
};
class MultiCoreB : public MultiCore
{
public:
void Show() { cout<<"Multi Core B"<<endl; }
};
//工廠
class CoreFactory
{
public:
virtual SingleCore* CreateSingleCore() = 0;
virtual MultiCore* CreateMultiCore() = 0;
};
//工廠A,專門用來生產A型號的處理器
class FactoryA :public CoreFactory
{
public:
SingleCore* CreateSingleCore() { return new SingleCoreA(); }
MultiCore* CreateMultiCore() { return new MultiCoreA(); }
};
//工廠B,專門用來生產B型號的處理器
class FactoryB : public CoreFactory
{
public:
SingleCore* CreateSingleCore() { return new SingleCoreB(); }
MultiCore* CreateMultiCore() { return new MultiCoreB(); }
};