【C++】weak_ptr弱引用智能指針詳解

weak_ptr這個指針天生一副小弟的模樣,也是在C++11的時候引入的標準庫,它的出現完全是爲了彌補它老大shared_ptr天生有缺陷的問題。

相比於上一代的智能指針auto_ptr來說,新進老大shared_ptr可以說近乎完美,但是通過引用計數實現的它,雖然解決了指針獨佔的問題,但也引來了引用成環的問題,這種問題靠它自己是沒辦法解決的,所以在C++11的時候將shared_ptrweak_ptr一起引入了標準庫,用來解決循環引用的問題。

本文實例源碼github地址https://github.com/yngzMiao/yngzmiao-blogs/tree/master/2020Q2/20200503


循環引用

什麼是循環引用的問題呢?在shared_ptr的使用過程中,當強引用計數爲0是,就會釋放所指向的堆內存。那麼問題來了,如果和死鎖一樣,當兩個shared_ptr互相引用,那麼它們就永遠無法被釋放了。

例如:

#include <iostream>
#include <memory>

class CB;
class CA {
  public:
    CA() {
      std::cout << "CA()" << std::endl;
    }
    ~CA() {
      std::cout << "~CA()" << std::endl;
    }
    void set_ptr(std::shared_ptr<CB>& ptr) {
      m_ptr_b = ptr;
    }
  private:
    std::shared_ptr<CB> m_ptr_b;
};

class CB {
  public:
    CB() {
      std::cout << "CB()" << std::endl;
    }
    ~CB() {
      std::cout << "~CB()" << std::endl;
    }
    void set_ptr(std::shared_ptr<CA>& ptr) {
      m_ptr_a = ptr;
    }
  private:
    std::shared_ptr<CA> m_ptr_a;
};

int main()
{
  std::shared_ptr<CA> ptr_a(new CA());
  std::shared_ptr<CB> ptr_b(new CB());
  ptr_a->set_ptr(ptr_b);
  ptr_b->set_ptr(ptr_a);
  std::cout << ptr_a.use_count() << " " << ptr_b.use_count() << std::endl;

  return 0;
}

編譯並運行結果,打印爲:

yngzmiao@yngzmiao-virtual-machine:~/test$ ./main 
CA()
CB()
2 2

對於打印的內容,你可能會覺得很奇怪,爲什麼析構函數並沒有調用呢?

既然析構函數沒有調用,就說明ptr_a和ptr_b兩個變量的引用計數都不是0。下面分析一下例子中的引用情況:

起初定義完ptr_a和ptr_b時,只有①、③兩條引用,即ptr_a指向CA對象,ptr_b指向CB對象。然後調用函數set_ptr後又增加了②、④兩條引用,即CB對象中的m_ptr_a成員變量指向CA對象,CA對象中的m_ptr_b成員變量指向CB對象。

這個時候,指向CA對象的有兩個,指向CB對象的也有兩個。當main函數運行結束時,對象ptr_a和ptr_b被銷燬,也就是①、③兩條引用會被斷開,但是②、④兩條引用依然存在,每一個的引用計數都不爲0,結果就導致其指向的內部對象無法析構,造成內存泄漏。


weak_ptr

解決循環引用

weak_ptr的出現就是爲了解決shared_ptr的循環引用的問題的。以上文的例子來說,解決辦法就是將兩個類中的一個成員變量改爲weak_ptr對象,比如將CB中的成員變量改爲weak_ptr對象,即CB類的代碼如下:

class CB {
  public:
    CB() {
      std::cout << "CB()" << std::endl;
    }
    ~CB() {
      std::cout << "~CB()" << std::endl;
    }
    void set_ptr(std::shared_ptr<CA>& ptr) {
      m_ptr_a = ptr;
    }
  private:
    std::weak_ptr<CA> m_ptr_a;
};

編譯並運行結果,打印爲:

yngzmiao@yngzmiao-virtual-machine:~/test$ ./main 
CA()
CB()
1 2
~CA()
~CB()

通過這次結果可以看到,CA和CB的對象都被正常的析構了。修改後例子中的引用關係如下圖所示:

