1. 程式人生 > 實用技巧 >C++面試常見問題彙總

C++面試常見問題彙總

引自:https://blog.csdn.net/qq_22238021/article/details/79779574

一、extern關鍵字作用

1、extern用在變數或者函式的宣告前,用來說明此變數/函式是在別處定義的,要在此處引用extern宣告不是定義,即不分配儲存空間。也就是說,在一個檔案中定義了變數和函式, 在其他檔案中要使用它們, 可以有兩種方式:使用標頭檔案,然後宣告它們,然後其他檔案去包含標頭檔案;在其他檔案中直接extern。

2、extern C作用

連結指示符extern C
如果程式設計師希望呼叫其他程式設計語言尤其是C寫的函式,那麼呼叫函式時必須告訴編譯器使用不同的要求,例如當這樣的函式被呼叫時函式名或引數排列的順序可能

不同,無論是C++函式呼叫它還是用其他語言寫的函式呼叫它,程式設計師用連結指示符告訴編譯器該函式是用其他的程式設計語言編寫的,連結指示符有兩種形式既可以是單一語句形式也可以是複合語句形式。
//
單一語句形式的連結指示符
extern "C" void exit(int);
//
複合語句形式的連結指示符
extern "C" {
int printf( const char* ... );
int scanf( const char* ... );
}
//
複合語句形式的連結指示符
extern "C" {
#include <cmath>
}
連結指示符的第一種形式由關鍵字extern後跟一個字串常量以及一個普通的函式,宣告構成雖然函式是用另外一種語言編寫的但呼叫它仍然需要型別檢查例如編譯器會檢查傳遞給函式
exit()的實參的型別是否是int或者能夠隱式地轉換成int型,多個函式宣告可以用花括號包含在連結指示符複合語句中,這是連結指示符的第二種形式花擴號被用作分割符表示連結指示符應用在哪些宣告上在其他意義上該花括號被忽略,所以在花括號中宣告的函式名對外是可見的就好像函式是在複合語句外宣告的一樣,例如在前面的例子中複合語句extern "C"表示函式printf()scanf()是在C語言中寫的,函式因此這個宣告的意義就如同printf()scanf()是在extern "C"複合語句外面宣告的一樣,當複合語句連結指示符的括號中含有#include時,在標頭檔案中的函式宣告都被假定是用連結指示符的程式設計語言所寫的,在前面的例子中在標頭檔案
<cmath>中宣告的函式都是C函式連結指示符不能出現在函式體中下列程式碼段將會導致編譯錯誤。
int main()
{
//
錯誤:連結指示符不能出現在函式內
extern "C" double sqrt( double );
305
第七章函式
double getValue(); //ok
double result = sqrt ( getValue() );
//...
return 0;
}
如果把連結指示符移到函式體外程式編譯將無錯誤
extern "C" double sqrt( double );
int main()
{
double getValue(); //ok
double result = sqrt ( getValue() );
//...
return 0;
}
但是把連結指示符放在標頭檔案中更合適,在那裡函式宣告描述了函式的介面所屬,如果我們希望C++函式能夠為C程式所用又該怎麼辦呢我們也可以使用extern "C"連結指示符來使C++函式為C程式可用例如。
//
函式calc()可以被C程式呼叫
extern "C" double calc( double dparm ) { /* ... */ }
如果一個函式在同一檔案中不只被宣告一次則連結指示符可以出現在每個宣告中它,也可以只出現在函式的第一次宣告中,在這種情況下第二個及以後的宣告都接受第一個聲明中連結指示符指定的連結規則例如
// ---- myMath.h ----
extern "C" double calc( double );
// ---- myMath.C ----
//
Math.h中的calc()的宣告
#include "myMath.h"
//
定義了extern "C" calc()函式
// calc()
可以從C程式中被呼叫
double calc( double dparm ) { // ...
在本節中我們只看到為C語言提供的連結指示extern "C"extern "C"是惟一被保證由所有C++實現都支援的,每個編譯器實現都可以為其環境下常用的語言提供其他連結指示例如extern "Ada"可以用來宣告是用Ada語言寫的函式,extern "FORTRAN"用來宣告是用FORTRAN語言寫的函式,等等因為其他的連結指示隨著具體實現的不同而不同所以建議讀者檢視編譯器的使用者指南以獲得其他連結指示符的進一步資訊。

總結extern “C”

extern “C”不但具有傳統的宣告外部變數的功能,還具有告知C++連結器使用C函式規範來連結的功能。還具有告知C++編譯器使用C規範來命名的功能。

二、static關鍵字作用

1、隱藏變數

當兩個檔案中存在全域性變數時,通過extern關鍵字可以引用不同檔案中的變數。如果加入static關鍵字,全域性變數的作用域在檔案內,其他檔案無法訪問。利用這一特性可以在不同的檔案中定義同名函式和同名變數,而不必擔心命名衝突。static可以用作函式和變數的字首,對於函式來講,static的作用僅限於隱藏.

2、靜態全域性變數

靜態全域性變數具有全域性作用域,如果程式包含多個檔案的話,它作用於定義它的檔案裡,不能作用到其它檔案裡,即被static關鍵字修飾過的變數具有檔案作用域。這樣即使兩個不同的原始檔都定義了相同名字的靜態全域性變數,它們也是不同的變數。

靜態全域性變數在靜態儲存區分配空間,在程式剛開始執行時就完成初始化,也是唯一的一次初始化

全域性變數具有全域性作用域。全域性變數只需在一個原始檔中定義,就可以作用於所有的原始檔。當然,其他不包含全域性變數定義的原始檔需要用extern 關鍵字再次宣告這個全域性變數。

區域性變數也只有區域性作用域,它是自動物件(auto),它在程式執行期間不是一直存在,而是隻在函式執行期間存在,函式的一次呼叫執行結束後,變數被撤銷,其所佔用的記憶體也被收回。

靜態區域性變數具有區域性作用域,它只被初始化一次,自從第一次被初始化直到程式執行結束都一直存在,它和全域性變數的區別在於全域性變數對所有的函式都是可見的,而靜態區域性變數只對定義自己的函式體始終可見。

靜態全域性變數也具有全域性作用域,它與全域性變數的區別在於如果程式包含多個檔案的話,它作用於定義它的檔案裡,不能作用到其它檔案裡,即被static關鍵字修飾過的變數具有檔案作用域。這樣即使兩個不同的原始檔都定義了相同名字的靜態全域性變數,它們也是不同的變數。

從分配記憶體空間看:
全域性變數,靜態區域性變數,靜態全域性變數都在靜態儲存區分配空間,而區域性變數在棧裡分配空間。

從以上分析可以看出, 把區域性變數改變為靜態變數後是改變了它的儲存方式即改變了它的生存期。把全域性變數改變為靜態變數後是改變了它的作用域,限制了它的使用範圍。因此static這個說明符在不同的地方所起的作用是不同的。

3、預設初始化為0

全域性變數也具備這一屬性,因為全域性變數也儲存在靜態資料區。在靜態資料區,記憶體中所有的位元組預設值都是0x00,

最後對static的三條作用做一句話總結。首先static的最主要功能是隱藏,其次因為static變數存放在靜態儲存區,所以它具備永續性和預設值0.

4、修飾普通函式

static修飾一個函式,則這個函式只能在本檔案中呼叫,不能被同一程式其他檔案呼叫。則其他檔案可以定義相同名字的函式,不會發生衝突。

5、修飾成員變數

靜態資料成員是類的成員,而不是物件的成員,在類中宣告static變數或者函式時,初始化時使用作用域運算子來標明它所屬類,

(1)靜態資料成員可以實現多個物件之間的資料共享,它是類的所有物件的共享成員,它在記憶體中只佔一份空間,如果改變它的值,則各物件中這個資料成員的值都被改變。
(2)靜態資料成員是靜態儲存的,是在程式開始執行時被分配空間,到程式結束之後才釋放,只要類中指定了靜態資料成員,即使不定義物件,也會為靜態資料成員分配空間。非靜態成員(變數和方法)屬於類的物件,所以只有在類的物件產生(建立類的例項)時才會分配記憶體,然後通過類的物件(例項)去訪問。

(3)靜態資料成員可以被初始化,但是隻能在類體外進行初始化,若未對靜態資料成員賦初值,則編譯器會自動為其初始化為0

靜態成員初始化與一般資料成員初始化不同:
   初始化在類體外進行,而前面不加static,以免與一般靜態變數或物件相混淆;
   初始化時不加該成員的訪問許可權控制符private,public等;
   初始化時使用作用域運算子來標明它所屬類;
   所以我們得出靜態資料成員初始化的格式:
<資料型別><類名>::<靜態資料成員名>=<值>

(4)靜態資料成員既可以通過物件名引用,也可以通過類名引用。

6、修飾成員函式

(1)類的靜態成員函式是屬於整個類而非類的物件,所以它沒有this指標,這就導致了它僅能訪問類的靜態資料成員和靜態成員函式。

(2)不能將靜態成員函式定義為虛擬函式:

虛擬函式依靠vptr和vtable來處理。vptr是一個指標,在類的建構函式中建立生成,並且只能用this指標來訪問它,因為它是類的一個成員,並且vptr指向儲存虛擬函式地址的vtable. 對於靜態成員函式,它沒有this指標,所以無法訪問vptr. 這就是為何static函式不能為virtual. 虛擬函式的呼叫關係:this -> vptr -> vtable ->virtual function

(3)由於靜態成員聲明於類中,操作於其外,所以對其取地址操作,就多少有些特殊,變數地址是指向其資料型別的指標,函式地址型別是一個“nonmember函式指標”。

(4)由於靜態成員函式沒有this指標,所以就差不多等同於nonmember函式,結果就產生了一個意想不到的好處:成為一個callback函式,使得我們得以將C++和C-based X Window系統結合,同時也成功的應用於執行緒函式身上。
(5)static並沒有增加程式的時空開銷,相反它還縮短了子類對父類靜態成員的訪問時間,節省了子類的記憶體空間。
(9)為了防止父類的影響,可以在子類定義一個與父類相同的靜態變數,以遮蔽父類的影響。這裡有一點需要注意:我們說靜態成員為父類和子類共享,但我們又重複定義了靜態成員,這會不會引起錯誤呢?不會,我們的編譯器採用了一種絕妙的手法:name-mangling 用以生成唯一的標誌

三、volatile關鍵字

(1)訪問暫存器要比訪問記憶體要塊,因此CPU會優先訪問該資料在暫存器中的儲存結果,但是記憶體中的資料可能已經發生了改變,而暫存器中還保留著原來的結果。為了避免這種情況的發生將該變數宣告為volatile,告訴CPU每次都從記憶體去讀取資料。

(2)一個引數可以即是const又是volatile的嗎?可以,一個例子是隻讀狀態暫存器,是volatile是因為它可能被意想不到的被改變,是const告訴程式不應該試圖去修改他。

volatile 關鍵字是一種型別修飾符,用它宣告的型別變量表示可以被某些編譯器未知的因素更改,比如:作業系統、硬體或者其它執行緒等。遇到這個關鍵字宣告的變數,編譯器對訪問該變數的程式碼就不再進行優化,從而可以提供對特殊地址的穩定訪問。宣告時語法:int volatile vInt; 當要求使用 volatile 宣告的變數的值的時候,系統總是重新從它所在的記憶體讀取資料,即使它前面的指令剛剛從該處讀取過資料。而且讀取的資料立刻被儲存。

volatileintiNum=10;

volatile 指出 iNum 是隨時可能發生變化的,每次使用它的時候必須從原始記憶體地址中去讀取,因而編譯器生成的彙編程式碼會重新從iNum的原始記憶體地址中去讀取資料。而不是隻要編譯器發現iNum的值沒有發生變化,就只讀取一次資料,並放入暫存器中,下次直接從暫存器中去取值(優化做法),而是重新從記憶體中去讀取(不再優化).

多執行緒併發訪問共享變數時,一個執行緒改變了變數的值,怎樣讓改變後的值對其它執行緒 visible。一般說來,volatile用在如下的幾個地方:
1) 中斷服務程式中修改的供其它程式檢測的變數需要加volatile;
2) 多工環境下各任務間共享的標誌應該加volatile;

