智慧指標-使用、避坑和實現

高效能架構探索發表於2022-02-08

本文原文:智慧指標-使用、避坑和實現

在上篇文章(記憶體洩漏-原因、避免以及定位)中,我們提到了用智慧指標來避免記憶體洩漏,今天藉助本文,從實踐避坑實現原理三個角度分析下C++中的智慧指標。

本文主要內容如下圖所示:

智慧指標-使用、避坑和實現

  1. 智慧指標的由來
  2. auto_ptr為什麼被廢棄
  3. unique_ptr的使用、特點以及實現
  4. shared_ptr的使用、特點以及實現
  5. weak_ptr的使用、特點以及實現
  6. 介紹筆者在工作中遇到的一些職能指標相關的坑,並給出一些建議

背景

記憶體的分配與回收都是由開發人員在編寫程式碼時主動完成的,好處是記憶體管理的開銷較小,程式擁有更高的執行效率;弊端是依賴於開發者的水平,隨著程式碼規模的擴大,極容易遺漏釋放記憶體的步驟,或者一些不規範的程式設計可能會使程式具有安全隱患。如果對記憶體管理不當,可能導致程式中存在記憶體缺陷,甚至會在執行時產生記憶體故障錯誤。換句話說,開發者自己管理記憶體,最容易發生下面兩種情況:

  • 申請了記憶體卻沒有釋放,造成記憶體洩漏
  • 使用已經釋放的記憶體,造成segment fault

所以,為了在保證效能的前提下,又能使得開發者不需要關心記憶體的釋放,進而使得開發者能夠將更多的精力投入到業務上,自C++11開始,STL正式引入了智慧指標。

所有權

智慧指標一個很關鍵的一個點就是是否擁有一個物件的所有權,當我們通過std::make_xxx或者new一個物件,那麼就擁有了這個物件的所有權。

所有權分為獨佔所有權共享所有權以及弱共享所有權三種。

獨佔所有權

顧名思義,獨佔該物件。獨佔的意思就是不共享,所有權可以轉移,但是轉移之後,所有權也是獨佔。auto_ptr和unique_ptr就是一種獨佔所有權方式的智慧指標。

假設有個Object物件,如果A擁有該物件的話,就需要保證其在不使用該物件的時候,將該物件釋放;而此時如果B也想擁有Object物件,那麼就必須先讓A放棄該物件所有權,然後B獨享該物件,那麼該物件的使用和釋放就只歸B所有,跟A沒有關係了。

獨佔所有權具有以下幾個特點:

  • 如果建立或者複製了某個物件,就擁有了該物件
  • 如果沒有建立物件,而是將物件保留使用,同樣擁有該物件的所有權
  • 如果你擁有了某個物件的所有權,在不需要某一個物件時,需要釋放它們

共享所有權

共享所有權,與獨佔所有權正好相反,對某個物件的所有全可以共享。shared_ptr就是一種共享所有權方式的智慧指標。

假設此時A擁有物件Object,在沒有其它擁有該對物件的情況下,物件的釋放由A來負責;如果此時B也想擁有該物件,那麼物件的釋放由最後一個擁有它的來負責。

舉一個我們經常遇到的例子,socket連線,多個傳送端(sender)可以使用其傳送和接收資料。

智慧指標-使用、避坑和實現

弱共享所有權

弱共享所有權,指的是可以使用該物件,但是沒有所有權,由真正擁有其所有權的來負責釋放。weak_ptr就是一種弱共享所有權方式的智慧指標。

智慧指標-使用、避坑和實現

分類

在C++11中,有unique_ptrshared_ptr以及weak_ptr三種,auto_ptr因為自身轉移所有權的原因,在C++11中被廢棄(本節最後,將簡單說下被廢棄的原因)。

  • unique_ptr
    • 使用上限制最多的一種智慧指標,被用來取代之前的auto_ptr,一個物件只能被一個unique_ptr所擁有,而不能被共享,如果需要將其所擁有的物件轉移給其他unique_ptr,則需要使用move語義
  • shared_ptr
    • 與unique_ptr不同的是,unique_ptr是獨佔管理權,而shared_ptr則是共享管理權,即多個shared_ptr可以共用同一塊關聯物件,其內部採用的是引用計數,在拷貝的時候,引用計數+1,而在某個物件退出作用域或者釋放的時候,引用計數-1,當引用計數為0的時候,會自動釋放其管理的物件。
  • weak_ptr
    • weak_ptr的出現,主要是為了解決shared_ptr的迴圈引用,其主要是與shared_ptr一起來私用。和shared_ptr不同的地方在於,其並不會擁有資源,也就是說不能訪問物件所提供的成員函式,不過,可以通過weak_ptr.lock()來產生一個擁有訪問許可權的shared_ptr。

