1. 程式人生 > >C++11——智慧指標

C++11——智慧指標

1. 介紹

  一般一個程式在記憶體中可以大體劃分為三部分——靜態記憶體(區域性的static物件、類static資料成員以及所有定義在函式或者類之外的變數)、棧記憶體(儲存和定義在函式或者類內部的變數)和動態記憶體(實質上這塊記憶體池就是堆,通常通過new/malloc操作申請的記憶體)。對於靜態記憶體和棧記憶體來說,編譯器可以根據它們的定義去自動建立和銷燬的相應的記憶體空間。而對於動態記憶體,由於程式只有在執行時才知道需要分配多少記憶體空間,所以只能由程式設計師去動態的去建立和回收這塊記憶體。

  而對於動態記憶體的回收是一個很複雜的問題,經常會因為一些難以觀察的細節遺忘對一些物件的釋放造成記憶體洩露,比如下面的程式碼:

#include <iostream>
#include <exception>
using namespace std;
class myException : public exception
{
public:
    const char* what_happened() const throw(){
        return "error: what you have down is error.";
    }
};

void check(int x){
    if(x == 0){
        throw myException();
    }
}

int main(){
    string* str = new string("testing....");
    try {
       check(0);
       //do something I really want to do
       // ....
    } catch (myException &e) {
        cout << e.what_happened() << endl;
        return -1;
    }
    delete str;
    return 0;
}

  一旦專案的程式碼量非常龐大時,此時像這樣的記憶體洩露即便可以通過一些記憶體檢測工具(比如valgrind),但去定位並改正這些錯誤還是很繁瑣的。

  為了更方便且更安全的使用動態記憶體C++提供了四種智慧指標來動態管理這些物件——auto_ptr(C++98,現在基本被淘汰),unique_ptr,shared_ptr,weak_ptr(後三種是C++11的新標準)。上面的程式改成如下形式,同時去掉delete str;就可以了。

std::auto_ptr<std::string> ps(new string("testing....")); 

2.智慧指標

  使用智慧指標,需要引入標頭檔案#include <memory>,接受引數的智慧指標的建構函式是explict,如下

template<typename _Tp>
    class auto_ptr
    {
    private:
      _Tp* _M_ptr;
      
    public:
		explicit
     		 auto_ptr(element_type* __p = 0) throw() : _M_ptr(__p) { }
        //....
    }

  因此不能自動將指標轉換為智慧指標物件,而是採用直接初始化的方式來初始化一個指標,顯示的建立物件。如下:

shared_ptr<std::string> ps(new string("testing...."));	  //正確
shared_ptr<std::string> ps = new string("testing....");   //錯誤

同時,應該避免把一個區域性變數的指標傳給智慧指標:

//error —— double free or corruption (out): 0x00007fffffffd910 ***
string s("testing.....");
shared_ptr<string> pvac(&s);

//correct
string* str = new string("testing....");
shared_ptr<string> pvac(str);

區域性變數s是在棧上分配的記憶體,且其作用域範圍僅限於當前函式,一旦執行完,該變數將被自動釋放,而智慧指標shared_ptr又會自動再次呼叫s的解構函式,導致一個變數double free。而new方式申請的記憶體在堆上,該部分的記憶體不會隨著作用域範圍的結束而被釋放,只能等到智慧指標呼叫解構函式再去釋放。

題外話——隱式型別轉換

  隱式型別轉換可:能夠用一個實參來呼叫的建構函式定義了從形參型別到該類型別的一個隱式轉換。如下面程式:

#include <string>
#include <iostream>
using namespace std ;
class BOOK
{
private:
    string _bookISBN ;
    float _price ;

public:
    //這個函式用於比較兩本書的ISBN號是否相同
    bool isSameISBN(const BOOK& other){
        return other._bookISBN==_bookISBN;
    }