3) 儲存器對映的硬體暫存器通常也要加volatile說明,因為每次對它的讀寫都可能由不同意義;

volatile 指標

和 const 修飾詞類似,const 有常量指標和指標常量的說法,volatile 也有相應的概念:

  • 修飾由指標指向的物件、資料是 const 或 volatile 的:

    constchar* cpch;
    volatilechar* vpch;

    注意:對於 VC,這個特性實現在 VC 8 之後才是安全的。

  • 指標自身的值——一個代表地址的整數變數,是 const 或 volatile 的:

char* constpchc;

char* volatilepchv;

注意:(1)可以把一個非volatile int賦給volatile int,但是不能把非volatile物件賦給一個volatile物件

   (2) 除了基本型別外,對使用者定義型別也可以用volatile型別進行修飾。
(3) C++中一個有volatile識別符號的類只能訪問它介面的子集,一個由類的實現者控制的子集。使用者只能用const_cast來獲得對型別介面的完全訪問。此外,volatile像const一樣會從類傳遞到它的成員。

多執行緒下的volatile

有些變數是用volatile關鍵字宣告的。當兩個執行緒都要用到某一個變數且該變數的值會被改變時,應該用volatile宣告,該關鍵字的作用是防止優化編譯器把變數從記憶體裝入CPU暫存器中。如果變數被裝入暫存器,那麼兩個執行緒有可能一個使用記憶體中的變數,一個使用暫存器中的變數,這會造成程式的錯誤執行。volatile的意思是讓編譯器每次操作該變數時一定要從記憶體中真正取出,而不是使用已經存在暫存器中的值,如下:

volatileBOOLbStop=FALSE;
(1)在一個執行緒中:
while(!bStop){...}
bStop=FALSE;
return;
(2)在另外一個執行緒中,要終止上面的執行緒迴圈:
bStop=TRUE;
while(bStop);//等待上面的執行緒終止,如果bStop不使用volatile申明,那麼這個迴圈將是一個死迴圈,因為bStop已經讀取到了暫存器中,暫存器中bStop的值永遠不會變成FALSE,加上volatile,程式在執行時,每次均從記憶體中讀出bStop的值,就不會死迴圈了。
這個關鍵字是用來設定某個物件的儲存位置在記憶體中,而不是暫存器中。因為一般的物件編譯器可能會將其的拷貝放在暫存器中用以加快指令的執行速度,例如下段程式碼中:
...
intnMyCounter=0;
for(;nMyCounter<100;nMyCounter++)
{
...
}
...
在此段程式碼中,nMyCounter的拷貝可能存放到某個暫存器中(迴圈中,對nMyCounter的測試及操作總是對此暫存器中的值進行),但是另外又有段程式碼執行了這樣的操作:nMyCounter-=1;這個操作中,對nMyCounter的改變是對記憶體中的nMyCounter進行操作,於是出現了這樣一個現象:nMyCounter的改變不同步。

四、const的作用

只要一個變數前面用const來修飾,就意味著該變數裡的資料可以被訪問,不能被修改。也就是說const意味著“只讀” 規則:const離誰近,誰就不能被修改; const修飾一個變數,一定要給這個變數初始化值,若不初始化,後面就無法初始化。

const修飾全域性變數;

const修飾區域性變數;

const修飾指標,const int *p1或int const *p1;p1指向的資料不能被修改,但p1本身的值可以被修改(指向其他資料)const修飾指標指向的物件, int * const p2;p2本身的值不能被修改,但它指向的資料可以被修改。(const int *const p3;指標和所指向資料都是常量)const修飾引用做形參;

const 通常用在函式形參中,如果形參是一個指標,為了防止在函式內部修改指標指向的資料,就可以用 const 來限制。

在C語言標準庫中,有很多函式的形參都被 const 限制了,下面是部分函式的原型:

  1. size_tstrlen(constchar* str);
  2. intstrcmp(constchar* str1,constchar* str2);
  3. char*strcat(char* destination,constchar* source);
  4. char*strcpy(char* destination,constchar* source);
  5. intsystem(constchar* command);
  6. intputs(constchar* str);
  7. intprintf(constchar* format,...);

const修飾成員變數,必須在建構函式列表中初始化;const修飾成員函式,常量成員函式,說明該函式不應該修改非靜態成員,但是這並不是十分可靠的,指標所指的非成員物件值可能會被改變。
const成員函式可以被const或非const物件呼叫,但const物件只能呼叫const成員函式對於類的成員函式,有時候必須指定其返回值為const型別,以使得其返回值不為“左值”,只能作為右值使用。

constint getNum();getNum() =10; // 提示語法錯誤!

五、new與malloc的區別

new分配記憶體按照資料型別進行分配,malloc分配記憶體按照大小分配; new不僅分配一段記憶體,而且會呼叫建構函式,但是malloc則不會。new的實現原理?但是還需要注意的是,之前看到過一個題說intp = new int與intp = new int()的區別,因為int屬於C++內建物件,不會預設初始化,必須顯示呼叫預設建構函式,但是對於自定義物件都會預設呼叫建構函式初始化。翻閱資料後,在C++11中兩者沒有區別了,自己測試的結構也都是為0; new返回的是指定物件的指標,而malloc返回的是void*,因此malloc的返回值一般都需要進行型別轉化; new是一個操作符可以過載,malloc是一個庫函式; new分配的記憶體要用delete銷燬,malloc要用free來銷燬;delete銷燬的時候會呼叫物件的解構函式,而free則不會;malloc分配的記憶體不夠的時候,可以用realloc擴容。擴容的原理?new沒用這樣操作;
realloc是從堆上分配記憶體的.當擴大一塊記憶體空間時,realloc()試圖直接從堆上現存的資料後面的那些位元組中獲得附加的位元組,如果能夠滿足,自然天下太平;如果資料後面的位元組不夠,那麼就使用堆上第一個有足夠大小的自由塊,現存的資料然後就被拷貝至新的位置,而老塊則放回到堆上.這句話傳遞的一個重要的資訊就是資料可能被移動.

  1. intlen=7;
  2. int*a=(int*)malloc(sizeof(int)*len);
  3. len++;
  4. int*aold=a;//重新分配前儲存a的地址這個是多餘的
  5. a=(int*)realloc(sizeof(int)*len);//重新分配28+4=32位元組記憶體給陣列a
    前面兩句定義了1個長度為7的int 型別陣列, 每個元素的位元組長度是4, 所以共佔28byte 記憶體.
    第3句長度變數+1
    第4句 分兩種情況:
    1) 假如陣列a 記憶體裡接著的4個位元組還沒被其他物件或程式佔用, 那麼就直接把後面4個位元組加給陣列a, 陣列前面7箇舊的元素的值不變, 陣列a的頭部地址也不變.

    2) 假如陣列 a記憶體裡接著的4個位元組已經被佔用了, 那麼realloc 函式會在記憶體其他地方找1個連續的32byte 記憶體空間, 並且把陣列a的7箇舊元素的值搬過去, 所以陣列a的7箇舊元素的值也不變, 但是陣列a的頭部地址變化了.但是這時我們無需手動把舊的記憶體空間釋放. 因為realloc 函式改變地址後會自動釋放舊的記憶體, 再手動釋放程式就會出錯了

new如果分配失敗了會丟擲bad_alloc的異常,而malloc失敗了會返回NULL。因此對於new,正確的姿勢是採用try...catch語法,而malloc則應該判斷指標的返回值。為了相容很多c程式設計師的習慣,C++也可以採用new(nothrow)的方法禁止丟擲異常而返回NULL:

  1. #include <new>//必須使用new標頭檔案
  2. Manager * pManager = new (nothrow) Manager();
  3. if(NULL == pManager)
  4. {
  5. //記錄日誌
  6. return false;
  7. }
  1. void *__CRTDECL operator new(size_t size) _THROW1(_STD bad_alloc)
  2. { // try to allocate size bytes
  3. void *p;
  4. while ((p = malloc(size)) == 0)
  5. if (_callnewh(size) == 0)
  6. { // report no memory
  7. _THROW_NCEE(_XSTD bad_alloc, );
  8. }
  9. return (p);
  10. }

呼叫malloc失敗後會呼叫_callnewh。如果_callnewh返回0則丟擲bac_alloc異常,返回非零則繼續分配記憶體。_callnewh是一個new handler,通俗來講就是new失敗的時候呼叫的回撥函式。可以通過_set_new_handler來設定。

new和new[]的區別,new[]一次分配所有記憶體,多次呼叫建構函式,分別搭配使用delete和delete[],同理,delete[]多次呼叫解構函式,銷燬陣列中的每個物件。而malloc則只能sizeof(int) * n;如果不夠可以繼續談new和malloc的實現,空閒連結串列,分配方法(首次適配原則,最佳適配原則,最差適配原則,快速適配原則)。delete和free的實現原理,free為什麼知道銷燬多大的空間?

六、多型與虛擬函式表vbtl
C++多型的實現?
多型分為靜態多型和動態多型。靜態多型是通過過載和模板技術實現,在編譯的時候確定。動態多型通過虛擬函式和繼承關係來實現,執行動態繫結,在執行的時候確定。
動態多型實現有幾個條件:
(1) 虛擬函式;
(2) 一個基類的指標或引用指向派生類的物件;
基類指標在呼叫成員函式(虛擬函式)時,就會去查詢該物件的虛擬函式表。虛擬函式表的地址vptr在每個物件的首地址。查詢該虛擬函式表中該函式的指標進行呼叫。
每個物件中儲存的只是一個虛擬函式表的指標,C++內部為每一個類維持一個虛擬函式表,該類的物件都指向這同一個虛擬函式表。
虛擬函式表中為什麼就能準確查詢相應的函式指標呢?因為在類設計的時候,虛擬函式表直接從基類也繼承過來,如果覆蓋了其中的某個虛擬函式,那麼虛擬函式表的指標就會被替換,因此可以根據指標準確找到該呼叫哪個函式。點選開啟連結

虛擬函式的作用?

  1. 虛擬函式用於實現多型,這點大家都能答上來
  2. 但是虛擬函式在設計上還具有封裝和抽象的作用。比如抽象工廠模式。

動態繫結是如何實現的?
第一個問題中基本回答了,主要都是結合虛擬函式表來答就行。

靜態多型和動態多型。靜態多型是指通過模板技術或者函式過載技術實現的多型,其在編譯器確定行為。動態多型是指通過虛擬函式技術實現在執行期動態繫結的技術。

虛擬函式表

虛擬函式表是針對類的還是針對物件的?同一個類的兩個物件的虛擬函式表是怎麼維護的?

編譯器為每一個類維護一個虛擬函式表,每個物件的首地址儲存著該虛擬函式表的指標,同一個類的不同物件實際上指向同一張虛擬函式表。
七、純虛擬函式如何定義,為什麼對於存在虛擬函式的類中解構函式要定義成虛擬函式

為了實現多型進行動態繫結,將派生類物件指標繫結到基類指標上,物件銷燬時,如果解構函式沒有定義為虛解構函式,則會呼叫基類的解構函式,顯然只能銷燬部分資料。如果要呼叫物件的解構函式,就需要將該物件的解構函式定義為虛擬函式,銷燬時通過虛擬函式表找到對應的解構函式。

