c++動態記憶體管理與智慧指標

咪啪魔女發表於2022-03-01

程式使用三種不同的記憶體

  1. 靜態記憶體:static成員以及任何定義在函式之外的變數
  2. 棧記憶體:一般區域性變數
  3. 堆記憶體(自由空間):動態分配的物件

靜態記憶體和棧記憶體中的變數由編譯器產生和銷燬,動態分配的物件在我們不再使用它時要由程式設計師顯式地銷燬

一、介紹

動態分配記憶體

  1. new():為物件分配空間,並返回指向該物件的指標
  2. delete:銷燬物件,並釋放與之相關的記憶體

使用智慧指標:定義在標頭檔案memory

  1. shared_ptr:允許多個指標指向同一個物件
  2. unique_ptr:“獨佔”所使用的物件
  3. weak_ptr:伴隨類,弱引用,指向shared_ptr所管理的物件

和容器一樣,只能指標也是一種模板,需要給它傳入一個引數來指定型別

二、shared_ptr類

宣告shared_ptr:

shared_ptr<string> p1;	   //shared_ptr,可以指向string
shared_ptr<list<int>> p2;  //shared_ptr,可以指向list<int>

使用方式與普通指標一致,解引用返回它所指向的物件,在條件表示式中檢查是否為空

//若p1不為空且指向一個空string
if(p1 && p1->empty()){
    *p1 = "hi";   //對p1重新賦值
}

image-20220228105731951

make_shared函式

make_shared<typename>(arguments)

在動態記憶體中分配並初始化一個物件

返回指向此物件的shared_ptr指標

//指向一個值為42的int的shared_ptr
shared_ptr<int> p1 = make_shared<int>(42);
//指向一個值為"999"的string的shared_ptr
shared_ptr<string> p2 = make_shared<string>(3, '9');
//指向一個值為0的int的shared_ptr
shared_ptr<int> p3 = make_shared<int>();

沒有傳入引數時,進行值初始化

auto p4 = make_shared<string>(); //p4指向空string

shared_ptr的拷貝和引用

每個share_ptr都有一個關聯的計數器

  • 當拷貝shared_ptr時,計數器會遞增
  • 當shared_ptr被賦予新值或者shared_ptr被銷燬(如一個區域性的shared_ptr離開其作用域),計數器會遞減
  • 當一個shared_ptr的計數器==0時,記憶體會被釋放
auto r = make_shared<int>(42);
r = q; //給r賦值,使它指向另一個地址
	   //遞增q指向的物件的引用計數
	   //遞減r指向的物件的引用計數
       //如果計數器為0,自動釋放

shared_ptr自動銷燬所管理的物件…

和其他類一樣,shared_ptr型別也有解構函式

shared_ptr的解構函式會

  1. 遞減指標所指向的物件的引用計數
  2. 當物件的引用計數為0時,銷燬物件並釋放記憶體

…shared_ptr還會自動釋放相關聯物件的記憶體

舉例:

//factory返回一個share_ptr,指向一個動態分配的物件
shared_ptr<Foo> factory(T arg){
    //對arg的操作
    return make_shared<Foo>(arg);
}

void ues_factory(T arg){
    shared_ptr<Foo> p = factory(arg);
    //使用p
}
//p離開了作用域,由於引用計數由1減到0,物件被銷燬,記憶體釋放

如果有其他引用計數也指向該物件,則物件記憶體不會被釋放掉

//factory和上述一致
//ues_factory返回shared_ptr的拷貝
void use_factory(T arg){
    shared_ptr<Foo> p = factory(arg);
    //使用p
    return p; //返回p的拷貝,此時遞增了計數器,引用數為2
}//p離開作用域,物件計數器引用2-1=1,物件記憶體沒有釋放

return shared_ptr時,如果不是返回引用型別,則會進行拷貝,shared_ptr的計數器+1後-1,最終shared的計數器不變

由於在最後一個shared _ptr銷燬前記憶體都不會釋放,保證shared_ptr在無用之後不再保留就非常重要了。如果你忘記了銷燬程式不再需要的shared_ptr,程式仍會正確執行,但會浪費記憶體。