    //類的建構函式,即那個“能夠用一個引數進行呼叫的建構函式”(雖然它有兩個形參,但其中一個有預設實參,只用一個引數也能進行呼叫)
    BOOK(string ISBN,float price=0.0f):_bookISBN(ISBN),_price(price){}
};

int main()
{
    BOOK A("A-A-A");
    BOOK B("B-B-B");
    cout<<A.isSameISBN(B)<<endl;   //正常地進行比較,無需發生轉換
    cout<<A.isSameISBN(string("A-A-A"))<<endl; //此處即發生一個隱式轉換:string型別-->BOOK型別,藉助BOOK的建構函式進行轉換,以滿足isSameISBN函式的引數期待。
    cout<<A.isSameISBN(BOOK("A-A-A"))<<endl;    //顯式建立臨時物件,也即是編譯器乾的事情。

    return 0;
}

  此處發生了一個隱式型別轉換,將一個string型別轉化成了BOOK類,如果要阻止該型別的轉換,可以將建構函式定義成如下形式:

explicit BOOK(string ISBN,float price=0.0f):_bookISBN(ISBN),_price(price){}

現在,我們只能顯示的型別轉換和顯示的去建立BOOK物件。

2.1 auto_ptr

  auto_ptr是舊版gcc的智慧指標,現在新版本的已經將其摒棄,如下程式:

#include <iostream>
#include <exception>
#include <memory>
using namespace std;
int main(){
    auto_ptr<string> day[7] = {
        auto_ptr<string>(new string("Monday")),
        auto_ptr<string>(new string("Tudsday")),
        auto_ptr<string>(new string("Wednesday")),
        auto_ptr<string>(new string("Thursday")),
        auto_ptr<string>(new string("Friday")),
        auto_ptr<string>(new string("Saturday")),
        auto_ptr<string>(new string("Sunday"))
    };
    //將Saturday的值賦給today
    auto_ptr<string> today = day[5];
    cout << "today is " << *today << endl;
    for(int i = 0; i < 7; i++){
        cout << *day[i] << " ";
    }
    cout << endl;
    return 0;
}

對於上面程式,會發現,編譯的時候,沒有什麼問題,可以當執行的時候就會發生段錯誤。上面有兩個變數day[5]和today都指向同一記憶體地址,當這兩個變數的在這個作用域範圍失效時,就會呼叫各自的解構函式,造成同一塊記憶體被釋放兩次的情況。為了避免這種情況,在auto_ptr中有一種所有權的概念,一旦它指向一個物件後,這個物件的所有權都歸這個指標控制,但是如果此時又有一個新的auto_ptr指標指向了這個物件,舊的auto_ptr指標就需要將所有權轉讓給新的auto_ptr指標,此時舊的auto_ptr指標就是一個空指標了,上面的程式通過除錯可以看出這些變數值的變化過程。

程式可以編譯通過,但執行時會出錯,這種錯誤在專案中去查詢是一件很痛苦的事情,C++新標準避免潛在的記憶體崩潰問題而摒棄了auto_ptr。

2.2 unique_ptr

  unique_ptr和auto_ptr類似,也是採用所有權模型,但是如果同樣的程式,只是把指標的名字換了一下:

int main(){
    unique_ptr<string> day[7] = {
        unique_ptr<string>(new string("Monday")),
        unique_ptr<string>(new string("Tudsday")),
        unique_ptr<string>(new string("Wednesday")),
        unique_ptr<string>(new string("Thursday")),
        unique_ptr<string>(new string("Friday")),
        unique_ptr<string>(new string("Saturday")),
        unique_ptr<string>(new string("Sunday"))
    };
    //將Saturday的值賦給today
    unique_ptr<string> today = day[5];
    cout << "today is " << *today << endl;
    for(int i = 0; i < 7; i++){
        cout << *day[i] << " ";
    }
    cout << endl;
    return 0;
}
/* 編譯階段就會報錯
smart_ptr.cpp:17:37: error: use of deleted function ‘std::unique_ptr<_Tp, _Dp>::unique_ptr(const std::unique_ptr<_Tp, _Dp>&) [with _Tp = std::__cxx11::basic_string<char>; _Dp = std::default_delete<std::__cxx11::basic_string<char> >]’
     unique_ptr<string> today = day[5];
*/

