1. 程式人生 > 其它 >Effective C++讀書筆記~8 定製new和delete

Effective C++讀書筆記~8 定製new和delete

目錄

條款49:瞭解new-handle的行為

Understand the behavior of the new-handler.

operator new:分配例程;
operator delete:歸還例程。
new-handler:operator new無法滿足客戶的記憶體需求時所呼叫的函式。

由於heap是一個可寫的全域性資源,多執行緒環境下可能會出現多執行緒爭用情況。因此,多執行緒環境下,需要適當的同步控制(synchronization),加鎖等手段防止併發訪問(concurrent access)記憶體。

operator new,operator delete適合分配單一物件。Array所有的記憶體需要用operator[] new分配,由operator[] delete歸還。

STL容器使用的heap記憶體是由容器所擁有的分配器物件(allocator object)管理,不是被new和delete直接管理。

new-handler錯誤處理函式

當operator new無法滿足某一記憶體分配需求時,會丟擲異常。以前返回null指標,現在,在丟擲異常前,會先呼叫一個客戶指定的錯誤處理函式,即所謂new-handler。

客戶呼叫set_new_handler指定這個錯誤處理函式,其原型:

// Effective C++描述
namespace std {
    typedef void (*new_handler)();
    new_handler set_new_handler(new_handler p) throw();
}

// MSVC 2017中的宣告
// handler for operator new failures
typedef void (__CLRCALL_PURE_OR_CDECL * new_handler) ();
              // FUNCTION AND OBJECT DECLARATIONS
_CRTIMP2 new_handler __cdecl set_new_handler(_In_opt_ new_handler) noexcept;

new_handler是一個由typedef定義的函式指標型別,沒有返回值也不返回任何東西。
set_new_handler 是獲得一個new_handler型別引數p並返回一個new_handler函式。尾端throw()是一份異常說明,表明該函式不丟擲任何異常。

可以這樣使用set_new_handler:

// 當operator new無法分配足夠記憶體時,該函式被呼叫
void outOfMem()
{
    std::cerr << "Unable to satisfy request for memory" << endl;
    std::abort();
}

// 客戶端測試程式碼
int main()
{
       set_new_handler(outOfMem); //設定 new無法滿足客戶記憶體分配申請需求時,呼叫的錯誤處理函式
       int **p = new int*[1000];
       for (int i = 0; i < 1000; i++) {
              p[i] = new int[100000000L];
       }
       cout << "return from main" << endl;
       return 0;
}

在24GB記憶體機器上,報錯:"Unable to satisfy request for memory"。

new-handler函式的規範

當operator new無法滿足記憶體申請時,會不斷呼叫new-handler函式,直到找到足夠記憶體。一個設計良好的new-handler函式必須做以下事情:

  • 讓更多記憶體可被使用
    以便operator new的下一次記憶體分配動作可能成功。一個實現策略:程式一開始執行分配一大塊記憶體:而後當new-handler第一次被呼叫,就釋放以歸還給程式用。

  • 安裝另一個new-handler
    如果當前new-handler沒有取的更多可用記憶體能力,但知道哪個new-handler有這個能力,可以安裝另一個new-handler替換自己(呼叫set_new_handler即可)。

  • 卸除new-handler
    傳遞null指標給set_new_handler,operator new記憶體分配不成功時,不會丟擲任何異常。

  • 丟擲bad__alloc(或派生自bad_alloc)的異常
    該異常不會被operator new捕捉,因此會被傳播到記憶體申請處。

  • 不返回
    通常呼叫abort或exit。

new-handler的使用

有時,希望根據不同class以不同的方式處理記憶體分配失敗情況

class X {
public:
    static void outOfMemory();
    ...
};

class Y {
public:
    static void outOfMemory();
    ...
};

X *p1 = new X; // 如果分配不成功,呼叫X::outOfMemory

Y *p2 = new Y; // 如果分配不成功,呼叫Y::outOfMemory

C++不支援class專屬new-handler,只支援global new-handler。如果需要,就需要自行實現。方法是:令每個class提供自己的set_new_handler和operator new的static函式即可。
例如, class Widget使用operator new分配記憶體失敗時,利用輔助類NewHandlerHolder的解構函式幫助恢復原來的new-handler。

