單個class的new的重載和全局new的重載

單個class的new的重載

  1. 重載的 new、delete(或者 new[]、delete[])操作符必須是類的靜態成員函數(爲什麼必須是靜態成員函數,這很好理解,因爲 new 操作符被調用的時候,對象還未構建)或者是全局函數,函數的原型如下:
    1. void* operator new(size_t size) throw(std::bad_alloc);
    2. // 這裏的 size 爲分配的內存的總大小
    3. void* operator new[](size_t size) throw(std::bad_alloc);
    4.  
    5. void operator delete(void* p) throw();
    6. void operator delete[](void* p) throw();
    7.  
    8. void operator delete(void* p, size_t size) throw();
    9. // 區別於 new[] 的參數 size,這裏的 size 並非釋放的內存的總大小
    10. void operator delete[](void* p, size_t size) throw();

    另外,我們可以使用不同的參數來重載 new、delete(或者 new[]、delete[])操作符,例如:

    1. // 第一個參數仍爲 size_t
    2. void* operator new(size_t size, const char* file, int line);
    3. // 此操作符的使用
    4. string* str = new(__FILE__, __LINE__) string;

    重載全局的 new、delete(或者 new[]、delete[])操作符會改變所有默認分配行爲(包括某個類的分配行爲),因此必須小心使用,如果兩個庫都 new 等進行了全局重載,那麼就會出現鏈接錯誤(duplicated symbol link error)。而在類中定義的 new、delete(或者 new[]、delete[])操作符只會影響到本類以及派生類。 
    很多人完全沒有意識到 operator new、operator delete、operator new[]、operator delete[] 成員函數會被繼承(雖然它們是靜態函數)。有些時候,我們只想爲指定的類設置自定義的 operator new 成員函數,而不希望影響到子類的工作。《Effective C++ Third Edition》提供瞭如下的方案:

    1. void * Base::operator new(std::size_t size) throw(std::bad_alloc)
    2. {
    3. // 如果大小不爲基類大小
    4. if (size != sizeof(Base))
    5. // 調用標準的 new 操作符
    6. return ::operator new(size);
    7. 自定義大小爲基類大小的分配處理
    8. }

    這樣處理的一個前提是:認爲子類的大小一定大於父類。

    對於 operator new[] 來說,我們很難通過上面的方式檢查到底是父類還是子類調用了操作符。通過 operator new[] 操作符的參數,我們無法得知分配的元素的個數,無法得知分配的每個元素的大小。operator new[] 的參數 size_t 表明的內存分配的大小可能大於需要分配的元素的內存大小之和,因爲動態內存分配可能會分配額外的空間來保存數組元素的個數。

  2. 兼容默認的 new、delete 的錯誤處理方式 
    這不是個很簡單的事(詳細參考《Effective C++ Third Edition》 Item 51)。operator new 通常這樣編寫:
    1. // 這裏並沒有考慮多線程訪問的情況
    2. void* operator new(std::size_t size) throw(std::bad_alloc)
    3. {
    4. using namespace std;
    5.  
    6. // size == 0 時 new 也必須返回一個合法的指針
    7. if (size == 0)
    8. size = 1;
    9.  
    10. while (true) {
    11.  
    12. 嘗試進行內存的分配
    13.  
    14. if (內存分配成功)
    15. return (成功分配的內存的地址);
    16.  
    17. // 內存分配失敗時,查找當前的 new-handling function
    18. // 因爲沒有直接獲取到 new-handling function 的辦法,因此只能這麼做
    19. new_handler globalHandler = set_new_handler(0);
    20. set_new_handler(globalHandler);
    21.  
    22. // 如果存在 new-handling function 則調用
    23. if (globalHandler) (*globalHandler)();
    24. // 不存在 new-handling function 則拋出異常
    25. else throw std::bad_alloc();
    26. }
    27. }

    這一些方面是我們需要注意的:operator new 可以接受 size 爲 0 的內存分配且返回一個有效的指針;如果存在 new-handling function 那麼在內存分配失敗時會調用它並且再次嘗試內存分配;如果不存在 new-handling function 失敗時拋出 bad_alloc 異常。 
    要注意的是,一旦設置了 new-handling function 內存分配就會無限循環進行下去,爲了避免無限循環的發生,new-handling function 必須做以下幾件事中的一件(詳細參考《Effective C++ Third Edition》 Item 49):讓有更多內存可用、設置另一個能發揮作用的 new-handler、刪除當前的 new handler、拋出一個異常(bad_alloc 或者繼承於 bad_alloc)、直接調用 abort() 或者 exit() 等函數。

    對於 operator delete 的異常處理就簡單一些,只需要保證能夠安全的 delete 空指針即可:

    1. void operator delete(void *rawMemory) throw()
    2. {
    3. // 操作符可以接受空指針
    4. if (rawMemory == 0) return;
    5.  
    6. 釋放內存
    7. }

