天天看點

C++ 智能指針深剖

目錄

  • ​​傳統藝能😎​​
  • ​​背景😋​​
  • ​​概念😋​​
  • ​​原理😋​​
  • ​​智能指針三将星😋​​
  • ​​auto_ptr 🤔​​
  • ​​unique_ptr 🤔​​
  • ​​shared_ptr 🤔​​
  • ​​定制删除器🤔​​
  • ​​weak_ptr 🤔​​
  • ​​boost 的智能指針😋​​

傳統藝能😎

C++ 智能指針深剖

背景😋

記憶體洩露是造成程式未能釋放已經不再使用的記憶體的情況,下面是一個典型的記憶體洩漏場景,如果我們輸入的除數為 0,那麼 div 中就會抛出異常,這時程式的執行流會直接跳轉到主函數的 catch 塊中執行,最終導緻 func 中申請的記憶體資源不能釋放:

int div()
{
  int a, b;
  cin >> a >> b;
  if (b == 0)
    throw invalid_argument("除0錯誤");
  return a / b;
}
void func()
{
  int* ptr = new int;
  //...
  cout << div() << endl;
  //...
  delete ptr;
}
int main()
{
  try
  {
    func();
  }
  catch (exception& e)
  {
    cout << e.what() << endl;
  }
  return 0;
}      

這種情況我們應該會去 func 中捕獲抛出的異常,釋放洩露的記憶體後再重新抛出異常:

int div()
{
  int a, b;
  cin >> a >> b;
  if (b == 0)
    throw invalid_argument("除0錯誤");
  return a / b;
}
void func()
{
  int* ptr = new int;
  try
  {
    cout << div() << endl;
  }
  catch (...)
  {
    delete ptr;
    throw;
  }
  delete ptr;
}
int main()
{
  try
  {
    func();
  }
  catch (exception& e)
  {
    cout << e.what() << endl;
  }
  return 0;
}      

對于記憶體洩漏這個項目中的麻煩鬼,為了高效且便捷的防範與檢查, C++11 的智能指針就此應運而生。

概念😋

就上面記憶體洩漏的場景,我們就可以用智能指針解決:

template<class T>
class SmartPtr
{
public:
  SmartPtr(T* ptr)
    :_ptr(ptr)
  {}
  ~SmartPtr()
  {
    cout << "delete: " << _ptr << endl;
    delete _ptr;
  }
  T& operator*()
  {
    return *_ptr;
  }
  T* operator->()
  {
    return _ptr;
  }
private:
  T* _ptr;
};
int div()
{
  int a, b;
  cin >> a >> b;
  if (b == 0)
    throw invalid_argument("除0錯誤");
  return a / b;
}
void func()
{
  SmartPtr<int> sp(new int);
  //...
  cout << div() << endl;
  //...
}
int main()
{
  try
  {
    func();
  }
  catch (exception& e)
  {
    cout << e.what() << endl;
  }
  return 0;
}      

這裡将申請到的記憶體空間交給了一個 SmartPtr 對象進行管理,構造 SmartPtr 時,他将需要被管理的記憶體空間儲存起來,SmartPtr 析構時,他的析構函數中會自動進行釋放。

此外,為了讓 SmartPtr 對象能夠像原生指針一樣使用,還需要對 * 和 -> 運算符進行重載。這樣一來,無論程式是正常執行完畢,還是因為某些原因中途傳回了,或是抛異常傳回了,隻要 SmartPtr 對象的生命周期結束就會調用其對應的析構函數,進而完成記憶體資源的釋放

原理😋

首先智能指針的思想是 ,RAII 是利用對象的生命周期來控制資源(記憶體,句柄,互斥量等)的一種技術思想。

智能指針需要實作三個方面:

  1. 在對象構造時擷取資源,在對象析構的時候釋放資源,利用對象的生命周期來控制程式資源,即RAII特性
  2. 對 * 和 -> 運算符進行重載,使得該對象具有像指針一樣的行為
  3. 智能指針對象的拷貝問題

此時可能就會有鐵子疑惑,為什麼會要特意解決智能指針拷貝問題,我們不妨看一個場景:

