1. 程式人生 > >淺談 C++ 中的 new/delete 和 new[]/delete[]

淺談 C++ 中的 new/delete 和 new[]/delete[]

在 C++ 中,你也許經常使用 new 和 delete 來動態申請和釋放記憶體,但你可曾想過以下問題呢?

  • new 和 delete 是函式嗎?
  • new [] 和 delete [] 又是什麼?什麼時候用它們?
  • 你知道 operator new 和 operator delete 嗎?
  • 為什麼 new [] 出來的陣列有時可以用 delete 釋放有時又不行?

如果你對這些問題都有疑問的話,不妨看看我這篇文章。

new 和 delete 到底是什麼?

如果找工作的同學看一些面試的書,我相信都會遇到這樣的題:sizeof 不是函式,然後舉出一堆的理由來證明 sizeof 不是函式。在這裡,和 sizeof 類似,new 和 delete 也不是函式,它們都是 C++ 定義的關鍵字,通過特定的語法可以組成表示式。和 sizeof 不同的是,sizeof 在編譯時候就可以確定其返回值,new 和 delete 背後的機制則比較複雜。
繼續往下之前,請你想想你認為 new 應該要做些什麼?也許你第一反應是,new 不就和 C 語言中的 malloc 函式一樣嘛,就用來動態申請空間的。你答對了一半,看看下面語句:

string *ps = new string("hello world");

你就可以看出 new 和 malloc 還是有點不同的,malloc 申請完空間之後不會對記憶體進行必要的初始化,而 new 可以。所以 new expression 背後要做的事情不是你想象的那麼簡單。在我用例項來解釋 new 背後的機制之前,你需要知道 operator new 和 operator delete 是什麼玩意。

operator new 和 operator delete

這兩個其實是 C++ 語言標準庫的庫函式,原型分別如下:

void *operator new(
size_t); //allocate an object void *operator delete(void *); //free an object void *operator new[](size_t); //allocate an array void *operator delete[](void *); //free an array

後面兩個你可以先不看,後面再介紹。前面兩個均是 C++ 標準庫函式,你可能會覺得這是函式嗎?請不要懷疑,這就是函式!C++ Primer 一書上說這不是過載 new 和 delete 表示式(如 operator=

 就是過載 = 操作符),因為 new 和 delete 是不允許過載的。但我還沒搞清楚為什麼要用 operator new 和 operator delete 來命名,比較費解。我們只要知道它們的意思就可以了,這兩個函式和 C 語言中的 malloc 和 free 函式有點像了,都是用來申請和釋放記憶體的,並且 operator new 申請記憶體之後不對記憶體進行初始化,直接返回申請記憶體的指標。

我們可以直接在我們的程式中使用這幾個函式。

new 和 delete 背後機制

知道上面兩個函式之後,我們用一個例項來解釋 new 和 delete 背後的機制:

我們不用簡單的 C++ 內建型別來舉例,使用複雜一點的類型別,定義一個類 A:

class A
{
public:
    A(int v) : var(v)
    {
        fopen_s(&file, "test", "r");
    }
    ~A()
    {
        fclose(file);
    }

private:
    int var;
    FILE *file;
};

很簡單,類 A 中有兩個私有成員,有一個建構函式和一個解構函式,建構函式中初始化私有變數 var 以及開啟一個檔案,解構函式關閉開啟的檔案。

我們使用

class A *pA = new A(10);

來建立一個類的物件,返回其指標 pA。如下圖所示 new 背後完成的工作:

簡單總結一下:

  1. 首先需要呼叫上面提到的 operator new 標準庫函式,傳入的引數為 class A 的大小,這裡為 8 個位元組,至於為什麼是 8 個位元組,你可以看看《深入 C++ 物件模型》一書,這裡不做多解釋。這樣函式返回的是分配記憶體的起始地址,這裡假設是 0x007da290。
  2. 上面分配的記憶體是未初始化的,也是未型別化的,第二步就在這一塊原始的記憶體上對類物件進行初始化,呼叫的是相應的建構函式,這裡是呼叫 A:A(10); 這個函式,從圖中也可以看到對這塊申請的記憶體進行了初始化,var=10, file 指向開啟的檔案
  3. 最後一步就是返回新分配並構造好的物件的指標,這裡 pA 就指向 0x007da290 這塊記憶體,pA 的型別為類 A 物件的指標。

所有這三步,你都可以通過反彙編找到相應的彙編程式碼,在這裡我就不列出了。

好了,那麼 delete 都幹了什麼呢?還是接著上面的例子,如果這時想釋放掉申請的類的物件怎麼辦?當然我們可以使用下面的語句來完成:

delete pA;

delete 所做的事情如下圖所示:

delete 就做了兩件事情:

  1. 呼叫 pA 指向物件的解構函式,對開啟的檔案進行關閉。
  2. 通過上面提到的標準庫函式 operator delete 來釋放該物件的記憶體,傳入函式的引數為 pA 的值,也就是 0x007d290。

