【C++ 面试真题】26. 聊聊 C++ 的智能指针

【C++ 面试真题】聊聊 C++ 的智能指针

智能指针是 C++ 内存管理的"正解"------上一期说的三类内存事故,答案全在这里。背得出"shared_ptr 引用计数"只是及格,真考你的是"unique_ptr 凭什么零开销、控制块是什么、make_shared 好在哪、循环引用怎么破、手撕一个 shared_ptr 要写哪些函数"。本文把三种智能指针的用法、原理、避坑一次讲透。


一、开场:智能指针家族有哪些?

❓ 介绍一下 C++ 的智能指针?

<memory> 里的现役三种 + 一个退役选手:

指针 引入 所有权 拷贝 开销
unique_ptr [C++11] 独占 ❌ 只能移动 零开销
shared_ptr [C++11] 共享(计数) ✅ 计数 +1 引用计数
weak_ptr [C++11] 观测不拥有 ✅ 不增计数 配合 shared
auto_ptr C++98 "拷贝即转移" 危险 C++17 已移除

回答思路:先报"独占/共享/观测"三种角色,再点 auto_ptr 的历史教训------面试官大概率会挑 shared_ptr 的原理或 unique_ptr 的零开销深入。

💡 共同本质:都是把"所有权"编码进类型的小对象------析构时按所有权规则自动释放资源(RAII)。栈上小对象,代价趋近于零。


二、unique_ptr:独占,零开销

❓ unique_ptr 怎么用?为什么说它零开销?

同一时刻只有一个 unique_ptr 拥有对象------不能拷贝、只能移动:

cpp 复制代码
auto p = make_unique<int>(42);
// auto p2 = p;      // ❌ 编译报错
auto p2 = std::move(p);  // ✅ 所有权转移
// p 现在是 nullptr

零开销的底气 :它就是裸指针的薄包装,析构时 delete------没有计数、没有额外内存,生成的代码和手写 new/delete 相同(优化后甚至无差别)。默认选择:要独占资源时,它优于裸指针。

两个进阶用法:

cpp 复制代码
// ① 数组特化:
// 会逐个析构,配 delete[]
auto arr =
    make_unique<int[]>(100);

// ② 自定义删除器:类型的一部分
auto f = unique_ptr<FILE,
    int(*)(FILE*)>(
        fopen("a.txt", "r"),
        &fclose);

⚠️ unique_ptr<T, D> 的删除器 D 是类型的一部分------lambda 删除器得写全类型(或用 decltype),这是它和 shared_ptr 删除器(构造期传入、不影响类型)的关键区别。


三、shared_ptr:共享,引用计数

❓ shared_ptr 的原理是什么?

✅ 每个对象配一个控制块(control block),里面存两套计数:

text 复制代码
┌──────────────┐
│ 控制块        │
│ strong count │ ← shared 计数
│ weak count   │ ← weak 观测计数
└──────────────┘
        ▲
  ┌─────┴─────┐
  │ 堆上的对象 │
  └───────────┘
   ↑ 被多个 shared_ptr 指着
  • 拷贝 shared_ptr → 强计数 +1(原子操作);
  • 析构 → 强计数 -1;减到 0 → 析构对象;弱计数也归 0 → 释放控制块本身。
cpp 复制代码
auto p = make_shared<string>("hi");
{
    auto p2 = p;   // 强计数 2
}                  // p2 死,计数 1
// p 死,计数 0 → 析构 string

💡 make_shared 为什么更好shared_ptr<T>(new T)两次堆分配 (对象一次、控制块一次);make_shared 一次分配把对象和控制块放同一块内存------更快、缓存更友好,还免除了"构造参数求值和 new 之间穿插其他代码"的异常安全缝隙。


四、shared_ptr 的两个坑

❓ shared_ptr 最容易踩什么坑?

✅ 两个经典坑:

坑①:循环引用------两个对象互相持 shared_ptr,计数永不归零:

cpp 复制代码
struct B;
struct A {
    shared_ptr<B> b_;
    ~A() { cout << "~A"; }
};
struct B {
    shared_ptr<A> a_;   // ❌ 互指
    ~B() { cout << "~B"; }
};
auto a = make_shared<A>();
auto b = make_shared<B>();
a->b_ = b;   // B 计数 2
b->a_ = a;   // A 计数 2
// 离开作用域:a、b 各减 1
// 互指的对方还握着 1 → 永不析构 → 泄漏

解法:把"被指向方"改成 weak_ptr------父子关系里子指父、缓存指源对象、观察者指主体,都该用 weak:

cpp 复制代码
struct B {
    weak_ptr<A> a_;   // ✅ 只观测
};

坑②:线程安全的真实范围 ------计数是原子的 (多线程各自拷贝/析构同一个对象的 shared_ptr 安全);但它指向的对象本身不保护 ------多线程并发写 *p 照样要加锁。

⚠️ 另一个隐蔽坑:同一裸指针构造多个 shared_ptr ------shared_ptr<T> a(p); shared_ptr<T> b(p); 会造出两个控制块 ,double free。想从同一裸指针共享,用 b = a;


五、weak_ptr:观测不拥有

❓ weak_ptr 怎么用?

✅ 指向 shared_ptr 管理的对象但不增加强计数,三个核心操作:

cpp 复制代码
auto sp = make_shared<int>(42);
weak_ptr<int> wp = sp;   // 观测

// ① 过期了吗?
if (wp.expired()) { /* 对象没了 */ }

// ② 安全访问:lock() 拿 shared_ptr
if (auto locked = wp.lock()) {
    // 活着:这段时间 *locked 安全
}
// ③ use_count() 看强计数

lock() 是标准姿势 :它原子地"检查存活 + 临时升格",拿到手的使用期内对象保证不被释放------比先 expired() 再解引用安全(两步之间对象可能正好析构)。

典型场景:缓存 (持有引用但不阻止回收)、观察者列表打破循环引用(上节的父子关系)。


六、手撕 shared_ptr(高频手撕题)

❓ 手写一个简化版 shared_ptr 要写什么?

✅ 骨架四件套:构造、拷贝、析构、移动(真实版还要 swap/赋值/数组支持等):

cpp 复制代码
template<class T>
class SharedPtr {
    T* p_ = nullptr;
    long* cnt_ = nullptr;  // 控制块
public:
    explicit SharedPtr(T* p)
        : p_(p), cnt_(new long(1)) {}

    // 拷贝:计数 +1
    SharedPtr(const SharedPtr& o)
        : p_(o.p_), cnt_(o.cnt_) {
        ++*cnt_;
    }

    // 析构:计数 -1,归零释放
    ~SharedPtr() {
        if (cnt_ &&
            --*cnt_ == 0) {
            delete p_;
            delete cnt_;
        }
    }

    // 移动:接管、原者置空
    SharedPtr(SharedPtr&& o) noexcept
        : p_(o.p_), cnt_(o.cnt_) {
        o.p_ = nullptr;
        o.cnt_ = nullptr;
    }

    T& operator*()  { return *p_; }
    T* operator->() { return p_; }
};

面试加分点:指出这份骨架的缺陷 ------计数不是原子的(真实版要 atomic<long>);自赋值在拷贝赋值里要先 ++*o.cnt_ 再减自己的;不支持别名构造与 weak_ptr。能主动说出哪里不完整,比背出完美代码更加分。


七、面试高频追问

❓ Q1:为什么 auto_ptr 被废弃?

✅ 它用拷贝语法实现所有权转移 ------auto_ptr b = a; 后 a 变空,和"拷贝"的直觉完全相反,放进容器里就是灾难(拷贝元素导致源失效)。C++11 有了移动语义,unique_ptr 用显式 std::move 表达转移,auto_ptr 于 C++17 正式移除。

❓ Q2:unique_ptr 为什么不能放进 vector 后拷贝容器?