// RAII物件管理new_handler, 物件建立時儲存原來的global new_handler到handler, 析構時還原global new_handler
class NewHandlerHolder {
public:
       explicit NewHandlerHolder(std::new_handler nh) : handler(nh) {}
       ~NewHandlerHolder() { std::set_new_handler(handler); }
private:
       std::new_handler handler;
       NewHandlerHolder(const NewHandlerHolder&); // 阻止copying constructor
       NewHandlerHolder& operator=(const NewHandlerHolder&); // 阻止copying  assignment
};

// 假設要處理Widget class記憶體分配失敗情況
class Widget
{
public:
       static std::new_handler set_new_handler(std::new_handler p) throw();
       static void* operator new(std::size_t size) throw(std::bad_alloc);
private:
       static std::new_handler currentHandler; // currentHandler用來儲存當前要傳入的錯誤處理函式, 是在物件生成之前就有的, 所以是static
};
std::new_handler Widget::currentHandler = nullptr;

std::new_handler Widget::set_new_handler(std::new_handler p) throw()
{
       std::new_handler oldHandler = currentHandler;
       currentHandler = p;
       return oldHandler;
}
void* Widget::operator new(std::size_t size) throw(std::bad_alloc)
{
       // 安裝Widget的new-handler, 分配記憶體或丟擲異常就恢復global new-handler
       NewHandlerHolder h(std::set_new_handler(currentHandler)); // 建立區域性變數, 退出local作用域時自動析構, i.e. 使用者自定義new-handler只有operator new申請分配記憶體期間有效
       return ::operator new(size);
}

// 客戶端測試程式碼
void outOfMem()
{
       cerr << "out of memory" << endl;
       std::abort();
}
int main()
{
       Widget::set_new_handler(outOfMem);
       // 會導致out of memory
       for (size_t i = 0; i < LLONG_MAX; i++) {
              Widget *pwl = new Widget;
       }
       std::string* ps = new std::string;
       Widget::set_new_handler(0);
       Widget *pw2 = new Widget;
       cout << "return from main" << endl;
       return 0;
}

上面程式碼巧妙之處,就是利用RAII方式,恢復Widget用operator new申請記憶體發生時的錯誤處理函式。
operator new中的臨時物件 NewHandlerHolder h會在呼叫全域性operator new之後,自動恢復global new-handler(不論成功與否)。

奇特的迴圈模板模式 CRTP

上面程式碼只適用於具體的class,然而每個要這樣處理operator new異常的class都會這樣寫。於是,我們改寫成class template形式:

template<typename T>
class NewHandlerSupport {
public:
       static std::new_handler set_new_handler(std::new_handler p) throw();
       static void* operator new(std::size_t size) throw(std::bad_alloc);

       ~NewHandlerSupport() { std::set_new_handler(olderHandler); }
private:
       static std::new_handler currentHandler;
       static std::new_handler olderHandler;
};

template<typename T>
std::new_handler NewHandlerSupport<T>::currentHandler;

template<typename T>
std::new_handler NewHandlerSupport<T>::set_new_handler(std::new_handler p) throw()
{
       olderHandler = currentHandler;
       currentHandler = p;
       return olderHandler;
}

template<typename T>
void* NewHandlerSupport<T>::operator new(std::size_t size) throw(std::bad_alloc)
{
       NewHandlerHolder h(std::set_new_handler(currentHandler));
       return ::operator new(size);
}

class Widget : public NewHandlerSupport<Widget> {
       // 已經擁有了NewHandlerSupport<T> 那部分成員
       // ... 和先前一樣, 但不必宣告
};

為什麼使用template?

我們並沒有使用NewHandlerSupport template中的引數T,只是希望繼承自NewHandlerSupport的每個class,都擁有不同的NewHandlerSupport復件,明確說,是其static成員currentHandler,引數T只是用來區分不同derived class。Template機制會為每個T生成一份currenHandler。

迴圈模板模式 CRTP - Do It For Me

Widget繼承自一個模板化的templated base class,而後者又以Widget作為型別引數。這種技術被稱為 奇特的迴圈模板模式(curiously recurring template pattern;CRTP)。有了NewHandlerSupport這樣的template,為任何class新增一個專屬new-handler成為易事。模板化的NewHandlerSupport,更像是為了Widget而存在的專屬template,因此另一種理解這種模式為Do It For Me。

