C++智能指针(先行版)


第一部分:为什么要有智能指针?它解决了什么问题?

在深入了解智能指针之前,我们必须先理解它所面临的“敌人”。

1. 原生指针的痛点:手动内存管理

在C++中,我们使用 newdelete 来手动管理堆上的内存。

void problemExample() {
    MyClass* ptr = new MyClass(); // 分配资源

    // ... 一些业务逻辑 ...
    if (someCondition) {
        return; // 糟糕!提前返回了,delete没有被执行!内存泄漏!
    }

    // ... 可能还有异常抛出 ...
    someFunctionThatMightThrow(); // 如果异常抛出,函数栈展开,delete再次被跳过!

    delete ptr; // 只有一切顺利,才会执行到这里
}
int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void Func()
{
	// 1、如果p1这里new 抛异常会如何?
	int* p1 = new int;
    // 2、如果p2这里new 抛异常会如何?
	int* p2 = new int;
    // 3、如果div调用这里又会抛异常会如何?
	cout << div() << endl;
	delete p1;
	delete p2;
}
int main()
{
	try
	{
		Func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}
	return 0;
}

我们来分析这段代码中的三个问题:

  1. 如果p1这里new抛异常会如何?

    • int* p1 = new int;抛出异常时(通常是bad_alloc异常),此时还没有任何动态内存被成功分配(p1未初始化)。
    • 异常会直接向上传播到main函数的catch块,不会有内存泄漏,因为还没有分配任何需要手动释放的内存。
  2. 如果p2这里new抛异常会如何?

    • int* p2 = new int;抛出异常时,p1已经成功分配了内存。
    • 由于异常发生在p2分配时,函数会直接退出,后续的delete p1;delete p2;不会执行。
    • 这会导致p1所指向的内存无法释放,造成内存泄漏。
  3. 如果div调用这里又会抛异常会如何?

    • div()调用抛出异常时(如除0错误),p1和p2都已经成功分配了内存。
    • 异常会导致函数提前退出,delete p1;delete p2;语句不会执行。
    • 这会导致p1和p2所指向的内存都无法释放,造成内存泄漏。

上述代码会导致什么问题?

  • 内存泄漏 (Memory Leak):如果函数提前返回(如条件判断、return语句)或抛出异常,delete语句将无法执行。已分配的内存永远无法被释放,程序占用的内存会不断增长。

    • **什么是内存泄漏:**内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
    • 内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死。
    • 这里要解释的一下就是内存泄露的危害主要在于那种:一旦开始服务后就不会轻易停止的程序,就好比如什么王者荣耀昂,微信,QQ这些,而且市面上大多数程序都是这样的。对于这些程序来说,一旦某些地方申请了内存空间,使用完后,你后续不会再使用这段内存空间了,但是你忘记释放了,这就会导致这段空间一直被占据且无法被使用,因为只要你还没释放,这段空间就还被申请者占据。别人就用不了这段空间,如果这种行为在同一个程序还有很多,那么久而久之就会导致响应越来越慢,最终卡死。
    • 那么对于我们平时练习代码写的那些程序,都是一执行完就停止了的,就算程序还在运行的时候我们申请了内存空间但是没有释放。这也不会对我们的计算机或者什么别的东西有影响,因为程序一旦结束了,内存空间它自己就会被释放掉的。不过就算是没有什么影响,也轻易不要这样做,申请了空间,就得好好处理它。
  • 悬空指针 (Dangling Pointer):如果你不小心多次 delete 同一个指针,或者在一个指针被释放后继续使用它,会导致未定义行为(程序崩溃或数据损坏)。

  • 所有权不明确:当一个指针被传递给多个函数或对象时,很难确定谁最终拥有这个指针并负责释放它。

2. 智能指针的解决方案:RAII

智能指针的核心思想是 RAII (Resource Acquisition Is Initialization),即“资源获取即初始化”。

  • 原理:将资源(这里是动态内存)的生命周期与一个对象的生命周期绑定。
    • 获取资源:在对象的构造函数中获取资源(例如,在 std::unique_ptr 构造函数中调用 new)。
    • 释放资源:在对象的析构函数中自动释放资源(例如,在 std::unique_ptr 析构函数中调用 delete)。

由于C++保证,当栈上的对象离开其作用域时(无论是正常离开还是因为异常),其析构函数一定会被调用。因此,绑定了资源的智能指针在析构时,一定会帮我们释放内存。