int main()
{
  SmartPtr<int> sp1(new int);
  SmartPtr<int> sp2(sp1); //拷貝構造

  SmartPtr<int> sp3(new int);
  SmartPtr<int> sp4(new int);
  sp3 = sp4; //拷貝指派
  
  return 0;
}      

編譯器預設生成的拷貝構造對内置類型完成值拷貝,即淺拷貝,是以用 sp1 拷貝構造 sp2 後,相當于 sp1 和 sp2 管理了同一塊記憶體空間,當 sp1 和 sp2 析構時就會導緻這塊空間被釋放兩次

拷貝指派函也是同理,sp4 指派給 sp3 後,相當于 sp3 和 sp4 管理的都是原來 sp3 管理的空間,析構時不僅會導緻釋放兩次,還會導緻 sp4 原來管理的空間無法釋放

需要注意的是,智能指針就是要模拟原生指針的行為,當我們将一個指針指派給另一個指針時,目的就是讓這兩個指針指向同一塊記憶體空間,是以這裡本就應該進行淺拷貝,但單純的淺拷貝又會導緻空間被多次釋放,是以根據解決拷貝問題的方式不同,進而衍生出了各種針對性的智能指針

智能指針三将星😋

,其優點是會自動配置設定記憶體,不用擔心潛在的記憶體洩露。 C++11 提供了 3 個不同類型的智能指針:

unique_ptr

weak_ptr

shared_ptr

std::auto_ptr (已被廢棄)

auto_ptr 🤔

最後一個 auto_ptr 是 C++98 中引入的智能指針,auto_ptr 通過管理權轉移的方式解決拷貝問題,保證資源在任何時刻都隻有一個對象在對其進行管理,這時同一個資源就不會被多次釋放了

int main()
{
  std::auto_ptr<int> ap1(new int(1));
  std::auto_ptr<int> ap2(ap1);
  *ap2 = 10;
  //*ap1 = 20; //error

  std::auto_ptr<int> ap3(new int(1));
  std::auto_ptr<int> ap4(new int(2));
  ap3 = ap4;
  return 0;
}      

但是他之是以會被 ban 就是因為他的管理權轉移,如果還用原來的管理的資源進行通路,就會導緻程式崩潰,是以不了解他就去使用很可能是你扣工資的原因。

簡易版的auto_ptr的實作步驟如下:

在構造函數中擷取資源,在析構函數中釋放資源,利用對象生命周期來控制資源對 * 和 -> 運算符進行重載,使 auto_ptr 對象具有指針一樣的行為

在拷貝構造函數中,用傳入對象管理的資源來構造目前對象,并将傳入對象管理資源的指針置空

在拷貝指派函數中,先将目前對象管理的資源釋放,然後再接管傳入對象管理的資源,最後将傳入對象管理資源的指針置空

namespace cl
{
  template<class T>
  class auto_ptr
  {
  public:
    //RAII
    auto_ptr(T* ptr = nullptr)
      :_ptr(ptr)
    {}
    ~auto_ptr()
    {
      if (_ptr != nullptr)
      {
        cout << "delete: " << _ptr << endl;
        delete _ptr;
        _ptr = nullptr;
      }
    }
    auto_ptr(auto_ptr<T>& ap)
      :_ptr(ap._ptr)
    {
      ap._ptr = nullptr; //管理權轉移ap被置空
    }
    auto_ptr& operator=(auto_ptr<T>& ap)
    {
      if (this != &ap)
      {
        delete _ptr;       //釋放管理的資源
        _ptr = ap._ptr;    //接管對象的資源
        ap._ptr = nullptr; //管理權轉移ap被置空
      }
      return *this;
    }
    //模仿指針行為
    T& operator*()
    {
      return *_ptr;
    }
    T* operator->()
    {
      return _ptr;
    }
  private:
    T* _ptr; //管理的資源
  };
}      

unique_ptr 🤔

unique_ptr 是防止拷貝的智能指針,也就是簡單粗暴的防止對象進行拷貝,這樣也能保證資源不會被多次釋放

int main()
{
  std::unique_ptr<int> up1(new int(0));
  //std::unique_ptr<int> up2(up1); //報錯
  return 0;
}      

