天天看點

《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

就不會被調用到。或者說在一個循環内

new

delete

,然後又在中途調用了

continue

break

之類的語句。這樣都會造成記憶體的洩露,同時該對象持有的所有資源也被洩露了。

即使我們很小心的編寫代碼,在每個離開的地方都去判斷是否需要

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

指向同一片記憶體,否則就會造成重複釋放,是以它的複制特性會看起來有點奇怪,在複制的同時會将原來的指針置空。我自己寫了個小的測試程式來驗證這一點:

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

可以很清楚的看到,用

pInt1

來拷貝構造

pInt2

之後

pInt1

就為空了,而

pInt2

指向了

pInt1

原來指向的記憶體。當再次把

pInt2

指派給

pInt1

後,

pInt1

又重新指向了原來的區域,而

pInt2

被置空。

這樣用起來會有很大的局限性,因為沒法讓兩個指針指向同一個區域了,于是就有了用的最多的

shared_ptr

,引用計數指針(reference-counting smart pointer)。它的作用就是當沒有指針指向某個對象後會負責釋放它,類似于垃圾回收,但是它無法打破循環引用(A指向B,B指向A,那麼這兩個對象永遠不會被釋放)。我也寫了個小的測試程式感受了一下:

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

可以看到,每有一個指針指向配置設定的那一片記憶體,引用計數就會加一,而指針被銷毀後引用計數也會下降,最後當最後一個指向該記憶體的指針也被銷毀時它會負責釋放這一片記憶體。有一點需要注意:

auto_ptr

shared_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_ptr

shared_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_ptr

shared_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

時最大的問題就是:有多少個對象駐留在記憶體中?這決定了應該調用多少次析構函數。

而單個對象和數組對象的記憶體布局是不同的,我們可以了解成數組對象應當先存儲它的大小,然後才是它所包含的對象,如圖:

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

是以當調用

delete

的時候,我們必須告訴編譯器應該是哪種布局,否則它不知道是否會有數組大小這樣一個資訊在該記憶體區域,而告訴它的方法就是

[]

。錯誤的調用

delete

或者

delete []

都會造成無法預期的結果,通常都是不好的。其實用

vector

之後就幾乎可以不用原生的數組了,也更加安全。

總計:

如果

new

的時候沒有

[]

delete

的時候就不用

[]

,如果

new

的時候用了

[]

delete

的時候就要用

[]

ITEM 17: STORE

NEW

ED 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
           

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

總結:

用單獨的語句來建立智能指針,否則可能因為異常的出現導緻記憶體洩露

繼續閱讀