auto_ptr

auto_ptr自C++98被引入,因為其存在較多問題,所以在c++11中被廢棄,自C++17開始正式從STL中移除。

首先我們看下auto_ptr的簡單實現(為了方便閱讀,進行了修改,基本功能類似於std::auto_ptr):

template<class T> 
class auto_ptr 
{ 
    T* p; 
public: 
    auto_ptr(T* s) :p(s) {} 
    ~auto_ptr() { delete p; } 
     
    auto_ptr(auto_ptr& a) { 
      p = a.p; 
      a.p = NULL; 
    } 
    auto_ptr& operator=(auto_ptr& a) { 
      delete p; 
      p=a.p; 
      a.p = NULL; 
      return *this; 
    } 
 
    T& operator*() const { return *p; } 
    T* operator->() const { return p; } 
}; 

從上面程式碼可以看出,auto_ptr採用copy語義來轉移所有權,轉移之後,其關聯的資源指標設定為NULL,而這跟我們理解上copy行為不一致。

在<< Effective STL >>第8條,作者提出永不建立auto_ptr的容器,並以一個例子來說明原因,感興趣的可以去看看這本書,還是不錯的。

實際上,auto_ptr被廢棄的直接原因是拷貝造成所有權轉移,如下程式碼:

auto_ptr<ClassA> a(new ClassA);
auto_ptr<ClassA> b = a;
a->Method();

在上述程式碼中,因為b = a導致所有權被轉移,即a關聯的物件為NULL,如果再呼叫a的成員函式,顯然會造成coredump。

正是因為拷貝導致所有權被轉移,所以auto_ptr使用上有很多限制:

  • 不能在STL容器中使用,因為複製將導致資料無效
  • 一些STL演算法也可能導致auto_ptr失效,比如std::sort演算法
  • 不能作為函式引數,因為這會導致複製,並且在呼叫後,導致原資料無效
  • 如果作為類的成員變數,需要注意在類拷貝時候導致的資料無效

正是因為auto_ptr的諸多限制,所以自C++11起,廢棄了auto_ptr,引入unique_ptr。

unique_ptr

unique_ptr是C++11提供的用於防止記憶體洩漏的智慧指標中的一種實現(用來替代auto_ptr),獨享被管理物件指標所有權的智慧指標。

unique_ptr物件包裝一個原始指標,並負責其生命週期。當該物件被銷燬時,會在其解構函式中刪除關聯的原始指標。具有->和*運算子過載符,因此它可以像普通指標一樣使用。

分類

unique_ptr分為以下兩種:

  • 指向單個物件
std::unique_ptr<Type> p1; // p1關聯Type物件
  • 指向一個陣列
unique_ptr<Type[]> p2; // p2關聯Type物件陣列

特點

在前面的內容中,我們已經提到了unique_ptr的特點,主要具有以下:

  • 獨享所有權,在作用域結束時候,自動釋放所關聯的物件
void fun() {
  unique_ptr<int> a(new int(1));
}
  • 無法進行拷貝與賦值操作
unique_ptr<int> ptr(new int(1));
unique_ptr<int> ptr1(ptr) ; // error
unique_ptr<int> ptr2 = ptr; //error
  • 顯示的所有權轉移(通過move語義)
unique_ptr<int> ptr(new int(1));
unique_ptr<int> ptr1 = std::move(ptr) ; // ok
  • 作為容器元素儲存在容器中
unique_ptr<int> ptr(new int(1));
std::vector<unique_ptr<int>> v;

v.push_back(ptr); // error
v.push_back(std::move(ptr)); // ok

std::cout << *ptr << std::endl;// error

需要注意的是,自c++14起,可以使用下面的方式對unique_ptr進行初始化:

auto p1 = std::make_unique<double>(3.14);
auto p2 = std::make_unique<double[]>(n);

如果在c++11中使用上述方法進行初始化,會得到下面的錯誤提示:

error: ‘make_unique’ is not a member of ‘std’

因此,如果為了使得c++11也可以使用std::make_unique,我們可以自己進行封裝,如下:

