1. 程式人生 > 程式設計 >搞定技術面試:C++ 11 智慧指標詳解

搞定技術面試:C++ 11 智慧指標詳解

引言

最近在敲一個C++專案的時候,出現了令人喪失自我的嚴重的記憶體洩露,如下圖所示:

經過除錯後,最終發現導致記憶體洩漏的地點是一個頻繁呼叫的函式中,有一定概率使四個指標沒有釋放,每個指標大小應該與記憶體寬度一致,也就是每個指標為 64位 8位元組,四個指標就是32位元組。而小小的32位元組的洩露積蓄的能量可以達到數十G空間直至吃掉所有記憶體。

本文介紹一種不借助其他檢測工具的情況下如何對記憶體洩露的點進行檢測的方法,同時也介紹下STL中智慧指標的使用方法。

查詢記憶體洩露方法

啥是記憶體洩露

記憶體洩露在維基百科中的解釋如下:

在電腦科學中,記憶體洩漏指由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體。記憶體洩漏並非指記憶體在物理上的消失,而是應用程式分配某段記憶體後,由於設計錯誤,導致在釋放該段記憶體之前就失去了對該段記憶體的控制,從而造成了記憶體的浪費。

在C++中出現記憶體洩露的主要原因就是程式猿在申請了記憶體後(malloc(),new),沒有及時釋放沒用的記憶體空間,甚至消滅了指標導致該區域記憶體空間根本無法釋放。

知道了出現記憶體洩露的原因就能知道如何應對記憶體洩露,即:不用了的記憶體空間記得釋放,不釋放留著過年哇!

記憶體洩漏可能會導致嚴重的後果:

  • 程式執行後,隨著時間佔用了更多的記憶體,最後無記憶體可用而崩潰;
  • 程式消耗了大量的記憶體,導致其他程式無法正常使用;
  • 程式消耗了大量記憶體,導致消費者選用了別人的程式而不是你的;
  • 經常做出記憶體洩露bug的程式猿被公司開出而貧困潦倒。

如何知道自己的程式存在記憶體洩露?

根據記憶體洩露的原因及其惡劣的後果,我們可以通過其主要表現來發現程式是否存在記憶體洩漏:程式長時間執行後記憶體佔用率一直不斷的緩慢的上升,而實際上在你的邏輯中並沒有這麼多的記憶體需求。

如何定位到洩露點呢?

  1. 根據原理,我們可以先review自己的程式碼,利用"查詢"功能,查詢newdelete,看看記憶體的申請與釋放是不是成對釋放的,這使你迅速發現一些邏輯較為簡單的記憶體洩露情況。

  2. 如果依舊發生記憶體洩露,可以通過記錄申請與釋放的物件數目是否一致來判斷。在類中追加一個靜態變數 static int count;在建構函式中執行count++;在解構函式中執行count--;,通過在程式結束前將所有類析構,之後輸出靜態變數,看count的值是否為0,如果為0,則問題並非出現在該處,如果不為0,則是該型別物件沒有完全釋放。

  3. 檢查類中申請的空間是否完全釋放,尤其是存在繼承父類的情況,看看子類中是否呼叫了父類的解構函式,有可能會因為子類析構時沒有是否父類中申請的記憶體空間。

  4. 對於函式中申請的臨時空間,認真檢查,是否存在提前跳出函式的地方沒有釋放記憶體。

STL 的智慧指標

為了減少出現記憶體洩露的情況,STL中使用智慧指標來減少洩露。STL中一般有四種智慧指標:

指標類別 支援 備註
unique_ptr C++ 11 擁有獨有物件所有權語義的智慧指標
shared_ptr C++ 11 擁有共享物件所有權語義的智慧指標
weak_ptr C++ 11 到 std::shared_ptr 所管理物件的弱引用
auto_ptr C++ 17中移除 擁有嚴格物件所有權語義的智慧指標

因為 auto_ptr 已經在 C++ 17 中移除,對於面向未來的程式設計師來說,最好減少在程式碼中出現該使用的頻次吧,這裡我便不再研究該型別。又因為weak_ptrshared_ptr的弱引用,所以,主要的只能指標分為兩個unique_ptrshared_ptr

std::unique_ptr 是通過指標佔有並管理另一物件,並在 unique_ptr 離開作用域時釋放該物件的智慧指標。在下列兩者之一發生時用關聯的刪除器釋放物件:

  • 銷燬了管理的 unique_ptr 物件
  • 通過 operator= 或 reset() 賦值另一指標給管理的 unique_ptr 物件。

std::shared_ptr 是通過指標保持物件共享所有權的智慧指標。多個 shared_ptr 物件可佔有同一物件。下列情況之一出現時銷燬物件並解分配其記憶體:

  • 最後剩下的佔有物件的 shared_ptr 被銷燬;
  • 最後剩下的佔有物件的 shared_ptr 被通過 operator= 或 reset() 賦值為另一指標。