好了,解釋完了 new 和 delete 背後所做的事情了,是不是覺得也很簡單?不就多了一個建構函式和解構函式的呼叫嘛。

如何申請和釋放一個數組?

我們經常要用到動態分配一個數組,也許是這樣的:

string *psa = new string[10];      //array of 10 empty strings
int *pia = new int[10];           //array of 10 uninitialized ints

上面在申請一個數組時都用到了 new [] 這個表示式來完成,按照我們上面講到的 new 和 delete 知識,第一個陣列是 string 型別,分配了儲存物件的記憶體空間之後,將呼叫 string 型別的預設建構函式依次初始化陣列中每個元素;第二個是申請具有內建型別的陣列,分配了儲存 10 個 int 物件的記憶體空間,但並沒有初始化。

如果我們想釋放空間了,可以用下面兩條語句:

delete [] psa;
delete [] pia;

都用到 delete [] 表示式,注意這地方的 [] 一般情況下不能漏掉!我們也可以想象這兩個語句分別幹了什麼:第一個對 10 個 string 物件分別呼叫解構函式,然後再釋放掉為物件分配的所有記憶體空間;第二個因為是內建型別不存在解構函式,直接釋放為 10 個 int 型分配的所有記憶體空間。

這裡對於第一種情況就有一個問題了:我們如何知道 psa 指向物件的陣列的大小?怎麼知道呼叫幾次解構函式?

這個問題直接導致我們需要在 new [] 一個物件陣列時,需要儲存陣列的維度,C++ 的做法是在分配陣列空間時多分配了 4 個位元組的大小,專門儲存陣列的大小,在 delete [] 時就可以取出這個儲存的數,就知道了需要呼叫解構函式多少次了。

還是用圖來說明比較清楚,我們定義了一個類 A,但不具體描述類的內容,這個類中有顯示的建構函式、解構函式等。那麼 當我們呼叫

class A *pAa = new A[3];

時需要做的事情如下:

從這個圖中我們可以看到申請時在陣列物件的上面還多分配了 4 個位元組用來儲存陣列的大小,但是最終返回的是物件陣列的指標,而不是所有分配空間的起始地址。

這樣的話,釋放就很簡單了:

delete []pAa;

這裡要注意的兩點是:

  • 呼叫解構函式的次數是從陣列物件指標前面的 4 個位元組中取出;
  • 傳入 operator delete[] 函式的引數不是陣列物件的指標 pAa,而是 pAa 的值減 4。

為什麼 new/delete 、new []/delete[] 要配對使用?

其實說了這麼多,還沒到我寫這篇文章的最原始意圖。從上面解釋的你應該懂了 new/delete、new[]/delete[] 的工作原理了,因為它們之間有差別,所以需要配對使用。但偏偏問題不是這麼簡單,這也是我遇到的問題,如下這段程式碼:

int *pia = new int[10];
delete []pia;

這肯定是沒問題的,但如果把 delete []pia; 換成 delete pia; 的話,會出問題嗎?

這就涉及到上面一節沒提到的問題了。上面我提到了在 new [] 時多分配 4 個位元組的緣由,因為析構時需要知道陣列的大小,但如果不呼叫解構函式呢(如內建型別,這裡的 int 陣列)?我們在 new [] 時就沒必要多分配那 4 個位元組, delete [] 時直接到第二步釋放為 int 陣列分配的空間。如果這裡使用 delete pia;那麼將會呼叫 operator delete 函式,傳入的引數是分配給陣列的起始地址,所做的事情就是釋放掉這塊記憶體空間。不存在問題的。

這裡說的使用 new [] 用 delete 來釋放物件的提前是:物件的型別是內建型別或者是無自定義的解構函式的類型別!

我們看看如果是帶有自定義解構函式的類型別,用 new [] 來建立類物件陣列,而用 delete 來釋放會發生什麼?用上面的例子來說明:

class A *pAa = new class A[3];
delete pAa;

那麼 delete pAa; 做了兩件事:

  • 呼叫一次 pAa 指向的物件的解構函式;
  • 呼叫 operator delete(pAa); 釋放記憶體。

顯然,這裡只對陣列的第一個類物件呼叫了解構函式,後面的兩個物件均沒呼叫解構函式,如果類物件中申請了大量的記憶體需要在解構函式中釋放,而你卻在銷燬陣列物件時少呼叫了解構函式,這會造成記憶體洩漏。

上面的問題你如果說沒關係的話,那麼第二點就是致命的了!直接釋放 pAa 指向的記憶體空間,這個總是會造成嚴重的段錯誤,程式必然會奔潰!因為分配的空間的起始地址是 pAa 指向的地方減去 4 個位元組的地方。你應該傳入引數設為那個地址!

同理,你可以分析如果使用 new 來分配,用 delete [] 來釋放會出現什麼問題?是不是總會導致程式錯誤?

總的來說,記住一點即可:new/delete、new[]/delete[] 要配套使用總是沒錯的!

參考資料:

C++ Primer 第四版