weak_ptr
這個指針天生一副小弟的模樣,也是在C++11的時候引入的標準庫,它的出現完全是爲了彌補它老大shared_ptr
天生有缺陷的問題。
相比於上一代的智能指針auto_ptr
來說,新進老大shared_ptr
可以說近乎完美,但是通過引用計數實現的它,雖然解決了指針獨佔的問題,但也引來了引用成環的問題,這種問題靠它自己是沒辦法解決的,所以在C++11的時候將shared_ptr
和weak_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_ptr
和shared_ptr
的聯繫是什麼呢?
weak_ptr的原理
weak_ptr是爲了配合shared_ptr而引入的一種智能指針,它指向一個由shared_ptr管理的對象而不影響所指對象的生命週期,也就是,將一個weak_ptr綁定到一個shared_ptr不會改變shared_ptr的引用計數。不論是否有weak_ptr指向,一旦最後一個指向對象的shared_ptr被銷燬,對象就會被釋放。從這個角度看,weak_ptr更像是shared_ptr的一個助手而不是智能指針。
初始化方式
- 通過shared_ptr直接初始化,也可以通過隱式轉換來構造;
- 允許移動構造,也允許拷貝構造。
#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。