智能指针是一个类,包装了裸指针,重载了->和\*操作符,让你用得跟指针一样,但析构的时候自动释放资源。

大家好,我是蟹老板~

问大家一个问题:

“C++ 最容易写出 Bug 的地方是什么?”

其实我心里的答案一直没变:内存管理。

以前我天真以为,只要自己写代码足够细心,每次new都配对delete,就能彻底解决问题。可现实开发中函数中途返回、异常抛出、多线程嵌套逻辑,哪怕你再严谨,总有疏漏的角落。人工手动管内存,根本靠不住啊。

那有没有一种机制,能让内存资源自己管理自己?申请之后不用手动释放,生命周期结束自动回收?

当然有,这就是C++核心编程思想——RAII。

而智能指针,就是RAII思想最经典、最常用的落地实现。

一、智能指针到底是什么?

本质就一句话:智能指针是一个类,包装了裸指针,重载了->和\*操作符,让你用得跟指针一样,但析构的时候自动释放资源。

C++11 之后,标准库提供了三种核心智能指针:unique_ptr、shared_ptr 和 weak_ptr。它们解决的核心问题就一个——自动管理动态内存的生命周期,让你忘掉delete这回事。

当然,每个都有自己的脾气和适用场景。后面咱们一个一个拆。

简单过一下标准演进吧,有个印象就行:

  • C++98:引入了 auto_ptr,但设计有缺陷。
  • C++11:重头戏。unique_ptr、shared_ptr、weak_ptr 正式登场,auto_ptr 被标记为废弃。
  • C++14:补了 make_unique。
  • C++17:auto_ptr 正式移除。
  • C++20/23:各种小修小补,后面会提到。

二、unique_ptr:独占所有权的智能指针

在三个主流智能指针里,unique_ptr 是我最喜欢推荐新手先掌握的智能指针。

为什么?因为它零开销、最安全、语义最清晰。只要不需要多指针共享资源。

1. unique_ptr 的核心语义与特点

unique_ptr的核心语义,就两个字:独占。

它所管理的堆资源,同一时刻,只能被这一个unique_ptr持有。没有第二个指针可以共享这份资源。

这种设计带来了两个极致的优点。第一,没有引用计数开销,性能和原生裸指针完全一致,是真正的零开销抽象。第二,所有权唯一,不会出现共享冲突、循环引用的问题,安全性拉满。

简单说,谁拿到这个unique_ptr,谁就是资源的唯一主人。指针销毁,资源立刻释放,没有任何扯皮空间。

2. unique_ptr 的基本用法

用起来特别简单:

#include <iostream>
#include <memory>
using namespace std;

struct Student {
    string name;
    int age;
    Student(string n, int a) : name(n), age(a) {}
    ~Student() {
        cout << "Student 对象析构:" << name << endl;
    }
};