可以看出unique比auto_ptr更加安全,在編譯階段就可以提前告知錯誤,而且unique_ptr還有一個很智慧的地方,就是雖然不允許兩個unique_ptr的賦值操作,但是允許在函式返回值處去接受這個型別的指標,如下: 

unique_ptr<string> test(const char* c){
    unique_ptr<string> temp(new string(c));
    return temp;
}
int main(){
    unique_ptr<string> ptr;
    ptr = test("haha");
    return 0;
}

  如果確實想讓兩個unique_ptr進行賦值操作,可以呼叫標準庫函式std::move()函式,它可以實現物件資源的安全轉移,如下:  

unique_ptr<string> today = std::move(day[5]);
cout << "today is " << *today << endl;
for(int i = 0; i < 7; i++){
    cout << *day[i] << " ";
}
cout << endl;

  上面的程式碼雖然可以安全編譯過,day[5]將資源所有權轉移到today上,會造成像auto_ptr一樣出現訪問day[5]這個空指標異常的錯誤。

2.3 shared_ptr

  現在將上面的程式碼換成shared_ptr:

#include <iostream>
#include <exception>
#include <memory>
using namespace std;
shared_ptr<string> test(const char* c){
    shared_ptr<string> temp(new string(c));
    return temp;
}
int main(){
    shared_ptr<string> day[7] = {
        shared_ptr<string>(new string("Monday")),
        shared_ptr<string>(new string("Tudsday")),
        shared_ptr<string>(new string("Wednesday")),
        shared_ptr<string>(new string("Thursday")),
        shared_ptr<string>(new string("Friday")),
        shared_ptr<string>(new string("Saturday")),		//指向new string("Saturday")計數器為1
        shared_ptr<string>(new string("Sunday"))
    };
    //將Saturday的值賦給today
    shared_ptr<string> today = day[5];  //指向new string("Saturday")計數器為2
    cout << "today is " << *today << endl;
    for(int i = 0; i < 7; i++){
        cout << *day[i] << " ";
    }
    cout << endl;
    return 0;
}
/* output
today is Saturday
Monday Tudsday Wednesday Thursday Friday Saturday Sunday
*/

我們會驚訝的發現這個程式是可以正常的跑過的,而且day[5]也是可以正常打印出來的,原因在於share_ptr並不是採用所有權機制,當有多個share_ptr指向同一物件時,它就會向java的垃圾回收機制一樣採用引用計數器,賦值的時候,計數器加1,而指標物件過期的時候,計數器減1,直到計數器的值為0的時候,才會呼叫解構函式將物件的記憶體清空。

shared_ptr記憶體也可以這樣申請:

std::shared_ptr<ClassA> p1 = std::shared_ptr<ClassA>();
std::shared_ptr<ClassA> p2 = std::make_shared<ClassA>();

  

第一種方式會先申請A類物件所需的空間,然後再去申請針對對該空間控制的記憶體控制塊。而第二種方式是資料塊和控制塊會一塊申請,所以它的效率會更高一點。

2.4 wek_ptr

先來看一個例子,假設有兩個物件,他們之間重存在這相互引用的關係:

#include <iostream>
#include <memory>
#include <vector>
using namespace std;

class ClassB;

class ClassA
{
public:
    ClassA() { cout << "ClassA Constructor..." << endl; }
    ~ClassA() { cout << "ClassA Destructor..." << endl; }
    shared_ptr<ClassB> pb;  // 在A中引用B
};

class ClassB
{
public:
    ClassB() { cout << "ClassB Constructor..." << endl; }
    ~ClassB() { cout << "ClassB Destructor..." << endl; }
    shared_ptr<ClassA> pa;  // 在B中引用A
};