但防拷貝其實也不是萬金油,因為總有一些場景需要進行拷貝,既然知道他的功能,我們也可以實作一個簡易版的 unique_ptr 來看看他的底層原理:

在構造函數中擷取資源,在析構函數中釋放資源,利用對象的生命周期來控制資源

對 * 和 -> 運算符進行重載,使 unique_ptr 對象具有指針一樣的行為

用 C++98 的方式将拷貝構造函數和拷貝指派函數聲明為私有,或者用 C++11 的方式在這兩個函數後面加上=delete,防止外部調用

namespace cl
{
  template<class T>
  class unique_ptr
  {
  public:
    //RAII
    unique_ptr(T* ptr = nullptr)
      :_ptr(ptr)
    {}
    ~unique_ptr()
    {
      if (_ptr != nullptr)
      {
        cout << "delete: " << _ptr << endl;
        delete _ptr;
        _ptr = nullptr;
      }
    }
    //可以像指針一樣使用
    T& operator*()
    {
      return *_ptr;
    }
    T* operator->()
    {
      return _ptr;
    }
    //防拷貝
    unique_ptr(unique_ptr<T>& up) = delete;
    unique_ptr& operator=(unique_ptr<T>& up) = delete;
  private:
    T* _ptr; //管理的資源
  };
}      

shared_ptr 🤔

自然會有必須要拷貝的場景,這種場景又如何考慮拷貝問題呢?這就誕生了 shared_ptr 。shared_ptr 是 C++11 中引入的智能指針,shared_ptr 允許拷貝且通過 的方式解決拷貝問題。

每一個被管理的資源都有一個對應的引用計數,記錄目前有多少個對象在管理這塊資源,每新增一個管理者引用計數就 ++ 一次,當不再管理或該對象被析構時則将引用計數 --,當一個資源的引用計數減為 0 時已經沒有對象在管理了,這時就可以釋放資源了

int main()
{
  cl::shared_ptr<int> sp1(new int(1));
  cl::shared_ptr<int> sp2(sp1);
  *sp1 = 10;
  *sp2 = 20;
  // use_count函數用于擷取目前管理資源的引用計數
  cout << sp1.use_count() << endl; //2

  cl::shared_ptr<int> sp3(new int(1));
  cl::shared_ptr<int> sp4(new int(2));
  sp3 = sp4;
  cout << sp3.use_count() << endl; //2
  return 0;
}      

同樣也來模拟一下簡易版的 shared_ptr :

對 * 和 -> 運算符進行重載,使shared_ptr對象具有指針一樣的行為

在 shared_ptr 類中增加成員變量 count,表示資源對應的引用計數。

在構造函數中擷取資源,并将該資源對應的引用計數設定為 1,表示目前隻有一個對象在管理這個資源

在拷貝構造函數中,與傳入對象一起管理它管理的資源,同時将該資源對應的引用計數++

在拷貝指派函數中,先将目前資源對應的引用計數–(減為0則釋放),然後再與傳入對象一起管理它管理的資源,同時将該資源引用計數++

在析構函數中,将管理資源對應的引用計數–,如果減為0則需要将該資源釋放

namespace cl
{
  template<class T>
  class shared_ptr
  {
  public:
    //RAII
    shared_ptr(T* ptr = nullptr)
      :_ptr(ptr)
      , _pcount(new int(1))
    {}
    ~shared_ptr()
    {
      if (--(*_pcount) == 0)
      {
        if (_ptr != nullptr)
        {
          cout << "delete: " << _ptr << endl;
          delete _ptr;
          _ptr = nullptr;
        }
        delete _pcount;
        _pcount = nullptr;
      }
    }
    shared_ptr(shared_ptr<T>& sp)
      :_ptr(sp._ptr)
      , _pcount(sp._pcount)
    {
      (*_pcount)++;
    }
    shared_ptr& operator=(shared_ptr<T>& sp)
    {
      if (_ptr != sp._ptr) //管理同一塊空間的對象之間無需進行指派操作
      {
        if (--(*_pcount) == 0) //将管理的資源對應的引用計數--
        {
          cout << "delete: " << _ptr << endl;
          delete _ptr;
          delete _pcount;
        }
        _ptr = sp._ptr;       //與sp對象一同管理它的資源
        _pcount = sp._pcount; //擷取sp對象管理的資源對應的引用計數
        (*_pcount)++;         //新增一個對象來管理該資源,引用計數++
      }
      return *this;
    }
    //擷取引用計數
    int use_count()
    {
      return *_pcount;
    }
    //可以像指針一樣使用
    T& operator*()
    {
      return *_ptr;
    }
    T* operator->()
    {
      return _ptr;
    }
  private:
    T* _ptr;      //管理的資源
    int* _pcount; //管理的資源對應的引用計數
  };
}      