int main() {
    // 推荐写法:C++14及以上使用make_unique
    unique_ptr<Student> stu = make_unique<Student>("张三", 18);
    
    // 像裸指针一样正常使用
    cout << stu->name << " " << stu->age << endl;
    
    // 作用域结束,自动析构释放内存
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.

运行代码就能看到,程序运行结束后,对象会自动调用析构函数,完全不用手动delete。这就是RAII的魅力,省心又安全。

可能有人会问,能不能用new直接初始化?

当然可以,但不推荐。后面我会详细讲make_unique和直接new的差距,差别真的挺大的。

3. 为什么 unique_ptr 禁止拷贝?

这是unique_ptr最核心的特性,也是很多人疑惑的点。为什么它不能拷贝?

我们换位思考一下。如果unique_ptr支持拷贝,那一个资源就会被两个unique_ptr同时管理。

第一个指针出作用域,释放资源。第二个指针再出作用域,会再次释放同一块资源,直接触发双重释放崩溃。

为了守住独占所有权的核心语义,标准库直接删掉了unique_ptr的拷贝构造、拷贝赋值函数。从语法层面杜绝拷贝操作,从根源上避免bug。

你试着写一行拷贝代码,编译器会直接报错,根本不让你编译通过,这就是编译期安全保障。

unique_ptr<Student> stu1 = make_unique<Student>("李四", 20);
unique_ptr<Student> stu2 = stu1; // 编译直接报错!禁止拷贝
  • 1.
  • 2.

4. unique_ptr 的移动语义与所有权转移

禁止拷贝,不代表不能传递资源。unique_ptr支持移动语义,这是C++11移动语法的经典应用。

拷贝是复制一份资源,两个指针共存。移动是转移所有权,原指针放弃控制权,新指针接管资源。整个过程,始终只有一个指针管理资源,完全符合独占语义。

来看代码:

int main() {
    unique_ptr<Student> stu1 = make_unique<Student>("王五", 19);
    // 所有权转移,stu1 变为空指针,stu2 接管资源
    unique_ptr<Student> stu2 = move(stu1);
    
    if (!stu1) {
        cout << "stu1 已空,失去所有权" << endl;
    }
    cout << stu2->name << endl;
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

这个特性在函数传参、函数返回值场景里用得特别多。比如函数返回一个unique_ptr,本质就是把资源所有权转移给调用方,安全又高效。

5. 进阶特性:数组特化与自定义删除器

很多人只知道unique_ptr管理单个对象,不知道它还能管理数组,真的有点可惜。

unique_ptr针对数组类型做了模板特化,支持动态数组的自动释放,不用手动delete[]。

// 自动匹配数组删除器 delete[]
unique_ptr<int[]> arr = make_unique<int[]>(10);
arr[0] = 100;
arr[1] = 200;
// 作用域结束自动释放数组内存
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

除了数组特化,unique_ptr还支持自定义删除器,这是工程开发里的高频用法。

不是所有资源都是堆内存。文件句柄、socket套接字、硬件资源,都需要专属的释放函数。默认的delete肯定不适用,这时候就需要自定义删除器。

我举个文件句柄的实操例子:

// 自定义文件删除器
void fileClose(FILE* f) {
    if (f) fclose(f);
    cout << "文件句柄已关闭" << endl;
}

int main() {
    // 绑定自定义删除器
    unique_ptr<FILE, decltype(&fileClose)> file(fopen("test.txt", "w"), fileClose);
    // 无需手动fclose,作用域结束自动关闭文件
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

这种写法能完美托管所有需要手动释放的资源,RAII机制通吃,是不是很绝。

6. 与 C 接口兼容:get /release/reset 的边界

C++项目里经常要调用C语言接口,C接口只认裸指针,不认智能指针。这时候就需要用到三个核心接口:get、release、reset。

  • get():只借不送。返回内部裸指针,但是不转移所有权,智能指针依然持有资源,出作用域依旧会释放。绝对不能手动deleteget出来的指针,否则必崩。
  • release():彻底放权。返回裸指针,同时将智能指针置空,放弃资源所有权。后续必须手动释放资源,否则内存泄漏。
  • reset():强制换资源。释放当前持有资源,重新绑定新的裸指针,不传参数则直接清空资源。

给大家上一段对比代码,看一眼就能知道其区别了:

int main() {
    unique_ptr<int> p = make_unique<int>(100);
    
    // get:获取裸指针,所有权不变
    int* raw1 = p.get();
    cout << *raw1 << endl;
    
    // release:释放所有权
    int* raw2 = p.release();
    if (!p) cout << "p 已释放所有权" << endl;
    delete raw2; // 必须手动释放
    
    // reset:重置资源
    p.reset(new int(200));
    cout << *p << endl;
    
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

7. unique_ptr 的典型使用场景

它非常适合表达对象成员的独占资源。

class Server {
public:
    Server() : config_(std::make_unique<Config>()) {}

private:
    std::unique_ptr<Config> config_;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

适合工厂函数返回对象。

std::unique_ptr<Connection> create_connection() {
    return std::make_unique<TcpConnection>();
}
  • 1.
  • 2.
  • 3.

适合多态对象。

std::unique_ptr<Shape> shape = std::make_unique<Circle>();
shape->draw();
  • 1.
  • 2.

适合 STL 容器保存不可拷贝对象。

std::vector<std::unique_ptr<Task>> tasks;
tasks.push_back(std::make_unique<Task>());
  • 1.
  • 2.

注意这里要移动。

auto task = std::make_unique<Task>();
tasks.push_back(std::move(task));
  • 1.
  • 2.

移动后 task 就空了。别再访问它。

8. unique_ptr 高频踩坑与避坑

哪怕是这么安全的unique_ptr,使用过程仍旧有坑:

(1) 坑一:移动后继续使用。

auto ptr = std::make_unique<int>(42);
auto ptr2 = std::move(ptr);
*ptr = 100;  // 崩!ptr 已经是空的了
  • 1.
  • 2.
  • 3.

移动后的 unique_ptr 处于“有效但未指定”的状态,大部分实现里就是 nullptr。别碰它。

(2) 坑二:用裸指针初始化多个unique_ptr****。

int* p = new int(42);
std::unique_ptr<int> ptr1(p);
std::unique_ptr<int> ptr2(p);  // 双重释放!
  • 1.
  • 2.
  • 3.

千万别这么干。每个资源只能被一个 unique_ptr 管理。

(3) 坑三:管理数组时用了默认删除器。

std::unique_ptr<int> ptr(new int[10]);  // 错误!会调用 delete 而不是 delete[]
  • 1.

数组要用 std::make_unique<int[]>(10) 或者显式指定 std::default_delete<int[]>。

三、shared_ptr:共享所有权的智能指针

shared_ptr 是项目里最容易被滥用的智能指针。

很多人一看 unique_ptr 不能拷贝,就说烦,直接全部 shared_ptr。这样写当然省事,然后半年后你会得到一个对象生命周期巨大迷宫。

1. shared_ptr 的核心语义与特点

shared_ptr的核心语义是共享所有权。

多个shared_ptr可以同时管理同一个堆资源,系统会维护一个引用计数,记录当前有多少个指针持有这份资源。

每新增一个指针,引用计数加一。每销毁一个指针,引用计数减一。当计数归零的瞬间,资源自动释放。

支持拷贝、支持共享、生命周期动态管理。代价就是需要维护控制块和引用计数,有轻微的性能开销。

2. shared_ptr 的基本用法

基础用法很简单,依旧推荐用make_shared初始化,安全性更高。

int main() {
    // 初始化共享指针
    shared_ptr<Student> s1 = make_shared<Student>("小明", 17);
    cout << "当前引用计数:" << s1.use_count() << endl;
    
    // 支持拷贝,引用计数+1
    shared_ptr<Student> s2 = s1;
    cout << "当前引用计数:" << s1.use_count() << endl;
    
    // s2 置空,引用计数-1
    s2.reset();
    cout << "当前引用计数:" << s1.use_count() << endl;
    
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.

运行结果能清晰看到引用计数的变化,这就是共享管理的核心逻辑。多个指针共享资源,互不冲突。

3. 引用计数的基础工作机制

你可以想象 shared_ptr 内部有两个东西。 一个是指向对象的指针。 一个是指向控制块的指针。

控制块里保存引用计数、弱引用计数、删除器、分配器等信息。

大概像这样:

template <typename T>
class SharedPtr {
private:
    T* ptr_;
    ControlBlock* cb_;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

控制块长这样:

struct ControlBlock {
    std::atomic<long> strong_count;
    std::atomic<long> weak_count;
    Deleter deleter;
    Allocator allocator;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

实际标准库实现比这复杂得多,但思路差不多。

拷贝 shared_ptr 时:

std::shared_ptr<User> p2 = p1;
  • 1.

发生的是:

p2.ptr_ = p1.ptr_;
p2.cb_ = p1.cb_;
++cb_->strong_count;
  • 1.
  • 2.
  • 3.

析构时:

--cb_->strong_count;
if (strong_count == 0) {
    delete object;
}
if (strong_count == 0 && weak_count == 0) {
    delete control_block;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

所以 shared_ptr 拷贝不是零成本。

它要改引用计数,而且通常是原子操作。原子操作比普通整数加减贵。不是贵到不能用,但别把它当空气。

4. 控制块的概念与基础组成

控制块是shared_ptr最核心的底层结构,也是面试必考重点。

控制块是堆上的独立内存,和托管对象分开存储,主要包含四个核心部分: 第一,强引用计数。记录当前存活的shared_ptr数量,计数为0则释放对象。 第二,弱引用计数。记录当前存活的weak_ptr数量,配合弱引用机制使用。 第三,删除器指针。存储自定义删除器,负责资源释放逻辑。 第四,分配器。负责内存的申请和回收,适配不同的内存分配策略。

为什么控制块要独立存在?

因为哪怕托管对象已经释放了,weak_ptr还需要读取控制块的状态,判断对象是否存活。如果控制块和对象绑定,对象释放后weak_ptr就无法判断状态了。

5. 进阶特性:自定义删除器与分配器

和unique_ptr一样,shared_ptr也支持自定义删除器,但用法有一点区别。

unique_ptr的删除器是类型绑定,会影响模板参数。shared_ptr的删除器是运行时绑定,不影响模板类型,更加灵活。

这就意味着,不同删除器的shared_ptr,可以互相赋值、拷贝,类型完全兼容。

// 自定义删除器
void delStudent(Student* s) {
    delete s;
    cout << "自定义删除器释放对象" << endl;
}

int main() {
    // 绑定自定义删除器
    shared_ptr<Student> s(new Student("小红", 16), delStudent);
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

6. 深度对比:make_shared 与直接 new 的区别

这是面试超级高频的问题,也是工程开发必须懂的知识点。为什么强烈推荐make_shared,不推荐直接new初始化shared_ptr?

make_shared 的一个优势是少一次内存分配。

直接 new 通常是两次分配。

std::shared_ptr<User> p(new User());
  • 1.

一次分配 User 对象。一次分配控制块。

make_shared 通常可以把对象和控制块放在同一块内存里。

auto p = std::make_shared<User>();
  • 1.

这通常更快,局部性更好,也更异常安全。

异常安全这块,老 C++ 里有个经典坑。比如函数调用参数求值顺序相关的问题可能让裸 new 暴露风险。现代标准改善了不少,但工程建议没变:能用 make_shared 就用 make_shared。

不过 make_shared 不是永远完美。

因为对象和控制块放在一起,假如还有 weak_ptr 活着,即使强引用没了,对象析构了,控制块还得留着。那块合并分配的内存可能要等弱引用也没了才释放。

意思是,对象本体析构了,但内存块没立刻还给系统。如果对象很大,weak_ptr 又活得很久,这就有点难受。

还有,自定义删除器场景下你不能用普通 make_shared 传删除器。这种时候只能直接构造 shared_ptr。

std::shared_ptr<FILE> fp(
    std::fopen("a.txt", "r"),
    [](FILE* f) { if (f) std::fclose(f); }
);
  • 1.
  • 2.
  • 3.
  • 4.

所以工程里选择的话:普通对象,用 make_shared。对象巨大且弱引用可能长期存在,考虑直接new。需要自定义删除器,直接构造 shared_ptr。需要自定义分配器,看 allocate_shared。

7. enable_shared_from_this:从 this 获取 shared_ptr

这是类成员获取自身shared_ptr的核心工具。

当我们需要在类的成员函数里,获取当前对象的shared_ptr时。如果直接用this裸指针创建shared_ptr,会直接崩,为什么?

因为this是裸指针,新创建的shared_ptr会生成一个全新的控制块,和原本托管对象的shared_ptr不是一套体系。最终会导致双重释放。

正确的做法,是让类继承enable_shared_from_this,通过shared_from_this()获取自身共享指针。

class Teacher : public enable_shared_from_this<Teacher> {
public:
    shared_ptr<Teacher> getSelf() {
        // 正确获取自身shared_ptr
        return shared_from_this();
    }
    ~Teacher() {
        cout << "Teacher 析构" << endl;
    }
};

int main() {
    shared_ptr<Teacher> t1 = make_shared<Teacher>();
    shared_ptr<Teacher> t2 = t1->getSelf();
    cout << "引用计数:" << t1.use_count() << endl; // 计数为2
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

这里必须提醒一个致命坑:绝对不能在构造函数里调用shared_from_this()。

构造函数执行时,对象还没初始化完成,控制块没有创建,调用这个函数直接程序崩溃,没有任何例外。

8. shared_ptr 的典型使用场景

shared_ptr 适合对象确实需要被多个地方共享生命周期的场景。

比如异步任务。任务提交到线程池后,调用方可能已经返回,但任务对象要活到回调执行完。

class Session : public std::enable_shared_from_this<Session> {
public:
    void async_read() {
        auto self = shared_from_this();

        io_.async_read([self](const std::string& data) {
            self->on_read(data);
        });
    }

private:
    void on_read(const std::string& data) {
        // 处理数据
    }

    IO io_;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.

这里 lambda 捕获 self,保证回调执行期间 Session 还活着。

再比如缓存对象被多个模块引用。

再比如图结构中某些节点确实共享,但这时候要特别警惕循环引用。

shared_ptr 不适合什么场景? 对象只有一个明确所有者时,别用。 函数只是临时访问对象时,别用。 为了省事逃避生命周期设计时,别用。

9. shared_ptr 高频踩坑与避坑

同一裸指针构造多个 shared_ptr 是第一大坑。

Foo* raw = new Foo;

std::shared_ptr<Foo> a(raw);
std::shared_ptr<Foo> b(raw); // 双控制块
  • 1.
  • 2.
  • 3.
  • 4.

从 this 构造 shared_ptr 是第二大坑。

return std::shared_ptr<Foo>(this);
  • 1.

循环引用是第三大坑。

struct A {
    std::shared_ptr<B> b;
};

struct B {
    std::shared_ptr<A> a;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

A 引用 B,B 引用 A。外部引用都释放了,两个对象的引用计数还是互相撑着。析构函数不调用。内存泄露。

还有到处传 shared_ptr 导致引用计数乱跳。

void foo(std::shared_ptr<User> user) {
    // 这里会增加引用计数
}
  • 1.
  • 2.
  • 3.

如果函数不需要延长生命周期,传引用。

void foo(const User& user) {
}
  • 1.
  • 2.

或者传裸指针表示可空观察。

void foo(const User* user) {
}
  • 1.
  • 2.

use_count() 也别拿来写业务判断。

if (p.use_count() == 1) {
    // 你以为只有自己持有?
}
  • 1.
  • 2.
  • 3.

多线程下这个值随时变。用它调试可以,做业务决策很容易翻车。

四、weak_ptr:打破循环引用的弱引用观察者

weak_ptr 刚学时一定人思考过这个问题。

它既不能直接 *p,也不能 p->xxx,还不负责释放对象。那它有什么用?

答案是,它负责“观察”,不负责“拥有”。

weak_ptr 必须从 shared_ptr 来。

auto sp = std::make_shared<User>();
std::weak_ptr<User> wp = sp;
  • 1.
  • 2.

它不会增加强引用计数。对象会不会活着,不由 weak_ptr 决定。

想使用对象时,需要调用 lock()。

if (auto p = wp.lock()) {
    p->login();
} else {
    std::cout << "object expired\n";
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

lock() 如果成功,会返回一个 shared_ptr,临时延长对象生命周期。

如果对象已经销毁,返回空 shared_ptr。

1. 为什么需要 weak_ptr?

先看一个百分百内存泄漏的循环引用案例,看完你就懂weak_ptr的价值了。

class A {
public:
    shared_ptr<B> b_ptr;
    ~A() { cout << "A 析构" << endl; }
};

class B {
public:
    shared_ptr<A> a_ptr;
    ~B() { cout << "B 析构" << endl; }
};

int main() {
    shared_ptr<A> a = make_shared<A>();
    shared_ptr<B> b = make_shared<B>();
    // 互相引用
    a->b_ptr = b;
    b->a_ptr = a;
    // 作用域结束,对象无法析构,内存泄漏
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.

大家可以复制代码运行,会发现A和B的析构函数完全不会执行,内存直接泄露。

为什么会这样?我给大家拆解一下计数逻辑。

a对象初始引用计数是1,b对象初始引用计数是1。互相赋值后,a的计数变成2,b的计数变成2。

main函数结束,局部变量a、b析构,各自计数减1,变成1和1。

两个对象的引用计数都不为0,所以永远不会释放。互相锁住对方,形成死循环,这就是循环引用内存泄漏。

想要破解这个死局,就必须引入不增加引用计数的指针——weak_ptr。

2. weak_ptr 的核心语义与特点

weak_ptr是弱引用指针,核心特点就一个:持有资源但不占有资源。

它由shared_ptr构造而来,不会增加强引用计数,只会操作弱引用计数。

它不保证资源存活,只能观察资源状态。资源活着,它可以临时锁定资源使用;资源销毁了,它就变成空指针,不会产生任何崩溃。

简单说,shared_ptr是租客,占用资源。weak_ptr是路人,只看看不占用,不影响资源的生命周期。

3. 核心接口与基本用法

weak_ptr没有重载*和->运算符,不能直接访问对象,必须通过lock()方法临时获取shared_ptr才能使用。

核心接口就三个:lock()、expired()、use_count()。

int main() {
    shared_ptr<Student> s = make_shared<Student>("小刚", 18);
    weak_ptr<Student> w = s;
    
    // 判断资源是否过期
    if (!w.expired()) {
        // 临时锁定资源,获取shared_ptr
        shared_ptr<Student> temp = w.lock();
        cout << temp->name << endl;
    }
    
    // 释放原资源
    s.reset();
    if (w.expired()) {
        cout << "资源已销毁" << endl;
    }
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.

4. weak_ptr 解决循环引用的标准方案

我们把上面的循环引用案例改造一下,用weak_ptr破解,问题直接解决。

class A {
public:
    weak_ptr<B> b_ptr; // 改为弱引用
    ~A() { cout << "A 析构" << endl; }
};

class B {
public:
    weak_ptr<A> a_ptr; // 改为弱引用
    ~B() { cout << "B 析构" << endl; }
};

int main() {
    shared_ptr<A> a = make_shared<A>();
    shared_ptr<B> b = make_shared<B>();
    a->b_ptr = b;
    b->a_ptr = a;
    // 作用域结束,正常析构,无内存泄漏
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.

改造后,weak_ptr不会增加引用计数。双方赋值后,引用计数依旧是1。main函数结束,计数归零,对象正常析构,彻底解决循环引用问题。

5. weak_ptr 高频踩坑与避坑

weak_ptr的坑不多,但都很隐蔽。

  • 第一,直接使用过期的weak_ptr。不判断expired就lock,拿到空指针,解引用崩溃。使用前必须先判断资源是否存活。
  • 第二,用weak_ptr长期持有临时资源。weak_ptr只适合观察,不适合长期托管核心业务资源,业务资源必须用shared_ptr。
  • 第三,lock后不判空。多线程场景下,判断expired和lock之间,资源可能被销毁,lock会返回空指针,必须判空再使用。

五、auto_ptr 为什么被彻底废弃?

现在的新项目,基本看不到auto_ptr的影子了,但面试偶尔还会问到,老项目也可能遇到。

auto_ptr 是 C++ 早期的一次尝试。它也想解决自动释放问题。可惜生得太早,那个年代还没有移动语义,所以它为了模拟所有权转移,做了一个非常反直觉的设计。

拷贝会转移所有权。

std::auto_ptr<int> p1(new int(10));
std::auto_ptr<int> p2 = p1;

// p1 变空,p2 拥有对象
  • 1.
  • 2.
  • 3.
  • 4.

这在今天看简直离谱。拷贝这个词,大家直觉上会理解为复制一份。

结果 auto_ptr 拷贝完,原对象没了。

这就非常容易制造事故。

void print(std::auto_ptr<User> user) {
    user->print();
}

std::auto_ptr<User> u(new User());
print(u);

// u 已经空了
u->login(); // 崩
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

更致命的是 STL 容器。

容器要求元素类型的拷贝语义要正常。

auto_ptr 的拷贝会转移所有权,这和容器内部扩容、搬移、排序等操作天然冲突。

std::vector<std::auto_ptr<User>> users; // 老标准里各种风险
  • 1.

容器一调整元素,你对象可能就莫名其妙空了。

这种语义太危险,标准库后来干脆让 unique_ptr 接班。

unique_ptr 的设计更诚实。

不能拷贝就是不能拷贝。

要转移就显式 std::move。

std::unique_ptr<User> p1 = std::make_unique<User>();
std::unique_ptr<User> p2 = std::move(p1);
  • 1.
  • 2.

这行代码写出来,任何人都知道所有权转移了。

C++ 里很多“安全”不是运行时帮你擦屁股,而是让危险动作必须显式写出来。

时间线上,auto_ptr 在 C11 被弃用,C17 正式移除。现在还用它,除非你在维护古董项目,否则真的没必要。

六、智能指针底层原理深度拆解

只会用不懂底层,只能算入门。想要吃透智能指针,搞定面试、写出高质量代码,底层原理必须烂熟于心,面试官最喜欢问原理了。

1. 智能指针自动释放的本质:RAII 与运算符重载

智能指针的所有功能,归根结底就两个技术:RAII生命周期管理 + 指针运算符重载。

构造函数接收裸指针,托管资源。析构函数自动释放资源,这是RAII的核心。

重载*、->运算符,让类对象可以像普通指针一样解引用、访问成员,这是使用体验的核心。

所有智能指针,不管是unique、shared还是weak,底层都是这套逻辑,没有例外。

2. shared_ptr 控制块(Control Block)全揭秘

前面简单提过控制块,这里做一次全面拆解。

控制块是shared_ptr和weak_ptr能够工作的核心基石,独立于业务对象存在。

强引用计数:记录shared_ptr数量,为0则释放业务对象内存。 弱引用计数:记录weak_ptr数量,为0则释放控制块内存。

很多人不知道的细节:业务对象和控制块是分开销毁的。

强引用归0,立刻销毁业务对象。弱引用归0,才会销毁控制块。只要有一个weak_ptr存活,控制块就不会释放,这也是make_shared内存残留的根源。

3. 引用计数的线程安全边界

这是超级容易混淆的知识点,我用最直白的话讲清楚。

引用计数的增减操作是线程安全的,因为底层用了原子指令。多线程同时拷贝、析构shared_ptr,计数不会错乱。

但是!智能指针指向的对象读写是线程不安全的。

多个线程同时通过shared_ptr修改同一个对象数据,不加锁必然数据竞争。

还有,同一个**shared_ptr**对象的读写操作线程不安全。多线程同时修改同一个shared_ptr本身(赋值、reset),需要加锁。

4. 自定义删除器的实现原理与性能影响

unique_ptr的删除器是模板参数,编译期确定,零运行时开销,和原生delete性能一致。

shared_ptr的删除器是运行时绑定,通过虚函数调用,会有轻微的虚函数开销,但日常业务开发完全可以忽略。

这也是unique_ptr性能优于shared_ptr的核心原因之一。

5. make_unique /make_shared 的底层优势与代价

优势前面讲过:单次内存分配、内存紧凑、缓存友好、异常安全。

唯一代价:weak_ptr残留导致的内存延迟释放。

如果业务场景存在大量weak_ptr长期存活,且对象频繁创建销毁,建议不用make_shared,改用new手动构造,避免内存堆积。普通场景无脑用make系列即可。

6. unique_ptr 零开销抽象的真相

很多人不信unique_ptr零开销,觉得封装后肯定有损耗。

真相是:编译期所有封装逻辑都会被优化掉。

unique_ptr的内部方法、运算符重载,都是inline内联函数,编译器优化后,最终生成的汇编代码和裸指针完全一致。没有任何额外开销,是真正意义上的零成本抽象。

七、智能指针与工程接口设计

会用只是基础,智能指针真正拉开水平差距的地方,不是会不会写 make_unique。是能用智能指针设计出优雅、安全、高性能的工程接口。一个函数参数写成什么类型,基本就在告诉调用方:我和这个对象是什么关系。

1. 函数参数的选型规范

如果函数只读取对象,不接管生命周期,传引用。

void print_user(const User& user);
  • 1.

如果对象可能为空,传裸指针。

void print_user(const User* user);
  • 1.

这里裸指针表达“观察,不拥有”。这很合理。

如果函数要接管独占所有权,传 unique_ptr 值。

void set_owner(std::unique_ptr<User> user);
  • 1.

调用方必须显式移动。

set_owner(std::move(user));
  • 1.

如果函数需要共享所有权,传 shared_ptr 值。

void register_session(std::shared_ptr<Session> session);
  • 1.

值传递会增加引用计数,表示函数内部可能保存一份。

如果函数只想访问,但调用方已经有 shared_ptr,不要为了省事传 shared_ptr。

void render(const Image& image); // 更好
  • 1.

不要写成:

void render(std::shared_ptr<Image> image); // 没必要增加引用计数
  • 1.

除非你真的要延长生命周期。

2. 函数返回值的选型规范

返回新创建对象且独占所有权,用 unique_ptr。

std::unique_ptr<Parser> create_parser();
  • 1.

返回共享对象,用 shared_ptr。

std::shared_ptr<Config> get_global_config();
  • 1.

返回非拥有对象,可以返回引用或裸指针。

User& current_user();
User* find_user(int id);
  • 1.
  • 2.

引用通常表示一定存在。 裸指针可以表达可能找不到。

User* find_user(int id) {
    if (auto it = users_.find(id); it != users_.end()) {
        return it->second.get();
    }
    return nullptr;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

别返回局部对象地址。这个不用智能指针也知道,但我还是写一下,因为真的有人这么干过。

User* bad() {
    User user;
    return &user; // 局部对象离开函数就没了
}
  • 1.
  • 2.
  • 3.
  • 4.

3. 智能指针与多态:类型转换函数

unique_ptr<Derived> 可以移动转换成 unique_ptr<Base>,前提是删除行为正确,基类析构函数通常要是虚函数。

class Base {
public:
    virtual ~Base() = default;
    virtual void run() = 0;
};

class Derived : public Base {
public:
    void run() override {}
};

std::unique_ptr<Base> p = std::make_unique<Derived>();
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

shared_ptr 有专门的转换函数。

std::shared_ptr<Base> base = std::make_shared<Derived>();

auto derived = std::dynamic_pointer_cast<Derived>(base);
if (derived) {
    derived->run();
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.

常见几个:

std::static_pointer_cast<T>(p);
std::dynamic_pointer_cast<T>(p);
std::const_pointer_cast<T>(p);
std::reinterpret_pointer_cast<T>(p); // C++17
  • 1.
  • 2.
  • 3.
  • 4.

少用 reinterpret_pointer_cast。它不是不能用,是一用就要有很强的理由。没有理由就是事故预备役。

unique_ptr 没有对应的标准 dynamic_unique_ptr_cast。因为失败时所有权怎么处理比较麻烦。你可以自己写,但要非常小心。

4. 智能指针与 STL 容器

容器存储动态对象,优先存储unique_ptr。避免对象拷贝,内存自动管理,性能最优。

需要多线程共享容器元素,再用shared_ptr。绝对不要存储裸指针,内存泄漏、悬空问题无法管控。

5. 接口设计核心原则

我自己的习惯是这样。 能用值就别用指针。 需要可空再用指针。 需要独占所有权就用 unique_ptr。 需要共享生命周期才用 shared_ptr。 只是观察共享对象就用 weak_ptr。

不要为了“现代 C++”把所有东西都智能指针化。

八、智能指针的线程安全问题

shared_ptr 的控制块引用计数是线程安全的,但被管理对象不是自动线程安全的,同一个 shared_ptr 变量的并发读写也不是随便安全的。

这句话有点绕,我们拆开。

1. 三类操作的边界要分清

(1) 第一类,不同线程操作不同的 shared_ptr 对象,但它们共享同一个控制块。

auto p = std::make_shared<User>();

std::thread t1([p] {
    // p 是拷贝进来的
});

std::thread t2([p] {
    // p 也是拷贝进来的
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

这种引用计数增减是安全的。

(2) 第二类,不同线程通过 shared_ptr 访问同一个被管理对象。

auto p = std::make_shared<std::vector<int>>();

std::thread t1([p] {
    p->push_back(1);
});

std::thread t2([p] {
    p->push_back(2);
});
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

这不安全。因为 vector 不是这样并发写的。你需要锁。

struct SafeVec {
    std::mutex m;
    std::vector<int> v;

    void push(int x) {
        std::lock_guard<std::mutex> lock(m);
        v.push_back(x);
    }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

(3) 第三类,多个线程读写同一个 shared_ptr 变量。

std::shared_ptr<User> g_user;

void update() {
    g_user = std::make_shared<User>();
}

void use() {
    if (g_user) {
        g_user->login();
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

这个变量本身有并发访问风险。需要锁,或者使用原子方式管理。

C++20 提供了 std::atomic<std::shared_ptr<T>>。

std::atomic<std::shared_ptr<User>> g_user;

void update() {
    g_user.store(std::make_shared<User>());
}

void use() {
    auto local = g_user.load();
    if (local) {
        local->login();
    }
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.

这里 load() 得到一个本地 shared_ptr,保证使用期间对象不会被释放。 对象内部状态仍然要自己同步。

2. 跨线程传递生命周期怎么保障

异步编程里最常见的是回调捕获 shared_ptr。

class Session : public std::enable_shared_from_this<Session> {
public:
    void start() {
        auto self = shared_from_this();

        worker_.post([self] {
            self->do_work();
        });
    }

private:
    void do_work() {}
    Worker worker_;
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.

lambda 捕获 self,任务没执行完,Session 不会释放。

但这里也可能引入生命周期延长过久的问题。 如果任务队列堵了,Session 会一直活着。 如果 Session 里又持有 worker 或其他资源,可能形成复杂引用链。

另一个写法是捕获 weak_ptr,执行时再 lock。

void start() {
    std::weak_ptr<Session> weak_self = shared_from_this();

    worker_.post([weak_self] {
        if (auto self = weak_self.lock()) {
            self->do_work();
        }
    });
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.

这表示任务不强行延长 Session 生命周期。对象还活着就执行,没了就算了。

该捕获 shared_ptr 还是 weak_ptr? 看业务语义。 任务必须完成,捕获 shared_ptr。 对象销毁后任务可以丢弃,捕获 weak_ptr。

这个判断不能让框架替你做。它只和业务正确性有关。

九、高频错误案例与避坑

这一章是我十年踩坑总结的干货,每一个都是线上真实出现过的bug。

1. 同一裸指针初始化多个智能指针 → 双重释放

这绝对是新手入门最容易犯的低级错误,没有之一。很多人觉得裸指针可以随便赋值给智能指针,反正能自动释放,殊不知直接踩进双重释放的大坑。

我刚学智能指针的时候,写过一段超级离谱的代码,测试环境偶尔崩、线上必崩,排查了好久才明白问题所在。

int main() {
    // 原生裸指针申请内存
    int* raw_ptr = new int(999);

    // 用同一个裸指针初始化两个独立shared_ptr
    shared_ptr<int> p1(raw_ptr);
    shared_ptr<int> p2(raw_ptr);

    // 两个智能指针拥有各自的控制块,互不感知
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.

大家能看出问题在哪吗?p1和p2是两个完全独立的shared_ptr,各自创建了一套独立的控制块,但是托管的是同一块堆内存。

程序结束时,p1先析构,根据自己的控制块计数归零,释放raw_ptr指向的内存。紧接着p2析构,同样判定计数归零,再次释放已经销毁的内存。双重释放直接触发程序崩溃,毫无例外。

很多人疑惑,用make_shared为什么不会有这个问题?因为make_shared直接创建智能指针,不会暴露裸指针,从根源杜绝了二次绑定的可能。

避坑铁律:绝对不要把同一个裸指针,手动初始化多个智能指针。裸指针最多绑定一次智能指针,后续共享全部依靠智能指针拷贝,绝不复用裸指针。

2. shared_ptr 循环引用 → 对象无法析构、内存泄漏

前面讲weak_ptr的时候简单演示过循环引用问题,这里结合工程场景深度拆解,这是中长期运行服务内存泄漏的头号元凶。

简单的双向类引用只是基础场景,实际项目里的循环引用更隐蔽。比如回调函数持有宿主对象shared_ptr、容器互相嵌套引用、状态类双向绑定,都会悄悄形成循环引用。

而且这种bug极难排查,程序不会当场崩溃,只会随着运行时间增加,内存占用持续走高,最终OOM宕机。日志还不会报任何错,新手根本无从下手。

我之前维护一个老后台服务,就是因为两个业务类互相持有shared_ptr,线上跑一周内存翻十倍,最后一步步梳理对象依赖才找到根源。

避坑铁律:双向依赖场景,必须一端用shared_ptr,一端用weak_ptr。观察者、回调监听、双向绑定场景,统一用weak_ptr做弱引用观察,不占用资源所有权。

3. 构造函数中调用 shared_from_this → 未初始化控制块导致崩溃

这个坑看似基础,但我面试十个开发者,八个都会答错,实际开发中翻车的更是数不胜数。

很多人想在构造函数中注册回调、传递自身对象,顺手调用shared_from_this(),结果程序一启动直接闪退。

原因真的很简单,但很多人记不住:shared_from_this() 能生效的前提是,当前对象已经被一个合法的shared_ptr托管,控制块完全初始化完成。

而类的构造函数执行阶段,对象还在半初始化状态,外部的shared_ptr控制块还没构建完毕,此时调用该函数,相当于访问空的控制块,直接触发断言崩溃。

class Test : public enable_shared_from_this<Test> {
public:
    Test() {
        // 致命错误!构造函数禁止调用
        shared_from_this(); 
    }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.

那如果我必须在初始化时传递自身指针怎么办?

最优方案是提供一个init初始化函数,构造完成后,由外部调用init,再在init内部获取shared_from_this,安全又稳妥。

4. 手动 delete get() 返回的裸指针 → 二次释放与悬空指针

这是新手最顽固的错误,没有之一。很多人分不清智能指针的借用和放权。

get() 仅仅是借出裸指针使用权,所有权依旧牢牢握在智能指针手里。作用域结束后,智能指针依然会执行析构释放内存。如果我们手动delete这个裸指针,必然二次释放崩溃。

除了直接delete,还有一个隐蔽坑:把get()得到的裸指针交给其他第三方接口去释放,本质也是手动释放,一样会炸。

我之前对接C开源库,图省事把智能指针get出来的指针传给库的释放接口,测试全程没问题,上线直接崩了三台服务器,印象太深了。

避坑铁律:get()获取的裸指针,只读不改、只用不删。需要移交释放权限,必须用release(),绝对不要手动干预get()返回的指针生命周期。

5. 所有权转移后继续访问原智能指针 → 空指针解引用

unique_ptr移动语义是高频考点,也是高频翻车点。很多人知道move可以转移所有权,但不知道move之后原指针彻底失效。

move操作不是拷贝,是剥夺所有权。原unique_ptr会被置为空智能指针,内部裸指针变成nullptr。后续任何解引用、调用成员方法的操作,都是空指针访问,直接崩溃。

int main() {
    unique_ptr<string> p = make_unique<string>("技术博主干货");
    unique_ptr<string> q = move(p);

    // 错误!p已经是空指针
    cout << *p << endl; 
    return 0;
}
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.

而且这个错误编译器不会报错,属于运行时崩溃,偶现概率很高,调试成本巨大。

避坑铁律:对unique_ptr执行move之后,立刻放弃使用原指针。如果不确定指针是否为空,使用前一定要做判空校验。

6. 跨模块(DLL/So)传递智能指针 → 堆内存不统一导致崩溃

这个坑属于工程进阶大坑,新手基本遇不到,但做客户端、跨模块开发的程序员,大概率都踩过。

Windows的DLL、Linux的So动态库,都有独立的堆内存管理器。主程序和动态库的堆是两套体系,互不通用。

如果在主程序new对象、用智能指针托管,再传递给动态库;或者动态库创建的智能指针传回主程序,最终析构时会出现跨堆释放的问题。

简单说,A堆申请的内存,用B堆的释放逻辑回收,直接内存错乱、程序闪退,而且这个崩溃是延迟触发的,极难定位。

避坑铁律:跨模块传递,一律传裸指针,不要传递智能指针。对象的创建和销毁,必须在同一个模块内完成,杜绝跨堆内存操作。

7. 类成员智能指针的拷贝 / 赋值语义设计错误

很多人定义类成员智能指针后,忽略了类的默认拷贝构造、赋值构造函数带来的问题。

如果类包含unique_ptr成员,unique_ptr禁止拷贝,编译器默认生成的拷贝函数会编译报错,很多新手不知道原因,瞎改代码。

如果类包含shared_ptr成员,默认拷贝会自动递增引用计数,看似没问题,但很容易造成隐性的生命周期延长,导致内存常驻泄漏。

我见过很多业务类,就是因为默认拷贝shared_ptr,导致核心资源永远无法释放,服务内存只增不减。

避坑铁律:包含unique_ptr成员的类,手动删除拷贝构造、赋值函数,或者自定义移动语义。包含shared_ptr成员的类,禁止无脑默认拷贝,按需控制对象复制逻辑。

8. 构造函数异常导致的半构造对象资源泄漏

这是一个超级隐蔽、99%的人都不知道的冷门坑。

如果我们在类构造函数中手动new资源,中途抛出异常,类对象构造失败,析构函数不会执行。手动new的内存直接泄漏。

但如果用智能指针托管成员资源,哪怕构造函数异常,栈上的智能指针依然会正常析构,资源自动回收,完美规避泄漏。

这也是智能指针的异常安全特性,人工手动内存管理永远做不到这一点。

9. 裸指针与智能指针混合管理同一资源 → 所有权混乱

工程开发中最忌讳的就是双重托管。

一块堆内存,既用裸指针操作,又用智能指针托管,所有权完全混乱。你不知道谁该释放、谁不该释放,最终结局无非两种:要么重复释放崩溃,要么无人释放泄漏。

很多老项目重构都会遇到这个问题,旧代码用裸指针,新代码用智能指针,混用之后bug层出不穷,重构成本直接拉满。

避坑铁律:一块资源,只允许一种管理方式。要么全程裸指针手动管控,要么全程智能指针自动管控,坚决不混用。

10. 用 unique_ptr 管理数组误用默认删除器

之前简单提过这个问题,这里重点强调,这是典型的语法正确、逻辑错误的隐蔽bug。

普通unique_ptr<T>的默认删除器是 delete,而数组内存必须用 delete[] 释放。

如果用unique_ptr<int>管理int数组,内存释放不完整,会出现内存错乱、内存泄漏、偶现崩溃等玄学问题。

// 错误写法!默认删除器是 delete,不是 delete[]
unique_ptr<int> wrong_arr(new int[100]);

// 正确写法!数组特化,自动匹配 delete[]
unique_ptr<int[]> right_arr(new int[100]);
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.

避坑铁律:管理数组资源,必须强制使用 T[] 特化版本的unique_ptr,绝不混用普通版本。

十、智能指针选型指南

讲完所有原理和坑,给大家整理一套可以直接落地的选型标准和编码规范,以后写代码不用纠结,直接对照着用就行。

10. 选型决策树:什么时候用哪一种智能指针

我总结了一句极简选型口诀,记下来终身受用:独占优先 unique,共享只用 shared,解环观察用 weak。

场景细化如下:

  • 资源唯一归属、无需共享、追求极致性能:无脑用 unique_ptr。零开销、最安全,是绝大多数场景的最优解。
  • 多模块共享资源、生命周期不确定、异步回调持有:用 shared_ptr。接受轻微引用计数开销,换取资源生命周期自动管控。
  • 仅观察资源状态、不持有所有权、破解循环引用:用 weak_ptr。绝对不单独使用,只配合shared_ptr工作。

任何场景,坚决不用 auto_ptr。彻底淘汰,没有例外。

2. 通用编码规范

  • 第一,优先使用 make_unique / make_shared 初始化,拒绝裸指针直接初始化。更安全、性能更好、代码更简洁。
  • 第二,能独占绝不共享。不要滥用shared_ptr,无意义的共享会增加计数开销、提升代码复杂度、引入更多线程安全坑点。
  • 第三,函数传参优先传引用、裸指针,尽量少传智能指针。只有需要延长资源生命周期时,才传递shared_ptr副本。
  • 第四,循环依赖场景,强制弱引用打断。双向依赖必有一端weak_ptr,杜绝内存泄漏。
  • 第五,禁止手动delete智能指针取出的裸指针,杜绝二次释放。
  • 第六,move转移所有权后,禁止复用原unique_ptr。

3. 性能开销全景对比

很多人不知道三者的性能差距,我直白说清楚:

  • unique_ptr:零开销。编译期优化后和裸指针性能完全一致,无任何运行时损耗。
  • shared_ptr:轻微开销。内存多分配控制块、引用计数原子操作、虚函数删除器调用。单线程感知不到,高频创建销毁场景会有性能损耗。
  • weak_ptr:极小开销。lock加解锁、状态判断,开销可以忽略不计。

所以高频循环、高性能服务、实时性要求高的场景,尽量少用shared_ptr,优先unique_ptr。

4. 异常安全保证

智能指针是 C++ 异常安全编程的核心利器,这一点是手动内存管理永远做不到的。

函数执行抛出异常,栈上所有局部对象都会自动析构。智能指针作为栈对象,会自动释放托管堆资源,全程无泄漏。

手动new/delete的代码,一旦中途抛异常,delete代码直接跳过,必然内存泄漏。

可以这么说:想要写出异常安全的 C++ 代码,智能指针是必备基础。

十一、动手实现:从零手写智能指针

看懂原理不算真的懂,能手写出来,才算彻底吃透。面试也经常让手写简易智能指针,我带大家从零实现一套极简版,剥离所有标准库复杂逻辑,只保留核心原理。

1. 简易版 unique_ptr 实现

unique_ptr核心就三点:封装裸指针、禁止拷贝、支持移动、析构自动释放。

template<typename T>
class MyUniquePtr {
private:
    // 封装原生裸指针
    T* _ptr;
public:
    // 构造函数:接管资源
    explicit MyUniquePtr(T* p = nullptr) : _ptr(p) {}

    // 析构函数:自动释放资源
    ~MyUniquePtr() {
        delete _ptr;
        _ptr = nullptr;
    }

    // 禁止拷贝构造
    MyUniquePtr(const MyUniquePtr&) = delete;
    // 禁止拷贝赋值
    MyUniquePtr& operator=(const MyUniquePtr&) = delete;

    // 允许移动构造
    MyUniquePtr(MyUniquePtr&& other) noexcept {
        // 接管对方资源
        _ptr = other._ptr;
        // 对方置空,放弃所有权
        other._ptr = nullptr;
    }

    // 允许移动赋值
    MyUniquePtr& operator=(MyUniquePtr&& other) noexcept {
        if (this != &other) {
            // 释放自身原有资源
            delete _ptr;
            // 接管新资源
            _ptr = other._ptr;
            other._ptr = nullptr;
        }
        return *this;
    }

    // 重载解引用
    T& operator*() const { return *_ptr; }
    // 重载箭头
    T* operator->() const { return _ptr; }
    // 判断是否为空
    explicit operator bool() const{ return _ptr != nullptr; }
    // 释放所有权
    T* release(){
        T* temp = _ptr;
        _ptr = nullptr;
        return temp;
    }
    // 重置资源
    void reset(T* p = nullptr){
        delete _ptr;
        _ptr = p;
    }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.

这份极简代码,完整复刻了unique_ptr的核心逻辑。禁止拷贝、支持移动、自动析构、reset/release 接口,和标准库设计思想完全一致。

2. 带引用计数的 shared_ptr 实现

shared_ptr核心是引用计数和控制块,我们手写极简控制块,实现计数增减、自动释放。

// 极简控制块
template<typename T>
struct ControlBlock {
    T* ptr;
    int ref_count; // 强引用计数

    ControlBlock(T* p) : ptr(p), ref_count(1) {}
};

template<typename T>
class MySharedPtr {
private:
    ControlBlock<T>* _cb;
public:
    // 构造函数
    explicit MySharedPtr(T* p = nullptr){
        _cb = p ? new ControlBlock<T>(p) : nullptr;
    }

    // 拷贝构造:计数+1
    MySharedPtr(const MySharedPtr& other) {
        _cb = other._cb;
        if (_cb) {
            _cb->ref_count++;
        }
    }

    // 赋值重载
    MySharedPtr& operator=(const MySharedPtr& other) {
        if (this == &other) return *this;

        // 自身资源计数-1
        if (_cb && --_cb->ref_count == 0) {
            delete _cb->ptr;
            delete _cb;
        }

        // 接管新资源
        _cb = other._cb;
        if (_cb) {
            _cb->ref_count++;
        }
        return *this;
    }

    // 析构函数:计数-1,归零释放
    ~MySharedPtr() {
        if (_cb && --_cb->ref_count == 0) {
            delete _cb->ptr;
            delete _cb;
        }
    }

    // 重载运算符
    T& operator*() const { return *_cb->ptr; }
    T* operator->() const { return _cb->ptr; }
    int use_count() const{ return _cb ? _cb->ref_count : 0; }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.
  • 20.
  • 21.
  • 22.
  • 23.
  • 24.
  • 25.
  • 26.
  • 27.
  • 28.
  • 29.
  • 30.
  • 31.
  • 32.
  • 33.
  • 34.
  • 35.
  • 36.
  • 37.
  • 38.
  • 39.
  • 40.
  • 41.
  • 42.
  • 43.
  • 44.
  • 45.
  • 46.
  • 47.
  • 48.
  • 49.
  • 50.
  • 51.
  • 52.
  • 53.
  • 54.
  • 55.
  • 56.
  • 57.
  • 58.

这份代码完美体现了shared_ptr的核心原理:拷贝计数递增、析构计数递减、计数归零释放资源。虽然没有标准库的线程安全、弱引用、自定义删除器,但核心逻辑完全一致,吃透这个,shared_ptr底层就彻底懂了。

3. 配套 weak_ptr 实现

基于上面的shared_ptr控制块,我们实现极简weak_ptr,核心就是不增加强引用计数,只观察资源状态。

template<typename T>
class MyWeakPtr {
private:
    ControlBlock<T>* _cb;
public:
    // 从shared_ptr构造
    MyWeakPtr(const MySharedPtr<T>& sp) : _cb(sp._cb) {}

    // 判断资源是否过期
    bool expired() const {
        return !_cb || _cb->ref_count == 0;
    }

    // 锁定获取shared_ptr
    MySharedPtr<T> lock() const {
        if (expired()) return MySharedPtr<T>();
        return MySharedPtr<T>(_cb->ptr);
    }
};
  • 1.
  • 2.
  • 3.
  • 4.
  • 5.
  • 6.
  • 7.
  • 8.
  • 9.
  • 10.
  • 11.
  • 12.
  • 13.
  • 14.
  • 15.
  • 16.
  • 17.
  • 18.
  • 19.

weak_ptr的精髓就在这,全程不修改强引用计数,只做状态观察,完美破解循环引用问题。

十二、C++ 标准演进:智能指针的迭代与未来

1. C++11:现代智能指针体系定型

C11 是智能指针的里程碑版本。直接推翻了 C98 的auto_ptr垃圾设计,一次性推出unique_ptr、shared_ptr、weak_ptr三套完整体系。

同时引入移动语义、右值引用,让unique_ptr的所有权转移、shared_ptr的拷贝语义有了语法支撑,现代智能指针体系正式成型。

2. C++14:std::make_unique 补全标准接口

这是一个超级实用的补全。C++11 只有make_shared,没有make_unique。导致unique_ptr只能手动new初始化,存在异常安全漏洞。

C++14 补齐make_unique,让unique_ptr也能安全、高效初始化,彻底统一了智能指针的创建规范。

3. C++17:auto_ptr 正式移除与语法优化

C++17 彻底从标准库中删除了auto_ptr,不再兼容上古垃圾语法。同时优化了智能指针的模板推导,简化了多态类型的转换逻辑,语法更简洁。

4. C++20:make_shared_for_overwrite 与分配器增强

C++20 新增make_shared_for_overwrite,专门适配需要覆盖初始化的场景,避免无用的初始化开销,性能进一步优化。同时增强了自定义分配器的兼容性,适配更多内存池场景。

5. C++23:std::out_ptr /std::inout_ptr 与 C 接口互操作

这是非常实用的更新,解决了智能指针和 C 接口双向赋值的痛点。之前很多 C 接口需要输出裸指针,智能指针很难适配,C++23 新增的指针适配器,完美兼容这类场景,跨语言交互更丝滑。

6. 未来展望:智能指针还有哪些进化空间

目前智能指针的核心语义已经非常成熟,未来不会有颠覆性改动。迭代方向主要是性能优化、语法简化、安全增强、内存池适配。

大概率会进一步弱化裸指针的使用场景,强化智能指针的默认地位,让 C++ 内存管理更安全、更现代化。

十三、面试常问问题总结

结合十年面试经验,我整理了智能指针最高频、最容易挂人的面试真题,全部附带标准答案,面试前直接背就行。

(1) unique_ptr和shared_ptr的核心区别?

unique_ptr独占所有权、禁止拷贝、支持移动、零开销、无引用计数;shared_ptr共享所有权、支持拷贝、有原子引用计数开销、需要控制块。优先用unique_ptr,共享场景用shared_ptr。

(2) 为什么unique_ptr禁止拷贝?

为了保证资源独占语义,防止多个智能指针管理同一块内存,避免双重释放崩溃。拷贝构造和拷贝赋值被标准库显式删除。

(3) make_shared和new初始化shared_ptr的区别?

make_shared一次性分配对象 + 控制块连续内存,性能更高、缓存更友好、异常更安全;new需要两次内存分配,开销更大。唯一缺点是weak_ptr残留会导致内存延迟释放。

(4) shared_ptr循环引用怎么解决?原理是什么?

用weak_ptr打破循环引用。weak_ptr不增加强引用计数,对象引用计数可以正常归零,避免内存泄漏。

(5) shared_ptr**线程安全吗?

引用计数增减线程安全;同一智能指针的修改操作非线程安全;托管对象的数据读写非线程安全。

(6) 为什么不能在构造函数调用shared_from_this?

构造函数执行时对象未初始化完成,外部shared_ptr控制块未创建,调用会触发空指针访问,程序崩溃。

(7) auto_ptr为什么被废弃?

隐式拷贝转移所有权,极易导致空指针崩溃;不支持 STL 容器;无移动语义,设计缺陷无法修复。

(8) get、release、reset三个接口的区别?

get仅借用裸指针,不转移所有权;release移交所有权,原指针置空;reset释放原有资源,重置新资源。

(9) unique_ptr如何管理数组和自定义资源?

使用unique_ptr<T[]>数组特化版本自动匹配delete[];通过模板参数绑定自定义删除器,适配文件句柄、套接字等资源。

(10) weak_ptr的expired和lock为什么要配合使用?

多线程场景下,expired判断和资源销毁存在时间窗口,单独判断不可靠,必须lock后判空,保证资源安全。

文章来自:51CTO

Loading

作者 yinhua

发表回复