int main02() {
    //也可以通過make_shared來返回一個shared_ptr物件,它的效率會更高
    shared_ptr<ClassA> spa = make_shared<ClassA>();
    shared_ptr<ClassB> spb = make_shared<ClassB>();
    spa->pb = spb;
    spb->pa = spa;
    // 函式結束:spa和spb會釋放資源麼?
    return 0;
}

/** valgrind 一部分報告
==812== LEAK SUMMARY:
==812==    definitely lost: 32 bytes in 1 blocks
==812==    indirectly lost: 32 bytes in 1 blocks
==812==      possibly lost: 0 bytes in 0 blocks
==812==    still reachable: 72,704 bytes in 1 blocks
*/

  

使用valgrind可以看出確實造成了記憶體洩露,因為ClassA和ClassB相互迴圈的引用對方,造成各自的引用計數器都會加1,使得最終解構函式呼叫無法將其置為0。

這個時候可以用到wek_ptr,weak_ptr是一種“弱”共享物件的智慧指標,它指向一個由share_ptr管理的物件,講一個weak_ptr繫結到shared_ptr指向的物件去,並不會增加物件的引用計數器的大小,即使weak_ptr還指向某一個物件,也不會阻止該物件的解構函式的呼叫。這個時候需要判斷一個物件是否存在,然後才可以去訪問物件,如下程式碼:

class C
{
public:
    C() : a(8) { cout << "C Constructor..." << endl; }
    ~C() { cout << "C Destructor..." << endl; }
    int a;
};
int main() {
    shared_ptr<C> sp(new C());
    weak_ptr<C> wp(sp);
    if (shared_ptr<C> pa = wp.lock())
    {
        cout << pa->a << endl;
    }
    else
    {
        cout << "wp指向物件為空" << endl;
    }
    sp.reset(); //reset--釋放sp關聯記憶體塊的所有權,如果是最後一個指向該資源的(引用計數為0),就釋放這塊記憶體
    //wp.lock()檢查和shared_ptr繫結的物件是否還存在
    if (shared_ptr<C> pa = wp.lock())
    {
        cout << pa->a << endl;
    }
    else
    {
        cout << "wp指向物件為空" << endl;
    }
}
/* output
C Constructor...
8
C Destructor...
wp指向物件為空
*/

  然後將最開始的程式改成如下形式,則可以避免迴圈引用而造成的記憶體洩漏問題。

class ClassB;

class ClassA
{
public:
    ClassA() { cout << "ClassA Constructor..." << endl; }
    ~ClassA() { cout << "ClassA Destructor..." << endl; }
    weak_ptr<ClassB> pb;  // 在A中引用B
};

class ClassB
{
public:
    ClassB() { cout << "ClassB Constructor..." << endl; }
    ~ClassB() { cout << "ClassB Destructor..." << endl; }
    weak_ptr<ClassA> pa;  // 在B中引用A
};

int main() {
    shared_ptr<ClassA> spa = make_shared<ClassA>();
    shared_ptr<ClassB> spb = make_shared<ClassB>();
    spa->pb = spb;
    spb->pa = spa;
    // 函式結束,思考一下:spa和spb會釋放資源麼?
    return 0;
}

/* valgrind報告
==5401== LEAK SUMMARY:
==5401==    definitely lost: 0 bytes in 0 blocks
==5401==    indirectly lost: 0 bytes in 0 blocks
==5401==      possibly lost: 0 bytes in 0 blocks
==5401==    still reachable: 72,704 bytes in 1 blocks
==5401==         suppressed: 0 bytes in 0 blocks
*/

參考資料

  1. C++ Primer(第五版)

  1. C++智慧指標簡單剖析

  2. C++ 隱式類型別轉換

  3. 【C++11新特性】 C++11智慧指標之weak_ptr