namespace details {

#if __cplusplus >= 201402L // C++14及以後使用STL實現的
using std::make_unique;
#else
template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args &&... args)
{
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
#endif
} // namespace details

使用

為了儘可能瞭解unique_ptr的使用姿勢,我們使用以下程式碼為例:

#include <memory>
#include <utility> // std::move

void fun1(double *);
void fun2(std::unique<double> *);
void fun3(std::unique<double> &);
void fun4(std::unique<double> );

int main() {
  std::unique_ptr<double> p(new double(3.14));
  
  fun1(p.get());
  fun2(&p);
  fun3(p);
  
  if (p) {
    std::cout << "is valid" << std::endl;
  }
  auto p2(p.release()); // 轉移所有權
  auto p2.reset(new double(1.0));
  fun4(std::move(p2));
  
  return 0;
}

上述程式碼,基本覆蓋了常見的unique_ptr用法:

  • 第10行,通過new建立一個unique_ptr物件
  • 第11行,通過get()函式獲取其關聯的原生指標
  • 第12行,通過unique_ptr物件的指標進行訪問
  • 第13行,通過unique_ptr物件的引用進行訪問
  • 第16行,通過if(p)來判斷其是否有效
  • 第18行,通過release函式釋放所有權,並將所有權進行轉移
  • 第19行,通過reset釋放之前的原生指標,並重新關聯一個新的指標
  • 第20行,通過std::move轉移所有權

簡單實現

本部分只是基於原始碼的一些思路,便於理解,實現的一個簡單方案,如果想要閱讀原始碼,請點選unique_ptr檢視。

基本程式碼如下:

template<class T> 
class unique_ptr 
{ 
   T* p; 
public: 
   unique_ptr() :p() {} 
   unique_ptr(T* s) :p(s) {} 
   ~unique_ptr() { delete p; } 
  
    unique_ptr(const unique_ptr&) = delete;
     unique_ptr& operator=(const unique_ptr&) = delete;
 
   unique_ptr(unique_ptr&& s) :p(s.p) { s.p = nullptr } 
 
   unique_ptr& operator=(unique_ptr s) 
   { delete p; p = s.p; s.p=nullptr; return *this; } 
 
   T* operator->() const { return p; } 
   T& operator*() const { return *p; } 
}; 

從上面程式碼基本可以看出,unique_ptr的控制權轉移是通過move語義來實現的,相比於auto_ptr的拷貝語義轉移所有權,更為合理。

shared_ptr

unique_ptr因為其侷限性(獨享所有權),一般很少用於多執行緒操作。在多執行緒操作的時候,既可以共享資源,又可以自動釋放資源,這就引入了shared_ptr。

shared_ptr為了支援跨執行緒訪問,其內部有一個引用計數(執行緒安全),用來記錄當前使用該資源的shared_ptr個數,在結束使用的時候,引用計數為-1,當引用計數為0時,會自動釋放其關聯的資源。

特點

相對於unique_ptr的獨享所有權,shared_ptr可以共享所有權。其內部有一個引用計數,用來記錄共享該資源的shared_ptr個數,當共享數為0的時候,會自動釋放其關聯的資源。

shared_ptr不支援陣列,所以,如果用shared_ptr指向一個陣列的話,需要自己手動實現deleter,如下所示:

std::shared_ptr<int> p(new int[8], [](int *ptr){delete []ptr;});

使用

仍然以一段程式碼來說明,畢竟程式碼更有說服力。

#include <iostream>
#include  <memory> 

int main() {
    // 建立shared_ptr物件
    std::shared_ptr<int> p1 = std::make_shared<int>();
    *p1 = 78;
    std::cout << "p1 = " << *p1 << std::endl;
    // 列印引用計數
    std::cout << "p1 Reference count = " << p1.use_count() << std::endl;
    
    std::shared_ptr<int> p2(p1);
    // 列印引用計數
    std::cout << "p2 Reference count = " << p2.use_count() << std::endl;
    std::cout << "p1 Reference count = " << p1.use_count() << std::endl;
    
    if (p1 == p2)
    {
        std::cout << "p1 and p2 are pointing to same pointer\n";
    }
    std::cout<<"Reset p1 "<<std::endl;
    // 引用計數-1
    p1.reset();
    
    std::cout << "p1 Reference Count = " << p1.use_count() << std::endl;
    
    // 重置
    p1.reset(new int(11));
    std::cout << "p1  Reference Count = " << p1.use_count() << std::endl;
    
    p1 = nullptr;
    std::cout << "p1  Reference Count = " << p1.use_count() << std::endl;
    if (!p1) // 通過此種方式來判斷關聯的資源是否為空
    {
        std::cout << "p1 is NULL" << std::endl;
    }
    return 0;
}

