《Effective C++》讀書筆記——第三章:Resource Management

這一章主要在講資源的管理,電腦的資源就跟圖書館的書一樣,你想看的時候可以借,但看完了就應該還,否則其他人就沒法看你借的書。其中最重要的也就是內存的分配和回收了,比較常見的性能問題就是由於分配了內存但是沒有回收,於是就會造成泄露。


ITEM 13: USE OBJECTS TO MANAGE RESOURCES

所謂“誰污染,誰治理”,在程序中也是一樣,誰申請內存,誰就應該負責在用完後釋放它,因此一條基本原則就是每有一個new,就應當有一個delete。比如下面的代碼:

void f()
{
  Investment *pInv = createInvestment();         // call factory function

  ...                                            // use pInv

  delete pInv;                                   // release object
}

在理想情況下這樣當然沒有什麼問題,但是事情往往不會永遠按照我們的預期發展,在有些情況下delete的釋放會失敗,比如:在使用這個對象的過程中整個函數提前返回了,那麼最後的delete就不會被調用到。或者說在一個循環內newdelete,然後又在中途調用了continuebreak之類的語句。這樣都會造成內存的泄露,同時該對象持有的所有資源也被泄露了。

即使我們很小心的編寫代碼,在每個離開的地方都去判斷是否需要delete,但並不是每個人都會注意,如果是其他人也要修改這一塊的代碼,他很有可能不知道這裏有這麼個坑,於是就在無意識中造成了內存的泄露,於是你就得花上幾天的功夫來debug到底是哪裏造成了泄露。因此最理想的情況應該是,當指針離開了某個塊或作用域,它就會自動被釋放掉。

於是就引出了這樣的想法:依賴C++的析構函數來幫助我們釋放內存,因爲當一個對象離開了作用域後它的析構函數會自動被調用,所以我們應當使用對象來管理資源。標準庫中的智能指針auto_ptr就可以幫助我們做到這件事,它是一個類似指針的對象,會自動在析構函數中釋放所指向的內存。改進之後代碼如下:

void f()
{
  std::auto_ptr<Investment> pInv(createInvestment());  // call factory
                                                       // function

  ...                                                  // use pInv as
                                                       // before

}                                                      // automatically
                                                       // delete pInv via
                                                       // auto_ptr's dtor

可以看到,當Investment類型的對象創建後,它的資源就轉交給auto_ptr進行了管理,實際上是用這個對象來初始化了auto_ptr,這種用對象管理資源的做法叫做Resource Acquisition Is Initialization(RAII)。智能指針使用起來跟普通的指針沒有區別,但是當智能指針被銷燬時它會自動幫我們釋放這一片內存。這裏有一個需要注意的地方,因爲auto_ptr會自動釋放內存,所以不能讓兩個auto_ptr指向同一片內存,否則就會造成重複釋放,因此它的複製特性會看起來有點奇怪,在複製的同時會將原來的指針置空。我自己寫了個小的測試程序來驗證這一點:
auto_ptr的拷貝
可以很清楚的看到,用pInt1來拷貝構造pInt2之後pInt1就爲空了,而pInt2指向了pInt1原來指向的內存。當再次把pInt2賦值給pInt1後,pInt1又重新指向了原來的區域,而pInt2被置空。

這樣用起來會有很大的侷限性,因爲沒法讓兩個指針指向同一個區域了,於是就有了用的最多的shared_ptr,引用計數指針(reference-counting smart pointer)。它的作用就是當沒有指針指向某個對象後會負責釋放它,類似於垃圾回收,但是它無法打破循環引用(A指向B,B指向A,那麼這兩個對象永遠不會被釋放)。我也寫了個小的測試程序感受了一下:
shared_ptr的引用計數
可以看到,每有一個指針指向分配的那一片內存,引用計數就會加一,而指針被銷燬後引用計數也會下降,最後當最後一個指向該內存的指針也被銷燬時它會負責釋放這一片內存。有一點需要注意:auto_ptrshared_ptr的析構函數調用的都是delete而不是delete [],也就是說我們不應該用智能指針來管理動態的數組,因爲它被自動析構時只有第一個元素的內存會被釋放。

std::auto_ptr<std::string>                       // bad idea! the wrong
  aps(new std::string[10]);                      // delete form will be used

std::tr1::shared_ptr<int> spi(new int[1024]);    // same problem

總結:

