现代C++ | 智能指针

前言

C++98/03 时代,手动 new / delete 是最大内存泄漏源头,忘 delete、异常抛出时漏 delete、多线程下更乱。 C++11 委员会决定引入RAII(Resource Acquisition Is Initialization)风格的智能指针,目标是"让内存管理自动、零开销、安全"。 Herb Sutter 当时说:"我们要把 delete 从程序员手里彻底拿走。" C++14 又补了 make_unique,C++17 进一步完善了 weak_ptr 使用场景。

概念说明: RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、互斥量等等)的简单技术。

什么意思?就是我们可以利用对象生命周期来释放程序申请资源,以下面的的代码为例:

cpp 复制代码
// 以下是模拟实现一个简单的SmartPtr类
template<class T>
class SmartPtr
{
public:
    SmartPtr(T* ptr) :_ptr(ptr)
    {}
    ~SmartPtr()
    {
        delete _ptr;
    }
    T& operator*()
    {
        return *_ptr;
    }
    T* operator->()
    {
        return _ptr;
    }
private:
    T* _prt = nullptr;
};

以上是一个基于RAII思想实现的简单的SmartPtr类。

在构造SmartPtr对象时,SmartPtr将传入的需要被管理的内存空间保存起来。

在析构SmartPtr对象时,SmartPtr的析构函数中会自动将管理的内存空间进行释放。

此外,为了让SmartPtr对象能够像原生指针一样使用,还需要对*和->运算符进行重载

我们将需要管理的资源交付给SmartPtr对象,只要SmartPtr对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放。

为什么需要这个基于RAII思想实现的简单的SmartPtr类?程序员在写代码的时候手动delete不行?

因为在部分情况下,程序并不是按我们"以为"的情况下顺利运行的,因为疏忽或错误,造成程序未能释放已经不再使用的内存的情况。

就比如下面的情况:

cpp 复制代码
int div()
{
	int a, b;
	cin >> a >> b;
	if (b == 0)
		throw invalid_argument("除0错误");
	return a / b;
}
void func()
{
	int* ptr = new int;
	cout << div() << endl;
	delete ptr;
}
int main()
{
	try
	{
		func();
	}
	catch (exception& e)
	{
		cout << e.what() << endl;
	}
	return 0;
}

执行上述代码时,如果用户输入的除数为0,那么div函数中就会抛出异常,这时程序的执行流会直接跳转到主函数中的catch块中执行,最终导致func函数中申请的内存资源没有得到释放,造成内存泄露。


智能指针的原理

在上述代码中,如果我们将申请到的资源交给SmartPtr管理,如果用户输入的除数为0,那么div函数中就会抛出异常,这时程序的执行流会直接跳转到主函数中的catch块中执行,而我们的SmartPtr对象因生命周期结束,因此会自动帮我们释放申请到的资源,成功地防止住了内存泄漏。

但是,实现智能指针时需要考虑以下三个方面的问题:

  • 在对象构造时获取资源,在对象析构的时候释放资源,利用对象的生命周期来控制程序资源,即RAII特性。
  • 对*和->运算符进行重载,使得该对象具有像指针一样的行为。
  • 智能指针对象的拷贝问题。

上述代码的SmartPtr类存在一个致命问题,就是类的对象拷贝的问题。如果用一个SmartPtr对象来拷贝构造另一个SmartPtr对象,或是将一个SmartPtr对象赋值给另一个SmartPtr对象,都会导致程序崩溃。

cpp 复制代码
int main()
{
	SmartPtr<int> sp1(new int);
	SmartPtr<int> sp2(sp1); //拷贝构造
 
	SmartPtr<int> sp3(new int);
	SmartPtr<int> sp4(new int);
	sp3 = sp4; //拷贝赋值
	
	return 0;
}

原因是,因为编译器默认生成的拷贝构造函数和拷贝赋值函数,对内置类型完成值拷贝(浅拷贝),因此用sp1拷贝构造sp2后,相当于这sp1和sp2管理了同一块内存空间,当sp1和sp2析构时就会导致这块空间被释放两次,因此造成程序崩溃。