1993年以前,舊的operator new在無法分配足夠記憶體時,返回null。新operator new,則應該丟擲bad_alloc異常。由於要相容新規範以前的程式,C++提供另一種形式operator new:負責供應傳統的“分配失敗便返回null”,稱為“nothrow”形式 -- 因為在new的使用場合用了nothrow物件(標頭檔案):

class Widget { ... };
Widget* pw1 = new Widget; // 如果分配失敗,丟擲bad_alloc
if (pw1 == 0) ... // 該測試一定失敗,因為pw1不會為null
Widget* pw2 = new (std::nothrow) Widget; // 如果分配失敗,則返回0
if (pw2 == 0) ... // 該測試可能成功

nothrow new對異常強制保證性並不高。表示式“new(std::nothrow) Widget”發生兩件事:1)nothrow 版的operator new被呼叫,用來分配足夠記憶體給Widget物件。2)如果分配失敗,返回null;如果分配成功,接下來呼叫Widget建構函式。nothrow new只能保證operator new不丟擲異常,無法保證建構函式的呼叫不丟擲異常

小結

1)set_new_handler允許客戶指定一個函式,在記憶體分配無法獲得滿足時被呼叫;
2)Nothrow new是有很多侷限的工具,因為它只適用於記憶體分配,後繼的建構函式呼叫還是可能丟擲異常;

[======]

條款50:瞭解new和delete的合理替換時機

Understand when it makes sense to replace new and delete.

為什麼會需要替換編譯器提供的operator new或operator delete?
常見三個理由:

  • 用來檢測運用上的錯誤。
    如果new所得記憶體,delete失敗(或者沒有delete),會導致記憶體洩漏。
    如果new所得記憶體,多次呼叫delete,會導致不確定行為。
    如果程式很可能導致資料“overruns”(寫入點在分配區塊尾端之後)或“underruns”(寫入點在分配區塊之前)。
    重寫operator new可超額分配記憶體,提供額外空間用於簽名,重寫operator delete變可以檢查是否有越界操作。如果有,operator delete可以log發生問題的指標。

  • 為了強化效能
    編譯器提供的operator new和operator delete主要用於一般目的,但對於特定問題,定製版本修改記憶體的分配和回收策略,可能更有效。

  • 為了收集使用上的統計資料
    在定製new和delete前,如何得知動態記憶體的使用情況?比如,分配區塊大小分佈,FIFO or LIFO or 隨機分配和歸還?自定義operator new和operator delete能輕鬆收集到這些資訊。

定製operator new示例

例,定製簡單operator new,協助檢測“overruns”和“underruns”。

static const int signature = 0xDEADBEEF; // 簽名
typedef unsigned char Byte;

// 定製operator new
// 這段程式碼還有若干小錯誤
void* operator new(std::size_t size) throw(std::bad_alloc)
{
       using namespace std;
       size_t realSize = size + 2 * sizeof(int);
       void *pMem = malloc(realSize);
       if (!pMem) throw bad_alloc();
       
       // 將signature寫入記憶體的最前段落和最後段落
       *(static_cast<int*>(pMem)) = signature;
       *(reinterpret_cast<int*>(static_cast<Byte*>(pMem)
              + realSize - sizeof(int))) = signature;
       // 返回指標,指向第一個signature之後的記憶體位置
       return static_cast<Byte*>(pMem) + sizeof(int);
}
// 定製配套operator delete
void operator delete(void* p) throw()
{
       void *start = (static_cast<Byte*>(p) - sizeof(int));
       free(start);
}

// 客戶端測試
int main()
{
       int* p = new int;
       *p = 1;
       cout << *p << endl;
       delete p;
       return 0;
}

該定製版operator new缺點:
1)沒有遵循條款51,未內含一個無窮迴圈並在其中嘗試分配記憶體,呼叫new-handler。也沒有處理0byte申請。
2)沒有考慮對齊問題。
3)還有可移植性,執行緒安全等問題。

對齊問題