流程與上一例子大體相似,但是不同的是④這條引用是通過weak_ptr建立的,並不會增加引用計數。也就是說,CA的對象只有一個引用計數,而CB的對象只有2個引用計數,當main函數返回時,對象ptr_a和ptr_b被銷燬,也就是①、③兩條引用會被斷開,此時CA對象的引用計數會減爲0,對象被銷燬,其內部的m_ptr_b成員變量也會被析構,導致CB對象的引用計數會減爲0,對象被銷燬,進而解決了引用成環的問題。

如果仔細看代碼的話,會覺得很神奇!定義m_ptr_a修改成std::weak_ptr類型,但是set_ptr函數定義的參數還是std::shared_ptr類型。這個時候爲什麼沒有報錯?weak_ptrshared_ptr的聯繫是什麼呢?

weak_ptr的原理

weak_ptr是爲了配合shared_ptr而引入的一種智能指針,它指向一個由shared_ptr管理的對象而不影響所指對象的生命週期,也就是,將一個weak_ptr綁定到一個shared_ptr不會改變shared_ptr的引用計數。不論是否有weak_ptr指向,一旦最後一個指向對象的shared_ptr被銷燬,對象就會被釋放。從這個角度看,weak_ptr更像是shared_ptr的一個助手而不是智能指針

初始化方式

  1. 通過shared_ptr直接初始化,也可以通過隱式轉換來構造
  2. 允許移動構造,也允許拷貝構造
#include <iostream>
#include <memory>

class Frame {};

int main()
{
  std::shared_ptr<Frame> f(new Frame());
  std::weak_ptr<Frame> f1(f);                     // shared_ptr直接構造
  std::weak_ptr<Frame> f2 = f;                    // 隱式轉換
  std::weak_ptr<Frame> f3(f1);                    // 拷貝構造函數
  std::weak_ptr<Frame> f4 = f1;                   // 拷貝構造函數
  std::weak_ptr<Frame> f5;
  f5 = f;                                         // 拷貝賦值函數
  f5 = f2;                                        // 拷貝賦值函數
  std::cout << f.use_count() << std::endl;        // 1

  return 0;
}

需要注意,weak_ptr綁定到一個shared_ptr不會改變shared_ptr的引用計數

常用操作

  • w.user_count():返回weak_ptr的強引用計數;
  • w.reset(…):重置weak_ptr。

如何判斷weak_ptr指向對象是否存在?

既然weak_ptr並不改變其所共享的shared_ptr實例的引用計數,那就可能存在weak_ptr指向的對象被釋放掉這種情況。這時,就不能使用weak_ptr直接訪問對象。那麼如何判斷weak_ptr指向對象是否存在呢?C++中提供了lock函數來實現該功能。如果對象存在,lock()函數返回一個指向共享對象的shared_ptr(引用計數會增1),否則返回一個空shared_ptr。weak_ptr還提供了expired()函數來判斷所指對象是否已經被銷燬

由於weak_ptr並沒有重載operator ->operator *操作符,因此不可直接通過weak_ptr使用對象,同時也沒有提供get函數直接獲取裸指針。典型的用法是調用其lock函數來獲得shared_ptr示例,進而訪問原始對象。


使用場景

共享對象的線程安全問題

例如:線程A和線程B訪問一個共享的對象,如果線程A正在析構這個對象的時候,線程B又要調用該共享對象的成員方法,此時可能線程A已經把對象析構完了,線程B再去訪問該對象,就會發生不可預期的錯誤。

#include <iostream>
#include <memory>
#include <thread>

class Test {
  public:
    Test(int id) : m_id(id) {}
    void showID() {
      std::cout << m_id << std::endl;
    }
  private:
    int m_id;
};

void thread1(Test* t) {
  std::this_thread::sleep_for(std::chrono::seconds(2));
  t->showID();                      // 打印結果:0
}

int main()
{
  Test* t = new Test(2);
  std::thread t1(thread1, t);
  delete t;
  t1.join();

  return 0;
}