1. 使用RAII的方法在構造函數中獲取資源,並在析構函數中釋放它
2. 兩種常用的RAII類是auto_ptrshared_ptr,後者通常是更好的選擇,因爲它的複製操作更加合理,前者的複製操作會將源指針置空(類似於轉移)


ITEM 14: THINK CAREFULLY ABOUT COPYING BEHAVIOR IN RESOURCE-MANAGING CLASSES

上面一條講的主要是管理堆上的內存,但我們並不是所有的資源都在堆上,這個時候用智能指針可能不太合適,我們就得自己寫一個管理類。比如說我們正在使用C的API操作mutex對象,那就不可避免的要加鎖和解鎖:

void lock(Mutex *pm);               // lock mutex pointed to by pm

void unlock(Mutex *pm);             // unlock the mutex

於是我們希望有一個類在構造函數中加鎖,在析構函數中解鎖:

class Lock {
public:
  explicit Lock(Mutex *pm)
  : mutexPtr(pm)
  { lock(mutexPtr); }                          // acquire resource

  ~Lock() { unlock(mutexPtr); }                // release resource

private:
  Mutex *mutexPtr;
};

客戶端代碼

Mutex m;                    // define the mutex you need to use

...

{                           // create block to define critical section
Lock ml(&m);               // lock the mutex

...                         // perform critical section operations

}                           // automatically unlock mutex at end
                            // of block

正常情況下OK,但是如果要複製的時候會發生什麼呢

Lock ml1(&m);                      // lock m

Lock ml2(ml1);                     // copy ml1 to ml2—what should
                                   // happen here?

這其實是一個很寬泛的問題,就是RAII的複製操作應該如何進行,在大多數情況下有以下幾種選擇:

  • 禁止拷貝:這種情況下對RAII的拷貝沒有意義,比如上面的Lock類,因此我們直接禁止進行拷貝操作,具體做法參照第二章的item 6
  • 引用計數管理的資源:這種情況就是shared_ptr的行爲,大家共用一個,最後用的負責釋放。如果一個類需要引用計數的特性,它可以包含一個shared_ptr來實現。不幸的是shared_ptr在計數歸零後的默認行爲是釋放管理的對象,幸運的是我們可以改寫它的deleter,讓它做我們希望做的事(在Lock中就是解鎖)
class Lock {
public:
  explicit Lock(Mutex *pm)       // init shared_ptr with the Mutex
  : mutexPtr(pm, unlock)         // to point to and the unlock func
  {                              // as the deleter

    lock(mutexPtr.get());   // see Item 15 for info on "get"
  }
private:
  std::tr1::shared_ptr<Mutex> mutexPtr;    // use shared_ptr
};                                         // instead of raw pointer

這裏我們沒有聲明析構函數了,因爲沒有這個必要,當Lock被析構時會自動調用成員對象的析構函數,所以智能指針就會自動調用unlock

  • 拷貝資源:既拷貝管理類對象也拷貝管理的資源,既深拷貝
  • 轉移控制權:auto_ptr的行爲,保證只有一個類在對資源進行管理

總結:

1. 拷貝一個RAII對象涉及到拷貝它管理的資源,所以對資源的拷貝方式決定了拷貝RAII對象的方式
2. 一般的RAII類不支持拷貝和引用計數,但其他行爲是允許的


ITEM 15: PROVIDE ACCESS TO RAW RESOURCES IN RESOURCE-MANAGING CLASSES

資源管理類用着很方便,但是工作中難免會出現需要直接訪問它所管理的對象的情況,比如QT中連信號和槽就必須把QObject*作爲參數,因此我們最好提供對原始資源的直接訪問,通常有顯式轉換和隱式轉換兩種方法。

比如auto_ptrshared_ptr都提供了get方法顯式的返回一份對內部原始指針的拷貝。同時它們也重載瞭解引用運算符(operator*operater->),提供了隱式轉換的方法。

有的RAII類爲了用起來更方便,會提供隱式轉換的方法(否則每次都要調用get,會讓代碼看起來很冗餘),這裏學習了一下用operator關鍵字進行類型轉化的用法:

class Font {
public:
  ...
  operator FontHandle() const { return f; }        // implicit conversion function
  
  ...
};