✅ vector 扩容要搬移元素,搬移需要拷贝或移动------unique_ptr 只能移动,std::move 下完全合法。真正不行的是"要求元素可拷贝"的老代码或算法。容器里放 vector<unique_ptr<T>> 是现代 C++ 的常见组合。

❓ Q3:shared_ptr 的计数为什么必须是原子的?

✅ 多线程可能同时拷贝/析构各自的 shared_ptr 副本------计数是共享数据,非原子加减会丢更新,导致过早释放(UAF)或永不释放(泄漏)。原子操作保证计数正确,但代价是每次拷贝/析构都有原子指令的开销。

❓ Q4:enable_shared_from_this 是干什么的?

✅ 对象内部想安全拿到管理自己的 shared_ptr(常见于异步回调时延长自身寿命)------直接 shared_ptr<T>(this) 会造第二个控制块直接 double free。继承 enable_shared_from_this<T> 后用 shared_from_this(),内部持一个 weak_ptr 桥接。

❓ Q5:make_shared 有什么缺点?

✅ 对象和控制块同块内存------weak_ptr 还活着时整个块都不能释放 (对象已析构,但内存要等弱计数归零),大对象 + 长寿 weak_ptr 的场景会延迟内存归还。此时用 shared_ptr<T>(new T) 分离两块更好。

❓ Q6:智能指针和裸指针还能共存吗?

✅ 能且应该:所有权用智能指针,纯观察用裸指针/引用 ------函数只是"看看"参数就传 T*/T&(不参与生命周期管理),这样接口清爽、语义明确。裸指针的危险只在"靠它管理所有权"时才存在。


八、总结速查表

考点 一句话结论
三种角色 独占/共享/观测
unique_ptr 零开销、只能移动、默认首选
删除器 unique 是类型,shared 是构造参数
控制块 强计数 + 弱计数
make_shared 一次分配、异常安全
循环引用 互指 shared 泄漏,改 weak
线程安全 计数原子,对象不保护
同裸指针两次构造 两个控制块,double free
lock() 原子"检查 + 升格"的标准访问
手撕四件套 构造/拷贝/析构/移动 + 说出缺陷

一句话回顾

unique_ptr 独占零开销、shared_ptr 共享靠控制块计数、weak_ptr 观测不拥有:默认 unique、确需共享才 shared、互相引用处必 weak;make_shared 一次分配更好,但 weak_ptr 长寿会延迟内存归还;手撕 shared_ptr 时,能主动说出"计数该原子、自赋值要防"这些缺陷,就是最好的答案。

如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期我们继续内存管理篇------聊聊内存泄漏与进程内存布局:泄漏怎么排查、布局图长什么样、全局对象什么时候构造,一次讲清,敬请关注 👋

相关推荐
汉克老师1 小时前
GESP 四级明日冲刺:最后一晚只抓两个大方向
c++·gesp·小学生·学c++编程
wdfk_prog1 小时前
lely-canopen-rtt:为什么推荐在 RT-Thread 上使用 Lely CANopen 构建主站
java·运维·缓存·docker·容器
Token掘金室1 小时前
JSON模式结构化输出报错
java·服务器·json
秋饼1 小时前
Spring AI 2.0 接入 DeepSeek V4.1 Flash 生产级实战
java·ai·技术分享·后端开发
IT枫斗者枫哥1 小时前
Java 分批导出仍然 OOM?用 32 MiB 堆复现三种 CSV 写法
java
一木 之林1 小时前
第 3 节 在 AI 行业中,C/C++ 编程中的动态库和静态库
c语言·c++·后端
库玛西1 小时前
深度解构高并发利器:纯事件驱动 Reactor 模式的设计哲学与工程实践
服务器·开发语言·c++·笔记·tcp/ip
shehuiyuelaiyuehao1 小时前
算法43,外观数列,模拟算法+双指针
java·算法
linx2951 小时前
单元五 · 对称认知·下:编译与链接
linux·c语言·开发语言·c++·嵌入式硬件
lhldsg2 小时前
多商户团购系统源码:技术架构选型与二次开发实战指南
java·小程序·架构