C++11 智慧指標

卡巴拉的樹發表於2017-12-29

原作者:Babu_Abdulsalam 本文翻譯自CodeProject,轉載請註明出處。

引入

儘管有另外一篇文章說C++11裡的智慧指標了。近來,我聽到許多人談論C++新標準,就是所謂的C++0x/C++11。 我研究了一下C++11的一些語言特性,發現確實它確實有一些巨大的改變。我將重點關注C++11的智慧指標部分。

背景

普通指標(normal/raw/naked pointers)的問題?

讓我們一個接一個的討論。

如果不恰當處理指標就會帶來許多問題,所以人們總是避免使用它。這也是許多新手程式設計師不喜歡指標的原因。指標總是會扯上很多問題,例如指標所指向物件的生命週期,掛起引用(dangling references)以及記憶體洩露。

如果一塊記憶體被多個指標引用,但其中的一個指標釋放且其餘的指標並不知道,這樣的情況下,就發生了掛起引用。而記憶體洩露,就如你知道的一樣,當從堆中申請了記憶體後不釋放回去,這時就會發生記憶體洩露。有人說,我寫了清晰並且帶有錯誤驗證的程式碼,為什麼我還要使用智慧指標呢?一個程式設計師也問我:“嗨,下面是我的程式碼,我從堆(heap)中申請了一塊記憶體,使用完後,我又正確的把它歸還給了堆,那麼使用智慧指標的必要在哪裡?”

void Foo( )
{ 
    int* iPtr = new int[5];  
    //manipulate the memory block . . .  
    delete[ ] iPtr;
 }
複製程式碼

理想狀況下,上面這段程式碼確實能夠工作的很好,記憶體也能夠恰當的釋放回去。但是仔細思考一下實際的工作環境以及程式碼執行條件。在記憶體分配和釋放的間隙,程式指令確實能做許多糟糕的事情,比如訪問無效的記憶體地址,除以0,或者有另外一個程式設計師在你的程式中修改了一個bug,他根據一個條件增加了一個過早的返回語句。

在以上所有情況下,你的程式都走不到記憶體釋放的那部分。前兩種情況下,程式丟擲了異常,而第三種情況,記憶體還沒釋放,程式就過早的return了。所以程式執行時,記憶體就已經洩露了。

解決以上所有問題的方法就是使用智慧指標[如果它們足夠智慧的話]。

什麼是智慧指標?

智慧指標是一個RAIIResource Acquisition is initialization)類模型,用來動態的分配記憶體。它提供所有普通指標提供的介面,卻很少發生異常。在構造中,它分配記憶體,當離開作用域時,它會自動釋放已分配的記憶體。這樣的話,程式設計師就從手動管理動態記憶體的繁雜任務中解放出來了。

C++98提供了第一種智慧指標:auto_ptr

auto_ptr

讓我們來見識一下auto_ptr如何解決上述問題的吧。

class Test
{
    public: 
    Test(int a = 0 ) : m_a(a) { }
    ~Test( )
    { 
       cout << "Calling destructor" << endl; 
    }
    public: int m_a;
};
void main( )
{ 
    std::auto_ptr<Test> p( new Test(5) ); 
    cout << p->m_a << endl;
}  
複製程式碼

上述程式碼會智慧地釋放與指標繫結的記憶體。作用的過程是這樣的:我們申請了一塊記憶體來放Test物件,並且把他繫結到auto_ptr p上。所以當p離開作用域時,它所指向的記憶體塊也會被自動釋放。

//***************************************************************
class Test
{
public:
 Test(int a = 0 ) : m_a(a)
 {
 }
 ~Test( )
 {
  cout<<"Calling destructor"<<endl;
 }
public:
 int m_a;
};
//***************************************************************
void Fun( )
{
 int a = 0, b= 5, c;
 if( a ==0 )
 {
  throw "Invalid divisor";
 }
 c = b/a;
 return;
}
//***************************************************************
void main( )
{
 try
 {
  std::auto_ptr<Test> p( new Test(5) ); 
  Fun( );
  cout<<p->m_a<<endl;
 }
 catch(...)
 {
  cout<<"Something has gone wrong"<<endl;
 }
}
複製程式碼

上面的例子中,儘管異常被丟擲,但是指標仍然正確地被釋放了。這是因為當異常丟擲時,棧鬆綁(stack unwinding),當try 塊中的所有物件destroy後,p 離開了該作用域,所以它繫結的記憶體也就釋放了。