純虛擬函式定義:

virtual ~myClass()=0;

八、解構函式能丟擲異常嗎

答案肯定是不能。

  • C++標準指明解構函式不能、也不應該丟擲異常。C++異常處理模型最大的特點和優勢就是對C++中的面向物件提供了最強大的無縫支援。那麼如果物件在執行期間出現了異常,C++異常處理模型有責任清除那些由於出現異常所導致的已經失效了的物件(也即物件超出了它原來的作用域),並釋放物件原來所分配的資源, 這就是呼叫這些物件的解構函式來完成釋放資源的任務,所以從這個意義上說,解構函式已經變成了異常處理的一部分。

(1) 如果解構函式丟擲異常,則異常點之後的程式不會執行,如果解構函式在異常點之後執行了某些必要的動作比如釋放某些資源,則這些動作不會執行,會造成諸如資源洩漏的問題。

(2) 通常異常發生時,c++的機制會呼叫已經構造物件的解構函式來釋放資源,此時若解構函式本身也丟擲異常,則前一個異常尚未處理,又有新的異常,會造成程式崩潰的問題。

九、建構函式和解構函式中呼叫虛擬函式嗎?

從語法上講,呼叫完全沒有問題。但是從效果上看,往往不能達到需要的目的。 Effective C++的解釋是:派生類物件的基類成分會在派生類自身成分被構造之前先構造妥當, 派生類物件構造期間會首先進入基類的建構函式,在基類建構函式執行時繼承類的成員變數尚未初始化,物件型別是基類型別,而不是派生類型別,虛擬函式會被編譯器解析為基類,若使用執行時型別資訊,也會把物件視為基類型別,構造期間呼叫虛擬函式,會呼叫自己的虛擬函式,此時虛擬函式和普通函式沒有區別了,達不到多型的效果。

同樣,進入基類解構函式時,物件也是基類型別。C++中派生類在構造時會先呼叫基類的建構函式再呼叫派生類的建構函式,析構時則相反,先呼叫派生類的解構函式再呼叫基類的建構函式。一旦派生類解構函式執行,這個物件的派生類資料成員就被視為未定義的值,所以 C++ 就將它們視為不再存在。假設一個派生類的物件進行析構,首先呼叫了派生類的析構,然後再呼叫基類的析構時,遇到了一個虛擬函式,這個時候有兩種選擇:Plan A是編譯器呼叫這個虛擬函式的基類版本,那麼虛擬函式則失去了執行時呼叫正確版本的意義;Plan B是編譯器呼叫這個虛擬函式的派生類版本,但是此時物件的派生類部分已經完成析構,“資料成員就被視為未定義的值”,這個函式呼叫會導致未知行為。

所以,虛擬函式始終僅僅呼叫基類的虛擬函式(如果是基類呼叫虛擬函式),不能達到多型的效果,所以放在建構函式中是沒有意義的,而且往往不能達到本來想要的效果。

十、指標和引用的區別點選開啟連結

指標儲存的是所指物件的地址,引用是所指物件的別名,

指標需要通過解引用間接訪問,而引用是直接訪問;

指標可以改變地址,從而改變所指的物件,而引用必須從一而終,總是指向最初獲得的物件;

引用在定義的時候必須初始化,而指標則不需要;

指標有指向常量的指標和指標常量,而引用沒有常量引用???

指標更靈活,用的好威力無比,用的不好處處是坑,而引用用起來則安全多了,但是比較死板。

沒有所謂的空引用,但可以有空指標。

引用可能比使用指標更高效,因為使用引用不用測試其有效性

  1. double dval = 3.14;
  2. const int &ri = dval;
  3. dval = 5;//dval=5,ri=3

當一個常量引用被繫結到另一種型別上時發生什麼,上面那段程式碼中,ri引用了int型的數,對ri的操作應該是整數運算,但dval卻是一個雙精度浮點數而非整數。因此為了確保ri繫結的是一個整數,編譯器會把上面那段程式碼變成:

  1. double dval = 3.14;
  2. const int temp = dval;
  3. const int &ri = temp;

ri繫結的物件並不是dval,而是一個臨時量temp,所以此時無論你怎麼改變dval的值都不會影響到ri的值,這就是為什麼修改dval的值為5後ri的值認為3的原因。

  1. double dval = 3.14;
  2. int temp = dval;
  3. int & ri = temp;

如果ri不是常量,就允許對ri賦值,這樣就會改變ri所繫結物件的值,值得注意的是,上面ri繫結的是臨時量temp,我們既然讓ri繫結dval,就肯定是想通過ri來改變dval的值,否則幹嘛要給ri賦值呢,如此,我們也不會想著把引用繫結到臨時量上面吧,所以C++也把這種行為歸為非法,即不允許將一個非常量引用繫結到另外一種型別的物件上.