多態的問題(詳細參考《ISO/IEC 14882》) 
前面談到了 new、delete(new[]、delete[])操作符的繼承,這裏額外討論一下多態的問題,顯然我們只需要討論 delete、delete[] 操作符:

  1. struct B {
  2. virtual ~B();
  3. void operator delete(void*, size_t);
  4. };
  5.  
  6. struct D : B {
  7. void operator delete(void*);
  8. };
  9.  
  10. void f()
  11. {
  12. B* bp = new D;
  13. delete bp; //1: uses D::operator delete(void*)
  14. }

通過上面的例子,我們可以看到,delete 時正確的調用了 D 的 operator delete 操作符。但是同樣的,對於 delete[] 操作符工作就不正常了(因爲對於 delete[] 操作符的檢查是靜態的):

 

  1. struct B {
  2. virtual ~B();
  3. void operator delete[](void*, size_t);
  4. };
  5.  
  6. struct D : B {
  7. void operator delete[](void*, size_t);
  8. };
  9.  
  10. void f(int i)
  11. {
  12. D* dp = new D[i];
  13. delete [] dp; //uses D::operator delete[](void*, size_t)
  14. B* bp = new D[i];
  15. delete[] bp; //undefined behavior
  16. }

 

 

全局new的重載

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include "iostream"
#include "malloc.h"
 
 
using namespace std;
void* operator new(unsigned int size)
{
    if(size == 0) 
      size = 1; 
     void *res; 
     for(;;) 
     
       //allocate memory block 
       res = malloc(size); 
       //if successful allocation, return pointer to memory 
       if(res) 
           break
      //call installed new handler 
 
     cout<<"global new Override"<<endl;
 return res; 
}
class A{
public:
    int a;
 
};
 
class B{
public:    
    char b;
};
 
int main()
{
 
    A* pa = new A();
    B* pb = new B();
    delete pa;
    delete pb;
    return 0;
}

 

image

 

 

下面代碼有一些不同的地方

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include "iostream"
#include "malloc.h"
 
 
using namespace std;
void* operator new(unsigned int size)
{
    if(size == 0) 
      size = 1; 
     void *res; 
     for(;;) 
     
       //allocate memory block 
       res = malloc(size); 
       //if successful allocation, return pointer to memory 
       if(res) 
           break
      //call installed new handler 
 
     cout<<"global new Override"<<endl;
 return res; 
}
class A{
public:
    int a;
    A(){cout<<"A construtor"<<endl;}
    A(int i){cout<<"A construtor int i "<<endl;a=i;}
};
 
class B{
public:    
    char b;
};
 
int main()
{
 
    A* pa = new A(2);
    B* pb = new B();
    delete pa;
    delete pb;
    return 0;
}

image

 

看到重載new後並不會阻止調用構造函數

所以new的過程其實分爲兩個步驟 一個是調用分配內存,一個是調用構造函數。

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include "iostream"
#include "malloc.h"
 
 
using namespace std;
void* operator new(unsigned int size)
{
    if(size == 0) 
      size = 1; 
     void *res; 
     for(;;) 
     
       //allocate memory block 
       res = malloc(size); 
       //if successful allocation, return pointer to memory 
       if(res) 
           break
      //call installed new handler 
 
     cout<<"global new Override"<<endl;
 return res; 
}
class A{
public:
    int a;
    A(){cout<<"A construtor"<<endl;}
    A(int i){cout<<"A construtor int i "<<endl;a=i;}
    void* operator new(unsigned int size){
        cout<<"class new Override"<<endl;
         return ::operator new(size);
    }
};
 
class B{
public:    
    char b;
};
 
int main()
{
 
    A* pa = new A;
    B* pb = new B();
    delete pa;
    delete pb;
    return 0;
}

image

觀點:

是否應該爲單獨的class重載new

與全局 ::operator new() 不同,per-class operator new() 和 operator delete () 的影響面要小得多,它隻影響本 class 及其派生類。似乎重載 member operator new() 是可行的。我對此持反對態度。