share_ptr 在無用之後仍然保留的一種可能情況是,你將shared _ptr存放在一個容器中,隨後重排了容器,從而不再需要某些元素。在這種情況下,你應該確保用erase刪除那些不再需要的shared_ptr元素。

如果你將shared ptr存放於一個容器中,而後不再需要全部元素,而只使用其中一部分,要記得用erase刪除不再需要的那些元素。

使用動態生存期的資源的類

程式使用動態記憶體的三種原因

  1. 程式不知道自己需要使用多少物件
  2. 不知道所需物件的準確型別
  3. 需要在多個物件間共享資料

容器類常出於第一種原因使用動態記憶體,在15章會看見出於第二種原因的例子,本節討論第三種原因

先考慮這麼一種情況:

我們要定義一個Blob類,當該型別的物件拷貝時,物件共享底層資料。

如b2 = b1時,b2,b1共享底層資料,對b2的操作也會印象到b1,且銷燬b2時,b1的仍指向原資料

Blob<string> b1; //空Blob
{
    //新作用域
    Blob<string> b2 = {"a","an","the"};
    b1 = b2; //b1和b2共享資料
}//b2離開作用域,被銷燬了,但b2的資料不能被銷燬
//b1指向b2的原資料

應用舉例:Blob類

定義Blob類

最終,我們希望將Blob定義為一個模板類,但現在我們先將其定義為StrBlob,即底層資料是vector<string>的Blob

class StrBlob{
public:
    //拷貝控制
    StrBlob();//預設建構函式
    StrBlob(initializer_list<string> il); //列表初始化
    StrBlob(const StrBlob& strb);
    //查詢
    int size() const {return data->size();}
    bool empty() const {return data->empty();}
    //新增和刪除元素
    void push_back(const string &t) {data->push_back(t);}
    void pop_back() {data->pop_back();}
    //訪問元素
    string& front();
    string& back();
private:
    shared_ptr<vector<string>> data;
    //如果data[i]不合法,丟擲異常
    void check(int i, const string &msg) const;
};

StrBlob的建構函式

StrBlob::StrBlob() : data(make_shared<vector<string>>())
        {cout<<"in StrBlob dafault"<<endl;};
StrBlob::StrBlob(initializer_list<string> il) :
    data(make_shared<vector<string>>(il))
    {cout<<"in StrBlob initializer_list"<<endl;}

元素訪問成員函式

在訪問時必須保證容器非空,定義check函式進行檢查

void StrBlob::check(int i, const string& msg) const{
    if(i >= data->size())
        throw out_of_range(msg);
}

元素訪問成員函式:

string& StrBlob::front(){
    //如果vector為空,check會丟擲一個異常
    check(0, "front on empty StrBlob");
    return data->front();
}
string& StrBlob::back(){
    check(0, "back on empty StrBlob");
    return data->back();
}

StrBlob的拷貝、賦值和銷燬

StrBlob使用預設的拷貝、賦值和解構函式對此型別的物件進行操作

當我們對StrBlob物件進行拷貝、賦值和銷燬時,它的shared_ptr成員也會預設地進行拷貝、賦值和銷燬

//由於data是private的
//在StrBlob中設定一個介面look_data
//look_data返回data的引用
class StrBlob{
public:
    //...
    shared_ptr<vector<string>>& look_data()
    {return data;} //返回引用,避免物件拷貝
private:
    //其餘部分都不變
};

測試程式:

//測試程式
int main(){
    StrBlob b1;
    {//新作用域
        StrBlob b2 = {"first element","second element"};
        cout<<"before assignment : "
            <<b2.look_data().use_count()<<endl;
        b1 = b2;
        cout<<"after assignment  : "
            <<b2.look_data().use_count()<<endl;
    }//b2被銷燬,計數器遞減
    //b1仍指向b2的原資料
    cout<<b1.front()<<endl;
    //列印此時b1的計數器
    cout<<"b2 has been dstoryed : "
        <<b1.look_data().use_count()<<endl;
    return 0;
}

輸出結果:

image-20220228163045670

如果look_data值返回,而不是引用返回,那麼會存在拷貝【見6.2.2節筆記】,所有計數器的值會+1

image-20220228163205679

三、直接管理記憶體