C++中的智能指针

auto_ptr

auto_prt已于C++11 deprecated → C++17 彻底移除,因此了解下即可。

auto_ptr是C++98中引入的智能指针,auto_ptr通过管理权转移的方式解决智能指针的拷贝问题,即保证一个资源在任何时刻都只有一个对象在对其进行管理,这时同一个资源就不会被多次释放了。比如:

cpp 复制代码
int main()
{
	std::auto_ptr<int> ap1(new int(1));
	std::auto_ptr<int> ap2(ap1);
	*ap2 = 10;
 
	//*ap1 = 20; // error,程序崩溃
 
	std::auto_ptr<int> ap3(new int(1));
	std::auto_ptr<int> ap4(new int(2));
	ap3 = ap4;
	return 0;
}

auto_ptr 的拷贝构造 / 赋值运算符会偷偷转移所有权,代码里没有任何显式标记,程序员很容易忽略,甚至把 auto_ptr 传给函数都会意外转移:

cpp 复制代码
auto_ptr<int> p1(new int(10));
auto_ptr<int> p2 = p1; // 没有任何提示,p1 直接变空!
// 后续代码如果忘了 p1 已空,解引用就崩溃

void func(auto_ptr<int> p) { /* ... */ }
auto_ptr<int> p1(new int(10));
func(p1); // 调用后 p1 直接变空!

管理权转移是auto_ptr的亮点,但是 一个对象的管理权转移后也就意味着,该对象不能再用对原来管理的资源进行访问了,否则程序就会崩溃,因此很多公司也都明确规定了禁止使用auto_ptr。

std::unique_ptr

设计原因: 解决"谁负责 delete"的问题,只允许一个指针拥有资源,禁止拷贝,只允许通过 std::move 显式转移所有权,从语法上强制安全。

底层原理:

  • 内部只存一个裸指针 + deleter(默认是 delete)。

  • 拷贝构造函数被 delete(禁止拷贝),只允许移动(std::move)。

  • 析构时自动 delete 资源(RAII)。

  • 零开销:大小和裸指针一样,现代编译器会优化掉。

实际老代码 vs 新代码例子:

cpp 复制代码
// C++98 老写法
MyClass* p = new MyClass();
p->doSomething();
delete p;          // 很容易忘、异常时漏、return 时漏

// C++11/14 
auto p = std::make_unique<MyClass>();   // C++14 最推荐
p->doSomething();
// 函数结束或异常时自动 delete,无需手动写 delete!

转移所有权:

cpp 复制代码
auto p1 = std::make_unique<MyClass>();
// auto p2 = p1; // error
auto p2 = std::move(p1); // p1 变为空,p2 接管资源所有权

需要注意的是,make_unique 不支持自定义删除器,如果需要用自定义删除器,比如管理数组、文件句柄,不能用 make_unique,得手动写 unique_ptr + 删除器。

std::shared_ptr

**设计原因:**当多个地方都需要同一份资源时(比如树节点、缓存),需要引用计数。

底层原理:

  • 内部有两个指针:一个指向对象,一个指向控制块(存储引用计数、删除器、弱引用计数等)。

  • 拷贝时引用计数 +1,析构时 -1,减到 0 才 delete。

  • 引用计数的增减是原子操作,支持多线程环境。

cpp 复制代码
auto sp1 = std::make_shared<MyClass>();
{
    auto sp2 = sp1; // 引用计数变为 2
    auto sp3 = sp2; // 引用计数变为 3
} // sp2、sp3 析构,计数回到 1
// sp1 析构时计数到 0,自动释放资源

控制块里存了什么?

控制块是一个动态分配的小对象,里面至少包含:

  • 强引用计数shared_count):有多少个 shared_ptr 指向这个对象。

  • 弱引用计数weak_count):有多少个 weak_ptr 指向这个对象(后面讲)。

  • 删除器 (Deleter):用来释放对象的函数,默认是 delete,也可以自定义。

  • 分配器(Allocator):用来分配控制块内存的,默认是标准分配器。