輸出如下:

p1 = 78
p1 Reference count = 1
p2 Reference count = 2
p1 Reference count = 2
p1 and p2 are pointing to same pointer
Reset p1 
p1 Reference Count = 0
p1  Reference Count = 1
p1  Reference Count = 0
p1 is NULL

上面程式碼基本羅列了shared_ptr的常用方法,對於其他方法,可以參考原始碼或者官網。

執行緒安全

可能很多人都對shared_ptr是否執行緒安全存在疑惑,藉助本節,對執行緒安全方面的問題進行分析和解釋。

shared_ptr的執行緒安全問題主要有以下兩種:

  • 引用計數的加減操作是否執行緒安全
  • shared_ptr修改指向時是否執行緒安全

引用計數

shared_ptr中有兩個指標,一個指向所管理資料的地址,另一個一個指向執行控制塊的地址

執行控制塊包括對關聯資源的引用計數以及弱引用計數等。在前面我們提到shared_ptr支援跨執行緒操作,引用計數變數是儲存在堆上的,那麼在多執行緒的情況下,指向同一資料的多個shared_ptr在進行計數的++或--時是否執行緒安全呢?

引用計數在STL中的定義如下:

_Atomic_word  _M_use_count;     // #shared
_Atomic_word  _M_weak_count;    // #weak + (#shared != 0)

當對shared_ptr進行拷貝時,引入計數增加,實現如下:

template<> 
  inline void
 _Sp_counted_base<_S_atomic>::
 _M_add_ref_lock() {
       // Perform lock-free add-if-not-zero operation.
       _Atomic_word __count;
       do
     {
       __count = _M_use_count;
       if (__count == 0)
         __throw_bad_weak_ptr(); 
     }
       while (!__sync_bool_compare_and_swap(&_M_use_count, __count,
                        __count + 1));
     }

最終,計數的增加,是呼叫__sync_bool_compare_and_swap實現的,而該函式是執行緒安全的,因此我們可以得出結論:在多執行緒環境下,管理同一個資料的shared_ptr在進行計數的增加或減少的時候是執行緒安全的,這是一波原子操作

修改指向

修改指向分為操作同一個物件和操作不同物件兩種。

同一物件

以下面程式碼為例:

void fun(shared_ptr<Type> &p) {
  if (...) {
    p = p1;
  } else {
    p = p2;
  }
}

當在多執行緒場景下呼叫該函式時候,p之前的引用計數要進行-1操作,而p1物件的引用計數要進行+1操作,雖然這倆的引用計數操作都是執行緒安全的,但是對這倆物件的引用計數的操作在一起時候卻不是執行緒安全的。這是因為當對p1的引用計數進行+1時候,恰恰前一時刻,p1的物件被釋放,後面再進行+1操作,會導致segment fault

不同物件

程式碼如下:

void fun1(std::shared_ptr<Type> &p) {
  p = p1;
}

void fun2(std::shared_ptr<Type> &p) {
  p = p2;
}

int main() {
  std::shared_ptr<Type> p = std::make_shared<Type>();
  auto p1 = p;
  auto p2 = p;
  std::thread t1(fun1, p1);
  std::thread t2(fun2, p2);
  
  t1.join();
  t2.join();
  
  return 0;
}

在上述程式碼中,p、p1、p2指向同一個資源,分別有兩個執行緒操作不同的shared_ptr物件(雖然關聯的底層資源是同一個),這樣在多執行緒下,只對p1和p2的引用計數進行操作,不會引起segment fault,所以是執行緒安全的。

同一個shared_ptr被多個執行緒同時讀是安全的

同一個shared_ptr被多個執行緒同時讀寫是不安全的

簡單實現

本部分只是基於原始碼的一些思路,便於理解,實現的一個簡單方案,如果想要閱讀原始碼,請點選shared_ptr檢視。

記得之前看過一個問題為什麼引用計數要new,這個問題我在面試的時候也問過,很少有人能夠回答出來,其實,很簡單,因為要支援多執行緒訪問,所以只能要new呀?。