這裡,我們主要探討對齊(alignment):有些計算機體系結構要求特定型別必須放在特定記憶體地址上。如指標地址必須是4倍數(four-byte aligned)或double地址必須是8倍數(eight-byte aligned)。如果沒有遵循這個約束條件,可能導致執行期硬體異常。而有些並沒有這麼嚴格要求,對於如double,只要是byte對齊即可,但如果是8byte對齊,則訪問速度會快很多。
malloc返回的指標是安全的,但我們在程式裡面對其偏移了一個int大小的位置,而int是固定4byte,如果我們申請8byte的double,就可能導致對齊問題。

何時替換new/delete

何時在“全域性性的”或者“class專屬的”基礎上,合理替換預設的new和delete?

  • 為了檢測運用錯誤;
  • 為了收集動態分配記憶體之使用統計資訊;
  • 為了增加分配和歸還的速度
    針對特定型別定製new和delete的速度,往往快於編譯器提供的預設new和delete
  • 為了降低預設記憶體管理器帶來的空間額外開銷
    泛用型記憶體管理器往往比定製性慢,還使用更多記憶體,因為它們常常在每個分配區塊上招引某些額外開銷。針對小型物件開發的分配器(如Boost的Pool程式庫)本質上消除了這樣的額外開銷。
  • 為了彌補預設分配器中的非最佳對齊
    將不保證對齊的new替換為對齊的版本,可能導致程式效率大幅提升。
  • 為了將相關物件成簇集中
    如果指定某個資料結構往往一起使用,而你有希望處理這些資料時,將“記憶體頁錯誤”(page fault)的頻率降至最低,那麼為此資料結構建立另一個heap就有意義,這樣它們就可以被成簇集中在儘可能少的記憶體頁(page)上。見條款52。
  • 為了獲得非傳統的行為
    有時希望operator new和delete做編譯器提供的預設版本沒做的事情,如將C API封裝成C++ API,將歸還記憶體覆蓋為0。

小結

1)有許多理由寫個自定義的new和delete,包括改善效能、對heap運用錯誤進行除錯、收集heap使用資訊。

[======]

條款51:編寫new和delete時需固守常規

Adhere to conversion when writing new and delete.

條款50解釋何時需要編寫自己的operator new和operator delete。但如果定製自己的new和delete,應當遵守什麼規則呢?

自定義new需要遵循的規則

1)記憶體不足時,必呼叫new-handler函式,必須有對付零記憶體需求的準備,需避免不慎掩蓋正常形式的new(介面要求)。

2)operator new的返回值十分單純。如果有能力提供客戶申請的記憶體,就返回一個指標指向那塊記憶體;如果沒有能力,就遵循條款49,丟擲bad_alloc異常。不過,operator new實際上不止一次嘗試分配記憶體,並在每次失敗後呼叫new-handling函式。這裡假設new-handling函式也許能做某些動作,將某些記憶體釋放。只有當指向new-handling函式的指標是null,operator new才會丟擲異常。

3)處理零記憶體申請:即使客戶要求0byte,operator new也得返回一個合法指標。這個看似詭異的行為,是為了簡化語言的其他部分。

例,適用於單執行緒

void *operator new(std::size_t size) throw(std::bad_alloc)
{
       using namespace std;
       if (size == 0) { // 處理0byte申請
              size = 1; // 將其視為1byte申請
       }
       while (true) {
              嘗試分配size bytes;
              if (分配成功)
                     return  (一個指標,指向分配得來的記憶體);

              // 分配失敗:找出目前的new-handling函式
              new_handler globalHandler = set_new_handler(0);
              set_new_handler(globalHandler);

              if (globalHandler) (*globalHandler)();
              else throw std::bad_alloc();
       }
}

當上述operator new作為一個class的專屬operator new時,存在一個問題:class作為Base可能會被繼承,而針對class Base設計的operator new可能只剛好只為sizeof(Base)大小物件而設計,對於繼承自Base的Derived,其物件大小很可能大於Base物件大小,這樣就會導致“記憶體申請量錯誤”的問題。

class Base {
public:
       static void* operator new(std::size_t size) throw(std::bad_alloc);
       ...
};
class Derived: public Base { ... }; //假設Derived未重寫operator new

// 客戶端
Derived *p = new Derived; // 這裡呼叫Base::operator new