使用new分配記憶體

  • new分配動態記憶體
  • delete銷燬動態記憶體

new和delete與智慧指標不同,類物件的拷貝、賦值和銷燬操作都不會預設地對動態分配的物件進行管理,無論是物件的建立還是銷燬,都需要程式設計師顯式地操作,在大型的應用場景中會十分複雜。

在熟悉C++拷貝控制之前,儘量只使用智慧指標,而不是本節的方法管理動態記憶體

使用new動態分配和初始化物件

new type_name:返回一個指向該物件的指標

//pi指向一個動態分配,預設初始化的無名物件
int *pi = new int;
//*pi的值是未定義的
cout<<*pi<<endl;

物件是預設初始化這意味著:

  1. 指向的是:內建型別和組合型別物件。物件的值是未定義的

  2. 指向的是:類型別物件。呼叫預設建構函式

可以直接初始化動態分配的物件

  • 直接呼叫建構函式
  • 列表初始化
//pi指向物件的值為42
int *pi = new int(42); 
//"9999999999"
string *ps = new string(10, '9'); 
//vector有5個元素,依次為0,1,2,3,4
vector<int> *pv = new vector<int>{0,1,2,3,4};

也可以值初始化

string *ps1 = new string(); //值初始化為空string
string *ps = new string;    //預設初始化為空string
int *pi1 = new int;         //預設初始化,值未定義
int *pi  = new int();       //值初始化,*pi = 0;

所以,初始化動態分配的物件是一個好習慣

動態分配const物件

new可以分配const物件

和其他const物件一樣,動態分配的const物件必須被初始化

//分配並初始化const int
const int *pi = new const int(1024);
//分配並預設初始化const string
const string *ps = new const string;

記憶體耗盡

如果new分配動態記憶體失敗,返回一個空指標,並報出std::bad_alloc異常

int *p1 = new int; //返回空指標,丟擲異常
int *p2 = new (nothrow) int; //如果分配失敗,new返回空指標

我們第二種形式的new為定位new (placement new),其原因我們將在19.1.2節(第729頁)中解釋。

定位new表示式允許我們向new傳遞額外的引數

在此例中,我們傳遞給它一個由標準庫定義的名為nothrow的物件。如果將nothrow傳遞給new,我們的意圖是告訴它不能丟擲異常。如果這種形式的 new不能分配所需記憶體,它會返回一個空指標。bad_alloc和nothrow都定義在標頭檔案new中。

使用delete釋放記憶體

基本介紹

delete():接受一個指標,指向我們想要銷燬的物件

執行兩個操作

  • 銷燬物件
  • 釋放對應的記憶體

注意點:

  1. 保證只傳給delete動態分配的指標,將一般指標傳給delete,其行為是未定義的
  2. 同一塊記憶體不能釋放兩次
  3. 不要忘記delete記憶體
  4. 不要使用已經delete的物件
int i, *pi = &i;
int *pd = new int();
delete pd; //正確:釋放pd記憶體
pd = nullptr; //好習慣:指出pd不再指向動態記憶體
delete pi; //未定義:pi沒有指向動態分配的記憶體
delete pd; //未定義:pd記憶體已經被釋放

保證以上兩點是程式設計師的責任,編譯器並不會檢查以上錯誤

舉例

在被顯式地delete前,用new動態分配的記憶體一直存在

Foo* factory(T arg){
    //處理arg
    return new Foo(arg);
}//呼叫者負責釋放

void ues_factory(T arg){
    Foo *p = factory(arg);
    //使用p但不delete它
}//p離開了作用域,但它所指向的記憶體沒有被釋放!!

use_factory返回時,區域性變數p被銷燬。但此變數是一個內建指標,而不是一個智慧指標,所以p所指向的記憶體並沒有被銷燬

這樣就產生了一塊無名的記憶體塊,存在又無法刪除。

這也體現了智慧指標與普通指標的區別:智慧指標在離開自己的作用域,自己的變數名失效時,銷燬指向的物件並釋放關聯記憶體;而new產生的指標不會。

修改use_factory:

void use_factory(T arg){
    Foo *p = factory(arg);
    //使用p
    delete p;  //記得釋放p
}

堅持使用智慧指標,可以避免上述的絕大部分問題