函數名是什麼類就表示轉化成什麼類,於是下次傳遞RAII類時如果不寫get就會調用這個轉化函數進行隱式轉化。當然這樣做的風險就是增加了出錯的概率,因爲有可能你其實想拷貝RAII類但是卻錯誤的拷貝了它所管理的類。總之,需要自己權衡應該用顯式轉換保證直觀性,還是用隱式轉換保證自然性。

最後聊了一下關於RAII類對封裝的破壞,作者對此的解釋是:RAII類並不是用來封裝的,它是爲了保證資源被正確的分配和釋放。而且,有的RAII類結合了良好的封裝性和較松的封裝性,比如shared_ptr對於引用計數的實現進行了封裝,同時又提供了get方法允許用戶訪問管理的資源。好的類應當隱藏客戶端不需要知道的,但提供客戶端可能需要知道的。

總結:

1. API經常需要訪問原始類的指針,因此在RAII類中需要提供方法對資源進行訪問
2. 對資源的訪問可以通過顯式或隱式的方法完成,通常來說顯式更安全,隱式用起來更方便


ITEM 16: USE THE SAME FORM IN CORRESPONDING USES OF NEW AND DELETE

這條比較簡單,一句話概括就是如果用new創建單個對象,就直接delete,如果用new創建了數組,就必須用delete [],這個知識點也是之前聽了課才知道的,這裏再鞏固一下。

std::string *stringArray = new std::string[100];

...

delete stringArray;

以上代碼會導致只有第一個string被釋放,剩下99個都泄露了。當調用new的時候,首先會分配內存(通過operator new實現),然後會調用一次或多次相應的構造函數。當調用delete的時候,首先會調用一次或多次相應的析構函數,然後會釋放內存(通過operator delete實現)。而delete時最大的問題就是:有多少個對象駐留在內存中?這決定了應該調用多少次析構函數。

而單個對象和數組對象的內存佈局是不同的,我們可以理解成數組對象應當先存儲它的大小,然後纔是它所包含的對象,如圖:
單個對象與數組對象的內存佈局
所以當調用delete的時候,我們必須告訴編譯器應該是哪種佈局,否則它不知道是否會有數組大小這樣一個信息在該內存區域,而告訴它的方法就是[]。錯誤的調用delete或者delete []都會造成無法預期的結果,通常都是不好的。其實用vector之後就幾乎可以不用原生的數組了,也更加安全。

總計:

如果new的時候沒有[]delete的時候就不用[],如果new的時候用了[]delete的時候就要用[]


ITEM 17: STORE NEWED OBJECTS IN SMART POINTERS IN STANDALONE STATEMENTS

這一條主要是說應該用單獨的語句來創建智能指針,事實上代碼本來也是應該一行就做一行的事情,不應當有過於複雜的表達式,當然這裏主要是在講可能會發生的內存泄露。

int priority();
void processWidget(std::tr1::shared_ptr<Widget> pw, int priority);

上面的代碼用了智能指針管理的作爲參數

processWidget(new Widget, priority());

但是這樣會報錯,因爲智能指針的構造函數是顯式的,不能直接傳入Widget的指針進行隱式轉換,所以需要這樣寫:

processWidget(std::tr1::shared_ptr<Widget>(new Widget), priority());

以上的代碼是可能造成泄露的,原因如下:
編譯器需要先生成processWidget的參數,第二個參數是直接通過priority函數獲得,但第一個參數其實包含兩步:new Widget,調用智能指針的構造函數。也就是說編譯器要做三件事:

  • 調用priority
  • 執行new Widget
  • 調用shared_ptr的構造函數
    而C++的編譯器具有比較大的自由度,所以這三件事的順序可能是這樣的:
  1. 執行new Widget
  2. 調用priority
  3. 調用shared_ptr的構造函數
    如果這種情況下,調用priority產生了異常,就會導致我們只做了第一步而沒有做第三步,也就是隻創建了對象但是還沒來得及將它轉交給智能指針,也因此就會造成內存泄漏。防止這種情況也很簡單,只要單獨寫一條語句來創建智能指針然後把它作爲參數傳入就行了:
std::tr1::shared_ptr<Widget> pw(new Widget);  // store newed object
                                              // in a smart pointer in a
                                              // standalone statement

processWidget(pw, priority());                // this call won't leak

編譯器在語句之間是沒有很大的自由度的,只在語句內纔可能調換順序,所以這樣寫就不會出現剛纔的問題了。

總結:

用單獨的語句來創建智能指針,否則可能因爲異常的出現導致內存泄露

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