首先要明白引用計數的數是放在堆區的,那為什麼引用計數要存放在堆區?

因為 shared_ptr 對象都有一個自己的count成員變量,會面臨着多個對象要管理同一個資源,這時引用計數 count 絕不是單純的定義成一個 int 類型,這幾個對象應該用到的是同一個引用計數!

C++ 智能指針深剖

當然也不可以定義成一個靜态變量,如果所有類型對量都共享的話,是不是管理這塊資源的對象都會用同一個引用計數!

C++ 智能指針深剖

而如果将 shared_ptr 中的引用計數 count 定義成一個指針,一開始就在堆區開辟一塊空間用于存儲其對應的引用計數,如果有其他對象也想要管理這個資源,那麼除了将這塊資源給他,還要将引用計數也給他,這時管理同一個資源的多個對象通路到的就是同一個引用計數,而管理不同資源的對象通路到的就是不同的引用計數了,相當于将各個資源與其對應的引用計數進行了綁定

C++ 智能指針深剖

定制删除器🤔

當智能指針對象的生命周期結束時,以 delete 的方式将所有的智能指針釋放,這是不太合适的,因為智能指針并不隻管理以 new 到的空間,也可能是以 new[] 申請到的空間,或管理的是一個檔案指針!

struct ListNode
{
  ListNode* _next;
  ListNode* _prev;
  int _val;
  ~ListNode()
  {
    cout << "~ListNode()" << endl;
  }
};
int main()
{
  std::shared_ptr<ListNode> sp1(new ListNode[10]);   //報錯,new[]類型
  std::shared_ptr<FILE> sp2(fopen("test.cpp", "r")); //報錯,檔案指針

  return 0;
}      

因為我們都知道必須以 delete[] 的方式釋放 new[],而檔案指針必須通過 fclose() 進行釋放。這時就橫空出世了:

//C++标準庫提供的構造函數
template <class U, class D>
shared_ptr (U* p, D del);      

p:需要管理的資源

del:删除器,可以是一個可調用對象,比如函數指針、仿函數、lambda表達式以及被包裝器包裝後的可調用對象

當 shared_ptr 對象的生命周期結束時就會調用傳入的删除器完成資源的釋放,調用該删除器時會将 shared_ptr 管理的資源作為參數進行傳入:

template<class T>
struct DelArr
{
  void operator()(const T* ptr)
  {
    cout << "delete[]: " << ptr << endl;
    delete[] ptr;
  }
};
int main()
{
  std::shared_ptr<ListNode> sp1(new ListNode[10], DelArr<ListNode>());
  std::shared_ptr<FILE> sp2(fopen("test.cpp", "r"), [](FILE* ptr));
  {
    cout << "fclose: " << ptr << endl;
    fclose(ptr);
  }
  return 0;
}      

C++ 标準庫中實作shared_ptr時是分成了很多個類的,是以标準庫中可以将删除器的類型設定為構造函數的模闆參數,然後将删除器的類型在各個類之間進行傳遞

但我們是直接用一個類來模拟實作的,是以不能将删除器類型設定為模闆參數。又因為删除器不是在構造函數中調用的,而是需要在析構函數中調用,是以必須用一個成員變量将删除器儲存下來,而在定義時就需要指定删除器類型,是以這裡模拟實作的時候不能将删除器類型設定為構造函數的模闆參數