客戶端呼叫operator new時,傳入的是Derived物件大小sizeof(Derived),而Base::operator new中考慮申請物件大小是sizeof(Base),該引數由編譯器自動生成並傳入,Base物件大小通常大於Derived物件大小,這樣就產生了問題。

解決辦法:

void* Base::operator new(std::size_t size) throw(std::bad_alloc)
{
     if (size != sizeof(Base)) //如果大小錯誤, 就令標準的operator new處理
          return ::operator new(size);
     ... // 否則,在這裡處理
}

撰寫operator new時,不能保證要申請元素大小一定是當前class物件大小。傳遞給operator newp[]的大小,也不一定是每個元素大小 * 元素個數,因為可能還包含其他資訊,如元素個數。

自定義delete需要遵循的規則

撰寫operator delete時,需要記住:C++保證“刪除null指標永遠安全”。
1)non-member operator delete偽碼(pseudocode)

void operator delete(void *rawMemory) throw()
{
     if (rawMemory == 0) return; // 如果被刪除的是null指標,那就什麼都不做
     現在,歸還rawMemory所指記憶體;
}

2)member operator delete偽碼

class Base {
public:
       static void* operator new(std::size_t size) throw(std::bad_alloc);
       static void operator delete(void* rawMemory, std::size_t size) throw();
       ...
};

void Base::operator delete(void* rawMemory, std::size_t size) throw()
{
       if (rawMemory == 0) return; // 如果被刪除的是null指標,那就什麼都不做
       if (size != sizeof(Base)) {
              ::operator delete(rawMemory);
              return;
       }
       現在,歸還rawMemory所指記憶體;
       return;
}

需要注意的是:如果即將被刪除的物件派生自某個base class,而後者欠缺virtual解構函式,那麼C++傳給operator delete的size_t數值可能不正確。

小結

1)operator new應該內含一個無窮迴圈,並在其中嘗試分配記憶體。無關無法滿足記憶體需求,就應該呼叫new-handler(錯誤處理)。應該有能力處理0byte申請。Class專屬版本還應該處理“比正確大小更大的(錯誤)申請”。
2)operator delete應該在收到null指標時,不做任何處理。Class專屬版本應該處理“比正確大小更大的(錯誤)申請”。

[======]

條款52:寫了placement new也要寫placement delete

Write placement delete if you write placement new.

什麼是placement new,placement delete?

預設情況下,我們使用operator new給物件分配儲存空間並呼叫其建構函式

void* operator new(std::size_t) throw(std::bad_alloc); // 預設new

// 客戶端 對應呼叫方式
Widget* pw = new Widget;
// delete 對應正常的operator delete
void operator delete(void* )

客戶端呼叫了2個函式:1)operator new分配記憶體,2)Widget的default建構函式。
假設第一個函式(operator new分配記憶體)呼叫成功,第二個函式(default建構函式)呼叫失敗。那麼第一步申請分配的記憶體就必須釋放並恢復到舊的狀態,否則會造成記憶體洩漏(memory leak)。但此時,客戶還沒用能力歸還記憶體,因為Widget建構函式丟擲了異常,也就是說,pw指標並沒有被賦值,客戶也就沒有指向這塊記憶體的指標。因此,歸還記憶體就成了執行期系統的責任。

典型的正常形式operator new和delete:

void* operator new(std::size_t) throw(std::bad_alloc); // global和class作用域正常簽名式

void operator delete(void* rawMem) throw(); // global作用域的正常簽名式
void operator delete(void* rawMem, std::size_t size) throw(); // class作用域的典型簽名式

placement new與placement delete

如果operator new接受的引數除了預設size_t外,還有其他引數,那麼就稱該operator new為placement new。
其中,有個特別的placement new版本,接受一個指標指向物件被構造之處,也就是說,pMem指向一塊已經分配得到的記憶體,呼叫該placement new可以在指定記憶體(引數pMem指向的)上建立物件。大多數情況下,人們所指的placement new就是特定版本的operator new(唯一額外引數是void*),少數情況指包含任意額外引數。

與placement new相對地,也存在placement delete。

#include <new>

void* operator new(std::size_t, void* pMem) throw(std::bad_alloc); // placement new
void operator delete(void* pMem, std::size_t size) throw(); // placement delete

