1. 程式人生 > >C++ 面試知識點總結

C++ 面試知識點總結

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的作用

  1. 在定義常變數時必須同時對它初始化,此後它的值不能再改變。常變數不能出現在賦值號的左邊(不為“左值”);
  2. 對指標來說,可以指定指標本身為const,也可以指定指標所指的資料為const,或二者同時指定為const;
  3. 在一個函式宣告中,const可以修飾形參,表明它是一個輸入引數,在函式內部不能改變其值;
  4. 對於類的成員函式,若指定其為const型別,則表明其是一個常函式,不能修改類的成員變數;
  5. 對於類的成員函式,有時候必須指定其返回值為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的區別

  1. const常量有資料型別,而巨集常量沒有資料型別。編譯器可以對前者進行型別安全檢查。而對後者只進行字元替換,沒有型別安全檢查,並且在字元替換可能會產生意料不到的錯誤(邊際效應)。
  2. 有些整合化的除錯工具可以對const常量進行除錯,但是不能對巨集常量進行除錯。
  3. 在C++程式中只使用const常量而不使用巨集常量,即const常量完全取代巨集常量。

1.5 陣列與指標的區別

  1. 陣列要麼在靜態儲存區被建立(如全域性陣列),要麼在棧上被建立。指標可以隨時指向任意型別的記憶體塊。
  2. 用運算子sizeof可以計算出陣列的容量(位元組數)。sizeof(p),p為指標得到的是一個指標變數的位元組數,而不是p所指的記憶體容量。C/C++語言沒有辦法知道指標所指的記憶體容量,除非在申請記憶體時記住它。
  3. 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. 陣列型別,其結果是陣列的總位元組數;指向陣列的指標,其結果是該指標的位元組數。
  2. 函式中的陣列形參函式型別的形參,其結果是指標的位元組數。
  3. 聯合型別,其結果採用成員最大長度對齊。
  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):

  1. 安全的基類和子類之間轉換。
  2. 必須要有虛擬函式。
  3. 相同基類不同子類之間的交叉轉換。但結果是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的區別

  1. malloc與free是C++/C語言的標準庫函式,new/delete是C++的運算子。它們都可用於申請和釋放動態記憶體。
  2. 對於非內部資料型別的物件而言,用maloc/free無法滿足動態物件的要求。物件在建立的同時要自動執行建構函式,物件在消亡之前要自動執行解構函式。由malloc/free是庫函式而不是運算子,不在編譯器控制權限之內,不能夠把執行建構函式和解構函式的任務強加於malloc/free,因此C++語言需要一個能完成動態記憶體分配和初始化工作的運算子new,和一個能完成清理與釋放記憶體工作的運算子delete。
  3. new可以認為是malloc加建構函式的執行。new出來的指標是直接帶型別資訊的。而malloc返回的都是void*指標。newdelete在實現上其實呼叫了malloc,free函式。
  4. 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 派生類中建構函式與解構函式,呼叫順序

建構函式的呼叫順序總是如下:

  1. 基類建構函式。如果有多個基類,則建構函式的呼叫順序是某類在類派生表中出現的順序,而不是它們在成員初始化表中的順序。
  2. 成員類物件建構函式。如果有多個成員類物件則建構函式的呼叫順序是物件在類中被宣告的順序,而不是它們出現在成員初始化表中的順序。如果有的成員不是類物件,而是基本型別,則初始化順序按照宣告的順序來確定,而不是在初始化列表中的順序。
  3. 派生類建構函式。

解構函式正好和建構函式相反。

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 常見的記憶體錯誤及其對策

  1. 記憶體分配未成功,卻使用了它,因為沒有意識到記憶體分配會不成功。
    解決辦法:在使用記憶體之前檢查指標是否為NULL。如果指標p是函式的引數,那麼在函式的入口處用assert(p!=NULL)進行檢查。如果是用malloc或new來申請記憶體,應該用if(p==NULL) 或if(p!=NULL)進行防錯處理。

  2. 記憶體分配雖然成功,但是尚未初始化就引用它。犯這種錯誤主要有兩個起因:一是沒有初始化的觀念;二是誤以為記憶體的預設初值全為零,導致引用初值錯誤(例如陣列)。
    解決方法:不要忘記為陣列和動態記憶體賦初值,即便是賦零值也不可省略。防止將未被初始化的記憶體作為右值使用。

  3. 記憶體分配成功並且已經初始化,但操作越過了記憶體的邊界。例如在使用陣列時經常發生下標“多1”或者“少1”的操作。特別是在for迴圈語句中,迴圈次數很容易搞錯,導致陣列操作越界。
    解決方法:避免陣列或指標的下標越界,特別要當心發生“多1”或者“少1”操作。

  4. 忘記了釋放記憶體,造成記憶體洩露。含有這種錯誤的函式每被呼叫一次就丟失一塊記憶體。剛開始時系統的記憶體充足,你看不到錯誤。終有一次程式突然死掉,系統出現提示:記憶體耗盡。
    解決方法:動態記憶體的申請與釋放必須配對,程式中malloc與free的使用次數一定要相同,否則肯定有錯誤(new/delete同理)。

  5. 釋放了記憶體卻繼續使用它。有三種情況:(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++物件模型中,非靜態資料成員被配置於每一個類的物件之中,靜態資料成員則被存放在所有的類物件之外;靜態及非靜態成員函式也被放在類物件之外,虛擬函式則通過以下兩個步驟支援:

  1. 每一個類產生出一堆指向虛擬函式的指標,放在表格之中,這個表格被稱為虛擬函式表(virtual table, vtbl)。
  2. 每一個類物件被添加了一個指標,指向相關的虛擬函式表,通常這個指標被稱為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;
}

該類有以下特徵:

  1. 它的建構函式是私有的,這樣就不能從別處建立該類的例項。
  2. 它有一個唯一例項的靜態指標m_pInstance,且是私有的。
  3. 它有一個公有的函式,可以獲取這個唯一的例項,並在需要的時候建立該例項。

此處進行了兩次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(); }
};

6. 深入理解C++11



作者:Mr希靈
連結:https://www.jianshu.com/p/cc1bdada166f
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。