四、shared_ptr和new結合使用

new直接初始化share_ptr

可以用new返回的指標初始化share_ptr

建構函式是explicit

所以,不存在new產生的指標向shared_ptr的隱式型別轉換,必須採用直接初始化,而不是拷貝初始化或者賦值

shared_ptr<int> p1(new int(42)); //正確:使用直接初始化
shared_ptr<int> p2 = new int(30);//錯誤:new產生的指標

同理,返回shared_ptr的函式不能返回new產生的指標

shared_ptr<int> clone(int p){
    //錯誤:建構函式為explicit,無法轉換
    //return new int(p);  
    //正確:顯式地用int*構造shared_ptr<int>
    return shared_ptr<int>(new int(p));
}

如對隱式型別轉換有疑問檢視 7-5筆記第三點”隱式類型別轉換”

初始化時傳入可呼叫物件代替delete

預設情況下,一個用來初始化智慧指標的普通指標必須指向動態記憶體,因為智慧指標預設使用delete釋放它所關聯的物件。我們可以將智慧指標繫結到一個指向其他型別的資源的指標上,但是為了這樣做,必須提供自己的操作來替代 delete。我們將在12.1.4節介紹如何定義自己的釋放操作。

image-20220228230530493

image-20220228230537447

五、unique_ptr

和shared_ptr不同,某個時刻只能有一個unique_ptr指向一個給定物件

基本操作

必須採用直接初始化

unique_ptr<double> p1;  //可以指向double的一個unique_ptr
unique_ptr<int> p2(new int(42)); //p2指向一個值為42的int

unique_ptr不支援拷貝與賦值

unique_ptr<string> p1(new string("hello"));
unique_ptr<string> p2(p1); //錯誤:不支援拷貝
unique_ptr<string> p3;
p3 = p1; //錯誤:不支援賦值

unique_ptr支援的操作

image-20220301083622870

可以使用release和reset將指標的所有權從一個(非const)unique_ptr轉移到另一個unique_ptr

//將所有權從p1,轉移到p2
unique_ptr<string> p1(new string("hello"));
unique_ptr<string> p2(p1.release()); //release將p1置空
cout<<*p2<<endl; //輸出 hello
unique_ptr<string> p3(new string("world"));
//p2繫結的物件被釋放,p3置空,p2指向p3原來指向的物件
p2.reset(p3.release());
cout<<*p2<<endl; //輸出: world

傳遞和返回unique_ptr

不能拷貝unique_ptr 的規則有一個例外:我們可以拷貝或賦值一個將要被銷燬的unique_ptr。最常見的例子是從函式返回一個unique_ptr:

unique_ptr<int> clone(int p){
    //正確:從int*建立一個unique_ptr<int>
    return unique_ptr<int>(new int(p));
}

還可以返回一個區域性變數的拷貝

unique_ptr<int> clone(int p){
    unique_ptr<int> ret(new int(p));
    return ret;
}

對於兩段程式碼,編譯器都知道要返回的物件將要被銷燬。在此情況下,編譯器執行一種特殊的“拷貝”,我們將在13.6.2節(移動建構函式和移動運算子)中介紹它。

向unique_ptr傳遞刪除器

//p指向一個型別為objT的物件
//並使用一個型別為delT的可呼叫物件釋放objT
//p會使用一個名為fcnd的delT物件來刪除objT
unique_ptr<objT, delT> p(new objT, fcn);

作為一個更具體的例子,我們將寫一個連線程式,用unique_ptr來代替shared_ptr,如下所示:

void f(destination &d /*其他需要的引數*/)
{
	connection c = connect(&d);//開啟連結
	unique_ptr<connection, decltype(end_connection)*>
		p(&c, end_connection);
	//使用連結
	//當f退出時(即使是由於異常而退出)
	//connection會呼叫end_connection正常退出
}

注意decltype(end_connection)返回一個函式型別,而函式型別不能作為引數,函式指標可以
所以要加上*表示函式指標
p(&c, end_connection)中,類似於陣列名錶示指標一樣,函式名實際上就表示函式指標
所以也可寫作p(&c, &end_connection),但沒必要。【前一個&表示引用傳遞,後一個&表示取址得到指標】

相關文章