如果一個 class Node 需要重載 member operator new(),說明它用到了特殊的內存分配策略,常見的情況是使用了內存池或對象池。我寧願把這一事實明顯地擺出來,而不是改變 new Node 的默認行爲。具體地說,是用 factory 來創建對象,比如 static Node* Node::createNode() 或者 static shared_ptr Node::createNode();。

這可以歸結爲最小驚訝原則:如果我在代碼裏讀到 Node* p = new Node,我會認爲它在 heap 上分配了內存,如果 Node class 重載了 member operator new(),那麼我要事先仔細閱讀 node.h 才能發現其實這行代碼使用了私有的內存池。爲什麼不寫得明確一點呢?寫成 Node* p = Node::createNode(),那麼我能猜到 Node::createNode() 肯定做了什麼與 new Node 不一樣的事情,免得將來大吃一驚。

The Zen of Python 說 explicit is better than implicit,我深信不疑。

 

 

是否應該重載全局的new

我們常常會設法優化性能,如果 profiling 表明 hot spot 在內存分配和釋放上,重載全局的 ::operator new() 和 ::operator delete() 似乎是一個一勞永逸好辦法(以下簡寫爲“重載 ::operator new()”),本文試圖說明這個辦法往往行不通。

 

如果只考慮分配和釋放,內存管理基本要求是“不重不漏”:既不重複 delete,也不漏掉 delete。也就說我們常說的 new/delete 要配對,“配對”不僅是個數相等,還隱含了 new 和 delete 的調用本身要匹配,不要“東家借的東西西家還”。例如:

  • 用系統默認的 malloc() 分配的內存要交給系統默認的 free() 去釋放;
  • 用系統默認的 new 表達式創建的對象要交給系統默認的 delete 表達式去析構並釋放;
  • 用系統默認的 new[] 表達式創建的對象要交給系統默認的 delete[] 表達式去析構並釋放;
  • 用系統默認的 ::operator new() 分配的的內存要交給系統默認的 ::operator delete() 去釋放;
  • 用 placement new 創建的對象要用 placement delete (爲了表述方便,姑且這麼說吧)去析構(其實就是直接調用析構函數);
  • 從某個內存池 A 分配的內存要還給這個內存池。
  • 如果定製 new/delete,那麼要按規矩來。見 Effective C++ 相關條款。

做到以上這些不難,是每個 C++ 開發人員的基本功。不過,如果你想重載全局的 ::operator new(),事情就麻煩了。

 

重載 ::operator new() 的理由

Effective C++ 第三版第 50 條列舉了定製 new/delete 的幾點理由:

  • 檢測代碼中的內存錯誤
  • 優化性能
  • 獲得內存使用的統計數據

這些都是正當的需求,文末我們將會看到,不重載 ::operator new() 也能達到同樣的目的。

 

::operator new() 的兩種重載方式

1. 不改變其簽名,無縫直接替換系統原有的版本,例如:

#include

void* operator new(size_t size);

void operator delete(void* p);

用這種方式的重載,使用方不需要包含任何特殊的頭文件,也就是說不需要看見這兩個函數聲明。“性能優化”通常用這種方式。

科普:簽名------ 方法簽名由方法名稱和一個參數列表(方法的參數的順序和類型)組成。 
 

2. 增加新的參數,調用時也提供這些額外的參數,例如:

void* operator new(size_t size, const char* file, int line);  // 其返回的指針必須能被普通的 ::operator delete(void*) 釋放

void operator delete(void* p, const char* file, int line);   // 這個函數只在析構函數拋異常的情況下才會被調用

然後用的時候是

Foo* p = new (__FILE, __LINE__) Foo;   // 這樣能跟蹤是哪個文件哪一行代碼分配的內存

我們也可以用宏替換 new 來節省打字。用這第二種方式重載,使用方需要看到這兩個函數聲明,也就是說要主動包含你提供的頭文件。“檢測內存錯誤”和“統計內存使用情況”通常會用這種方式重載。當然,這不是絕對的。

 

在學習 C++ 的階段,每個人都可以寫個一兩百行的程序來驗證教科書上的說法,重載 ::operator new() 在這樣的玩具程序裏邊不會造成什麼麻煩。

不過,我認爲在現實的產品開發中,重載 ::operator new() 乃是下策,我們有更簡單安全的辦法來到達以上目標。

 

現實的開發環境

