前言
學習C++智能指針。
指針(Pointer)就是一個變量,其存儲的是另一個變量的內存地址,理解指針是掌握 C++ 內存管理、數組、對象以及底層操作的關鍵。
為什麼使用指針
1. 動態內存管理:在運行時根據需要申請內存(使用 new 和 delete)。原生數組(如 int a[10])的大小在編譯時就確定了,存儲在棧(Stack)上。但很多時候,你並不知道程序運行過程中需要多少內存。
- 按需分配:指針允許你在程序運行時使用
new關鍵字在堆(Heap)上申請內存。 - 生命週期控制:棧上的變量在函數結束時會自動銷燬,而指針指向的堆內存可以跨越函數生命週期存在,直到你手動釋放它。
2. 傳遞大型對象:通過指針傳遞參數(或引用)可以避免複製整個對象的開銷,提高程序性能。
3. 實現複雜數據結構:如鏈表(每個節點通過指針指向下一個節點)、二叉樹、圖(父節點通過指針尋找子節點)等,必須依賴指針。
4. 底層操作:直接訪問硬件或操作特定的內存區域。
為什麼需使用智能指針
原生指針弊端
原生指針存在的問題:內存泄漏、懸空指針(重複釋放)、野指針、所有權不清晰。
內存泄漏
用 new 分配了內存但忘記用 delete 釋放!
-
指針重定向
C++
int* p = new int(10); // 申請了內存 A p = new int(20); // p 現在指向了內存 B,內存 A 的地址丟失了,再也找不回來。 -
指針未釋放
void func() { int* p = new int(10); if (true) return; // 為真,函數直接返回,delete 被跳過! delete p; p = nullptr; } -
異常跳出: 程序運行中拋出異常,導致執行流直接跳轉到
catch塊,沒能執行到釋放內存的代碼。 -
內存泄漏的後果,內存泄漏通常不會立刻導致程序崩潰,它的危害是漸進式的:
- 性能下降 隨着可用內存變少,操作系統會頻繁進行內存交換(Swap),系統變得越來越卡。
- 內存不足而崩潰 當內存被耗盡,新的
new請求會失敗,拋出異常,程序被迫中止。 - 隱蔽性極強 在本地測試可能跑得好好的,但在服務器上連續運行幾天甚至幾周後,程序會無預兆地突然倒下
懸空指針(重複釋放)
指向的內存已經被釋放,但指針依然指向那個地址,重複釋放指的是對同一塊動態分配的內存進行多次釋放操作。
int* original = new int(100);
int* alias = original; // 兩個指針指向同一內存
delete original; // ✅ 釋放內存
// 此時 original 和 alias 都成為懸空指針
delete alias; // ❌ 重複釋放!同一內存再次釋放
野指針
指針未初始化,是指向“不可預知”內存區域的指針。它不是 nullptr,也不是指向有效的內存地址,它的值是隨機的垃圾值。
//定義一個局部指針變量卻不初始化時,它在棧上的值是上一次程序運行留下的殘餘數據
int* p; // 野指針!沒有初始化,指向一個隨機地址(如 0xCC123456)
*p = 100; // 極其危險!你可能正在修改系統關鍵數據或其它變量
//指針跨越了作用域,返回局部變量的地址
int* getPointer() {
int x = 10;//x為棧上空間
return &x; // 錯誤!x 是局部變量,函數結束就被銷燬了
}
int* p = getPointer(); // p 變成了野指針(或懸空指針)
所有權不清晰
當這個指針不再被需要時,究竟該由誰來負責 delete 它
// 誰該負責釋放返回的這個指針?
Data* fetchData() {
return new Data();
}
void process() {
Data* ptr = fetchData();
// 如果我忘了寫 delete,內存泄漏
// 如果我 delete 了,但另一個地方也在用它,程序崩潰
}
智能指針優點
-
自動化的生命週期管理(核心優點),智能指針遵循 RAII(資源獲取即初始化)原則。不再需要手動寫
delete。當智能指針對象在棧上被銷燬(如函數返回、大括號結束、異常拋出)時,它會自動釋放所指向的堆內存。 -
防止野指針: 智能指針強制初始化,不會像原生指針那樣默認指向隨機地址。
-
防止重複釋放(Double Free):
std::unique_ptr通過禁止拷貝確保只有一個;std::shared_ptr通過計數確保只在最後一次被使用時才釋放。 -
解決懸空指針: 使用
std::weak_ptr可以在訪問對象前先檢查它是否還“活着”,從而避免訪問已被釋放的內存。 -
所有權
std::unique_ptr<T>獨佔、std::shared_ptr<T>共享、std::weak_ptr<T>觀察。 -
智能指針作為棧對象,即便發生異常,C++ 的“棧解旋(Stack Unwinding)”機制也會確保其析構函數被調用,從而安全地回收內存。這是編寫健壯工業級代碼的基礎。
-
std::unique_ptr:具有零開銷(Zero-overhead)。它在內存大小和運行速度上與原生指針完全一致,編譯器會將其優化為最高效的機器碼。 -
std::shared_ptr:雖然有引用計數的原子操作開銷,但對於大多數業務邏輯來説,這種開銷幾乎可以忽略不計。
std::shared_ptr
shared_ptr 實際上包含兩個指針:
-
指向數據的指針:直接指向你申請的內存對象。
-
指向控制塊的指針:控制塊是一個動態分配的內存區域,存放着:
-
引用計數(Shared Count):有多少個
shared_ptr擁有它。當你拷貝一個shared_ptr時,計數器加 1;當一個shared_ptr銷燬時,計數器減 1。計數 > 0:資源保持有效。計數 = 0:最後一個“擁有者”負責調用delete銷燬資源。引用計數的增加和減少是原子操作(使用類似
std::atomic的機制)。這樣可以保證在多線程環境下,多個線程同時拷貝或銷燬指向同一個對象的shared_ptr時,計數器不會亂掉,從而避免內存泄漏或重複釋放。它所指向的對象數據本身並不是線程安全的,多線程讀寫對象需要額外加鎖。
-
弱引用計數(Weak Count):有多少個
weak_ptr正在觀察它。 -
自定義刪除器(Deleter):如果需要特殊的釋放邏輯。
-
#include <iostream>
#include <memory>
struct Widget {
Widget() { std::cout << "Widget Created\n"; }
~Widget() { std::cout << "Widget Destroyed\n"; }
};
int main() {
// 1. 推薦使用 std::make_shared,更安全且性能更好
//在創建 shared_ptr 時,優先使用 std::make_shared<T>(args) 而不是 new,new 方式需要分兩次申請內存(一次給對象,一次給控制塊)
//而make_shared只需要一次性申請一塊大內存,減少了內存碎片。避免在構造過程中如果拋出異常導致內存泄漏。
std::shared_ptr<Widget> sp1 = std::make_shared<Widget>();
std::cout << "Count: " << sp1.use_count() << std::endl; // 輸出 1
{
std::shared_ptr<Widget> sp2 = sp1; // 拷貝,計數加 1
std::cout << "Count: " << sp1.use_count() << std::endl; // 輸出 2
} // sp2 離開作用域,計數減 1
std::cout << "Count: " << sp1.use_count() << std::endl; // 輸出 1
return 0;
} // sp1 離開作用域,計數變為 0,Widget 被銷燬
循環引用:如果 A 擁有 B,B 也擁有 A,兩者的引用計數永遠不會回零,導致內存泄漏。解決方法:其中一方使用 std::weak_ptr。
std::unique_ptr
獨佔所有權的
- 禁用拷貝 (Deleted Functions)
它顯式地刪除了拷貝構造函數和拷貝賦值操作符。
unique_ptr(const unique_ptr&) = delete; // 禁止拷貝構造
unique_ptr& operator=(const unique_ptr&) = delete; // 禁止拷貝賦值
如果你嘗試 p2 = p1,編譯器會直接報錯,在編譯階段就消滅了“多指針競爭同一資源”的可能性。
- 實現移動語義 (Move Semantics)
雖然不能拷貝,但它允許移動。它實現了移動構造函數,將內部指針的值“偷”給新對象,並將原對象的內部指針置為 nullptr。
unique_ptr(unique_ptr&& other) noexcept {
ptr = other.ptr;
other.ptr = nullptr; // 原指針瞬間變為空
}
- 自動釋放機制 (RAII)
unique_ptr 的析構函數是自動管理內存的核心。當 unique_ptr 對象離開作用域(在棧上被銷燬)時,析構函數會自動調用 delete:
~unique_ptr() {
if (ptr != nullptr) {
delete ptr; // 自動銷燬堆內存
}
}
- 示例
#include <iostream>
#include <memory> // 包含頭文件
struct Task {
int id;
Task(int i) : id(i) { std::cout << "Task " << id << " created\n"; }
~Task() { std::cout << "Task " << id << " destroyed\n"; }
};
int main() {
// 1. 創建:推薦使用 std::make_unique (C++14)
std::unique_ptr<Task> p1 = std::make_unique<Task>(101);
// 2. 拷貝嘗試(編譯錯誤!)
// std::unique_ptr<Task> p2 = p1;
// 3. 移動:所有權從 p1 轉移到 p3
std::unique_ptr<Task> p3 = std::move(p1);
if (p1 == nullptr) {
std::cout << "p1 is now empty.\n"; // p1 變成了空指針
}
// 4. 自動釋放
// 當 p3 離開作用域時,Task 101 會自動銷燬
return 0;
}
std::weak_ptr
- 解決循環引用(Memory Cycle)
如果兩個對象互相使用 shared_ptr 指向對方,它們的計數永遠不會減到 0,導致內存泄漏。將其中一方改為 weak_ptr 即可打破僵局。
- 解決“對象還活着嗎”的監測問題
原生指針無法知道它指向的內存是否已被釋放。weak_ptr 可以安全地探測對象的狀態,而不會延長對象的壽命
-
只觀察,不擁有
weak_ptr並不直接參與對象的生命週期管理。- 不影響釋放: 當所有強引用(
shared_ptr)銷燬時,對象就會被釋放,即使還有weak_ptr指向它。 - 不能直接訪問: 你不能直接用
*或->操作weak_ptr。你必須先把它“提升”為一個shared_ptr。
- 不影響釋放: 當所有強引用(
#include <iostream>
#include <memory>
int main() {
std::shared_ptr<int> sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp; // wp 觀察 sp,不增加計數
std::cout << "Count: " << sp.use_count() << std::endl; // 輸出 1
// 如何使用 wp 指向的數據?
if (std::shared_ptr<int> tempSp = wp.lock()) { // 嘗試鎖定(提升)
std::cout << "Data: " << *tempSp << std::endl;
} else {
std::cout << "Object is already destroyed." << std::endl;
}
sp.reset(); // 手動釋放強引用
if (wp.expired()) { // 檢查對象是否已失效
std::cout << "Object is gone!" << std::endl;
}
return 0;
}
思考
二級指針的使用場景
- 動態分配二維數組:使用指針的指針來表示二維數組,可以動態分配和釋放內存。
- 函數中修改指針:當需要在函數內部改變指針的指向(例如分配內存)並讓改變在函數外部生效時,需要傳遞指針的指針(或指針的引用)。
- 處理字符串數組(命令行參數):例如
main函數中的char** argv,表示字符串數組。 - 實現數據結構:如鏈表、樹等數據結構的某些操作中,需要修改指針的指向。
- 動態分配二維數組
#include <iostream>
int main() {
int rows = 3, cols = 4;
// 分配一個指針數組,每個指針指向一行
int** matrix = new int*[rows];
for (int i = 0; i < rows; ++i) {
matrix[i] = new int[cols];
}
// 初始化並打印
int count = 0;
for (int i = 0; i < rows; ++i) {
for (int j = 0; j < cols; ++j) {
matrix[i][j] = count++;
std::cout << matrix[i][j] << ' ';
}
std::cout << std::endl;
}
// 釋放內存
for (int i = 0; i < rows; ++i) {
delete[] matrix[i];
}
delete[] matrix;
return 0;
}
- 函數中修改指針
寫一個函數,它分配內存並將指針指向這塊內存。如果只傳遞指針(值傳遞),那麼函數內部修改的是指針的副本,原始指針不會改變。因此需要傳遞指針的指針(或指針的引用)。
#include <iostream>
void allocateMemory(int** ptr) {
*ptr = new int(100); // 修改原始指針的指向
}
int main() {
int* p = nullptr;
allocateMemory(&p); // 傳遞指針的地址
std::cout << *p << std::endl; // 輸出100
delete p;
return 0;
}
- 處理字符串數組
#include <iostream>
int main(int argc, char** argv) {
// 打印命令行參數
for (int i = 0; i < argc; ++i) {
std::cout << "argv[" << i << "] = " << argv[i] << std::endl;
}
return 0;
}
out
root1@ubuntu:~/work/hello/build$ ./hello_cmake_g a bcd ef a
argv[0] = ./hello_cmake_g
argv[1] = a
argv[2] = bcd
argv[3] = ef
argv[4] = a
- 數據結構
以鏈表的刪除節點為例,有時候我們需要修改頭指針(當刪除的是頭節點時),因此傳遞二級指針可以方便地修改頭指針。
struct Node {
int data;
Node* next;
};
// 使用二級指針刪除鏈表中值為key的節點
void deleteNode(Node** head, int key) {
Node* temp = *head;
Node* prev = nullptr;
// 如果頭節點就是要刪除的節點
if (temp != nullptr && temp->data == key) {
*head = temp->next; // 修改頭指針
delete temp;
return;
}
// 查找要刪除的節點
while (temp != nullptr && temp->data != key) {
prev = temp;
temp = temp->next;
}
if (temp == nullptr) return;
// 從鏈表中刪除節點
prev->next = temp->next;
delete temp;
}