程式碼如下:

template <class T>
class weak_ptr;

class Counter {
 public:
  Counter() = default;
  int s_ = 0; // shared_ptr的計數
  int w_ = 0; // weak_ptr的計數
};

template <class T>
class shared_ptr {
 public:
  shared_ptr(T *p = 0) : ptr_(p) {
   cnt_ = new Counter();
   if (p) {
     cnt_->s_ = 1;
   }
 }

  ~shared_ptr() {
    release();
  }

  shared_ptr(shared_ptr<T> const &s) {
    ptr_ = s.ptr_;
    (s.cnt)->s_++;
    cnt_ = s.cnt_;
  }

  shared_ptr(weakptr_<T> const &w) {
    ptr_ = w.ptr_;
    (w.cnt_)->s_++;
    cnt_ = w.cnt_;
  }

  shared_ptr<T> &operator=(shared_ptr<T> &s) {
    if (this != &s) {
      release();
      (s.cnt_)->s_++;
      cnt_ = s.cnt_;
      ptr_ = s.ptr_;
    }
      return *this;
  }

  T &operator*() {
    return *ptr_;
  }

  T *operator->() {
    return ptr_;
  }

  friend class weak_ptr<T>;

 protected:
  void release() {
    cnt_->s_--;
    if (cnt_->s_ < 1)
    {
      delete ptr_;
      if (cnt_->w_ < 1)
      {
          delete cnt_;
          cnt_ = NULL;
      }
    }
  }

private:
  T *ptr_;
  Counter *cnt_;
};

weak_ptr

在三個智慧指標中,weak_ptr是存在感最低的一個,也是最容易被大家忽略的一個智慧指標。它的引入是為了解決shared_ptr存在的一個問題迴圈引用

特點

  1. 不具有普通指標的行為,沒有過載operator*和operator->
  2. 沒有共享資源,它的構造不會引起引用計數增加
  3. 用於協助shared_ptr來解決迴圈引用問題
  4. 可以從一個shared_ptr或者另外一個weak_ptr物件構造,進而可以間接獲取資源的弱共享權。

使用

int main() {
    std::shared_ptr<int> p1 = std::make_shared<Entity>(14);
    {
        std::weak_ptr<int> weak = p1;
        std::shared_ptr<Entity> new_shared = weak.lock();
 
        shared_e1 = nullptr;
       
        new_shared = nullptr;
        if (weak.expired()) {
            std::cout << "weak pointer is expired" << std::endl;
        }
        
        new_shared = weak.lock();
        std::cout << new_shared << std::endl;
   }
  
  return 0;
}

上述程式碼輸出如下:

weak pointer is expired
0
  1. 使用成員函式use_count()和expired()來獲取資源的引用計數,如果返回為0或者false,則表示關聯的資源不存在
  2. 使用lock()成員函式獲得一個可用的shared_ptr物件,進而操作資源
  3. 當expired()為true的時候,lock()函式將返回一個空的shared_ptr

簡單實現

template <class T>
class weak_ptr
{
 public:
  weak_ptr() = default;

  weak_ptr(shared_ptr<T> &s) : ptr_(s.ptr_), cnt(s.cnt_) {
    cnt_->w_++;
  }

  weak_ptr(weak_ptr<T> &w) : ptr_(w.ptr_), cnt_(w.cnt_) {
    cnt_->w_++;
  }
  ~weak_ptr() {
    release();
  }
  weak_ptr<T> &operator=(weak_ptr<T> &w) {
    if (this != &w) {
      release();
      cnt_ = w.cnt_;
      cnt_->w_++;
      ptr_ = w.ptr_;
    }
    return *this;
  }
  weak_ptr<T> &operator=(shared_ptr<T> &s)
  {
    release();
    cnt_ = s.cnt_;
    cnt_->w_++;
    ptr_ = s.ptr_;
    return *this;
  }

  shared_ptr<T> lock() {
    return shared_ptr<T>(*this);
  }

  bool expired() {
    if (cnt) {
      if (cnt->s_ > 0) {
        return false;
      }
    }
    return true;
  }

  friend class shared_ptr<T>;

protected:
  void release() {
    if (cnt_) {
      cnt_->w_--;
      if (cnt_->w_ < 1 && cnt_->s_ < 1) {
        cnt_ = nullptr;
      }
    }
  }

private:
    T *ptr_ = nullptr;
    Counter *cnt_ = nullptr;
};