作爲 C++ 應用程序的開發人員,在編寫稍具規模的程序時,我們通常會用到一些 library。我們可以根據 library 的提供方把它們大致分爲這麼幾大類:

  1. C 語言的標準庫,也包括 Linux 編程環境提供的 Posix 系列函數。
  2. 第三方的 C 語言庫,例如 OpenSSL。
  3. C++ 語言的標準庫,主要是 STL。(我想沒有人在產品中使用 IOStream 吧?)
  4. 第三方的通用 C++ 庫,例如 Boost.Regex,或者某款 XML 庫。
  5. 公司其他團隊的人開發的內部基礎 C++ 庫,比如網絡通信和日誌等基礎設施。
  6. 本項目組的同事自己開發的針對本應用的基礎庫,比如某三維模型的仿射變換模塊。

在使用這些 library 的時候,不可避免地要在各個 library 之間交換數據。比方說 library A 的輸出作爲 library B 的輸入,而 library A 的輸出本身常常會用到動態分配的內存(比如 std::vector)。

如果所有的 C++ library 都用同一套內存分配器(就是系統默認的 new/delete ),那麼內存的釋放就很方便,直接交給 delete 去釋放就行。如果不是這樣,那就得時時刻刻記住“這一塊內存是屬於哪個分配器,是系統默認的還是我們定製的,釋放的時候不要還錯了地方”。

(由於 C 語言不像 C++ 一樣提過了那麼多的定製性,C library 通常都會默認直接用 malloc/free 來分配和釋放內存,不存在上面提到的“內存還錯地方”問題。或者有的考慮更全面的 C library 會讓你註冊兩個函數,用於它內部分配和釋放內存,這就就能完全掌控該 library 的內存使用。

 

但是,如果重載了 ::operator new(),事情恐怕就沒有這麼簡單了。

重載 ::operator new() 的困境

首先,重載 ::operator new() 不會給 C 語言的庫帶來任何麻煩,當然,重載它得到的三點好處也無法讓 C 語言的庫享受到。

以下僅考慮 C++ library 和 C++ 主程序。

規則 1:絕對不能在 library 裏重載 ::operator new()

如果你是某個 library 的作者,你的 library 要提供給別人使用,那麼你無權重載全局 ::operator new(size_t) (注意這是上面提到的第一種重載方式),因爲這非常具有侵略性:任何用到你的 library 的程序都被迫使用了你重載的 ::operator new(),而別人很可能不願意這麼做。另外,如果有兩個 library 都試圖重載 ::operator new(size_t),那麼它們會打架,我估計會發生 duplicated symbol link error。乾脆,作爲 library 的編寫者,大家都不要重載 ::operator new(size_t) 好了。

那麼第二種重載方式呢?首先 ,::operator new(size_t size, const char* file, int line) 這種方式得到的 void* 指針必須同時能被 ::operator delete(void*) 和 ::operator delete(void* p, const char* file, int line) 這兩個函數釋放。這時候你需要決定,你的 ::operator new(size_t size, const char* file, int line) 返回的指針是不是兼容系統默認的 ::operator delete(void*)。

  • 如果不兼容(也就是說不能用系統默認的 ::operator delete(void*) 來釋放內存),那麼你得重載 ::operator delete(void*),讓它的行爲與你的 operator new(size_t size, const char* file, int line) 匹配。一旦你決定重載 ::operator delete(void*),那麼你必須重載 ::operator new(size_t),這就回到了情況 1:你無權重載全局 ::operator new(size_t)。
  • 如果選擇兼容系統默認的 ::operator delete(void*),那麼你在 operator new(size_t size, const char* file, int line) 裏能做的事情非常有限,比方說你不能額外動態分配內存來做 house keeping 或保存統計數據(無論顯示還是隱式),因爲系統默認的 ::operator delete(void*) 不會釋放你額外分配的內存。(這裏隱式分配內存指的是往 std::map<> 這樣的容器裏添加元素。)

看到這裏估計很多人已經暈了,但這還沒完。

 

其次 ,在 library 裏重載 operator new(size_t size, const char* file, int line) 還涉及到你的重載要不要暴露給 library 的使用者(其他 library 或主程序)。這裏“暴露”有兩層意思:1) 包含你的頭文件的代碼會不會用你重載的 ::operator new(),2) 重載之後的 ::operator new() 分配的內存能不能在你的 library 之外被安全地釋放。如果不行,那麼你是不是要暴露某個接口函數來讓使用者安全地釋放內存?或者返回 shared_ptr ,利用其“捕獲”deleter 的特性?聽上去好像挺複雜?這裏就不一一展開討論了,總之,作爲 library 的作者,絕對不要動“重載 operator new()”的念頭。