// Widget class 宣告式
class Widget {
public:
    static void* operator new(std::size_t, std::ostream& logStream) throw(std::bad_alloc); // placement new
    static void operator delete(void* pMem, std::size_t size) throw();  // 正常class的專屬delete
    static void operator delete(void *pMem) throw();
    static void operator delete(void* pMem, std::ostream& logStream) throw(); // 與placement new配套的placement delete
};

如果Widget建構函式丟擲異常,呼叫哪個版本operator delete?

Widget建構函式丟擲了異常,執行期系統有責任取消operator new的分配並恢復到舊狀態,不過,執行期系統無法知道真正被呼叫的那個operator new如何運作(如在建構函式中又做了哪些事情),因此它無法取消分配、恢復舊狀態。取而代之的是,執行期系統尋找“引數個數和型別都與operator new相同的某個operator delete”。如果找到,呼叫之;如果沒有,就不會有任何operator delete被呼叫。

因此,Widget class的operator new丟擲異常時,對應版本placement delete會被自動呼叫,讓Widget有機會確保不洩漏任何記憶體。

// 客戶端
Widget* pw = new (std::cerr) Widget; // 呼叫Widget::operator new(sizeof(Widget), cerr);
// 出現異常時,執行期系統選擇呼叫operator new配套的operator delete(2者額外引數相同)
void Widget::operator delete(void*, std::ostream&) throw();

// 正常的釋放記憶體操作
delete pw;
// delete pw呼叫Widget::operator delete(void*, size_t)

注意:對一個指標施行delete絕不會呼叫placement delete。

placement與名稱遮掩問題

1)class專屬placement new會遮掩正常的global new

class B
{
public:
       ...
       // 該placement new會遮掩正常形式的global new
       static void* operator new(std::size_t size, std::ostream& logStream)  throw(std::bad_alloc);
}

B* pb = new B; // 錯誤:正常形式operator new會被遮掩
B* pb = new (std::cerr) B; // OK:呼叫B::operator new(size_t, ostream&)

2)derived class的專屬operator new會這樣global new和繼承而來的operator new

class C : public B {
public:
       ...
       // 該placement new會遮掩正常形式的global new和從B繼承的placement new
       static void* operator new(std::size_t size) throw(std::bad_alloc);
};

C* pc = new(std::clog) C; // 錯誤:B的placement new被遮掩
C* pc = new C; // OK:呼叫C::operator new(size_t)

如何解決placement名稱遮掩問題?

除非確定就是想遮掩global new和Base class的placement版本,否則,可以使用using,或者在當前class明確定義專屬placement new和placement delete。還有一個簡便辦法,就是建立一個base class,內含所有normal new和delete:

/* 標準形式new/delete */
class StandardNewDeleteForms {
public:
       // normal new/delete
       static void* operator new(std::size_t size) throw(std::bad_alloc)
       {
              return ::operator new(size);
       }
       static void operator delete(void* pMem) throw()
       {
              ::operator delete(pMem);
       }
       // placement new/delete
       static void* operator new(std::size_t size, void* ptr) throw()
       {
              return ::operator new(size, ptr);
       }
       static void operator delete(void* pMem, void* ptr) throw()
       {
              return ::operator delete(pMem, ptr);
       }
       // nothrow new/delete
       static void* operator new(std::size_t size, const std::nothrow_t& nt)  throw()
       {
              return ::operator new(size, nt);
       }
       static void operator delete(void* pMem, const std::nothrow_t) throw()
       {
              ::operator delete(pMem);
       }
};
class MyWidget : public StandardNewDeleteForms { // 繼承標準形式
public:
       // 避免基類的new/delete名稱被遮掩
       using StandardNewDeleteForms::operator new;
       using StandardNewDeleteForms::operator delete;
       // 新增一個自定義placement new
       static void* operator new(std::size_t size, std::ostream& logStream)  throw(std::bad_alloc);
       // 新增一個自定義placement delete
       static void operator delete(void* pMem, std::ostream& logStream) throw();
       // ...
};

小結

1)當你寫一個placement operator new,請確定也寫出了對應的placement operator delete。如果沒有這樣做,程式可能會發生隱蔽的記憶體洩漏問題;
2)當你宣告placement new和placement delete,請確定不要無意識(非故意)地遮掩了它們的正常版本。

[======]