前言
作為 C++ 開發者,你是否曾因以下場景頭疼不已?
- 函數中
new了數組,卻因異常拋出導致後續delete沒執行,排查半天定位到內存泄漏;- 多模塊共享一塊內存,不知道該由誰負責釋放,最後要麼重複釋放崩潰,要麼漏釋放泄漏;
- 用了
auto_ptr後,拷貝對象導致原對象 “懸空”,訪問時直接崩潰卻找不到原因。如果你有過這些經歷,那智能指針一定是你必須掌握的現代 C++ 工具。它基於 RAII 思想,自動管理動態資源,讓你無需手動
delete,從根源上減少內存泄漏風險。今天,我們就從 “為什麼需要智能指針” 到 “不同智能指針的實戰場景”,帶你係統掌握這一核心特性。
請君瀏覽
- 前言
- 一、智能指針的誕生:解決手動管理內存的 “千古難題”
- 1.1 一個典型的內存泄露場景
- 1.2 智能指針的核心:RAII 思想
- 二、C++ 標準庫智能指針:4 種指針的特性與適用場景
- 2.1 auto_ptr:被淘汰的 “過渡品”(C++98)
- 2.2 unique_ptr:不可共享的 “獨佔指針”(C++11)
- 2.3 shared_ptr:可共享的 “計數指針”(C++11)
- 2.4 weak_ptr:解決循環引用的 “輔助指針”(C++11)
- 2.5 刪除器
- 3. shared_ptr的模擬實現
- 3.1 原理
- 3.2 代碼
- 4. 總結:智能指針的最佳實踐
- 尾聲
一、智能指針的誕生:解決手動管理內存的 “千古難題”
在 C++ 中,內存泄漏的核心原因往往是 “資源申請與釋放不匹配”—— 尤其是當程序流程被異常、分支跳轉打斷時,手動編寫的delete可能永遠不會執行。
**內存泄漏:**內存泄漏指因為疏忽或錯誤造成程序未能釋放已經不再使⽤的內存,⼀般是忘記釋放或者發⽣異常釋放程序未能執⾏導致的。內存泄漏並不是指內存在物理上的消失,⽽是應⽤程序分配某段內存後,因為設計錯誤,失去了對該段內存的控制,因⽽造成了內存的浪費。
**危害:**普通程序運⾏⼀會就結束了,出現內存泄漏問題也不大,進程正常結束,⻚表的映射關係解除,物理內存也可以釋放。但⻓期運⾏的程序出現內存泄漏影響就很⼤了,如操作系統、後台服務、⻓時間運⾏的客⼾端等等,不斷出現內存泄漏會導致可⽤內存不斷變少,各種功能響應越來越慢,最終卡死。
1.1 一個典型的內存泄露場景
若函數中存在異常拋出,裸指針會因delete未執行導致泄漏,例如:我們在Func函數中new了兩個數組,但如果Divide拋異常,後續的delete會被跳過,導致內存泄漏,如下面代碼所示:
double Divide(int a, int b)
{
// 當b == 0時拋出異常
if (b == 0)
{
throw "Divide by zero condition!";
}
else
{
return (double)a / (double)b;
}
}
void Func()
{
int* array1 = new int[10];
int* array2 = new int[10];
//...
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
// ...
cout << "delete []" << endl;
delete[] array1;
delete[] array2;
}
int main()
{
try
{
Func();
}
catch (...)
{
cout << "abnormal" << endl;
}
return 0;
}
可以看到當Divide拋出異常時,我們new的兩個數組就無法正常釋放,導致內存泄漏:
即使我們加了try-catch,若new array2時本身拋異常,array1也無法釋放,代碼會變得臃腫且脆弱:
void Func()
{
int* array1 = new int[10];
int* array2 = new int[10];
//...
try
{
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
catch (...)
{
cout << "delete []" << endl;
delete[] array1;
delete[] array2;
throw; // 異常重新拋出,捕獲到什麼拋出什麼
}
// ...
cout << "delete []" << endl;
delete[] array1;
delete[] array2;
}
即便如此,因為new本⾝也可能拋異常,連續的兩個new和下⾯的Divide都可能會拋異常,讓我們處理起來很麻煩,這種場景下,我們需要一種 “能自動釋放資源” 的機制 —— 這就是智能指針的設計初衷。
1.2 智能指針的核心:RAII 思想
在 C++ 中,智能指針是一種封裝了裸指針(raw pointer)的模板類,其核心作用是自動管理動態內存,避免因手動調用delete疏忽導致的內存泄漏、重複釋放或懸空指針等問題。它基於RAII(資源獲取即初始化) 機制:在智能指針構造時獲取資源(如動態內存),在析構時自動釋放資源,無需手動干預。
智能指針的本質是RAII(Resource Acquisition Is Initialization,資源獲取即初始化) 的實踐:
- 資源(如動態內存、文件句柄)在智能指針對象構造時獲取,並委託給該對象管理;
- 智能指針對象析構時自動釋放資源,無論程序是正常結束還是異常退出(對象生命週期由作用域管理,析構總會執行);
- 為了方便使用,智能指針會重載
*、->、[]等運算符,模擬原生指針的行為。
基於此,我們可以先來自己簡單粗略的實現一下智能指針,如下面代碼所示:
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;
}
T& operator[](size_t i)
{
return _ptr[i];
}
private:
T* _ptr;
};
有了智能指針,我們的func函數就可以改成:
void Func()
{
SmartPtr<int> sp1 = new int[10];
SmartPtr<int> sp2 = new int[10];
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
通過運行結果我們可以看到即便Divide函數拋出異常也不會影響我們new出來兩個數組的釋放:
雖然我們上面設計的智能指針是十分粗略的,但是可以看到即便如此也可以幫助我們解決內存泄漏的問題。那麼接下來讓我們看一看標準庫中是如何設計智能指針的。
二、C++ 標準庫智能指針:4 種指針的特性與適用場景
C++ 標準庫(<memory>頭文件)提供了 4 種智能指針,它們分別是auto_ptr、unique_ptr、shared_ptr、weak_ptr。其中除了auto_ptr外的三個都是在C++11中提出的。除weak_ptr外均遵循 RAII,原理上而⾔主要是解決智能指針拷⻉時的思路不同。下面來讓我們看一看它們之間的區別,以及在不同場景下該如何選擇。
2.1 auto_ptr:被淘汰的 “過渡品”(C++98)
auto_ptr是C++98時設計出來的智能指針,設計思路是 “拷貝時轉移資源管理權”—— 但這是一個致命缺陷:拷貝後原對象會 “懸空”(資源指針被置空),後續訪問原對象會觸發空指針錯誤。如下面代碼所示:
int main()
{
auto_ptr<Date> ap1(new Date); // ap1管理Date對象
auto_ptr<Date> ap2(ap1); // 拷貝:ap2獲取管理權,ap1->_ptr被置空
// ap1->_year++; // 崩潰!ap1已懸空,訪問空指針
return 0;
}
auto_ptr拷貝的原理是將自己的指針賦值給新的auto_ptr,並且使自己的指針置為空,這樣我們再去訪問這個對象時,就會因為訪問空指針而導致報錯。可以看到,當我們將ap1拷貝給ap2後,我們就訪問ap1時就會報錯,因為ap1已經懸空,我們不能去訪問空指針。
正因如此,C++11 推出後,auto_ptr被明確標記為 “不推薦使用”,多數公司的編碼規範也會直接禁止它。
2.2 unique_ptr:不可共享的 “獨佔指針”(C++11)
unique_ptr(唯⼀指針)的設計思路是禁止拷貝、僅支持移動—— 確保同一時間只有一個unique_ptr管理資源,從根源上避免 “多個指針競爭釋放” 的問題。它是 C++11 中最常用的智能指針之一,適用於 “資源無需共享” 的場景。
核心特性:
- 不可複製,只能移動:由於是獨佔所有權,
unique_ptr不支持複製構造或賦值(會編譯報錯),但可以通過std::move轉移所有權(轉移後原unique_ptr會失效,變為空指針)。 - 高效輕量:無額外引用計數開銷,性能接近裸指針。
int main()
{
// 創建unique_ptr(推薦用make_unique,C++14起支持)
std::unique_ptr<int> ptr1 = std::make_unique<int>(10);
std::cout << *ptr1 << std::endl; // 輸出:10
// 轉移所有權(ptr1失效,ptr2擁有對象)
std::unique_ptr<int> ptr2 = std::move(ptr1);
if (ptr1 == nullptr) {
std::cout << "ptr1已失效" << std::endl; // 輸出:ptr1已失效
}
// 超出作用域時,ptr2析構,自動釋放內存
return 0;
}
適用場景:管理獨佔資源(如局部動態對象、類的成員變量),作為函數返回值(無需手動釋放,避免返回裸指針的風險)。
2.3 shared_ptr:可共享的 “計數指針”(C++11)
shared_ptr(共享指針)允許多個 shared_ptr 共同擁有同一個動態對象。。也就是説支持資源共享,其核心是通過 “引用計數” 跟蹤管理資源的指針數量:
- 當新的
shared_ptr拷貝或賦值時,引用計數+1; - 當
shared_ptr析構時,引用計數-1; - 當引用計數減至
0時,代表當前是最後一個管理資源的指針,自動釋放資源。
核心特性:
- 引用計數透明管理:用户無需手動維護計數,
use_count()方法可查看當前計數; - 支持拷貝與移動:拷貝時計數
+1,移動時計數不變(原對象懸空);
int main() {
// 創建shared_ptr(推薦用make_shared,更高效)
std::shared_ptr<int> ptr1 = std::make_shared<int>(20);
std::cout << "引用計數:" << ptr1.use_count() << std::endl; // 輸出:1
// 複製ptr1,引用計數+1
std::shared_ptr<int> ptr2 = ptr1;
std::cout << "引用計數:" << ptr1.use_count() << std::endl; // 輸出:2
// ptr2超出作用域,引用計數-1
{
std::shared_ptr<int> ptr3 = ptr1;
std::cout << "引用計數:" << ptr1.use_count() << std::endl; // 輸出:3
}
std::cout << "引用計數:" << ptr1.use_count() << std::endl; // 輸出:2
// 最後ptr1和ptr2析構,引用計數變為0,內存釋放
return 0;
}
shared_ptr的構造有兩種方式,除了⽀持⽤指向資源的指針構造,還⽀持 make_shared ⽤初始化資源對象的值直接構造:
shared_ptr<Date> sp(new Date(2024, 10, 1));:兩次內存分配(一次給Date對象,一次給引用計數);auto sp = make_shared<Date>(2024, 10, 1);:一次內存分配(同時存儲Date對象和引用計數),效率更高,且避免內存泄漏風險(若new成功但計數分配失敗,new的對象無法釋放)。
template <class T, class... Args>
shared_ptr<T> make_shared(Args&&... args);
對於shared_ptr和unique_ptr,我們還需要注意下面幾點:
shared_ptr和unique_ptr都⽀持了operator bool的類型轉換:如果智能指針對象是⼀個空對象沒有管理資源,則返回false,否則返回true,意味着我們可以直接把智能指針對象給if判斷是否為空。shared_ptr和unique_ptr的構造函數都使⽤explicit修飾,防⽌普通指針隱式類型轉換成智能指針對象。
// 報錯:無法進行隱式類型轉換
shared_ptr<Date> sp5 = new Date(2024, 9, 11);
unique_ptr<Date> sp6 = new Date(2024, 9, 11);
使用
shared_ptr還要注意線程安全問題,shared_ptr的引⽤計數對象在堆上,如果多個shared_ptr對象在多個線程中,進⾏shared_ptr的拷貝和析構時會訪問修改引⽤計數,就會存在線程安全問題,所以shared_ptr引⽤計數是需要加鎖或者原⼦操作保證線程安全的。
2.4 weak_ptr:解決循環引用的 “輔助指針”(C++11)
weak_ptr(弱指針)完全不同於上⾯的智能指針,是一個特殊的智能指針。它不⽀持RAII,也就意味着不能⽤它直接管理資源,weak_ptr的產⽣本質是要解決shared_ptr的⼀個循環引⽤導致內存泄漏的問題。
什麼是循環引用呢?shared_ptr 的引用計數機制可能導致循環引用問題:兩個對象互相持有對方的 shared_ptr,此時它們的引用計數永遠不會變為 0,導致內存泄漏。
例如:我們有兩個鏈表結點,把它們分別交給智能指針shared_ptr管理,然後將它們連接起來,如下面代碼所示:
struct ListNode
{
int _data;
shared_ptr<ListNode> _next; // 指向後一個節點
shared_ptr<ListNode> _prev; // 指向前一個節點
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
shared_ptr<ListNode> n1(new ListNode);
shared_ptr<ListNode> n2(new ListNode);
cout << n1.use_count() << endl; // 1
cout << n2.use_count() << endl; // 1
n1->_next = n2; // n2的計數+1 → 2
n2->_prev = n1; // n1的計數+1 → 2
// 析構n1和n2:計數各減1 → 1(而非0)
// 節點資源永遠無法釋放,內存泄漏!
return 0;
}
循環引用的邏輯鏈:n1->_next依賴n2釋放,n2->_prev依賴n1釋放,最終誰都無法釋放。
那麼該如何解決循環引用呢?這時候weak_ptr就派上用場了。weak_ptr 是一種弱引用智能指針,它不擁有對象的所有權,也不會增加引用計數,因此我們修改鏈表節點為weak_ptr後,循環引用被打破:
struct ListNode
{
int _data;
weak_ptr<ListNode> _next; // 改為weak_ptr,不增加計數
weak_ptr<ListNode> _prev;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main()
{
shared_ptr<ListNode> n1(new ListNode);
shared_ptr<ListNode> n2(new ListNode);
n1->_next = n2; // 不增加n2的計數(仍為1)
n2->_prev = n1; // 不增加n1的計數(仍為1)
// 析構n1和n2:計數各減1 → 0,節點資源正常釋放
return 0;
}
weak_ptr不⽀持RAII,也不⽀持訪問資源,所以weak_ptr構造時不⽀持綁定到資源,只⽀持綁定到shared_ptr,綁定到shared_ptr時,不增加shared_ptr的引⽤計數,那麼就可以解決上述的循環引⽤問題。
weak_ptr的核心特性是:綁定到shared_ptr時不增加引用計數,僅作為 “觀察者” 跟蹤資源是否有效。weak_ptr也沒有重載operator*和operator->等,因為他不參與資源管理 。那麼如果它綁定的shared_ptr已經釋放了資源,那麼它去訪問資源就是很危險的,為此它提供了兩個關鍵方法:
expired():判斷綁定的shared_ptr資源是否已釋放(計數為 0);lock():若資源有效,返回一個shared_ptr(計數 + 1,安全訪問資源);若無效,返回空shared_ptr。
int main()
{
std::shared_ptr<string> sp1(new string("111111"));
std::shared_ptr<string> sp2(sp1);
std::weak_ptr<string> wp = sp1;
cout << wp.expired() << endl;
cout << wp.use_count() << endl;
// sp1和sp2都指向了其他資源,則weak_ptr就過期了
sp1 = make_shared<string>("222222");
cout << wp.expired() << endl;
cout << wp.use_count() << endl;
sp2 = make_shared<string>("333333");
cout << wp.expired() << endl;
cout << wp.use_count() << endl;
wp = sp1;
//std::shared_ptr<string> sp3 = wp.lock();
auto sp3 = wp.lock();
cout << wp.expired() << endl;
cout << wp.use_count() << endl;
*sp3 += "###";
cout << *sp1 << endl;
return 0;
}
weak_ptr僅作為shared_ptr的輔助工具,解決循環引用(如鏈表、樹、圖等數據結構的節點引用)。
相關文檔weak_ptr
2.5 刪除器
智能指針析構時默認是進⾏delete釋放資源,這也就意味着如果不是new出來的資源,交給智能指針管理,析構時就會崩潰。**因此當管理非new資源(如new[]、文件指針)時,需自定義刪除器。**智能指針⽀持在構造時給⼀個刪除器,所謂刪除器本質就是⼀個可調⽤對象,在這個可調⽤對象中實現你想要的釋放資源的⽅式,當構造智能指針時,給了定製的刪除器,在智能指針析構時就會調⽤刪除器去釋放資源。因為new[]經常使⽤,所以為了簡潔,unique_ptr和shared_ptr都特化了⼀份[]的版本:
int main()
{
// 這樣實現程序會崩潰
// unique_ptr<Date> up1(new Date[10]);
// shared_ptr<Date> sp1(new Date[10]);
// 因為new[]經常使⽤,所以unique_ptr和shared_ptr
// 實現了⼀個特化版本,這個特化版本析構時⽤的delete[]
unique_ptr<Date[]> up1(new Date[5]);
shared_ptr<Date[]> sp1(new Date[5]);
return 0;
}
除此之外,我們還可以自定義刪除器,這裏我們有三種方式:
- 仿函數
template<class T>
class DeleteArray
{
public:
void operator()(T* ptr)
{
delete[] ptr;
}
};
unique_ptr<Date, DeleteArray<Date>> up2(new Date[5]);
shared_ptr<Date> sp2(new Date[5], DeleteArray<Date>());
- 函數指針
template<class T>
class DeleteArray
{
public:
void operator()(T* ptr)
{
delete[] ptr;
}
}
unique_ptr<Date, void(*)(Date*)> up3(new Date[5], DeleteArrayFunc<Date>);
shared_ptr<Date> sp3(new Date[5], DeleteArrayFunc<Date>);
- lambda表達式
auto delArrOBJ = [](Date* ptr) {delete[] ptr; };
unique_ptr<Date, decltype(delArrOBJ)> up4(new Date[5], delArrOBJ);
shared_ptr<Date> sp4(new Date[5], delArrOBJ);
我們可以看到,使用不同的可調用對象,unique_ptr和shared_ptr需要傳入的參數也是不同的,這是因為unique_ptr和shared_ptr⽀持刪除器的⽅式有所不同:
unique_ptr是在類模板參數⽀持的;shared_ptr是構造函數參數⽀持的。
這⾥沒有使⽤相同的⽅式還是挺不方便,也是標準庫中的一點小弊端。
使⽤仿函數unique_ptr可以不在構造函數傳遞,因為仿函數類型構造的對象直接就可以調用,但是函數指針和lambda表達式的類型是不可以的,所以在傳參時不僅要在模板傳類型,還要在構造傳入相應的對象。
3. shared_ptr的模擬實現
想要加深對智能指針的印象,我們可以自己來模擬實現一下智能指針,在標準庫中的四種智能指針中shared_ptr涉獵最廣,所以我們來模擬實現一下shared_ptr,當然我們這裏只是簡單的模擬,標準庫中的shared_ptr的實現是極為複雜的。
3.1 原理
實現shared_ptr我們需要搞定兩個比較重要的東西,其中之一是引用計數的設計,主要這⾥⼀份資源就需要⼀個引⽤計數,所以引⽤計數採⽤靜態成員的⽅式是⽆法實現的,要使⽤堆上動態開闢的⽅式,構造智能指針對象時來⼀份資源,就要new⼀個引⽤計數出來。多個shared_ptr指向資源時就++引⽤計數,shared_ptr對象析構時就--引⽤計數,引⽤計數減到0時代表當前析構的shared_ptr是最後⼀個管理資源的對象,則析構資源。
其次就是刪除器,我們要在構造時傳入刪除器,但是在析構時才會使用刪除器,也就是説我們需要將刪除器保存為成員函數,這樣才能在析構時去調用,那麼我們該如何保存刪除器呢?我們知道函數指針、仿函數、lambda表達式這些都可以做刪除器,這時候我們就需要用到function包裝器(詳情點擊)了,通過包裝器來存儲刪除器,這樣就可以存儲不同的刪除器了:function<void(T*)> _del = [](T* ptr) {delete ptr; };
3.2 代碼
下面讓我們來看具體的代碼實現:
template<class T>
class shared_ptr
{
public:
explicit shared_ptr(T* ptr = nullptr)
: _ptr(ptr)
, _pcount(new int(1))
{}
template<class D>
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _pcount(new int(1))
, _del(del)
{}
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
, _pcount(sp._pcount)
, _del(sp._del)
{
++(*_pcount);
}
void release()
{
if (--(*_pcount) == 0)
{
// 最後⼀個管理的對象,釋放資源
_del(_ptr);
delete _pcount;
_ptr = nullptr;
_pcount = nullptr;
}
}
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
_del = sp._del;
}
return *this;
}
~shared_ptr()
{
release();
}
//獲得原生指針
T* get() const
{
return _ptr;
}
int use_count() const
{
return *_pcount;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
function<void(T*)> _del = [](T* ptr) {delete ptr; };
};
需要注意的是我們這⾥實現的shared_ptr是以最簡潔的⽅式實現的,只能滿⾜基本的功能。感興趣的可以去查看源代碼。
4. 總結:智能指針的最佳實踐
掌握智能指針後,我們可以從 “手動管理內存” 的焦慮中解放出來。以下是核心實踐原則:
- 優先用 unique_ptr:若資源無需共享,
unique_ptr是最高效的選擇(無引用計數開銷); - 共享用 shared_ptr:需多模塊共享資源時用
shared_ptr,優先用make_shared優化; - 循環引用用 weak_ptr:鏈表、樹等結構中,節點間引用用
weak_ptr避免泄漏; - 自定義刪除器:管理
new[]、文件句柄等非new資源時,務必指定刪除器; - 避免裸指針混用:儘量不要用智能指針管理 “已被裸指針管理的資源”,避免重複釋放。
最後記住:智能指針不是 “銀彈”,但它是現代 C++ 中避免內存泄漏的最有效工具。用好智能指針,讓你的代碼更安全、更優雅!