博客 / 詳情

返回

【C++】智能指針

前言

學習C++智能指針。

指針(Pointer)就是一個變量,其存儲的是另一個變量的內存地址,理解指針是掌握 C++ 內存管理、數組、對象以及底層操作的關鍵。

為什麼使用指針

1. 動態內存管理:在運行時根據需要申請內存(使用 newdelete)。原生數組(如 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 塊,沒能執行到釋放內存的代碼。

  • 內存泄漏的後果,內存泄漏通常不會立刻導致程序崩潰,它的危害是漸進式的:

  1. 性能下降 隨着可用內存變少,操作系統會頻繁進行內存交換(Swap),系統變得越來越卡。
  2. 內存不足而崩潰 當內存被耗盡,新的 new 請求會失敗,拋出異常,程序被迫中止。
  3. 隱蔽性極強 在本地測試可能跑得好好的,但在服務器上連續運行幾天甚至幾周後,程序會無預兆地突然倒下

懸空指針(重複釋放)

指向的內存已經被釋放,但指針依然指向那個地址,重複釋放指的是對同一塊動態分配的內存進行多次釋放操作

	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 實際上包含兩個指針:

  1. 指向數據的指針:直接指向你申請的內存對象。

  2. 指向控制塊的指針:控制塊是一個動態分配的內存區域,存放着:

    • 引用計數(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;
}

思考

二級指針的使用場景

  1. 動態分配二維數組:使用指針的指針來表示二維數組,可以動態分配和釋放內存。
  2. 函數中修改指針:當需要在函數內部改變指針的指向(例如分配內存)並讓改變在函數外部生效時,需要傳遞指針的指針(或指針的引用)。
  3. 處理字符串數組(命令行參數):例如main函數中的char** argv,表示字符串數組。
  4. 實現數據結構:如鏈表、樹等數據結構的某些操作中,需要修改指針的指向。
  • 動態分配二維數組
#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;
}
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.