unique_ptr

這是個獨佔式的指標物件,在任何時間、資源只能被一個指標佔有,當unique_ptr離開作用域,指標所包含的內容會被釋放。

建立

unique_ptr<int> uptr( new int );
unique_ptr<int[ ]> uptr( new int[5] );

//宣告,可以用一個指標顯示的初始化,或者宣告成一個空指標,可以指向一個型別為T的物件
shared_ptr<T> sp;
unique_ptr<T> up;
//賦值,返回相對應型別的智慧指標,指向一個動態分配的T型別物件,並且用args來初始化這個物件
make_shared<T>(args);
make_unique<T>(args);     //注意make_unique是C++14之後才有的
//用來做條件判斷,如果其指向一個物件,則返回true否則返回false
p;
//解引用
*p;
//獲得其儲存的指標,一般不要用
p.get();
//交換指標
swap(p,q);
p.swap(q);

//release()用法
 //release()返回原來智慧指標指向的指標,只負責轉移控制權,不負責釋放記憶體,常見的用法
 unique_ptr<int> q(p.release()) // 此時p失去了原來的的控制權交由q,同時p指向nullptr  
 //所以如果單獨用:
 p.release()
 //則會導致p丟了控制權的同時,原來的記憶體得不到釋放
 //則會導致//reset()用法
 p.reset()     // 釋放p原來的物件,並將其置為nullptr,
 p = nullptr   // 等同於上面一步
 p.reset(q)    // 注意此處q為一個內建指標,令p釋放原來的記憶體,p新指向這個物件

複製程式碼

類滿足可移動構造 (MoveConstructible) 和可移動賦值 (MoveAssignable) 的要求,但不滿足可複製構造 (CopyConstructible) 或可複製賦值 (CopyAssignable) 的要求。 因此不可以使用 = 操作和拷貝建構函式,僅能使用移動操作。

Demo

#include <iostream>
#include <vector>
#include <memory>
#include <cstdio>
#include <fstream>
#include <cassert>
#include <functional>

struct B {
  virtual void bar() { std::cout << "B::bar\n"; }
  virtual ~B() = default;
};
struct D : B
{
    D() { std::cout << "D::D\n";  }
    ~D() { std::cout << "D::~D\n";  }
    void bar() override { std::cout << "D::bar\n";  }
};

// 消費 unique_ptr 的函式能以值或以右值引用接收它
std::unique_ptr<D> pass_through(std::unique_ptr<D> p)
{
    p->bar();
    return p;
}

void close_file(std::FILE* fp) { std::fclose(fp); }

int main()
{
  std::cout << "unique ownership semantics demo\n";
  {
      auto p = std::make_unique<D>(); // p 是佔有 D 的 unique_ptr
      auto q = pass_through(std::move(p));
      assert(!p); // 現在 p 不佔有任何內容並保有空指標
      q->bar();   // 而 q 佔有 D 物件
  } // ~D 呼叫於此

  std::cout << "Runtime polymorphism demo\n";
  {
    std::unique_ptr<B> p = std::make_unique<D>(); // p 是佔有 D 的 unique_ptr
                                                  // 作為指向基類的指標
    p->bar(); // 虛派發

    std::vector<std::unique_ptr<B>> v;  // unique_ptr 能儲存於容器
    v.push_back(std::make_unique<D>());
    v.push_back(std::move(p));
    v.emplace_back(new D);
    for(auto& p: v) p->bar(); // 虛派發
  } // ~D called 3 times

  std::cout << "Custom deleter demo\n";
  std::ofstream("demo.txt") << 'x'; // 準備要讀的檔案
  {
      std::unique_ptr<std::FILE,void (*)(std::FILE*) > fp(std::fopen("demo.txt","r"),close_file);
      if(fp) // fopen 可以開啟失敗;該情況下 fp 保有空指標
        std::cout << (char)std::fgetc(fp.get()) << '\n';
  } // fclose() 呼叫於此,但僅若 FILE* 不是空指標
    // (即 fopen 成功)

  std::cout << "Custom lambda-expression deleter demo\n";
  {
    std::unique_ptr<D,std::function<void(D*)>> p(new D,[](D* ptr)
        {
            std::cout << "destroying from a custom deleter...\n";
            delete ptr;
        });  // p 佔有 D
    p->bar();
  } // 呼叫上述 lambda 並銷燬 D

  std::cout << "Array form of unique_ptr demo\n";
  {
      std::unique_ptr<D[]> p{new D[3]};
  } // 呼叫 ~D 3 次
}
複製程式碼

輸出結果:

unique ownership semantics demo
D::D
D::bar
D::bar
D::~D
Runtime polymorphism demo
D::D
D::bar
D::D
D::D
D::bar
D::bar
D::bar
D::~D
D::~D
D::~D
Custom deleter demo
x
Custom lambda-expression deleter demo
D::D
D::bar
destroying from a custom deleter...
D::~D
Array form of unique_ptr demo
D::D
D::D
D::D
D::~D
D::~D
D::~D
複製程式碼

shared_ptr

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


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

析構

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

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

注意 儘量不要用裸指標建立 shared_ptr,以免出現分組不同導致錯誤

void main( )
{
// 錯誤
 int* p = new int;
 shared_ptr<int> sptr1( p);   // count 1
 shared_ptr<int> sptr2( p );  // count 1

// 正確
 shared_ptr<int> sptr1( new int );  // count 1
 shared_ptr<int> sptr2 = sptr1;     // count 2
 shared_ptr<int> sptr3;           
 sptr3 =sptr1                       // count 3
}
複製程式碼

迴圈引用

因為 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 );  // sptB count 1
 shared_ptr<A> sptrA( new A );  // sptB count 1
 sptrB->m_sptrA = sptrA;    // sptB count 2
 sptrA->m_sptrB = sptrB;    // sptA count 2
}

// 超出定義域
// sptA count 1
// sptB count 2
複製程式碼

demo

#include <iostream>
#include <memory>
#include <thread>
#include <chrono>
#include <mutex>

struct Base
{
    Base() { std::cout << "  Base::Base()\n"; }
    // 注意:此處非虛解構函式 OK
    ~Base() { std::cout << "  Base::~Base()\n"; }
};

struct Derived: public Base
{
    Derived() { std::cout << "  Derived::Derived()\n"; }
    ~Derived() { std::cout << "  Derived::~Derived()\n"; }
};

void thr(std::shared_ptr<Base> p)
{
    std::this_thread::sleep_for(std::chrono::seconds(1));
    std::shared_ptr<Base> lp = p; // 執行緒安全,雖然自增共享的 use_count
    {
        static std::mutex io_mutex;
        std::lock_guard<std::mutex> lk(io_mutex);
        std::cout << "local pointer in a thread:\n"
                  << "  lp.get() = " << lp.get()
                  << ",lp.use_count() = " << lp.use_count() << '\n';
    }
}

int main()
{
    std::shared_ptr<Base> p = std::make_shared<Derived>();

    std::cout << "Created a shared Derived (as a pointer to Base)\n"
              << "  p.get() = " << p.get()
              << ",p.use_count() = " << p.use_count() << '\n';
    std::thread t1(thr,p),t2(thr,t3(thr,p);
    p.reset(); // 從 main 釋放所有權
    std::cout << "Shared ownership between 3 threads and released\n"
              << "ownership from main:\n"
              << "  p.get() = " << p.get()
              << ",p.use_count() = " << p.use_count() << '\n';
    t1.join(); t2.join(); t3.join();
    std::cout << "All threads completed,the last one deleted Derived\n";
}
複製程式碼

可能的輸出結果

Base::Base()
  Derived::Derived()
Created a shared Derived (as a pointer to Base)
  p.get() = 0xc99028,p.use_count() = 1
Shared ownership between 3 threads and released
ownership from main:
  p.get() = (nil),p.use_count() = 0
local pointer in a thread:
  lp.get() = 0xc99028,lp.use_count() = 3
local pointer in a thread:
  lp.get() = 0xc99028,lp.use_count() = 4
local pointer in a thread:
  lp.get() = 0xc99028,lp.use_count() = 2
  Derived::~Derived()
  Base::~Base()
All threads completed,the last one deleted Derived
複製程式碼

weak_ptr

std::weak_ptr 是一種智慧指標,它對被 std::shared_ptr 管理的物件存在非擁有性(“弱”)引用。在訪問所引用的物件前必須先轉換為 std::shared_ptr。

std::weak_ptr 用來表達臨時所有權的概念:當某個物件只有存在時才需要被訪問,而且隨時可能被他人刪除時,可以使用 std::weak_ptr 來跟蹤該物件。需要獲得臨時所有權時,則將其轉換為 std::shared_ptr,此時如果原來的 std::shared_ptr 被銷燬,則該物件的生命期將被延長至這個臨時的 std::shared_ptr 同樣被銷燬為止。

std::weak_ptr 的另一用法是打斷 std::shared_ptr 所管理的物件組成的環狀引用。若這種環被孤立(例如無指向環中的外部共享指標),則 shared_ptr 引用計數無法抵達零,而記憶體被洩露。能令環中的指標之一為弱指標以避免此情況。

建立

void main( )
{
 shared_ptr<Test> sptr( new Test );   // 強引用 1
 weak_ptr<Test> wptr( sptr );         // 強引用 1 弱引用 1
 weak_ptr<Test> wptr1 = wptr;         // 強引用 1 弱引用 2
}
複製程式碼

將一個weak_ptr賦給另一個weak_ptr會增加弱引用計數(weak reference count)。 所以,當shared_ptr離開作用域時,其內的資源釋放了,這時候指向該shared_ptr的weak_ptr發生了什麼?weak_ptr過期了(expired)。如何判斷weak_ptr是否指向有效資源,有兩種方法:

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

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

解決 shared_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( );
}
複製程式碼