这就完美地解决了手动管理的问题:

  • 自动释放:无需手动调用 delete,杜绝了因忘记或执行路径复杂而导致的内存泄漏。
  • 异常安全:即使发生异常,栈展开过程也会调用智能指针的析构函数,确保资源被释放。
  • 明确所有权:不同类型的智能指针清晰地定义了内存的所有权语义。

第二部分:C++中的智能指针类型及详解

C++11在 <memory> 头文件中引入了三种主要的智能指针:

1. std::unique_ptr:独占所有权的智能指针
  • 所有权模型独占。同一时刻,只能有一个 unique_ptr 拥有并管理一个对象。它不能被复制拷贝(copy),只能被移动(move)。所有权可以从一个 unique_ptr 转移给另一个。
  • 使用场景:当你想要一个对象只有一个明确的拥有者时。这是最常用、开销最小的智能指针,通常应作为首选。

代码示例:

#include <iostream>
#include <memory>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed\n"; }
    ~MyClass() { std::cout << "MyClass destroyed\n"; }
    void doSomething() { std::cout << "Doing something...\n"; }
};

int main() {
    std::cout << "Creating unique_ptr...\n";
    {//这个东西叫:嵌套作用域。大家ai一下就知道了,这里就不多讲。
        // 推荐使用 std::make_unique (C++14)
        std::unique_ptr<MyClass> uPtr1 = std::make_unique<MyClass>();
        // 也可以直接构造(不推荐,可能出问题)
        // std::unique_ptr<MyClass> uPtr2(new MyClass());

        uPtr1->doSomething(); // 使用 -> 操作符访问成员

        // std::unique_ptr<MyClass> uPtr2 = uPtr1; // 错误!不能拷贝构造
        std::unique_ptr<MyClass> uPtr2 = std::move(uPtr1); // 正确!所有权转移

        if (uPtr2) { // 检查是否管理着对象(bool转换)
            std::cout << "uPtr2 owns the object\n";
        }
        if (!uPtr1) { // uPtr1 现在为空
            std::cout << "uPtr1 is now empty\n";
        }
    } // uPtr2 离开作用域,管理的对象被自动销毁
    std::cout << "Exiting main...\n";
    return 0;
}

输出:

Creating unique_ptr...
MyClass constructed
Doing something...
uPtr2 owns the object
uPtr1 is now empty
MyClass destroyed
Exiting main...
2. std::shared_ptr:共享所有权的智能指针
  • 所有权模型共享。多个 shared_ptr 可以共同“拥有”同一个对象。它使用引用计数来跟踪有多少个 shared_ptr 指向同一个对象。当最后一个指向该对象的 shared_ptr 被销毁或重置时,对象才会被销毁。
  • 使用场景:当你需要多个所有者共同管理同一个对象的生命周期时。例如,在容器中存储指针副本、在多模块间传递共享资源等。
  • 注意:循环引用问题(后面会讲)。

代码示例:

#include <iostream>
#include <memory>

class MyClass {
public:
    MyClass() { std::cout << "MyClass constructed\n"; }
    ~MyClass() { std::cout << "MyClass destroyed\n"; }
};

int main() {
    std::cout << "Creating shared_ptrs...\n";
    {
        // 推荐使用 std::make_shared (更高效)
        std::shared_ptr<MyClass> sPtr1 = std::make_shared<MyClass>();
        std::cout << "sPtr1 use_count: " << sPtr1.use_count() << "\n"; // 输出: 1

        {
            std::shared_ptr<MyClass> sPtr2 = sPtr1; // 复制,引用计数增加
            std::cout << "After copy:\n";
            std::cout << "sPtr1 use_count: " << sPtr1.use_count() << "\n"; // 输出: 2
            std::cout << "sPtr2 use_count: " << sPtr2.use_count() << "\n"; // 输出: 2
        } // sPtr2 离开作用域,析构,引用计数减为 1

        std::cout << "sPtr1 use_count: " << sPtr1.use_count() << "\n"; // 输出: 1
    } // sPtr1 离开作用域,析构,引用计数减为 0,对象被销毁
    std::cout << "Exiting main...\n";
    return 0;
}

输出:

Creating shared_ptrs...
MyClass constructed
sPtr1 use_count: 1
After copy:
sPtr1 use_count: 2
sPtr2 use_count: 2
sPtr1 use_count: 1
MyClass destroyed
Exiting main...
3. std::weak_ptr:弱引用的智能指针
  • 所有权模型不拥有weak_ptr 指向一个由 shared_ptr 管理的对象,但不增加其引用计数。它是对 shared_ptr 管理对象的一种“观察者”。
  • 用途
    1. 打破 shared_ptr 的循环引用(这是其主要目的)。
    2. 临时需要访问共享资源,但不想影响其生命周期。
  • 使用方法:不能直接访问资源,必须通过 lock() 方法将其转换为一个临时的 shared_ptr 来使用,以此检查对象是否还存活。