迴圈引用

在之前的文章記憶體洩漏-原因、避免以及定位中,我們講到使用weak_ptr來配合shared_ptr使用來解決迴圈引用的問題,藉助本文,我們深入說明下如何來解決迴圈引用的問題。

程式碼如下:

class Controller {
 public:
  Controller() = default;

  ~Controller() {
    std::cout << "in ~Controller" << std::endl;
  }

  class SubController {
   public:
    SubController() = default;

    ~SubController() {
      std::cout << "in ~SubController" << std::endl;
    }

    std::shared_ptr<Controller> controller_;
  };

  std::shared_ptr<SubController> sub_controller_;
};

在上述程式碼中,因為controller和sub_controller之間都有一個指向對方的shared_ptr,這樣就導致任意一個都因為對方有一個指向自己的物件,進而引用計數不能為0。

智慧指標-使用、避坑和實現

為了解決std::shared_ptr迴圈引用導致的記憶體洩漏,我們可以使用std::weak_ptr來單面去除上圖中的迴圈。

class Controller {
 public:
  Controller() = default;

  ~Controller() {
    std::cout << "in ~Controller" << std::endl;
  }

  class SubController {
   public:
    SubController() = default;

    ~SubController() {
      std::cout << "in ~SubController" << std::endl;
    }

    std::weak_ptr<Controller> controller_;
  };

  std::shared_ptr<SubController> sub_controller_;
};

在上述程式碼中,我們將SubController類中controller_的型別從std::shared_ptr變成std::weak_ptr。

智慧指標-使用、避坑和實現

那麼,為什麼將SubController中的shared_ptr換成weak_ptr就能解決這個問題呢?我們看下原始碼:

template<typename _Tp1>
         __weak_ptr&
         operator=(const __shared_ptr<_Tp1, _Lp>& __r) // never throws
         {
       _M_ptr = __r._M_ptr;
       _M_refcount = __r._M_refcount;
       return *this;
     }

在上面程式碼中,我們可以看到,將一個shared_ptr賦值給weak_ptr的時候,其引用計數並沒有+1,所以也就解決了迴圈引用的問題。

那麼,如果我們想要使用shared_ptr關聯的物件進行操作時候,該怎麼做呢?使用weak_ptr::lock()函式來實現,原始碼如下:

 __shared_ptr<_Tp, _Lp>
 lock() const {
   return expired() ? __shared_ptr<element_type, _Lp>() : __shared_ptr<element_type, _Lp>(*this);
 }

從上面程式碼可看出,使用lock()函式生成一個shared_ptr供使用,如果之前的shared_ptr已經被釋放,那麼就返回一個空shared_ptr物件,否則生成shared_ptr物件的拷貝(這樣即使之前的釋放也不會存在問題)。

經驗之談

不要混用

指標之間的混用,有時候會造成不可預知的錯誤,所以建議儘量不要混用。包括裸指標和智慧指標以及智慧指標之間的混用

裸指標和智慧指標混用

程式碼如下:

void fun() {
  auto ptr = new Type;
  std::shared_ptr<Type> t(ptr);
  
  delete ptr;
}

在上述程式碼中,將ptr所有權歸shared_ptr所擁有,所以在出fun()函式作用域的時候,會自動釋放ptr指標,而在函式末尾有主動呼叫delete來釋放,這就會造成double delete,會造成segment fault

智慧指標混用

程式碼如下:

void fun() {
  std::unique_ptr<Type> t(new Type);
  std::shared_ptr<Type> t1(t.get());
}

在上述程式碼中,將t關聯的物件又給了t1,也就是說同一個物件被兩個智慧指標所擁有,所以在出fun()函式作用域的時候,二者都會釋放其關聯的物件,這就會造成double delete,會造成segment fault

需要注意的是,下面程式碼在STL中是支援的:

void fun() {
  std::unique_ptr<Type> t(new Type);
  std::shared_ptr<Type> t1(std::move(t));
}

不要管理同一個裸指標

程式碼如下:

void fun() {
  auto ptr = new Type;
  std::unique_ptr<Type> t(ptr);
  std::shared_ptr<Type> t1(ptr);
}

在上述程式碼中,ptr所有權同時給了t和t1,也就是說同一個物件被兩個智慧指標所擁有,所以在出fun()函式作用域的時候,二者都會釋放其關聯的物件,這就會造成double delete,會造成segment fault