Issue1:

目前為止,auto_ptr還是足夠智慧的,但是它還是有一些根本性的破綻的。當把一個auto_ptr賦給另外一個auto_ptr時,它的所有權(ownship)也轉移了。當我在函式間傳遞auto_ptr時,這就是一個問題。話說,我在Foo()中有一個auto_ptr,然後在Foo()中我把指標傳遞給了Fun()函式,當Fun()函式執行完畢時,指標的所有權不會再返還給Foo

//***************************************************************
class Test
{
public:
 Test(int a = 0 ) : m_a(a)
 {
 }
 ~Test( )
 {
  cout<<"Calling destructor"<<endl;
 }
public:
 int m_a;
};
 
 
//***************************************************************
void Fun(auto_ptr<Test> p1 )
{
 cout<<p1->m_a<<endl;
}
//***************************************************************
void main( )
{
 std::auto_ptr<Test> p( new Test(5) ); 
 Fun(p);
 cout<<p->m_a<<endl;
} 
複製程式碼

由於auto_ptr的野指標行為,上面的程式碼導致程式崩潰。在這期間發生了這些細節,p擁有一塊記憶體,當Fun呼叫時, p把關聯的記憶體塊的所有權傳給了auto_ptr p1, p1p的copy(注:這裡從Fun函式的定義式看出,函式引數時值傳遞,所以把p的值拷進了函式中),這時p1就擁有了之前p擁有的記憶體塊。目前為止,一切安好。現在Fun函式執行完了,p1離開了作用域,所以p1關聯的記憶體塊也就釋放了。那麼p呢?p什麼都沒了,這就是crash的原因了,下一行程式碼還試圖訪問p,好像p還擁有什麼資源似的。

Issue2:

還有另外一個缺點。auto_ptr不能指向一組物件,就是說它不能和操作符new[]一起使用。

//***************************************************************
void main( )
{
 std::auto_ptr<Test> p(new Test[5]);
}
複製程式碼

上面的程式碼將產生一個執行時錯誤。因為當auto_ptr離開作用域時,delete被預設用來釋放關聯的記憶體空間。當auto_ptr只指向一個物件時,這當然是沒問題的,但是在上面的程式碼裡,我們在堆裡建立了一組物件,應該使用delete[]來釋放,而不是delete.

Issue3:

auto_ptr不能和標準容器(vector,list,map....)一起使用。

因為auto_ptr容易產生錯誤,所以它也將被廢棄了。C++11提供了一組新的智慧指標,每一個都各有用武之地。

  • shared_ptr
  • unique_ptr
  • weak_ptr

shared_ptr

好吧,準備享受真正的智慧。第一種智慧指標是shared_ptr,它有一個叫做共享所有權(sharedownership)的概念。shared_ptr的目標非常簡單:多個指標可以同時指向一個物件,當最後一個shared_ptr離開作用域時,記憶體才會自動釋放。

建立:

void main( )
{
 shared_ptr<int> sptr1( new int );
}
複製程式碼

使用make_shared巨集來加速建立的過程。因為shared_ptr主動分配記憶體並且儲存引用計數(reference count),make_shared 以一種更有效率的方法來實現建立工作。

void main( )
{
 shared_ptr<int> sptr1 = make_shared<int>(100);
}
複製程式碼

上面的程式碼建立了一個shared_ptr,指向一塊記憶體,該記憶體包含一個整數100,以及引用計數1.如果通過sptr1再建立一個shared_ptr,引用計數就會變成2. 該計數被稱為強引用(strong reference),除此之外,shared_ptr還有另外一種引用計數叫做弱引用(weak reference),後面將介紹。

通過呼叫use_count()可以得到引用計數, 據此你能找到shared_ptr的數量。當debug的時候,可以通過觀察shared_ptrstrong_ref的值得到引用計數。

reference count

析構

shared_ptr預設呼叫delete釋放關聯的資源。如果使用者採用一個不一樣的析構策略時,他可以自由指定構造這個shared_ptr的策略。下面的例子是一個由於採用預設析構策略導致的問題:

class Test
{
public:
 Test(int a = 0 ) : m_a(a)
 {
 }
 ~Test( )
 {
  cout<<"Calling destructor"<<endl;
 }
public:
         int m_a;
};
void main( )
{
 shared_ptr<Test> sptr1( new Test[5] );
}
複製程式碼