如果是對一個常量進行引用,則編譯器首先建立一個臨時變數,然後將該常量的值置入臨時變數中,對該引用的操作就是對該臨時變數的操作。對常量的引用(常量引用)可以用其它任何引用來初始化,但不能改變:(初始化常量引用時允許用任意表達式作為初始值
  1. int i = 42;
  2. const int &r1 = i; //正確:允許將const int & 繫結到一個普通int物件上
  3. const int &r2 = 42; //正確
  4. const int &r3 = r1 * 2; //正確
  5. int &r4 = r1 * 2; //錯誤 ,相當於int &r4=84;不對,初始化值是一個左值,只能對const T&(常量引用)賦值
  6. double dval = 3.14;
  7. const int &ri = dval; //正確
  8. //等價於
  9. const int temp = dval;
  10. const int &ri = temp;
  11. int i = 42;
  12. int &r1 = i;
  13. const int &r2 = i;
  14. r1 = 0; //正確
  15. r2 = 0; //錯誤 ,常量引用不能改變
關於引用的初始化有兩點值得注意:
  (1)當初始化值是一個左值(可以取得地址)時,沒有任何問題;
  (2)當初始化值不是一個左值時,則只能對一個const T&(常量引用)賦值。而且這個賦值是有一個過程的:
  首先將值隱式轉換到型別T,然後將這個轉換結果存放在一個臨時物件裡,最後用這個臨時物件來初始化這個引用變數。
  例子:
  double& dr = 1; // 錯誤:需要左值
  const double& cdr = 1; // ok
  第二句實際的過程如下:
  double temp = double(1);
  const double& cdr = temp; 

十一、指標與陣列千絲萬縷的聯絡

  1. 一個一維int陣列的陣列名實際上是一個int* const p型別;p指向第一個元素
  2. 一個二維int陣列的陣列名實際上是一個int (*const p)[n];p指向第一行
  3. 陣列名做引數會退化為指標,除了sizeof。
  1. int num =0 ; //在32機器中告訴C編譯器分配4個位元組的記憶體
  2. int a [] = {1,3,5,12,6,7,54,32}; //告訴C編譯器分配32個位元組的記憶體
  3. printf("a:%d ,a+1:%d,&a:%d,&a+1:%d\n",a,a+1,&a,&a+1) ;
  4. //a+1 和 &a+1 結果不一樣
  5. //雖然輸出結果上面,a和&a一樣 。 但是a 和 &a所代表的資料型別不一樣
  6. /*重要*/
  7. //a 代表的資料首元素的地址 (首元素),同時與整個陣列地址重合,但其不能代表整個陣列,只能代表起始個體的地址
  8. //&a代表的是整個陣列的地址 (特別特別的注意) 它的加1是以整塊陣列所佔位元組數總數為單位1

輸出結果:a:1638176 , a+1:1638180, &a:1638176, &a+1:1638208

假如有一維陣列如下:

  char a[3];

該陣列一共有3個元素,元素的型別為char,如果想定義一個指標指向該陣列,也就是如果想把陣列名a賦值給一個指標變數,那麼該指標變數的型別應該是什麼呢?前文說過,一個數組的陣列名代表其首元素的首地址,也就是相當於&a[0],而a[0]的型別為char,因此&a[0]型別為char *,因此,可以定義如下的指標變數:

  char * p = a;//相當於char * p = &a[0]

以上文字可用如下記憶體模型圖表示。

大家都應該知道,a和&a[0]代表的都是陣列首元素的首地址,而如果你將&a的值打印出來,會發現該值也等於陣列首元素的首地址。請注意我這裡的措辭,也就是說,&a雖然在數值上也等於陣列首元素首地址的值,但是其型別並不是陣列首元素首地址型別,也就是char *p = &a是錯誤的。

對陣列名進行取地址操作,其型別為整個陣列,因此,&a的型別是char (*)[3],所以正確的賦值方式如下:

  char (*p)[3] = &a;

注:很多人對類似於a+1,&a+1,&a[0]+1,sizeof(a),sizeof(&a)等感到迷惑,其實只要搞清楚指標的型別就可以迎刃而解。比如在面對a+1和&a+1的區別時,由於a表示陣列首元素首地址,其型別為char *,因此a+1相當於陣列首地址值+sizeof(char)即第二個元素的地址;而&a的型別為char (*)[3],代表整個陣列,因此&a+1相當於陣列首地址值+sizeof(a)。(sizeof(a)代表整個陣列大小,但是無論陣列大小如何,sizeof(&a)永遠等於一個指標變數佔用空間的大小,具體與系統平臺有關

假如有如下二維陣列:

  char a[3][2];

由於實際上並不存在多維陣列,因此,可以將a[3][2]看成是一個具有3個元素的一維陣列,只是這三個元素分別又是一個一維陣列。實際上,在記憶體中,該陣列的確是按照一維陣列的形式儲存的,儲存順序為(低地址在前):a[0][0]、a[0][1]、a[1][0]、a[1][1]、a[2][0]、a[2][1]。(此種方式也不是絕對,也有按列優先儲存的模式)

為了方便理解,我畫了一張邏輯上的記憶體圖,之所以說是邏輯上的,是因為該圖只是便於理解,並不是陣列在記憶體中實際的儲存模型(實際模型為前文所述)。

如上圖所示,我們可以將陣列分成兩個維度來看,首先是第一維,將a[3][2]看成一個具有三個元素的一維陣列,元素分別為:a[0]、a[1]、a[2],其中,a[0]、a[1]、a[2]又分別是一個具有兩個元素的一維陣列(元素型別為char)。從第二個維度看,此處可以將a[0]、a[1]、a[2]看成自己代表”第二維”陣列的陣列名,以a[0]為例,a[0](陣列名)代表的一維陣列是一個具有兩個char型別元素的陣列,而a[0]是這個陣列的陣列名(代表陣列首元素首地址),因此a[0]型別為char *,同理a[1]和a[2]型別都是char *。而a是第一維陣列的陣列名,代表首元素首地址,而首元素是一個具有兩個char型別元素的一維陣列,因此a就是一個指向具有兩個char型別元素陣列的陣列指標,也就是char(*)[2]。

也就是說,如下的賦值是正確的:

  char (*p)[2]  = a;//a為第一維陣列的陣列名,型別為char (*)[2]

  char * p = a[0];//a[0]為第二維陣列的陣列名,型別為char *

同樣,對a取地址操作代表整個陣列的首地址,型別為陣列型別(請允許我暫且這麼稱呼),也就是char (*)[3][2],所以如下賦值是正確的:

  char (*p)[3][2] = &a;

十二、智慧指標是怎麼實現的?什麼時候改變引用計數?

C++程式設計中使用堆記憶體是非常頻繁的操作,堆記憶體的申請和釋放都由程式設計師自己管理,malloc / freenew / delete,再到allocator的出現。程式設計師自己管理堆記憶體可以提高程式的效率,但是整體來說堆記憶體的管理是麻煩的,C++11中引入了智慧指標的概念,方便管理堆記憶體。使用普通指標,容易造成堆記憶體洩露(忘記釋放),二次釋放,程式發生異常時記憶體洩露等問題等,使用智慧指標能更好的管理堆記憶體。

智慧指標是利用了一種叫做RAII(資源獲取即初始化)的技術對普通的指標進行封裝,這使得智慧指標實質是一個物件,行為表現的卻像一個指標。智慧指標就是一個作用是資源管理的類,它是你在堆疊上宣告的類模板,並可通過使用指向某個堆分配的物件的原始指標進行初始化(RAII)。在初始化智慧指標後,它將擁有原始指標,這意味著智慧指標負責刪除原始指標指定的記憶體, 智慧指標解構函式包括要刪除的呼叫,當智慧指標超出範圍時將呼叫其解構函式,解構函式會自動釋放資源。

  • unique_ptr
    只允許基礎指標的一個所有者。 除非你確信需要 shared_ptr,否則請將該指標用作 POCO 的預設選項。 可以移到新所有者,但不會複製或共享。 替換已棄用的 auto_ptr。 與 boost::scoped_ptr 比較。 unique_ptr 小巧高效,大小等同於一個指標且支援 rvalue 引用,從而可實現快速插入和對 STL 集合的檢索。
  • shared_ptr
    採用引用計數的智慧指標。 如果你想要將一個原始指標分配給多個所有者(例如,從容器返回了指標副本又想保留原始指標時),請使用該指標。 直至所有 shared_ptr 所有者超出了範圍或放棄所有權,才會刪除原始指標。 大小為兩個指標:一個用於物件,另一個用於包含引用計數的共享控制塊。
  • weak_ptr
    結合 shared_ptr 使用的特例智慧指標。 weak_ptr 提供對一個或多個 shared_ptr 例項擁有的物件的訪問,但不參與引用計數。 如果你想要觀察某個物件但不需要其保持活動狀態,請使用該例項。 在某些情況下,用於斷開 shared_ptr 例項間的迴圈引用。

shared_ptr的使用

shared_ptr多個指標指向相同的物件。shared_ptr使用引用計數,每一個shared_ptr的拷貝都指向相同的記憶體。每使用他一次,內部的引用計數加1,每析構一次,內部的引用計數減1,減為0時,自動刪除所指向的堆記憶體。shared_ptr內部的引用計數是執行緒安全的,但是物件的讀取需要加鎖。

  • 初始化。智慧指標是個模板類,可以指定型別,傳入指標通過建構函式初始化。也可以使用make_shared函式初始化。不能將指標直接賦值給一個智慧指標,一個是類,一個是指標。例如std::shared_ptr<int> p4 = new int(1);的寫法是錯誤的
  • 拷貝和賦值。拷貝使得物件的引用計數增加1,賦值使得原物件引用計數減1,當計數為0時,自動釋放記憶體。後來指向的物件引用計數加1,指向後來的物件。
  • get函式獲取原始指標
  • 注意不要用一個原始指標初始化多個shared_ptr,否則會造成二次釋放同一記憶體
  • 注意避免迴圈引用,shared_ptr的一個最大的陷阱是迴圈引用,迴圈,迴圈引用會導致堆記憶體無法正確釋放,導致記憶體洩漏。迴圈引用在weak_ptr中介紹

unique_ptr的使用

  unique_ptr“唯一”擁有其所指物件,同一時刻只能有一個unique_ptr指向給定物件(通過禁止拷貝語義、只有移動語義來實現)。相比與原始指標unique_ptr用於其RAII的特性,使得在出現異常的情況下,動態資源能得到釋放。unique_ptr指標本身的生命週期:從unique_ptr指標建立時開始,直到離開作用域。離開作用域時,若其指向物件,則將其所指物件銷燬(預設使用delete操作符,使用者可指定其他操作)。unique_ptr指標與其所指物件的關係:在智慧指標生命週期內,可以改變智慧指標所指物件,如建立智慧指標時通過建構函式指定、通過reset方法重新指定、通過release方法釋放所有權、通過移動語義轉移所有權

  1. #include <iostream>
  2. #include <memory>
  3. int main() {
  4. {
  5. std::unique_ptr<int> uptr(new int(10)); //繫結動態物件
  6. //std::unique_ptr<int> uptr2 = uptr; //不能賦值
  7. //std::unique_ptr<int> uptr2(uptr); //不能拷貝
  8. std::unique_ptr<int> uptr2(uptr.release());//uptr放棄對指標的控制權,將所有權從uptr轉移給uptr2
      std::unique_ptr<int> uptr3(new int(1));

uptr2.reset(uptr3.release());//將所有權從uptr3轉移給uptr2,reset釋放了uptr2原來指向的記憶體 } //超過uptr的作用域,記憶體釋放}

weak_ptr的使用

  weak_ptr是為了配合shared_ptr而引入的一種智慧指標,因為它不具有普通指標的行為,沒有過載operator*和->,它的最大作用在於協助shared_ptr工作,像旁觀者那樣觀測資源的使用情況。weak_ptr可以從一個shared_ptr或者另一個weak_ptr物件構造,獲得資源的觀測權。但weak_ptr沒有共享資源,它的構造不會引起指標引用計數的增加。使用weak_ptr的成員函式use_count()可以觀測資源的引用計數,另一個成員函式expired()的功能等價於use_count()==0,但更快,表示被觀測的資源(也就是shared_ptr的管理的資源)已經不復存在。weak_ptr可以使用一個非常重要的成員函式lock()從被觀測的shared_ptr獲得一個可用的shared_ptr物件, 從而操作資源。但當expired()==true的時候,lock()函式將返回一個儲存空指標的shared_ptr。

1、建構函式中計數初始化為1

2、拷貝建構函式中計數值加1;

3、賦值運算子中,左邊的物件引用計數減/1,右邊的物件引用計數加1;

4、解構函式中引用計數減1;

5、在賦值運算子和解構函式中,如果減1後為0,則呼叫delete銷燬物件並釋放它佔用的記憶體

6、share_prt與weak_ptr的區別?

  1. //share_ptr可能出現迴圈引用,從而導致記憶體洩露
  2. class A
  3. {
  4. public:
  5. share_ptr p;
  6. };
  7. class B
  8. {
  9. public:
  10. share_ptr p;
  11. }
  12. int main()
  13. {
  14. while(true)
  15. {
  16. share_prt pa(new A()); //pa的引用計數初始化為1
  17. share_prt pb(new B()); //pb的引用計數初始化為1
  18. pa->p = pb; //pb的引用計數變為2
  19. pb->p = pa; //pa的引用計數變為2
  20. }
  21. //假設pa先離開,引用計數減一變為1,不為0因此不會呼叫class A的解構函式,因此其成員p也不會被析構,pb的引用計數仍然為2;
  22. //同理pb離開的時候,引用計數也不能減到0
  23. return 0;
  24. }
  25. /*
  26. ** weak_ptr是一種弱引用指標,其存在不會影響引用計數,從而解決迴圈引用的問題
  27. */
volatilechar* vpch;

十三、C++四種類型轉換static_cast, dynamic_cast, const_cast, reinterpret_cast點選開啟連結

const_cast用於將const變數轉為非const static_cast用的最多,對於各種隱式轉換,非const轉const,void*轉指標等, static_cast能用於多型想上轉化,如果向下轉能成功但是不安全,結果未知; dynamic_cast用於動態型別轉換。只能用於含有虛擬函式的類,用於類層次間的向上和向下轉化。只能轉指標或引用。向下轉化時,如果是非法的對於指標返回NULL,對於引用拋異常。要深入瞭解內部轉換的原理。 reinterpret_cast幾乎什麼都可以轉,比如將int轉指標,可能會出問題,儘量少用; 為什麼不使用C的強制轉換?C的強制轉換表面上看起來功能強大什麼都能轉,但是轉化不夠明確,不能進行錯誤檢查,容易出錯。

dynamic_cast和typeid一樣,是屬於c++中的RTTI機制:編譯器把一個類的相關資訊放在虛表中,然後利用class得到它的虛表指標,再從虛表指標轉到虛表,最後才能做檢查型別等動作。也就是說,dynamic_cast行為需要一個虛表,而想要虛表,就必須得是一個含有虛擬函式的多型型別!

十四、記憶體對齊的原則

從0位置開始儲存;變數儲存的起始位置是該變數大小的整數倍;結構體總的大小是其最大元素的整數倍,不足的後面要補齊;結構體中包含結構體,從結構體中最大元素的整數倍開始存如果加入pragma pack(n) ,取n和變數自身大小較小的一個。

結構體:

1、資料成員對齊規則:結構體(struct)的資料成員,第一個資料成員放在offset為0的地方,之後的每個資料成員儲存的起始位置要從該成員大小的整數倍開始(比如int在32位機子上為4位元組,所以要從4的整數倍地址開始儲存)。

2、結構體作為成員:如果一個結構體裡同時包含結構體成員,則結構體成員要從其內部最大元素大小的整數倍地址開始儲存(如struct a裡有struct b,b裡有char,int ,double等元素,那麼b應該從8(即double型別的大小)的整數倍開始儲存)。

3、結構體的總大小:即sizeof的結果。在按之前的對齊原則計算出來的大小的基礎上,必須還得是其內部最大成員的整數倍,不足的要補齊(如struct裡最大為double,現在計算得到的已經是11,則總大小為16)。

具體例子:

  1. typedef struct bb
  2. {
  3. int id; //[0]....[3] 表示4位元組
  4. double weight; //[8].....[15]      原則1
  5. float height; //[16]..[19],總長要為8的整數倍,僅對齊之後總長為[0]~[19]為20,補齊[20]...[23]     原則3
  6. }BB;
  1. typedef struct aa
  2. {
  3. int id; //[0]...[3]          原則1
  4. double score; //[8]....[15]    
  5. short grade; //[16],[17]        
  6. BB b; //[24]......[47]       原則2(因為BB內部最大成員為double,即8的整數倍開始儲存)
  7. char name[2]; //[48][49]
  8. }AA;

sizeof(bb)=24,sizeof(aa)=56

編譯器中提供了#pragmapack(n)來設定變數以n位元組對齊方式。//n為1、2、4、8、16...

n位元組對齊就是說變數存放的起始地址的偏移量有兩種情況:第一、如果n大於等於該變數所佔用的位元組數,那麼偏移量必須滿足預設的對齊方式,即該變數所佔用位元組數的整數倍;第二、如果n小於該變數的型別所佔用的位元組數,那麼偏移量為n的倍數,不用滿足預設的對齊方式。

結構的總大小也有個約束條件,分下面兩種情況:如果n大於所有成員變數型別所佔用的位元組數,那麼結構的總大小必須為佔用空間最大的變數佔用的空間數的倍數;否則必須為n的倍數。

所以在上面的程式碼前加一句#pragma pack(1),

則程式碼輸出為bb:(0~3)+(4~11)+(12~15)=16;aa:(0~1)+(2~5)+(6~13)+(14~15)+(16~31)=32,也就是說,#pragma pack(1)就是沒有對齊規則。

再考慮#pragma pack(4),bb:(0~3)+(4~11)+(12~15)=16;aa:(0~1)+(4~7)+(8~15)+(16~17)+(20~35)=36

聯合體:

共用體表示幾個變數共用一個記憶體位置,在不同的時間儲存不同的資料型別和不同長度的變數。在union中,所有的共用體成員共用一個空間,並且同一時間只能儲存其中一個成員變數的值。當一個共用體被宣告時, 編譯程式自動地產生一個變數, 其長度為聯合中元型別(如陣列,取其型別的資料長度)最大的變數長度的整數倍,且要大於等於其最大成員所佔的儲存空間。

  1. union foo
  2. {
  3. char s[10];
  4. int i;
  5. }

在這個union中,foo的記憶體空間的長度為12,是int型的3倍,而並不是陣列的長度10。若把int改為double,則foo的記憶體空間為16,是double型的兩倍。

  1. union mm{
  2. char a;//元長度1 1
  3. int b[5];//元長度4 20
  4. double c;//元長度8 8
  5. int d[3]; 12
  6. };
所以sizeof(mm)=8*3=24; 當在共用體中包含結構體時,如下:
  1. struct inner
  2. {
  3. char c1;
  4. double d;
  5. char c2;
  6. };
  7. union data4
  8. {
  9. struct inner t1;
  10. int i;
  11. char c;
  12. };

由於data4共用體中有一個inner結構體,所以最大的基本資料型別為double,因此以8位元組對齊。共用體的儲存長度取決於t1,而t1長度為24,因此sizeof(uniondata4)的值為24.

當在結構體中包含共用體時,共用體在結構體裡的對齊地址為共用體本身內部所對齊位數,如下:

  1. typedef union{
  2. long i;
  3. int k[5];
  4. char c;
  5. }DATE;
  6. struct data{
  7. int cat;
  8. char cc;
  9. DATE cow;
  10. char a[6];
  11. };

sizeof(DATE)=20, 而在結構體中中是4+1+3(補齊4對齊)+20+6+2(補齊4對齊)=36;

(1). 共用體和結構體都是由多個不同的資料型別成員組成, 但在任何同一時刻, 共用體只存放了一個被選中的成員, 而結構體的所有成員都存在。 (2). 對於共用體的不同成員賦值, 將會對其它成員重寫, 原來成員的值就不存在了, 而對於結構體的不同成員賦值是互不影響的。

十五、行內函數有什麼優點?行內函數與巨集定義的區別?

巨集定義在預編譯的時候就會進行巨集替換;行內函數在編譯階段,在呼叫行內函數的地方進行替換,減少了函式的呼叫過程,但是使得編譯檔案變大。因此,行內函數適合簡單函式,對於複雜函式,即使定義了內聯編譯器可能也不會按照內聯的方式進行編譯。行內函數相比巨集定義更安全,行內函數可以檢查引數,而巨集定義只是簡單的文字替換。因此推薦使用行內函數,而不是巨集定義。使用巨集定義函式要特別注意給所有單元都加上括號,#define MUL(a, b) ab,這很危險,正確寫法:#define MUL(a, b) ((a)(b))

十六、C++記憶體管理

(堆區,棧區,常量儲存區,自由儲存區,全域性/靜態儲存區)

2、每塊儲存哪些變數?

(1),就是那些由編譯器在需要的時候分配,在不需要的時候自動清除的變數的儲存區。裡面的變數通常是區域性變數、函式引數等。

(2),就是那些由new分配的記憶體塊,他們的釋放編譯器不去管,由我們的應用程式去控制,一般一個new就要對應一個delete。如果程式設計師沒有釋放掉,那麼在程式結束後,作業系統會自動回收;

(3)自由儲存區,就是那些由malloc等分配的記憶體塊,它和堆是十分相似的,不過它是用free來結束自己的生命的。

(4)全域性/靜態儲存區全域性變數靜態變數被分配到同一塊記憶體中,在以前的C語言中,全域性變數又分為初始化的和未初始化的(初始化的全域性變數和靜態變量在一塊區域,未初始化的全域性變數與靜態變數在相鄰的另一塊區域,同時未被初始化的物件儲存區可以通過void*來訪問和操縱,程式結束後由系統自行釋放),在C++裡面沒有這個區分了,他們共同佔用同一塊記憶體區。

(5)常量儲存區,這是一塊比較特殊的儲存區,他們裡面存放的是常量,不允許修改(當然,你要通過非正當手段也可以修改,而且方法很多)

比如:
程式碼:
int a = 0; //全域性初始化區
char *p1; //全域性未初始化區
main()
{
int b; //棧
char s[] = "abc"; //棧
char *p2; //棧
char *p3 = "123456"; //123456\0在常量區,p3在棧上。
static int c = 0; //全域性(靜態)初始化區
p1 = (char *)malloc(10);
p2 = (char *)malloc(20);
//分配得來得10和20位元組的區域就在堆區。
strcpy(p1, "123456");
//123456\0放在常量區,編譯器可能會將它與p3所指向的"123456"優化成一塊。
}

3、學會遷移,可以說到malloc,從malloc說到作業系統的記憶體管理,說到核心態和使用者態,然後就什麼高階記憶體,slab層,夥伴演算法,VMA可以巴拉巴拉了,接著可以遷移到fork()。

十七、STL裡的記憶體池實現(請看<<STL原始碼剖析>>)

  • STL記憶體分配分為一級分配器和二級分配器,一級分配器就是採用malloc分配記憶體,二級分配器採用記憶體池。

二級分配器設計的非常巧妙,分別給8k,16k,..., 128k比較小的記憶體片都維持一個空閒連結串列,每個連結串列的頭節點由一個數組來維護。需要分配記憶體時從合適大小的連結串列中取一塊下來。假設需要分配一塊10K的記憶體,那麼就找到最小的大於等於10k的塊,也就是16K,從16K的空閒連結串列裡取出一個用於分配。釋放該塊記憶體時,將記憶體節點歸還給連結串列。
如果要分配的記憶體大於128K則直接呼叫一級分配器。

為了節省維持連結串列的開銷,採用了一個union結構體,分配器使用union裡的next指標來指向下一個節點,而使用者則使用union的空指標來表示該節點的地址。點選開啟連結

預設記憶體管理函式的不足:

利用預設的記憶體管理函式new/delete或malloc/free在堆上分配和釋放記憶體會有一些額外的開銷。

系統在接收到分配一定大小記憶體的請求時,首先查詢內部維護的記憶體空閒塊表,並且需要根據一定的演算法(例如“最先分配”:分配最先找到的不小於申請大小的記憶體塊給請求者,或者“最優分配”:分配最適於申請大小的記憶體塊,或者分配最大空閒的記憶體塊等)找到合適大小的空閒記憶體塊。如果該空閒記憶體塊過大,還需要切割成已分配的部分和較小的空閒塊。然後系統更新記憶體空閒塊表,完成一次記憶體分配。類似地,在釋放記憶體時,系統把釋放的記憶體塊重新加入到空閒記憶體塊表中。如果有可能的話,可以把相鄰的空閒塊合併成較大的空閒塊。這都會產生額外開銷。

預設的記憶體管理函式還考慮到多執行緒的應用,需要在每次分配和釋放記憶體時加鎖,同樣增加了開銷。
可見,如果應用程式頻繁地在堆上分配和釋放記憶體,則會導致效能的損失。並且會使系統中出現大量的記憶體碎片,降低記憶體的利用率。

預設的分配和釋放記憶體演算法自然也考慮了效能,然而這些記憶體管理演算法的通用版本為了應付更復雜、更廣泛的情況,需要做更多的額外工作。而對於某一個具體的應用程式來說,適合自身特定的記憶體分配釋放模式的自定義記憶體池則可以獲得更好的效能。

記憶體池(Memory Pool)是一種記憶體分配方式,是一種代替直接呼叫malloc/free、new/delete進行記憶體管理的常用方法。通常我們習慣直接使用new、malloc等API申請分配記憶體,這樣做的缺點在於:由於所申請記憶體塊的大小不定,當頻繁使用時會造成大量的記憶體碎片並進而降低效能。記憶體池則是在真正使用記憶體之前,先申請分配一定數量的、大小相等(一般情況下)的記憶體塊留作備用。當有新的記憶體需求時,就從記憶體池中分出一部分記憶體塊,若記憶體塊不夠再繼續申請新的記憶體。這樣做的一個顯著優點是,使得記憶體分配效率得到提升。當申請記憶體空間時,首先到記憶體池中查找合適的記憶體塊,而不是直接向作業系統申請;同理,當程式釋放記憶體的時候,並不真正將記憶體返回給作業系統,而是返回記憶體池。當程式退出(或者特定時間)時,記憶體池才將之前申請的真正記憶體釋放。

記憶體池的一個簡單應用,c++的二級配置器:

一、STL中的記憶體管理 當我們new一個物件時,實際做了兩件事情:(1)使用malloc申請了一塊記憶體。(2)執行建構函式。在SGI中,這兩步獨立出了兩個函式:allocate申請記憶體,construct呼叫建構函式。這兩個函式分別在<stl_alloc.h>和<stl_construct.h>中。

二、第一級配置器

第一級配置器以malloc(),free(),realloc()等C函式執行實際的記憶體配置、釋放、重新配置等操作,並且能在記憶體需求不被滿足的時候,呼叫一個指定的函式。

三、第二級配置器 在STL的第二級配置器中多了一些機制,避免太多小區塊造成的記憶體碎片,小額區塊帶來的不僅是記憶體碎片,配置時還有額外的負擔。區塊越小,額外負擔所佔比例就越大。
如果要分配的區塊大於128bytes,則移交給第一級配置器處理。 如果要分配的區塊小於128bytes,則以記憶體池管理(memorypool),又稱之次層配置(sub-allocation):每次配置一大塊記憶體,並維護對應的16個空閒連結串列(free-list)。下次若有相同大小的記憶體需求,則直接從free-list中取。如果有小額區塊被釋放,則由配置器回收到free-list中。 (1)空閒連結串列的設計 這裡的16個空閒連結串列分別管理大小為8、16、24......120、128bytes的資料塊。這裡空閒連結串列節點的設計十分巧妙,這裡用了一個聯合體既可以表示下一個空閒資料塊(存在於空閒連結串列中)的地址,也可以表示已經被使用者使用的資料塊(不存在空閒連結串列中)的地址。

free_list的節點結構:union obj聯合體

  1. union obj {
  2.   union obj *free_list_link;
  3.   char client_data[1];
  4. };

在記憶體池中所有的空閒塊都以這樣的方式連線起來,我們知道這個聯合體的大小為4Byte,書中描述為這樣:由於union之故,從其第一欄位觀之,obj可被視為一個指標,指向相同形式的另一個obj。從其第二欄位觀之,obj可被視為一個指標,指向實際區塊。也就是說如果我們用下面的方式取出記憶體池中的可用記憶體:

  1. // 使用時將其取出,指向下一個區塊的free_list_link此時就被我們當做空閒區域來使用了,因而不會額外佔用空間
  2. obj *myBlock = free_list;
  3. free_list = free_list->free_list_link;
  4. // 之後直接使用myBlock->client_data來訪問該記憶體區域

也就是說:

  1. printf("%x\n", myblock);
  2. printf("%x\n", myblock->client_data);

它們的地址是一樣的,但是為什麼要這樣呢?在一般情況下,假設我們利用malloc申請了一塊記憶體,它返回的是void*的指標,如果我們要使用這塊記憶體,假設我們要讓它存放char型的資料,要進行(char *)myblock的轉換。但是這裡我們不需要轉換,直接使用myblock->client_data進行,就可以以char*型別獲得這塊記憶體的首地址!!這這使得操作更加方便!!!

(2)空間配置函式allocate() 首先先要檢查申請空間的大小,如果大於128位元組就呼叫第一級配置器,小於128位元組就檢查對應的空閒連結串列,如果該空閒連結串列中有可用資料塊,則直接拿來用(拿取空閒連結串列中的第一個可用資料塊,然後把該空閒連結串列的地址設定為該資料塊指向的下一個地址),如果沒有可用資料塊,則呼叫refill()重新填充空間。
  1. //申請大小為n的資料塊,返回該資料塊的起始地址
  2. static void * allocate(size_t n)
  3. {
  4. obj * __VOLATILE * my_free_list;
  5. obj * __RESTRICT result;
  6. if (n > (size_t) __MAX_BYTES)//大於128位元組呼叫第一級配置器
  7. {
  8. return(malloc_alloc::allocate(n));
  9. }
  10. my_free_list = free_list + FREELIST_INDEX(n);//根據申請空間的大小尋找相應的空閒連結串列(16個空閒連結串列中的一個)
  11. result = *my_free_list;
  12. if (result == 0)//如果該空閒連結串列沒有空閒的資料塊
  13. {
  14. void *r = refill(ROUND_UP(n));//為該空閒連結串列填充新的空間
  15. return r;
  16. }
  17. *my_free_list = result -> free_list_link;//如果空閒連結串列中有空閒資料塊,則取出一個,並把空閒連結串列的指標指向下一個資料塊
  18. return (result);
  19. };

(3)空間釋放函式deallocate() 首先先要檢查釋放資料塊的大小,如果大於128位元組就呼叫第一級配置器,小於128位元組則根據資料塊的大小來判斷回收後的空間會被插入到哪個空閒連結串列。 例如回收下面指定位置大小為16位元組的資料塊,首先資料塊的大小判斷回收後的資料塊應該插入到第二個空閒連結串列,把該節點指向的下一個地址修改為原連結串列指向的地址(這裡是NULL),然後將原連結串列指向該節點。
  1. //釋放地址為p,釋放大小為n
  2. static void deallocate(void *p, size_t n)
  3. {
  4. obj *q = (obj *)p;
  5. obj * __VOLATILE * my_free_list;
  6. if (n > (size_t) __MAX_BYTES)//如果空間大於128位元組,採用普通的方法析構
  7. {
  8. malloc_alloc::deallocate(p, n);
  9. return;
  10. }
  11. my_free_list = free_list + FREELIST_INDEX(n);//否則將空間回收到相應空閒連結串列(由釋放塊的大小決定)中
  12. q -> free_list_link = *my_free_list;
  13. *my_free_list = q;
  14. }

回收記憶體塊:

(4)重新填充空閒連結串列refill()

在用allocate()配置空間時,如果空閒連結串列中沒有可用資料塊,就會呼叫refill()來重新填充空間,新的空間取自記憶體池。預設取20個數據塊,如果記憶體池空間不足,那麼能取多少個節點就取多少個。
  1. template <bool threads, int inst>
  2. void* refill(size_t n)
  3. {
  4. int nobjs = 20;
  5. char * chunk = chunk_alloc(n, nobjs);//從記憶體池裡取出nobjs個大小為n的資料塊,返回值nobjs為真實申請到的資料塊個數,注意這裡nobjs個大小為n的資料塊所在的空間是連續的
  6. obj * __VOLATILE * my_free_list;
  7. obj * result;
  8. obj * current_obj, * next_obj;
  9. int i;
  10. if (1 == nobjs) return(chunk);//如果只獲得一個數據塊,那麼這個資料塊就直接分給呼叫者,空閒連結串列中不會增加新節點
  11. my_free_list = free_list + FREELIST_INDEX(n);//否則根據申請資料塊的大小找到相應空閒連結串列
  12. result = (obj *)chunk;
  13. *my_free_list = next_obj = (obj *)(chunk + n);//第0個數據塊給呼叫者,地址訪問即chunk~chunk + n - 1
  14. for (i = 1; ; i++)//1~nobjs-1的資料塊插入到空閒連結串列
  15. {
  16. current_obj = next_obj;
  17. next_obj = (obj *)((char *)next_obj + n);//由於之前記憶體池裡申請到的空間連續,所以這裡需要人工劃分成小塊一次插入到空閒連結串列
  18. if (nobjs - 1 == i)
  19. {
  20. current_obj -> free_list_link = 0;
  21. break;
  22. }
  23. else
  24. {
  25. current_obj -> free_list_link = next_obj;
  26. }
  27. }
  28. return(result);
  29. }
(5)從記憶體池取空間
從記憶體池取空間給空閒連結串列用是chunk_alloc的工作: 首先根據end_free-start_free來判斷記憶體池中的剩餘空間是否足以調出nobjs個大小為size的資料塊出去,如果記憶體連一個數據塊的空間都無法供應,需要用malloc從堆中申請記憶體。

申請記憶體後,如果要撥出去20個大小為8位元組的資料塊: 假如山窮水盡,整個系統的堆空間都不夠用了,malloc失敗,那麼chunk_alloc會從空閒連結串列中找是否有大的資料塊,然後將該資料塊的空間分給記憶體池(這個資料塊會從連結串列中去除):
舉例:

假設程式一開始呼叫chunk_alloc(32,20),於是,malloc()配置40個32bytes區塊,其中第一個交出,另19個交給free_list[3],餘20個留給記憶體池。接下來客端呼叫chunk_alloc(64,20),此時free_list[7]空空如也,必須向記憶體池要求支援,記憶體池只夠供應(32*20)/64=10個64bytes區塊,就把這10個區塊返回,第一個交給客端,其餘9個由free_list[7]維護。此時記憶體池全空。接下來再呼叫chunk_alloc(96,20),此時free_list[11]空空如也,必須向記憶體池要求支援,而記憶體池此時也空,於是以malloc()配置40+n(附加量)個96bytes區塊,第一個交給客端,其餘19個由free_list[11]維護,餘20+n(附加量)個區塊交給記憶體池......萬一整個system heap空間都不夠用了,malloc()行動失敗,chunk_alloc就四處尋找有無“尚有未用區塊,且區塊足夠大”之free_list,找到就挖一塊交出,找不到就呼叫第一級配置器。第一級配置器也是使用malloc()配置記憶體,但它有out-of-memory處理機制,或許有機會釋放其他的記憶體拿來此處使用,若可以就成功,否則丟擲bad_alloc異常


整個記憶體池在記憶體分配中的邏輯:

十八、STL裡set和map是基於什麼實現的。紅黑樹的特點?

紅黑樹的定義:
(1) 節點是紅色或者黑色;
(2) 父節點是紅色的話,子節點就不能為紅色;
(3) 從根節點到每個頁子節點路徑上黑色節點的數量相同;

(4) 根是黑色的,NULL節點被認為是黑色的。

set和map都是基於紅黑樹實現的。

紅黑樹是一種平衡二叉查詢樹,與AVL樹的區別是什麼? AVL樹是完全平衡的,紅黑樹基本上是平衡的。

為什麼選用紅黑樹呢? 因為紅黑樹是平衡二叉樹,其插入和刪除的效率都是(logN),與AVL相比紅黑樹插入和刪除最多隻需要3次旋轉,而AVL樹為了維持其完全平衡性,在壞的情況下要旋轉的次數太多。

紅黑樹並不是高度的平衡樹。所謂平衡樹指的是一棵空樹或它的左右兩個子樹的高度差的絕對值不超過1,紅黑樹放棄了高度平衡的特性而只追求部分平衡,這種特性降低了插入、刪除時對樹旋轉的要求,從而提升了樹的整體效能。而其他平衡樹比如AVL樹雖然查詢效能是O(logn),但是為了維護其平衡特性,可能要在插入、刪除操作時進行多次的旋轉,產生比較大的消耗。

1. 如果插入一個node引起了樹的不平衡,AVL和RB-Tree都是最多隻需要2次旋轉操作,即兩者都是O(1);但是在刪除node引起樹的不平衡時,最壞情況下,AVL需要維護從被刪node到root這條路徑上所有node的平衡性,因此需要旋轉的量級O(logN),而RB-Tree最多隻需3次旋轉,只需要O(1)的複雜度。

2. 其次,AVL的結構相較RB-Tree來說更為平衡,在插入和刪除node更容易引起Tree的unbalance,因此在大量資料需要插入或者刪除時,AVL需要rebalance的頻率會更高。因此,RB-Tree在需要大量插入和刪除node的場景下,效率更高。自然,由於AVL高度平衡,因此AVL的search效率更高。

紅黑樹的查詢效能略微遜色於AVL樹,因為他比avl樹會稍微不平衡最多一層,也就是說紅黑樹的查詢效能只比相同內容的avl樹最多多一次比較,但是,紅黑樹在插入和刪除上完爆AVL樹,AVL樹每次插入刪除會進行大量的平衡度計算,而紅黑樹為了維持紅黑性質所做的紅黑變換和旋轉的開銷,相較於AVL樹為了維持平衡的開銷要小得多

一棵含有n個節點的紅黑樹的高度至多為2log(n+1).高度為h的紅黑樹,它的包含的內節點個數至少為 2h/2-1個

從某個節點x出發(不包括該節點)到達一個葉節點的任意一條路徑上,黑色節點的個數稱為該節點的黑高度,記為bh(x)。關於bh(x)有兩點需要說明:
第1點:根據紅黑樹的"特性,即從一個節點到該節點的子孫節點的所有路徑上包含相同數目的黑節點"可知,從節點x出發到達的所有的葉節點具有相同數目的黑節點。這也就意味著,bh(x)的值是唯一的
第2點:根據紅黑色的"特性,即如果一個節點是紅色的,則它的子節點必須是黑色的"可知,從節點x出發達到葉節點"所經歷的黑節點數目">= "所經歷的紅節點的數目"。假設x是根節點,則可以得出結論"bh(x) >= h/2"。
進而, "高度為h的紅黑樹,它包含的黑節點個數至少為 2bh(x)-1個"。

紅黑樹插入

用BST(二叉搜尋樹)的方法將結點插入,將該結點標記為紅色的(因為如果標記為黑色,則會導致根結點到葉子結點的路徑會多出一個黑結點,無法滿足性質(3),而且不容易進行調整),插入的情況包括下面幾種:

  1. 插入到一個空的樹,插入結點則為根結點,只需要將紅色結點重新轉染成黑色結點來滿足性質2;
  2. 新結點的父結點為黑色,滿足所有條件;
  3. 新結點的父結點為紅色,因為性質(2)和性質(4),所以樹必然有祖父結點,則又包括以下的情況:
    1. 父親結點和叔父結點均為紅色,顯然無法滿足性質(2),則將父親結點和叔父結點繪製成黑色,祖父結點設定成紅色,但是仍然無法滿足情況,比如考慮到祖父結點可能是根結點,則無法滿足性質(4),或者祖父結點的父結點是紅色的,則無法滿足性質(2),這時需要將祖父結點作為新的結點來看待進行各種情況的判斷,涉及到對祖父結點的遞迴;

    2. 父親結點為紅色同時叔父結點為黑色或者從缺,這裡又分為兩種情況,新插入結點為父親結點的左子結點和右子結點(假設其中父親結點為祖父結點的左子結點),區別在於旋轉的方向,顯然,這棵樹父親結點既然為紅色,那麼其祖父結點則為黑色(性質2),不然無法滿足前提。
      1. 新插入結點為父親結點的左子結點,那麼就構成了一個左左的情況,在之前平衡樹中提到過,如果要將其進行平衡,則需要對父結點進行一次單右旋轉,形成一個父親結點為相對根結點,子結點和祖父結點為子結點的樹,同時將父親結點的紅色改為黑色,祖父結點更改為紅色,這下之前無法滿足的性質4和性質5就滿足了;

      2. 新插入結點為父親結點的右子結點,那麼就會構成一個左右的情況,在之前的平衡樹也提到過要進行一次雙旋轉,先對新結點進行一次單左旋轉,變成了左左的結構,再進行一次單右旋轉(上圖),從而達到滿足所有性質;

    3. 父親結點是祖父結點的右結點,參考平衡樹進行相應的操作,原理是一致的

紅黑樹刪除

刪除操作虛擬碼:

  1. RB-DELETE(T, z)
  2. if left[z] = nil[T] or right[z] = nil[T]
  3. then y ← z // 若“z的左孩子” 或 “z的右孩子”為空,則將“z”賦值給 “y”;
  4. else y ← TREE-SUCCESSOR(z) // 否則,將“z的後繼節點”賦值給 “y”。
  5. if left[y] ≠ nil[T]
  6. then x ← left[y] // 若“y的左孩子” 不為空,則將“y的左孩子” 賦值給 “x”;
  7. else x ← right[y] // 否則,“y的右孩子” 賦值給 “x”。
  8. p[x] ← p[y] // 將“y的父節點” 設定為 “x的父節點”
  9. if p[y] = nil[T]
  10. then root[T] ← x // 情況1:若“y的父節點” 為空,則設定“x” 為 “根節點”。
  11. else if y = left[p[y]]
  12. then left[p[y]] ← x // 情況2:若“y是它父節點的左孩子”,則設定“x” 為 “y的父節點的左孩子”
  13. else right[p[y]] ← x // 情況3:若“y是它父節點的右孩子”,則設定“x” 為 “y的父節點的右孩子”
  14. if y ≠ z
  15. then key[z] ← key[y] // 若“y的值” 賦值給 “z”。注意:這裡只拷貝y的值給z,而沒有拷貝y的顏色!!!
  16. copy y's satellite data into z
  17. if color[y] = BLACK
  18. then RB-DELETE-FIXUP(T, x) // 若“y為黑節點”,則呼叫
  19. return y
  1. RB-DELETE-FIXUP(T, x)
  2. while x ≠ root[T] and color[x] = BLACK
  3. do if x = left[p[x]]
  4. then w ← right[p[x]] // 若 “x”是“它父節點的左孩子”,則設定 “w”為“x的叔叔”(即x為它父節點的右孩子)
  5. if color[w] = RED // Case 1: x是“黑+黑”節點,x的兄弟節點是紅色。(此時x的父節點和x的兄弟節點的子節點都是黑節點)。
  6. then color[w] ← BLACK ▹ Case 1 // (01) 將x的兄弟節點設為“黑色”。
  7. color[p[x]] ← RED ▹ Case 1 // (02) 將x的父節點設為“紅色”。
  8. LEFT-ROTATE(T, p[x]) ▹ Case 1 // (03) 對x的父節點進行左旋。
  9. w ← right[p[x]] ▹ Case 1 // (04) 左旋後,重新設定x的兄弟節點。
  10. if color[left[w]] = BLACK and color[right[w]] = BLACK // Case 2: x是“黑+黑”節點,x的兄弟節點是黑色,x的兄弟節點的兩個孩子都是黑色。
  11. then color[w] ← RED ▹ Case 2 // (01) 將x的兄弟節點設為“紅色”。
  12. x ← p[x] ▹ Case 2 // (02) 設定“x的父節點”為“新的x節點”。
  13. else if color[right[w]] = BLACK // Case 3: x是“黑+黑”節點,x的兄弟節點是黑色;x的兄弟節點的左孩子是紅色,右孩子是黑色的。
  14. then color[left[w]] ← BLACK ▹ Case 3 // (01) 將x兄弟節點的左孩子設為“黑色”。
  15. color[w] ← RED ▹ Case 3 // (02) 將x兄弟節點設為“紅色”。
  16. RIGHT-ROTATE(T, w) ▹ Case 3 // (03) 對x的兄弟節點進行右旋。
  17. w ← right[p[x]] ▹ Case 3 // (04) 右旋後,重新設定x的兄弟節點。
  18. color[w] ← color[p[x]] ▹ Case 4 // Case 4: x是“黑+黑”節點,x的兄弟節點是黑色;x的兄弟節點的右孩子是紅色的。(01) 將x父節點顏色 賦值給 x的兄弟節點。
  19. color[p[x]] ← BLACK ▹ Case 4 // (02) 將x父節點設為“黑色”。
  20. color[right[w]] ← BLACK ▹ Case 4 // (03) 將x兄弟節點的右子節設為“黑色”。
  21. LEFT-ROTATE(T, p[x]) ▹ Case 4 // (04) 對x的父節點進行左旋。
  22. x ← root[T] ▹ Case 4 // (05) 設定“x”為“根節點”。
  23. else (same as then clause with "right" and "left" exchanged) // 若 “x”是“它父節點的右孩子”,將上面的操作中“right”和“left”交換位置,然後依次執行。
  24. color[x] ← BLACK

第一步:將紅黑樹當作一顆二叉查詢樹,將節點刪除。
這和"刪除常規二叉查詢樹中刪除節點的方法是一樣的"。分3種情況:
① 被刪除節點沒有兒子,即為葉節點。那麼,直接將該節點刪除就OK了。
② 被刪除節點只有一個兒子。那麼,直接刪除該節點,並用該節點的唯一子節點頂替它的位置。
③ 被刪除節點有兩個兒子。那麼,先找出它的後繼節點;然後把“它的後繼節點的內容”複製給“該節點的內容”;之後,刪除“它的後繼節點”。(被刪節點的右孩子節點的左子樹非空,後繼為最左下節點;被刪節點的右孩子的左子樹為空,後繼節點為被刪節點的右孩子)

第二步:通過"旋轉和重新著色"等一系列來修正該樹,使之重新成為一棵紅黑樹。
因為"第一步"中刪除節點之後,可能會違背紅黑樹的特性。所以需要通過"旋轉和重新著色"來修正該樹,使之重新成為一棵紅黑樹。

“在刪除節點後,原紅黑樹的性質可能被改變,如果刪除的是紅色節點,那麼原紅黑樹的性質依舊保持,此時不用做修正操作,如果刪除的節點是黑色節點,原紅黑樹的性質可能會被改變,我們要對其做修正操作。那麼哪些樹的性質會發生變化呢,如果刪除節點不是樹唯一節點,那麼刪除節點的那一個支的到各葉節點的黑色節點數會發生變化,此時性質3被破壞。如果被刪節點的唯一非空子節點是紅色,而被刪節點的父節點也是紅色,那麼性質2被破壞。如果被刪節點是根節點,而它的唯一非空子節點是紅色,則刪除後新根節點將變成紅色,違背性質4。”

為了便於分析,我們假設"x包含一個額外的黑色"(x原本的顏色還存在),這樣就不會違反"特性3"。為什麼呢?
刪除節點y之後,x佔據了原來節點y的位置。 既然刪除y(y是黑色),意味著減少一個黑色節點;那麼,再在該位置上增加一個黑色即可。這樣,當我們假設"x包含一個額外的黑色",就正好彌補了"刪除y所丟失的黑色節點",也就不會違反"特性3"。 因此,假設"x包含一個額外的黑色"(x原本的顏色還存在),這樣就不會違反"特性3"。

現在,x不僅包含它原本的顏色屬性,x還包含一個額外的黑色。即x的顏色屬性是"紅+黑"或"黑+黑",它違反了"特性1"。現在,我們面臨的問題,由解決"違反了特性(2)、(3)、(4)三個特性"轉換成了"解決違反特性(1)、(2)、(4)三個特性"

恢復紅黑樹特性:

將x所包含的額外的黑色不斷沿樹上移(向根方向移動),直到出現下面的姿態:
a) x指向一個"紅+黑"節點。此時,將x設為一個"黑"節點即可。
b) x指向根。此時,將x設為一個"黑"節點即可。
c) 非前面兩種姿態。

將上面的姿態,可以概括為3種情況。
① 情況說明:x是“紅+黑”節點。
處理方法:直接把x設為黑色,結束。此時紅黑樹性質全部恢復。
② 情況說明:x是“黑+黑”節點,且x是根。
處理方法:什麼都不做,結束。此時紅黑樹性質全部恢復。
③ 情況說明:x是“黑+黑”節點,且x不是根。

1. (Case 1)x是"黑+黑"節點,x的兄弟節點是紅色

(此時x的父節點和x的兄弟節點的子節點都是黑節點)。

處理策略
(01) 將x的兄弟節點設為“黑色”。
(02) 將x的父節點設為“紅色”。
(03) 對x的父節點進行左旋。
(04) 左旋後,重新設定x的兄弟節點。

下面談談為什麼要這樣處理。(建議理解的時候,通過下面的圖進行對比)
這樣做的目的是將“Case 1”轉換為“Case 2”、“Case 3”或“Case 4”,從而進行進一步的處理。對x的父節點進行左旋;左旋後,為了保持紅黑樹特性,就需要在左旋前“將x的兄弟節點設為黑色”,同時“將x的父節點設為紅色”;左旋後,由於x的兄弟節點發生了變化,需要更新x的兄弟節點,從而進行後續處理。

示意圖(圖中A為替換的節點x) 該圖有錯,B紅D黑

2. (Case 2) x是"黑+黑"節點,x的兄弟節點是黑色,x的兄弟節點的兩個孩子都是黑色

處理策略
(01) 將x的兄弟節點設為“紅色”。
(02) 設定“x的父節點”為“新的x節點”。

下面談談為什麼要這樣處理。(建議理解的時候,通過下面的圖進行對比)
這個情況的處理思想:是將“x中多餘的一個黑色屬性上移(往根方向移動)”。 x是“黑+黑”節點,我們將x由“黑+黑”節點 變成 “黑”節點,多餘的一個“黑”屬性移到x的父節點中,即x的父節點多出了一個黑屬性(若x的父節點原先是“黑”,則此時變成了“黑+黑”;若x的父節點原先時“紅”,則此時變成了“紅+黑”)。 此時,需要注意的是:所有經過x的分支中黑節點個數沒變化;但是,所有經過x的兄弟節點的分支中黑色節點的個數增加了1(因為x的父節點多了一個黑色屬性)!為了解決這個問題,我們需要將“所有經過x的兄弟節點的分支中黑色節點的個數減1”即可,那麼就可以通過“將x的兄弟節點由黑色變成紅色”來實現。
經過上面的步驟(將x的兄弟節點設為紅色),多餘的一個顏色屬性(黑色)已經跑到x的父節點中。我們需要將x的父節點設為“新的x節點”進行處理。若“新的x節點”是“紅+黑”,直接將“新的x節點”設為黑色,即可完全解決該問題;若“新的x節點”是“黑+黑”,則需要對“新的x節點”進行進一步處理。

示意圖

3. (Case 3)x是“黑+黑”節點,x的兄弟節點是黑色;x的兄弟節點的左孩子是紅色,右孩子是黑色的

處理策略
(01) 將x兄弟節點的左孩子設為“黑色”。
(02) 將x兄弟節點設為“紅色”。
(03) 對x的兄弟節點進行右旋。
(04) 右旋後,重新設定x的兄弟節點。

下面談談為什麼要這樣處理。(建議理解的時候,通過下面的圖進行對比)
我們處理“Case 3”的目的是為了將“Case 3”進行轉換,轉換成“Case 4”,從而進行進一步的處理。轉換的方式是對x的兄弟節點進行右旋;為了保證右旋後,它仍然是紅黑樹,就需要在右旋前“將x的兄弟節點的左孩子設為黑色”,同時“將x的兄弟節點設為紅色”;右旋後,由於x的兄弟節點發生了變化,需要更新x的兄弟節點,從而進行後續處理。

示意圖

4. (Case 4)x是“黑+黑”節點,x的兄弟節點是黑色;x的兄弟節點的右孩子是紅色的,x的兄弟節點的左孩子任意顏色

處理策略
(01) 將x父節點顏色賦值給 x的兄弟節點。
(02) 將x父節點設為“黑色”。
(03) 將x兄弟節點的右子節設為“黑色”。
(04) 對x的父節點進行左旋。
(05) 設定“x”為“根節點”。

下面談談為什麼要這樣處理。(建議理解的時候,通過下面的圖進行對比)
我們處理“Case 4”的目的是:去掉x中額外的黑色,將x變成單獨的黑色。處理的方式是“:進行顏色修改,然後對x的父節點進行左旋。下面,我們來分析是如何實現的。
為了便於說明,我們設定“當前節點”為S(Original Son),“兄弟節點”為B(Brother),“兄弟節點的左孩子”為BLS(Brother's Left Son),“兄弟節點的右孩子”為BRS(Brother's Right Son),“父節點”為F(Father)。
我們要對F進行左旋。但在左旋前,我們需要調換F和B的顏色,並設定BRS為黑色。為什麼需要這裡處理呢?因為左旋後,F和BLS是父子關係,而我們已知BLS是紅色,如果F是紅色,則違背了“特性(2)”;為了解決這一問題,我們將“F設定為黑色”。 但是,F設定為黑色之後,為了保證滿足“特性(3)”,即為了保證左旋之後:
第一,“同時經過根節點和S的分支的黑色節點個數不變”。
若滿足“第一”,只需要S丟棄它多餘的顏色即可。因為S的顏色是“黑+黑”,而左旋後“同時經過根節點和S的分支的黑色節點個數”增加了1;現在,只需將S由“黑+黑”變成單獨的“黑”節點,即可滿足“第一”。
第二,“同時經過根節點和BLS的分支的黑色節點數不變”。
若滿足“第二”,只需要將“F的原始顏色”賦值給B即可。之前,我們已經將“F設定為黑色”(即,將B的顏色"黑色",賦值給了F)。至此,我們算是調換了F和B的顏色。
第三,“同時經過根節點和BRS的分支的黑色節點數不變”。
在“第二”已經滿足的情況下,若要滿足“第三”,只需要將BRS設定為“黑色”即可。
經過,上面的處理之後。紅黑樹的特性全部得到的滿足!接著,我們將x設為根節點,就可以跳出while迴圈(參考虛擬碼);即完成了全部處理。

至此,我們就完成了Case 4的處理。理解Case 4的核心,是瞭解如何“去掉當前節點額外的黑色”。

示意圖

十九、STL裡的其他資料結構和演算法實現也要清楚

這個問題,把STL原始碼剖析好好看看,不僅面試不慌,自己對STL的使用也會上升一個層次。

set:點選開啟連結

set是一種關聯式容器,其特性如下:

  • set以RBTree作為底層容器
  • 所得元素的只有key沒有value,value就是key
  • 不允許出現鍵值重複
  • 所有的元素都會被自動排序
  • 不能通過迭代器來改變set的值,因為set的值就是鍵

如果set中允許修改鍵值的話,那麼首先需要刪除該鍵,然後調節平衡,再插入修改後的鍵值,再調節平衡,如此一來,嚴重破壞了set的結構,導致iterator失效,不知道應該指向之前的位置,還是指向改變後的位置。所以STL中將set的迭代器設定成const_iterator,不允許修改迭代器的值。

map:

map和set一樣是關聯式容器,它們的底層容器都是紅黑樹,區別就在於map的值不作為鍵,鍵和值是分開的。它的特性如下:

  • map以RBTree作為底層容器
  • 所有元素都是鍵+值存在
  • 不允許鍵重複
  • 所有元素是通過鍵進行自動排序的
  • map的鍵是不能修改的,但是其鍵對應的值是可以修改的

在map中,一個鍵對應一個值,其中鍵不允許重複,不允許修改,但是鍵對應的值是可以修改的,原因可以看上面set中的解釋。

點選開啟連結

二十、必須在建構函式初始化列表裡進行初始化的資料成員有哪些

(1)常量成員,因為常量只能初始化不能賦值,所以必須放在初始化列表裡面
(2)引用型別,引用必須在定義的時候初始化,並且不能重新賦值,所以也要寫在初始化列表裡面
(3)沒有預設建構函式的類型別若沒有提供顯示初始化式,則編譯器隱式使用成員型別的預設建構函式,若類沒有預設建構函式,則編譯器嘗試使用預設建構函式將會失敗。使用初始化列表可以不必呼叫預設建構函式來初始化,而是直接呼叫拷貝建構函式初始化。

物件成員:A類的成員是B類的物件,在構造A類時需對B類的物件進行構造,當B類沒有預設建構函式時需要在A類的建構函式初始化列表中對B類物件初始化

類的繼承:派生類在建構函式中要對自身成員初始化,也要對繼承過來的基類成員進行初始化,當基類沒有預設建構函式的時候,通過在派生類的建構函式初始化列表中呼叫基類的建構函式實現

類物件的構造順序顯示,進入建構函式體後,進行的是計算,是對已經構造好的成員變數的賦值操作,顯然,賦值和初始化是不同的,這樣就體現出了效率差異,如果不用成員初始化列表,那麼類對自己的類成員分別進行的是一次隱式的預設建構函式的呼叫(在進入函式體之前),和一次拷貝賦值運算子的呼叫(進入函式體之後),如果是類物件,這樣做效率就得不到保障。(類型別的資料成員物件在進入函式體前己經構造完成,也就是說在成員初始化列表處進行物件的構造工作,呼叫建構函式,在進入函式體之後,進行的是對己構造好的類物件賦值,又呼叫個拷貝賦值操作符才能完成(如果並未提供,則使用編譯器預設的按成員賦值行為))

初始化是從無到有的過程,先分配空間,然後再填充資料;賦值是對己有的物件進行操作。

使用初始化列表的建構函式顯式的初始化類的成員;而沒使用初始化列表的建構函式是對類的成員賦值,並沒有進行顯式的初始化。
  • 初始化和賦值對內建型別的成員沒有什麼大的區別。對非內建型別成員變數,為了避免兩次構造推薦使用類建構函式初始化列表。

二十一、模版特化

模板特化分為全特化和偏特化,模板特化的目的就是對於某一種變數型別具有不同的實現,因此需要特化版本。例如,在STL裡迭代器為了適應原生指標就將原生指標進行特化。

二十二、定位記憶體洩露

(1)在windows平臺下通過CRT中的庫函式進行檢測;
(2)在可能洩漏的呼叫前後生成塊的快照,比較前後的狀態,定位洩漏的位置

(3)Linux下通過工具valgrind檢測

二十三、手寫函式strcpy、memcpy、strcat、strcmp

已知strcpy函式的原型是

char *strcpy(char *strDest, const char *strSrc);

其中strDest是目的字串,strSrc是源字串。

(1)不呼叫C++/C的字串庫函式,請編寫函式strcpy

char*strcpy(char*strDest,constchar*strSrc)

{

assert((strDest!=NULL) && (strSrc!=NULL));// 2

char*address=strDest;// 2

while( (*strDest++ = *strSrc++) !='\0'); // 2

returnaddress;// 2

}

(2)strcpy能把strSrc的內容複製到strDest,為什麼還要char *型別的返回值?

答:為了實現鏈式表達式。// 2分

例如int length = strlen( strcpy( strDest, “hello world”) );

[1]const修飾

源字串引數用const修飾,防止修改源字串。

[2]空指標檢查

(A)不檢查指標的有效性,說明答題者不注重程式碼的健壯性。

(B)檢查指標的有效性時使用assert(!dst && !src);

char *轉換為bool即是型別隱式轉換,這種功能雖然靈活,但更多的是導致出錯概率增大和維護成本升高。

(C)檢查指標的有效性時使用assert(dst != 0 && src != 0);

直接使用常量(如本例中的0)會減少程式的可維護性。而使用NULL代替0,如果出現拼寫錯誤,編譯器就會檢查出來。

[3]返回目標地址

(A)忘記儲存原始的strdst值。

[4]'\0'

(A)迴圈寫成while (*dst++=*src++);明顯是錯誤的。

(B)迴圈寫成while (*src!='\0') *dst++=*src++;

迴圈體結束後,dst字串的末尾沒有正確地加上'\0'。

假如考慮strDest和strSrc記憶體重疊的情況,strcpy該怎麼實現?

har s[10]="hello";

strcpy(s, s+1); //應返回ello,

//strcpy(s+1, s); //應返回hhello,但實際會報錯,因為dst與src重疊了,把'\0'覆蓋了

所謂重疊,就是src未處理的部分已經被dst給覆蓋了,只有一種情況:src<=dst<=src+strlen(src)

  1. char *my_strcpy(char *dst,const char *src)
  2. {
  3. assert(dst != NULL);
  4. assert(src != NULL);
  5. char *ret = dst;
  6. memcpy(dst,src,strlen(src)+1);
  7. return ret;
  8. }

memcpy函式實現時考慮到了記憶體重疊的情況,可以完成指定大小的記憶體拷貝。

  1. void * my_memcpy(void *dst,const void *src,unsigned int count)
  2. {
  3. assert(dst);
  4. assert(src);
  5. void * ret = dst;
  6. if (dst <= src || (char *)dst >= ((char *)src + count))//源地址和目的地址不重疊,低位元組向高位元組拷貝
  7. {
  8. while(count--)
  9. {
  10. *(char *)dst = *(char *)src;
  11. dst = (char *)dst + 1;
  12. src = (char *)src + 1;
  13. }
  14. }
  15. else //源地址和目的地址重疊,高位元組向低位元組拷貝
  16. {
  17. dst = (char *)dst + count - 1;
  18. src = (char *)src + count - 1;
  19. while(count--)
  20. {
  21. *(char *)dst = *(char *)src;
  22. dst = (char *)dst - 1;
  23. src = (char *)src - 1;
  24. }
  25. }
  26. return ret;
  27. }


strcat() 函式用來連線字串,其原型為:
char *strcat(char *dest, const char *src);

【引數】dest 為目的字串指標,src 為源字串指標。

strcat() 會將引數 src 字串複製到引數 dest 所指的字串尾部;dest 最後的結束字元 NULL 會被覆蓋掉,並在連線後的字串的尾部再增加一個 NULL。

注意:dest 與 src 所指的記憶體空間不能重疊,且 dest 要有足夠的空間來容納要複製的字串。

【返回值】返回dest 字串起始地址。

  1. char* Strcat(char *dst, const char *src)
  2. {
  3. assert(dst != NULL && src != NULL);
  4. char *temp = dst;
  5. while (*temp != '\0')
  6. temp++;
  7. while ((*temp++ = *src++) != '\0');
  8. return dst;
  9. }

int strcmp(const char* str1, const char* str2);
其中str1和str2可以是字串常量或者字串變數,返回值為整形。返回結果如下規定:
① str1小於str2,返回負值或者-1(VC返回-1);by wgenek 轉載請註明出處
② str1等於str2,返回0;
③ str1大於str2,返回正值或者1(VC返回1);

strcmp函式實際上是對字元的ASCII碼進行比較,實現原理如下:首先比較兩個串的第一個字元,若不相等,則停止比較並得出兩個ASCII碼大小比較的結果;如果相等就接著 比較第二個字元然後第三個字元等等。無論兩個字串是什麼樣,strcmp函式最多比較到其中一個字串遇到結束符'/0'為止

  1. int strcmp(const char* str1, const char* str2)
  2. {
  3. int ret = 0;
  4. while(!(ret=*(unsigned char*)str1-*(unsigned char*)str2) && *str1)
  5. {
  6. str1++;
  7. str2++
  8. }
  9. if (ret < 0)
  10. return -1;
  11. else if (ret > 0)
  12. return 1;
  13. return 0;
  14. }
  1. int strcmp(const char *str1, const char *str2)
  2. {assert(str1!=NULL&&str2!=NULL);
  3. while(*str1 == *str2 && *str1 != '\0' && *str2 != '\0')
  4. {
  5. ++str1;
  6. ++str2;
  7. }
  8. return *str1 - *str2;