STL 智慧指標的陷阱/不夠智慧的地方

  1. 儘量用make_shared/make_unique,少用new

std::shared_ptr在實現的時候使用的refcount技術,因此內部會有一個計數器(控制塊,用來管理資料)和一個指標,指向資料。因此在執行std::shared_ptr<A> p2(new A) 的時候,首先會申請資料的記憶體,然後申請內控制塊,因此是兩次記憶體申請,而std::make_shared<A>()則是隻執行一次記憶體申請,將資料和控制塊的申請放到一起。

  1. 不要使用相同的內建指標來初始化(或者reset)多個智慧指標

  2. 不要delete get()返回的指標

  3. 不要用get()初始化/reset另一個智慧指標

  4. 智慧指標管理的資源它只會預設刪除new分配的記憶體,如果不是new分配的則要傳遞給其一個刪除器

  5. 不要把this指標交給智慧指標管理

    以下程式碼發生了什麼事情呢?還是同樣的錯誤。把原生指標 this 同時交付給了 m_sp 和 p 管理,這樣會導致 this 指標被 delete 兩次。 這裡值得注意的是:以上所說的交付給m_sp 和 p 管理不對,並不是指不能多個shared_ptr同時佔有同一類資源。shared_ptr之間的資源共享是通過shared_ptr智慧指標拷貝、賦值實現的,因為這樣可以引起計數器的更新;而如果直接通過原生指標來初始化,就會導致m_sp和p都根本不知道對方的存在,然而卻兩者都管理同一塊地方。相當於”一間廟裡請了兩尊神”。

    class Test{
    public:
        void Do(){  m_sp =  shared_ptr<Test>(this);  }
    private:
        shared_ptr<Test> m_sp;
    };
    int main()
    {
        Test* t = new Test;
        shared_ptr<Test> p(t);
        p->Do();
        return 0;
    }
    複製程式碼
  6. 不要把一個原生指標給多個shared_ptr或者unique_ptr管理

我們知道,在使用原生指標對智慧指標初始化的時候,智慧指標物件都視原生指標為自己管理的資源。換句話意思就說:初始化多個智慧指標之後,這些智慧指標都擔負起釋放記憶體的作用。那麼就會導致該原生指標會被釋放多次!!

```C++
int* ptr = new int;
shared_ptr<int> p1(ptr);
shared_ptr<int> p2(ptr);
//p1,p2析構的時候都會釋放ptr,同一記憶體被釋放多次!
```
複製程式碼
  1. 不是使用new出來的空間要自定義刪除器

以下程式碼試圖將malloc產生的動態記憶體交給shared_ptr管理;顯然是有問題的,delete 和 malloc 牛頭不對馬嘴!!! 所以我們需要自定義刪除器[](int* p){ free(p); }傳遞給shared_ptr。

```C++
    int main()
{
    int* pi = (int*)malloc(4 * sizeof(int));
    shared_ptr<int> sp(pi);
    return 0;
}
```
複製程式碼
  1. 儘量不要使用 get()

智慧指標設計者之處提供get()介面是為了使得智慧指標也能夠適配原生指標使用的相關函式。這個設計可以說是個好的設計,也可以說是個失敗的設計。因為根據封裝的封閉原則,我們將原生指標交付給智慧指標管理,我們就不應該也不能得到原生指標了;因為原生指標唯一的管理者就應該是智慧指標。而不是客戶邏輯區的其他什麼程式碼。 所以我們在使用get()的時候要額外小心,禁止使用get()返回的原生指標再去初始化其他智慧指標或者釋放。(只能夠被使用,不能夠被管理)。而下面這段程式碼就違反了這個規定:

int main()
{
    shared_ptr<int> sp(new int(4));
    shared_ptr<int> pp(sp.get());
    return 0;
}
複製程式碼

Reference

  1. cppreference.com
  2. C++11 智慧指標 作者:卡巴拉的樹
  3. C++11及C++14標準的智慧指標
  4. Item 21: 比起直接使用new優先使用std::make_unique和std::make_shared
  5. 必須要注意的 C++ 動態記憶體資源管理(五)——智慧指標陷阱