循环引用问题及 weak_ptr 的解决方案:

#include <iostream>
#include <memory>

class B;
class A {
public:
    std::shared_ptr<B> bPtr; // 之前用 shared_ptr,会导致循环引用
    // std::weak_ptr<B> bPtr; // 解决方案:改用 weak_ptr
    ~A() { std::cout << "A destroyed\n"; }
};

class B {
public:
    std::shared_ptr<A> aPtr;
    ~B() { std::cout << "B destroyed\n"; }
};

int main() {
    std::shared_ptr<A> a = std::make_shared<A>();
    std::shared_ptr<B> b = std::make_shared<B>();

    a->bPtr = b; // 如果 bPtr 是 shared_ptr,A 引用 B,引用计数为 2
    b->aPtr = a; // B 引用 A,引用计数为 2

    // 当 main 函数结束时,a 和 b 的引用计数都减 1,但都还剩下 1。
    // 因为循环引用,它们的析构函数不会被调用,内存泄漏!

    std::cout << "a use_count: " << a.use_count() << "\n"; // 输出 2
    std::cout << "b use_count: " << b.use_count() << "\n"; // 输出 2

    return 0;
} // 内存泄漏!A 和 B 都不会被销毁

解决方案:将 class A 中的 std::shared_ptr<B> bPtr; 改为 std::weak_ptr<B> bPtr;

  • weak_ptr 不增加 B 的引用计数。所以 b 的引用计数始终为 1(只有 b 自己拥有)。
  • main 结束时,b 的引用计数减为 0,被销毁。b 被销毁导致其成员 aPtr(一个 shared_ptr)被销毁,从而使 a 的引用计数也从 2 减为 1 再减为 0(因为 a 自己也离开了作用域),a 也被销毁。
  • 输出将正确显示 “A destroyed” 和 “B destroyed”

使用 weak_ptr::lock()

void useWeakPtr() {
    std::shared_ptr<MyClass> shared = std::make_shared<MyClass>();
    std::weak_ptr<MyClass> weak = shared;

    // 要使用 weak_ptr,必须先用 lock() 获取一个 shared_ptr
    if (auto tempShared = weak.lock()) { // 如果对象还存在
        tempShared->doSomething();       // 可以安全地使用
        std::cout << "Use count after lock: " << tempShared.use_count() << "\n"; // 输出 2
    } else {
        std::cout << "Object has been destroyed already.\n";
    }

    // tempShared 离开作用域,引用计数减少

    shared.reset(); // 手动释放对象

    if (auto tempShared = weak.lock()) {
        // 这里不会执行了
    } else {
        std::cout << "Object is gone.\n"; // 会执行这里
    }
}

第三部分:原理与底层实现(深入浅出)

智能指针不是魔法,它们本质上是类模板

1. std::unique_ptr 的简化实现

它的核心是一个指针和一个删除器。为了简化,我们忽略删除器和其他特性。

template<typename T>
class simplified_unique_ptr {
private:
    T* ptr; // 底层管理的原生指针

public:
    // 构造函数:获取资源
    explicit simplified_unique_ptr(T* p = nullptr) : ptr(p) {}
    
    // 禁止拷贝
    simplified_unique_ptr(const simplified_unique_ptr&) = delete;
    simplified_unique_ptr& operator=(const simplified_unique_ptr&) = delete;

    // 移动构造函数:转移所有权
    simplified_unique_ptr(simplified_unique_ptr&& other) noexcept : ptr(other.ptr) {
        other.ptr = nullptr; // 源对象放弃所有权
    }
    
    // 移动赋值运算符
    simplified_unique_ptr& operator=(simplified_unique_ptr&& other) noexcept {
        if (this != &other) {
            delete ptr;       // 先释放自己当前管理的资源
            ptr = other.ptr;
            other.ptr = nullptr;
        }
        return *this;
    }

    // 析构函数:释放资源
    ~simplified_unique_ptr() {
        delete ptr;
    }

    // 重载操作符,使其用起来像指针
    T& operator*() const { return *ptr; }
    T* operator->() const { return ptr; }
    explicit operator bool() const { return ptr != nullptr; }
};