要在目前模拟實作 shared_ptr 基礎上支援定制删除器,就隻能給 shared_ptr 類再增加一個模闆參數,在構造 shared_ptr 對象時就需要指定删除器的類型,然後增加一個支援傳入的構造函數,在構造時将删除器儲存下來,在需要釋放時調用該删除器進行釋放即可,最好在設定一個預設的删除器,如果使用者定義shared_ptr對象時不傳入删除器,則預設以delete的方式釋放資源

搞清楚這些問題後我們就可以模拟一個定制删除器了:

namespace cl
{
  //預設的删除器
  template<class T>
  struct Delete
  {
    void operator()(const T* ptr)
    {
      delete ptr;
    }
  };
  template<class T, class D = Delete<T>>
  class shared_ptr
  {
  private:
    void ReleaseRef()
    {
      _pmutex->lock();
      bool flag = false;
      if (--(*_pcount) == 0) //引用計數--
      {
        if (_ptr != nullptr)
        {
          cout << "delete: " << _ptr << endl;
          _del(_ptr); //定制删除器釋放資源
          _ptr = nullptr;
        }
        delete _pcount;
        _pcount = nullptr;
        flag = true;
      }
      _pmutex->unlock();
      if (flag == true)
      {
        delete _pmutex;
      }
    }
    //...
  public:
    shared_ptr(T* ptr, D del)
      : _ptr(ptr)
      , _pcount(new int(1))
      , _pmutex(new mutex)
      , _del(del)
    {}
    //...
  private:
    T* _ptr;        //管理的資源
    int* _pcount;   //引用計數
    mutex* _pmutex; //互斥鎖
    D _del;         //删除器
  };
}      

這時我們模拟實作的 shared_ptr 就支援定制删除器了,注意如果傳入的一個仿函數,那麼需要在構造時指明仿函數的類型。如果傳入的是一個 lambda 表達式就更麻煩了,因為 lambda 表達式的類型不太容易擷取,建議将 lambda 表達式的類型指明為一個包裝器類型,讓編譯器傳參時自行進行推演,也可以先用 auto 接收 lambda 表達式,然後再用 decltype 來聲明删除器的類型

template<class T>
struct DelArr
{
  void operator()(const T* ptr)
  {
    cout << "delete[]: " << ptr << endl;
    delete[] ptr;
  }
};
int main()
{
  //仿函數示例
  cl::shared_ptr<ListNode, DelArr<ListNode>> sp1(new ListNode[10], DelArr<ListNode>());

  //lambda表達式指明為一個包裝器類型
  cl::shared_ptr<FILE, function<void(FILE*)>> sp2(fopen("test.cpp", "r"), [](FILE* ptr){
    cout << "fclose: " << ptr << endl;
    fclose(ptr);
  });

  // auto 接收lambda表達式再用 decltype 聲明
  auto f = [](FILE* ptr){
    cout << "fclose: " << ptr << endl;
    fclose(ptr);
  };
  cl::shared_ptr<FILE, decltype(f)> sp3(fopen("test.cpp", "r"), f);

  return 0;
}      

weak_ptr 🤔

shared_ptr 在某些場景下也會有問題,他會引起循環引用,比如定義如下的結點類,在結點類的析構函數中列印一句提示語句,便于判斷結點是否正确釋放:

struct ListNode
{
    ListNode* _next;
    ListNode* _prev;
    int _val;
    ~ListNode()
    {
        cout << "~ListNode()" << endl;
    }
};
int main()
{
    ListNode* node1 = new ListNode;
    ListNode* node2 = new ListNode;
    //關系兩個節點
    node1->_next = node2;
    node2->_prev = node1;
    //...
    delete node1;
    delete node2;
    return 0;
}      

這個程式本身是沒有問題的,為了防止程式中途傳回或抛異常等原因導緻結點未被釋放,我們還應該将這兩個結點分别交給兩個 shared_ptr 對象進行管理,這時為了讓關系節點時的指派操作能夠執行,就需要把 ListNode 類中的 next 和 prev 成員變量的類型也改為 shared_ptr 類型:

struct ListNode
{
  std::shared_ptr<ListNode> _next;
  std::shared_ptr<ListNode> _prev;
  int _val;
  ~ListNode()
  {
    cout << "~ListNode()" << endl;
  }
};
int main()
{
  std::shared_ptr<ListNode> node1(new ListNode);
  std::shared_ptr<ListNode> node2(new ListNode);

  node1->_next = node2;
  node2->_prev = node1;
  //...

  return 0;
}      

程式運作結束後兩個結點都沒有被釋放,但如果去掉關系結點時的兩句代碼中的任意一句,就能保證正确釋放,根本原因就是因為這兩句關系結點的代碼導緻了

剛開始 new 出兩個節點借給智能指針管理時,計數器會 ++ 到 1:

C++ 智能指針深剖

進入到關系語句,兩個節點建立關系,next 與 node2 一同管理資源 2,prev 與 node1 一同管理資源 1,計數++:

C++ 智能指針深剖

出了 main 函數後 node1 和 node2 的生命周期就完了,此時的計數就會減到 1:

C++ 智能指針深剖

是以導緻資源無法釋放的原因就是:

  1. 當引用計數減為 0 時對應的資源才會被釋放,但資源1的釋放取決于資源2當中的 prev 成員,而資源2的釋放取決于資源1當中的 next 成員
  2. 而資源1當中的 next 成員的釋放又取決于資源1,資源2當中的 prev 成員的釋放又取決于資源2,于是這就變成了一個死循環,最終導緻資源無法釋放

那為什麼隻進行一個關系操作時就能正确釋放?node1 和 node2 生命周期結束時,就會有一個資源計數會減到 0,此時這個資源就會先釋放掉,緊接着另一個也會減到 0,随之釋放,最後兩個資源都可以得到釋放

要解決這個問題, 就橫空出世了!weak_ptr 是C++11中引入的智能指針,weak_ptr不是用來管理資源的釋放的,它主要是用來解決 shared_ptr 的循環引用問題的。

weak_ptr 支援用 shared_ptr 對象來構造對象,構造出的對象與 shared_ptr 對象管理同一個資源,但不會增加資源的引用計數

struct ListNode
{
  std::weak_ptr<ListNode> _next;
  std::weak_ptr<ListNode> _prev;
  int _val;
  ~ListNode()
  {
    cout << "~ListNode()" << endl;
  }
};
int main()
{
  std::shared_ptr<ListNode> node1(new ListNode);
  std::shared_ptr<ListNode> node2(new ListNode);
   
  cout << node1.use_count() << endl;//1
  cout << node2.use_count() << endl;//1
  node1->_next = node2;
  node2->_prev = node1;
  //weak_ptr不會增加資源的引用計數
  cout << node1.use_count() << endl;//1
  cout << node2.use_count() << endl;//1
  return 0;
}      

同理,我們也可以實作一個簡易的 weak_ptr :

提供一個無參構造函數,比如 new ListNode 時就會調用 weak_ptr 的無參構造函數, 支援用 shared_ptr

對象拷貝構造 weak_ptr 對象,構造時擷取 shared_ptr 管理的資源

支援用 shared_ptr 對象拷貝指派給 weak_ptr 對象,指派時擷取 shared_ptr 管理的資源

對 * 和 -> 運算符進行重載,使weak_ptr對象具有指針一樣的行為

namespace cl
{
  template<class T>
  class weak_ptr
  {
  public:
    weak_ptr()
      :_ptr(nullptr)
    {}
    weak_ptr(const shared_ptr<T>& sp)
      :_ptr(sp.get())//get函數用于擷取其管理的資源
    {}
    weak_ptr& operator=(const shared_ptr<T>& sp)
    {
      _ptr = sp.get();
      return *this;
    }
    //可以像指針一樣使用
    T& operator*()
    {
      return *_ptr;
    }
    T* operator->()
    {
      return _ptr;
    }
  private:
    T* _ptr; //管理的資源
  };
}      

boost 的智能指針😋

boost 庫是為 C++ 标準庫提供擴充的一些 C++ 程式庫的總稱,boost 庫社群建立的初衷之一就是為 C++ 的标準化工作提供可供參考的實作,比如在送審 C++ 标準庫TR1中,就有十個 boost 庫成為标準庫的候選方案

繼續閱讀