在此場景下,shared_ptr指向一組物件,但是當離開作用域時,預設的解構函式呼叫delete釋放資源。實際上,我們應該呼叫delete[]來銷燬這個陣列。使用者可以通過呼叫一個函式,例如一個lamda表示式,來指定一個通用的釋放步驟。

void main( )
{
 shared_ptr<Test> sptr1( new Test[5], 
        [ ](Test* p) { delete[ ] p; } );
}
複製程式碼

通過指定delete[]來析構,上面的程式碼可以完美執行。

介面 就像一個普通指標一樣,shared_ptr也提供解引用操作符*,->。除此之外,它還提供了一些更重要的介面:

  • get(): 獲取shared_ptr繫結的資源.
  • reset(): 釋放關聯記憶體塊的所有權,如果是最後一個指向該資源的shared_ptr,就釋放這塊記憶體。
  • unique: 判斷是否是唯一指向當前記憶體的shared_ptr.
  • operator bool : 判斷當前的shared_ptr是否指向一個記憶體塊,可以用if 表示式判斷。

OK,上面是所有關於shared_ptr的描述,但是shared_ptr也有一些問題: Issues:

void main( )
{
 shared_ptr<int> sptr1( new int );
 shared_ptr<int> sptr2 = sptr1;
 shared_ptr<int> sptr3;
 sptr3 =sptr1
複製程式碼

Issues: 下表是上面程式碼中引用計數變化情況:

引用計數變化

所有的shared_ptrs擁有相同的引用計數,屬於相同的組。上述程式碼工作良好,讓我們看另外一組例子。

void main( )
{
 int* p = new int;
 shared_ptr<int> sptr1( p);
 shared_ptr<int> sptr2( p );
}
複製程式碼

上述程式碼會產生一個錯誤,因為兩個來自不同組的shared_ptr指向同一個資源。下表給你關於錯誤原因的圖景:

引用計數

避免這個問題,儘量不要從一個裸指標(naked pointer)建立shared_ptr.

class B;
class A
{
public:
 A(  ) : m_sptrB(nullptr) { };
 ~A( )
 {
  cout<<" A is destroyed"<<endl;
 }
 shared_ptr<B> m_sptrB;
};
class B
{
public:
 B(  ) : m_sptrA(nullptr) { };
 ~B( )
 {
  cout<<" B is destroyed"<<endl;
 }
 shared_ptr<A> m_sptrA;
};
//***********************************************************
void main( )
{
 shared_ptr<B> sptrB( new B );
 shared_ptr<A> sptrA( new A );
 sptrB->m_sptrA = sptrA;
 sptrA->m_sptrB = sptrB;
}
複製程式碼

上面的程式碼產生了一個迴圈引用.AB有一個shared_ptr, BA也有一個shared_ptr ,與sptrAsptrB關聯的資源都沒有被釋放,參考下表:

sptrA&sptrB
sptrAsptrB離開作用域時,它們的引用計數都只減少到1,所以它們指向的資源並沒有釋放!!!!!

  1. 如果幾個shared_ptrs指向的記憶體塊屬於不同組,將產生錯誤。
  2. 如果從一個普通指標建立一個shared_ptr還會引發另外一個問題。在上面的程式碼中,考慮到只有一個shared_ptr是由p建立的,程式碼可以好好工作。萬一程式設計師在智慧指標作用域結束之前刪除了普通指標p。天啦嚕!!!又是一個crash。
  3. 迴圈引用:如果共享智慧指標捲入了迴圈引用,資源都不會正常釋放。

為了解決迴圈引用,C++提供了另外一種智慧指標:weak_ptr

Weak_Ptr

weak_ptr 擁有共享語義(sharing semantics)和不包含語義(not owning semantics)。這意味著,weak_ptr可以共享shared_ptr持有的資源。所以可以從一個包含資源的shared_ptr建立weak_ptr

weak_ptr不支援普通指標包含的*->操作。它並不包含資源所以也不允許程式設計師操作資源。既然如此,我們如何使用weak_ptr呢?

答案是從weak_ptr中建立shared_ptr然後再使用它。通過增加強引用計數,當使用時可以確保資源不會被銷燬。當引用計數增加時,可以肯定的是從weak_ptr中建立的shared_ptr引用計數至少為1.否則,當你使用weak_ptr就可能發生如下問題:當shared_ptr離開作用域時,其擁有的資源會釋放,從而導致了混亂。

建立

可以以shared_ptr作為引數構造weak_ptr.從shared_ptr建立一個weak_ptr增加了共享指標的弱引用計數(weak reference),意味著shared_ptr與其它的指標共享著它所擁有的資源。但是當shared_ptr離開作用域時,這個計數不作為是否釋放資源的依據。換句話說,就是除非強引用計數變為0,才會釋放掉指標指向的資源,在這裡,弱引用計數(weak reference)不起作用。

void main( )
{
 shared_ptr<Test> sptr( new Test );
 weak_ptr<Test> wptr( sptr );
 weak_ptr<Test> wptr1 = wptr;
}
複製程式碼

可以從下圖觀察shared_ptrweak_ptr的引用計數:

shared_ptr 和weak_ptr變化

將一個weak_ptr賦給另一個weak_ptr會增加弱引用計數(weak reference count)。

所以,當shared_ptr離開作用域時,其內的資源釋放了,這時候指向該shared_ptrweak_ptr發生了什麼?weak_ptr過期了(expired)。

如何判斷weak_ptr是否指向有效資源,有兩種方法:

  1. 呼叫use-count()去獲取引用計數,該方法只返回強引用計數,並不返回弱引用計數。
  2. 呼叫expired()方法。比呼叫use_count()方法速度更快。

weak_ptr呼叫lock()可以得到shared_ptr或者直接將weak_ptr轉型為shared_ptr

void main( )
{
 shared_ptr<Test> sptr( new Test );
 weak_ptr<Test> wptr( sptr );
 shared_ptr<Test> sptr2 = wptr.lock( );
}
複製程式碼

如之前所述,從weak_ptr中獲取shared_ptr增加強引用計數。

現在讓我們見識一下weak_ptr如何解決迴圈引用問題:

class B;
class A
{
public:
 A(  ) : m_a(5)  { };
 ~A( )
 {
  cout<<" A is destroyed"<<endl;
 }
 void PrintSpB( );
 weak_ptr<B> m_sptrB;
 int m_a;
};
class B
{
public:
 B(  ) : m_b(10) { };
 ~B( )
 {
  cout<<" B is destroyed"<<endl;
 }
 weak_ptr<A> m_sptrA;
 int m_b;
};

void A::PrintSpB( )
{
 if( !m_sptrB.expired() )
 {  
  cout<< m_sptrB.lock( )->m_b<<endl;
 }
}

void main( )
{
 shared_ptr<B> sptrB( new B );
 shared_ptr<A> sptrA( new A );
 sptrB->m_sptrA = sptrA;
 sptrA->m_sptrB = sptrB;
 sptrA->PrintSpB( ); 
}

複製程式碼

引用計數

unique_ptr

unique_ptr也是對auto_ptr的替換。unique_ptr遵循著獨佔語義。在任何時間點,資源只能唯一地被一個unique_ptr佔有。當unique_ptr離開作用域,所包含的資源被釋放。如果資源被其它資源重寫了,之前擁有的資源將被釋放。所以它保證了他所關聯的資源總是能被釋放。

建立 unique_ptr的建立方法和shared_ptr一樣,除非建立一個指向陣列型別的unique_ptr

unique_ptr<int> uptr( new int );
複製程式碼

unique_ptr提供了建立陣列物件的特殊方法,當指標離開作用域時,呼叫delete[]代替delete。當建立unique_ptr時,這一組物件被視作模板引數的部分。這樣,程式設計師就不需要再提供一個指定的析構方法,如下:

unique_ptr<int[ ]> uptr( new int[5] );
複製程式碼

當把unique_ptr賦給另外一個物件時,資源的所有權就會被轉移。

記住unique_ptr不提供複製語義(拷貝賦值和拷貝構造都不可以),只支援移動語義(move semantics).

在上面的例子裡,如果upt3upt5已經擁有了資源,只有當擁有新資源時,之前的資源才會釋放。

介面

unique_ptr提供的介面和傳統指標差不多,但是不支援指標運算。

unique_ptr提供一個release()的方法,釋放所有權。releasereset的區別在於,release僅僅釋放所有權但不釋放資源,reset也釋放資源。

使用哪一個?

完全取決於你想要如何擁有一個資源,如果需要共享資源使用shared_ptr,如果獨佔使用資源就使用unique_ptr.

除此之外,shared_ptrunique_ptr更加重,因為他還需要分配空間做其它的事,比如儲存強引用計數,弱引用計數。而unique_ptr不需要這些,它只需要獨佔著儲存資源物件。

相關文章