事實 2:在主程序裏重載 ::operator new() 作用不大

這不是一條規則,而是我試圖說明這麼做沒有多大意義。

如果用第一種方式重載全局 ::operator new(size_t),會影響本程序用到的所有 C++ library,這麼做或許不會有什麼問題,不過我建議你使用下一節介紹的更簡單的“替代辦法”。

如果用第二種方式重載 ::operator new(size_t size, const char* file, int line),那麼你的行爲是否惠及本程序用到的其他 C++ library 呢?比方說你要不要統計 C++ library 中的內存使用情況?如果某個 library 會返回它自己用 new 分配的內存和對象,讓你用完之後自己釋放,那麼是否打算對錯誤釋放內存做檢查?

C++ library 從代碼組織上有兩種形式:1) 以頭文件方式提供(如以 STL 和 Boost 爲代表的模板庫);2) 以頭文件+二進制庫文件方式提供(大多數非模板庫以此方式發佈)。

對於純以頭文件方式實現的 library,那麼你可以在你的程序的每個 .cpp 文件的第一行包含重載 ::operator new 的頭文件,這樣程序裏用到的其他 C++ library 也會轉而使用你的 ::operator new 來分配內存。當然這是一種相當有侵略性的做法,如果運氣好,編譯和運行都沒問題;如果運氣差一點,可能會遇到編譯錯誤,這其實還不算壞事;運氣更差一點,編譯沒有錯誤,運行的時候時不時出現非法訪問,導致 segment fault;或者在某些情況下你定製的分配策略與 library 有衝突,內存數據損壞,出現莫名其妙的行爲。

對於以庫文件方式實現的 library,這麼做並不能讓其受惠,因爲 library 的源文件已經編譯成了二進制代碼,它不會調用你新重載的 ::operator new(想想看,已經編譯的二進制代碼怎麼可能提供額外的 new (__FILE__, __LINE__) 參數呢?)更麻煩的是,如果某些頭文件有 inline function,還會引起詭異的“串擾”。即 library 有的部分用了你的分配器,有的部分用了系統默認的分配器,然後在釋放內存的時候沒有給對地方,造成分配器的數據結構被破壞。

總之,第二種重載方式看似功能更豐富,但其實與程序裏使用的其他 C++ library 很難無縫配合。

綜上,對於現實生活中的 C++ 項目,重載 ::operator new() 幾乎沒有用武之地,因爲很難處理好與程序所用的 C++ library 的關係,畢竟大多數 library 在設計的時候沒有考慮到你會重載 ::operator new() 並強塞給它。

如果確實需要定製內存分配,該如何辦?

替代辦法

很簡單,替換 malloc。如果需要,直接從 malloc 層面入手,通過 LD_PRELOAD 來加載一個 .so,其中有 malloc/free 的替代實現(drop-in replacement),這樣能同時爲 C 和 C++ 代碼服務,而且避免 C++ 重載 ::operator new() 的陰暗角落。

對於“檢測內存錯誤”這一用法,我們可以用 valgrind 或者 dmalloc 或者 efence 來達到相同的目的專業的除錯工具比自己山寨一個內存檢查器要靠譜。

對於“統計內存使用數據”,替換 malloc 同樣能得到足夠的信息,因爲我們可以用 backtrace() 函數來獲得調用棧,這比 new (__FILE__, __LINE__) 的信息更豐富。比方說你通過分析 (__FILE__, __LINE__) 發現 std::string 大量分配釋放內存,有超出預期的開銷,但是你卻不知道代碼裏哪一部分在反覆創建和銷燬 std::string 對象,因爲 (__FILE__, __LINE__) 只能告訴你最內層的調用函數。用 backtrace() 能找到真正的發起調用者。

對於“性能優化”這一用法,我認爲這目前的多線程開發中,自己實現一個能打敗系統默認的 malloc 的內存分配器是不現實的。一個通用的內存分配器本來就有相當的難度,爲多線程程序實現一個安全和高效的通用(全局)內存分配器超出了一般開發人員的能力。不如使用現有的針對多核多線程優化的 malloc,例如 Google tcmalloc 和Intel TBB 2.2 裏的內存分配器 。好在這些 allocator 都不是侵入式的,也無須重載 ::operator new()。

 

總結:重載 ::operator new() 或許在某些臨時的場合能應個急,但是不應該作爲一種策略來使用。如果需要,我們可以從 malloc 層面入手,徹底而全面地替換內存分配器。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章