这里需要注意的是,引用计数需要放在堆区。

  • shared_ptr中的引用计数count不能单纯的定义成一个int类型的成员变量,因为这就意味着每个shared_ptr对象都有一个自己的count成员变量。
  • shared_ptr中的引用计数count也不能定义成一个静态的成员变量,因为静态成员变量是所有类型对象共享的,这会导致管理相同资源的对象和管理不同资源的对象用到的都是同一个引用计数,如下图:

将shared_ptr中的引用计数count定义成一个指针,当一个资源第一次被管理时就在堆区开辟一块空间用于存储其对应的引用计数,如果有其他对象也想要管理这个资源,那么除了将这个资源给它之外,还需要把这个引用计数也给它。这时管理同一个资源的多个对象访问到的就是同一个引用计数,而管理不同资源的对象访问到的就是不同的引用计数了,相当于将各个资源与其对应的引用计数进行了绑定。

存在的坑:循环引用问题

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

struct ListNode {
	std::shared_ptr<ListNode> _next;
	std::shared_ptr<ListNode> _prev;
	int _val;
	~ListNode()
	{
		cout << "~ListNode()" << endl;
	}
};


int main() {
    // 1. 创建 node1:引用计数 = 1(只有 main 里的 node1 指向它)
    std::shared_ptr<ListNode> node1(new ListNode);
    
    // 2. 创建 node2:引用计数 = 1(只有 main 里的 node2 指向它)
    std::shared_ptr<ListNode> node2(new ListNode);

    // 3. node1 的 _next 指向 node2:node2 的引用计数 +=1 → 变成 2
    node1->_next = node2;
    
    // 4. node2 的 _prev 指向 node1:node1 的引用计数 +=1 → 变成 2
    node2->_prev = node1;

    // 5. main 函数结束,开始析构局部变量:
    //    先析构 node2:node2 的引用计数 -=1 → 变成 1(因为 node1->_next 还指着它)
    //    再析构 node1:node1 的引用计数 -=1 → 变成 1(因为 node2->_prev 还指着它)
    
    // 结果:两个结点的引用计数都停留在 1,永远不会降到 0,因此不会调用析构函数!
    return 0;
}

核心死锁:

  • node1 要释放,得等 node2->_prev 先释放(减少计数)。

  • node2->_prev 要释放,得等 node2 先释放。

  • node2 要释放,得等 node1->_next 先释放。

  • node1->_next 要释放,得等 node1 先释放。

  • ...... 无限循环,谁也释放不了。

解决方案:用 std::weak_ptr 打破循环

std::weak_ptr(解决循环引用)

**设计原因:**shared_ptr 之间形成环形引用会导致内存永远不释放。

**底层原理:**weak_ptr 不增加引用计数,只是"观察"shared_ptr。当 shared_ptr 被销毁时,weak_ptr 会自动变成 expired(过期)。

以上述代码为例子:

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

struct ListNode {
    std::shared_ptr<ListNode> _next; // 正向用 shared_ptr(控制生命周期)
    std::weak_ptr<ListNode> _prev;   // 反向用 weak_ptr(不增加引用计数)
    int _val;
    ~ListNode() {
        cout << "~ListNode()" << endl;
    }
};

int main() {
    std::shared_ptr<ListNode> node1(new ListNode);
    std::shared_ptr<ListNode> node2(new ListNode);

    node1->_next = node2;       // node2 计数 +=1 → 2
    node2->_prev = node1;        // weak_ptr 不增加计数!node1 计数仍为 1

    // main 结束,析构局部变量:
    // 1. 先析构 node2:
    //    - node2 计数 -=1 → 1(node1->_next 还指着)
    // 2. 再析构 node1:
    //    - node1 计数 -=1 → 0,释放 node1
    //    - node1 的 _next 析构,node2 计数 -=1 → 0,释放 node2

    // 结果:两个 ~ListNode() 都会打印!
    return 0;
}

weak_ptr的使用方法:

cpp 复制代码
#include <iostream>
#include <memory>
using namespace std;

struct ListNode {
    int _val;
    shared_ptr<ListNode> _next;  // 正向用 shared_ptr(控制生命周期)
    weak_ptr<ListNode> _prev;    // 反向用 weak_ptr(不控制生命周期)
    ~ListNode() { cout << "~ListNode()" << endl; }
};

int main() {
    // 1. 创建两个结点,形成安全的双向链表(无循环引用)
    auto node1 = make_shared<ListNode>();
    auto node2 = make_shared<ListNode>();
    node1->_val = 10;
    node2->_val = 20;

    node1->_next = node2;       // node2 强引用计数 +=1
    node2->_prev = node1;        // weak_ptr 不增加计数

    // 2. 错误示范:weak_ptr 不能直接访问!
    // node2->_prev->_val; // 编译报错!weak_ptr 没有 operator-> 或 operator*

    // 3. 正确示范:用 lock() 提升为 shared_ptr 后再访问
    if (auto prev = node2->_prev.lock()) { // 尝试提升
        // 提升成功:prev 是 shared_ptr,强引用计数 +=1,对象安全
        cout << "前一个结点的值:" << prev->_val << endl; // 输出 10
    } else {
        // 提升失败:对象已死
        cout << "前一个结点已销毁" << endl;
    }

    // 4. 离开作用域:node1、node2 依次析构,无循环引用,正常释放
    return 0;
}



// 模拟 weak_ptr::lock() 的底层逻辑
template<typename T>
std::shared_ptr<T> weak_ptr<T>::lock() const {
    // 1. 原子地检查强引用计数是否 > 0
    if (control_block->shared_count > 0) {
        // 2. 如果 > 0:原子地增加强引用计数 +1
        control_block->shared_count++;
        // 3. 返回一个共享控制块的 shared_ptr
        return std::shared_ptr<T>(object_ptr, control_block);
    } else {
        // 4. 如果 == 0:对象已死,返回空的 shared_ptr
        return std::shared_ptr<T>();
    }
}

建议:expired()检查对象是否已过期,但不推荐用来判断后访问。

expired() 会返回 bool,表示对象是否已死(强引用计数是否为 0)。不要用 if (!wp.expired()) 后直接假设对象还活着!因为 expired() 和访问之间有时间差,对象可能在这期间被释放(竞态条件)。


C++14:std::make_unique / std::make_shared

C++11 只有 std::make_shared,却没有 std::make_unique,导致很多人继续使用 new + unique_ptr 的危险写法,容易在异常抛出时发生内存泄漏。C++14 补齐 make_unique,让独占所有权的智能指针使用体验完全一致。

为什么必须用 make_xxx 而不是 new?

  • 异常安全 :避免 newshared_ptr 构造之间的异常导致泄漏。

  • 性能更好make_shared 会将对象和控制块一次分配内存,减少内存碎片和分配开销。

cpp 复制代码
// 危险写法:可能异常泄漏
std::shared_ptr<MyClass> p(new MyClass()); 
// 有风险:new 和 unique_ptr 构造分离
void func() {
    MyClass* p = new MyClass(); // 1. 先 new
    do_something();              // 2. 如果这里抛异常,p 永远不会被 delete!
    unique_ptr<MyClass> up(p);   // 3. 即使到这里,也晚了
}


// 安全写法(推荐)
auto p = std::make_shared<MyClass>(args...); // 异常安全 + 一次分配,要么全成功,要么全失败
auto q = std::make_unique<MyClass>(args...); // C++14 起支持

为什么直接 new 危险?(异常泄漏)

C++ 中函数参数的求值顺序是未指定的。如果写 std::shared_ptr<MyClass> p(new MyClass()),编译器可能先执行 new MyClass(),再执行 shared_ptr 的构造函数 ------ 如果中间(比如其他参数求值时)抛异常,new 出来的对象还没被 shared_ptr 接管,就会永远泄漏。