在例子中,由於thread1等待2s,此時,main線程早已經把t對象析構了。打印m_id,自然不能打印出2了。可以通過shared_ptr和weak_ptr來解決共享對象的線程安全問題。

#include <iostream>
#include <memory>
#include <thread>

class Test {
  public:
    Test(int id) : m_id(id) {}
    void showID() {
      std::cout << m_id << std::endl;
    }
  private:
    int m_id;
};

void thread2(std::weak_ptr<Test> t) {
  std::this_thread::sleep_for(std::chrono::seconds(2));
  std::shared_ptr<Test> sp = t.lock();
  if(sp)
    sp->showID();                      // 打印結果:2
}

int main()
{
  std::shared_ptr<Test> sp = std::make_shared<Test>(2);
  std::thread t2(thread2, sp);
  t2.join();

  return 0;
}

如果想訪問對象的方法,先通過t的lock方法進行提升操作,把weak_ptr提升爲shared_ptr強智能指針。提升過程中,是通過檢測它所觀察的強智能指針保存的Test對象的引用計數,來判定Test對象是否存活。ps如果爲nullptr,說明Test對象已經析構,不能再訪問;如果ps!=nullptr,則可以正常訪問Test對象的方法

如果設置t2爲分離線程t2.detach(),讓main主線程結束,sp智能指針析構,進而把Test對象析構,此時showID方法已經不會被調用,因爲在thread2方法中,t提升到sp時,lock方法判定Test對象已經析構,提升失敗!

觀察者模式

觀察者模式就是,當觀察者觀察到某事件發生時,需要通知監聽者進行事件處理的一種設計模式。

在多數實現中,觀察者通常都在另一個獨立的線程中,這就涉及到在多線程環境中,共享對象的線程安全問題(解決方法就是使用上文的智能指針)。這是因爲在找到監聽者並讓它處理事件時,其實在多線程環境中,肯定不明確此時監聽者對象是否還存活,或是已經在其它線程中被析構了,此時再去通知這樣的監聽者,肯定是有問題的。

也就是說,當觀察者運行在獨立的線程中時,在通知監聽者處理該事件時,應該先判斷監聽者對象是否存活,如果監聽者對象已經析構,那麼不用通知,並且需要從map表中刪除這樣的監聽者對象。其中的主要代碼爲:

// 存儲監聽者註冊的感興趣的事件
unordered_map<int, list<weak_ptr<Listener>>> listenerMap;

// 觀察者觀察到事件發生,轉發到對該事件感興趣的監聽者
void dispatchMessage(int msgid) {
  auto it = listenerMap.find(msgid);
  if (it != listenerMap.end()) {
    for (auto it1 = it->second.begin(); it1 != it->second.end(); ++it1) {
      shared_ptr<Listener> ps = it1->lock();            // 智能指針的提升操作,用來判斷監聽者對象是否存活
      if (ps != nullptr) {                              // 監聽者對象如果存活,才通知處理事件
        ps->handleMessage(msgid);
      } else {
        it1 = it->second.erase(it1);                    // 監聽者對象已經析構,從map中刪除這樣的監聽者對象
      }
    }
  }
}

這個想法來源於:一個用C++寫的開源網絡庫,muduo庫,作者陳碩。大家可以在網上下載到muduo的源代碼,該源碼中對於智能指針的應用非常優秀,其中藉助shared_ptr和weak_ptr解決了這樣一個問題,多線程訪問共享對象的線程安全問題。

解決循環引用

循環引用,簡單來說就是:兩個對象互相使用一個shared_ptr成員變量指向對方的會造成循環引用,導致引用計數失效。上文詳細講述了循環引用的錯誤原因和解決辦法。

監視this智能指針

在上文講述shared_ptr的博文中就有講述到:enable_shared_from_this中有一個弱指針weak_ptr,這個弱指針能夠監視this。在調用shared_from_this這個函數時,這個函數內部實際上是調用weak_ptr的lock方法。lock()會讓shared_ptr指針計數+1,同時返回這個shared_ptr。


相關閱讀

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章