【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 小时前
重生——第十一次面试之挖财一面2026.8.19已OC
java·服务器·jvm·数据库·spring·面试·职场和发展
To_OC1 小时前
啃完 TS 工具类型我发现:Pick 和 Omit 原来就是一层窗户纸
前端·面试·typescript
AI绘画哇哒哒1 小时前
【建议收藏!】35岁后端血泪忠告,这3类人别硬转Agent(过来人亲述)
java·人工智能·后端·ai·程序员·大模型·agent
最强小杰2 小时前
gpt-5.6-sol 频繁报 503 怎么办?区分容量熔断和限速 429 的排查方法 + 可复用 retry wrapper
java·人工智能·gpt·ai
吠品4 小时前
Wine 在 Linux 上运行 Windows 软件完整指南
java·linux·服务器
亚历克斯神5 小时前
智能搜索系统的升级复盘——从 Elasticsearch 到混合检索的检索质量提升
java·spring·微服务
夜不会漫长6 小时前
C++入门(1)
开发语言·c++·算法
金銀銅鐵6 小时前
[Java] 一个方法最多可以有多少个入参?
java·jvm