避免使用get()獲取原生指標

void fun(){
  auto ptr = std::make_shared<Type>();

  auto a= ptr.get();

  std::shared_ptr<Type> t(a);
  delete a;
}

一般情況下,生成的指標都要顯示呼叫delete來進行釋放,而上述這種,很容易稍不注意就呼叫delete;非必要不要使用get()獲取原生指標

不要管理this指標

class Type {
 private:
    void fun() {
      std::shared_ptr<Type> t(this);
    }
};

在上述程式碼中,如果Type在棧上,則會導致segment fault,堆上視實際情況(如果在物件在堆上生成,那麼使用合理的話,是允許的)。

只管理堆上的物件

void fun() {
   Type t;
     std::shared_ptr<Type> ptr(&t);
};

在上述程式碼中,t在棧上進行分配,在出作用域的時候,會自動釋放。而ptr在出作用域的時候,也會呼叫delete釋放t,而t本身在棧上,delete一個棧上的地址,會造成segment fault

優先使用unique_ptr

根據業務場景,如果需要資源獨佔,那麼建議使用unique_ptr而不是shared_ptr,原因如下:

  • 效能優於shared_ptr
    • 因為shared_ptr在拷貝或者釋放時候,都需要操作引用計數
  • 記憶體佔用上小於shared_ptr
    • shared_ptr需要維護它指向的物件的執行緒安全引用計數和一個控制塊,這使得它比unique_ptr更重量級

使用make_shared初始化

我們看下常用的初始化shared_ptr兩種方式,程式碼如下:

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

那麼,上述兩種方法孰優孰劣呢?我們且從原始碼的角度進行分析。

第一種初始化方法,有兩次記憶體分配:

  • new Type分配物件
  • 為p1分配控制塊(control block),控制塊用於存放引用計數等資訊

我們再看下make_shared原始碼:

template<class _Ty,
  class... _Types> inline
    shared_ptr<_Ty> make_shared(_Types&&... _Args)
  {  // make a shared_ptr
  _Ref_count_obj<_Ty> *_Rx =
    new _Ref_count_obj<_Ty>(_STD forward<_Types>(_Args)...);

  shared_ptr<_Ty> _Ret;
  _Ret._Resetp0(_Rx->_Getptr(), _Rx);
  return (_Ret);
  }

這裡的_Ref_count_obj類包含成員變數:

  • 控制塊
  • 一個記憶體塊,用於存放智慧指標管理的資源物件

再看看_Ref_count_obj的建構函式:

template<class... _Types>
  _Ref_count_obj(_Types&&... _Args)
  : _Ref_count_base()
  {  // construct from argument list
  ::new ((void *)&_Storage) _Ty(_STD forward<_Types>(_Args)...);
  }

此處雖然也有一個new操作,但是此處是placement new,所以不存在記憶體申請。

從上面分析我們可以看出,第一種初始化方式(new方式)共有兩次記憶體分配操作,而第二種初始化方式(make_shared)只有一次記憶體申請,所以建議使用make_shared方式進行初始化。

結語

智慧指標的出現,能夠使得開發者不需要關心記憶體的釋放,進而使得開發者能夠將更多的精力投入到業務上。但是,因為智慧指標本身也有其侷限性,如果使用不當,會造成意想不到的後果,所以,在使用之前,需要做一些必要的檢查,為了更好的用好智慧指標,建議看下原始碼實現,還是比較簡單的?。

好了,今天的分享就到這,我們下期見。

參考

https://docs.microsoft.com/en-us/previous-versions/visualstudio/visual-studio-2012/hh279676(v=vs.110)?redirectedfrom=MSDN
https://rufflewind.com/2016-03-05/unique-ptr
https://www.nextptr.com/tutorial/ta1450413058/unique_ptr-shared_ptr-weak_ptr-or-reference_wrapper-for-class-relationships
https://gcc.gnu.org/onlinedocs/gcc-4.6.3/libstdc++/api/a01099_source.html
https://gcc.gnu.org/onlinedocs/libstdc++/libstdc++-html-USERS-4.4/a01327.html
https://www.nextptr.com/tutorial/ta1358374985/shared_ptr-basics-and-internals-with-examples

作者:高效能架構探索
本文首發於公眾號【高效能架構探索】
個人技術部落格:高效能架構探索

相關文章