// 使用我们的简化版
void demo() {
    simplified_unique_ptr<MyClass> uPtr(new MyClass());
    uPtr->doSomething();
    // simplified_unique_ptr<MyClass> uPtr2 = uPtr; // 编译错误!
    simplified_unique_ptr<MyClass> uPtr2 = std::move(uPtr); // 正确,移动
}
2. std::shared_ptr 的原理:引用计数与控制块

shared_ptr 的实现更复杂。它通常包含两个指针

  1. 一个指向被管理对象的指针。
  2. 一个指向控制块 (Control Block) 的指针。

控制块是一个动态分配的结构,它包含:

  • 引用计数器 (Use Count):记录有多少个 shared_ptr 共享这个对象。
  • 弱引用计数器 (Weak Count):记录有多少个 weak_ptr 观察这个对象。(当 use_count 为 0 时,对象被销毁,但控制块要等到 weak_count 也为 0 时才被释放)。
  • 删除器 (Deleter):可选的,用于自定义如何释放对象。
  • 分配器 (Allocator):可选的,用于自定义如何分配控制块。

std::make_shared 的优势std::make_shared<MyClass>(args...) 通常会执行一次内存分配,同时为对象和控制块分配一块连续的内存。这提高了性能(减少一次分配)和局部性。而直接 std::shared_ptr<MyClass>(new MyClass(args...)) 会执行两次分配(一次给对象,一次给控制块)。

shared_ptr 的简化模型:

template<typename T>
class simplified_shared_ptr {
private:
    T* ptr;                  // 指向对象的指针
    ControlBlock* controlBlock; // 指向控制块的指针

    struct ControlBlock {
        size_t use_count;
        size_t weak_count;
        // ... 删除器等其他成员
    };

public:
    // 构造函数 (通过 new)
    simplified_shared_ptr(T* p) : ptr(p), controlBlock(new ControlBlock{1, 0}) {}
    
    // 拷贝构造函数:共享所有权,计数增加
    simplified_shared_ptr(const simplified_shared_ptr& other) : ptr(other.ptr), controlBlock(other.controlBlock) {
        if (controlBlock) {
            ++controlBlock->use_count;
        }
    }
    
    // 析构函数:计数减少,如果为0则销毁对象和控制块
    ~simplified_shared_ptr() {
        if (controlBlock) {
            --controlBlock->use_count;
            if (controlBlock->use_count == 0) {
                delete ptr; // 销毁管理的对象
                // 如果weak_count也为0,则删除controlBlock
                if (controlBlock->weak_count == 0) {
                    delete controlBlock;
                }
            }
        }
    }
    // ... 其他成员函数,如 operator=, operator->, etc.
};

第四部分:总结与最佳实践

特性std::unique_ptrstd::shared_ptrstd::weak_ptr
所有权独占共享不拥有(弱引用)
复制语义不可复制,只能移动可以复制可以复制
开销很小(几乎和原生指针一样)较大(需要维护控制块和引用计数)较大(需要控制块)
使用场景单一明确的所有者需要共享所有权的资源打破循环引用;缓存

最佳实践:

  1. 优先选择“唯一所有权”而非“共享所有权”std::unique_ptr 是默认选择,因为它更简单、更高效。只有在真正需要共享生命周期时才使用 std::shared_ptr
  2. 使用 std::make_uniquestd::make_shared
    • 它们更安全(避免了由于表达式求值顺序可能导致的内存泄漏)。
    • 它们更高效(特别是 make_shared,它合并了内存分配)。
    // 好!
    auto uPtr = std::make_unique<MyClass>();
    auto sPtr = std::make_shared<MyClass>();
    
    // 不好!(潜在风险且效率低)
    std::unique_ptr<MyClass> uPtr(new MyClass());
    std::shared_ptr<MyClass> sPtr(new MyClass());
    
  3. 警惕循环引用:如果两个由 shared_ptr 管理的对象互相持有对方的 shared_ptr,就会导致内存泄漏。使用 weak_ptr 来“断开”循环中的一环。
  4. 不要用智能指针管理非堆内存或静态寿命的对象
  5. 明确原始指针的所有权:如果一个函数接受一个原始指针,请在其文档中明确说明它是否会取得所有权。通常,函数参数如果是“只读且不取得所有权”,应该使用原始指针或引用;如果需要取得所有权,应该使用 unique_ptr 按值传递(表示所有权转移)或按 const& 传递(表示借用)。

更多推荐