为什么 make_shared/make_unique 安全?

  • 异常安全:内存分配和对象构造都在 make_xxx 函数内部完成,要么全成功,对象 + 控制块都分配好并交给智能指针;要么全失败,不会有半吊子的内存泄漏。就比如make_unique 内部使用完美转发 + 单次分配内存,避免了 new + unique_ptr 构造时可能出现的异常泄漏问题(new 成功但 unique_ptr 构造失败的场景)

  • 性能更好:make_shared 会把对象和控制块一次性分配(比分别 new 两次更快,内存碎片更少)。


常见坑与建议

坑:

  • make_unique 不支持自定义删除器 如果需要用自定义删除器(比如管理数组、文件句柄),不能用 make_unique,得手动写 unique_ptr + 删除器。

  • C++17 前shared_ptr 管理数组,需指定删除器,否则会调用错误的 delete。

    cpp 复制代码
    // C++17 前:需自定义删除器
    std::shared_ptr<int> sp(new int[10], [](int* p) { delete[] p; });
    // C++17 起:原生支持数组
    std::shared_ptr<int[]> sp(new int[10]);
  • shared_ptr的循环引用,务必用 weak_ptr 打破。

  • unique_ptr 不要乱 move,避免资源提前释放导致悬空指针,只在 "放弃所有权" 时用 move。

    cpp 复制代码
    #include <iostream>
    #include <memory>
    using namespace std;
    
    int main() {
        auto p1 = make_unique<int>(42); // p1 独占资源,值为 42
    
        // 把 p1 的所有权转移给 p2
        auto p2 = move(p1); 
    
        // 错误!乱 move 后还在用 p1
        // 此时 p1 已经是 nullptr 了,解引用空指针是未定义行为,程序会直接崩溃!
        cout << *p1 << endl; // error
        cout << *p2 << endl; // 输出 42,安全
    
        
        return 0;
    }

建议:

  • 默认用 unique_ptr:最快、最安全,无引用计数开销。

  • 需要共享时才用 shared_ptr:不要过度使用,避免不必要的性能损耗。

  • 永远用 make_unique / make_shared:异常安全 + 性能更好。

  • 返回智能指针时用 std::move:显式转移所有权,提高效率。


QA

unique_ptrshared_ptr 区别?什么时候用哪个?

unique_ptr 独占所有权,零开销,无引用计数;shared_ptr 共享所有权,有控制块和原子计数。默认用 unique_ptr,需要多个所有者共享资源时再换 shared_ptr。

make_shared 比直接 new shared_ptr 好在哪?

一是异常安全,避免 new 和构造函数之间的异常导致内存泄漏;二是性能更高,对象和控制块一次分配内存,减少分配次数和内存碎片。

weak_ptr 解决什么问题?怎么用 lock()

解决 shared_ptr 的循环引用问题。lock() 尝试将 weak_ptr 提升为 shared_ptr,如果对象还活着则返回有效指针,否则返回空。

智能指针的底层开销是多少?

unique_ptr 大小等于裸指针(零开销);shared_ptr 多一个控制块指针,且引用计数增减有原子操作开销。

相关推荐
ruxingli2 小时前
GoLang channel管道
开发语言·后端·golang
汉克老师2 小时前
GESP5级C++考试语法知识(十二、递归算法(二))
c++·算法·记忆化搜索·时间复杂度·递归算法·gesp5级·gesp五级
Risehuxyc2 小时前
PHP 的缓存机制
开发语言·缓存·php
旺仔.2912 小时前
顺序容器:Array 数组 详解
c++
sinat_255487812 小时前
JSON·学习笔记
java·开发语言·笔记·算法
Yupureki2 小时前
《算法竞赛从入门到国奖》算法基础:动态规划-基础线性dp
c语言·开发语言·算法·动态规划
_DCG_2 小时前
go第一个工程安装过程与问题汇总
开发语言·后端·golang
lsx2024062 小时前
Bootstrap 附加导航
开发语言
qq_392807952 小时前
Qt 注册 C++ 给 QML 调用的几种方式
数据库·c++·qt