想象你住进一间合租公寓,公共储物柜里堆着大家的行李箱、健身器材和旧书。每件物品都有一把钥匙,谁借用谁登记。但问题很快来了:有人借完忘记还钥匙,柜子越堆越满(内存泄漏);有人脾气急,同一把钥匙还了两次,锁直接卡死(双重释放);还有人钥匙都还了,转头又去开柜子,东西早被别人拿走了(悬空指针)。整个公寓乌烟瘴气。 C++ 的聪明人们也遇到过同样的困境,于是发明了一套“公寓自动管理规则”——智能指针。它就像一位尽职的宿管:你只管把物品交给它,它会记住谁在用、用完了没、该不该清理。shared_ptr 是“公共物品多张借书卡”,unique_ptr 是“只给一个人的专属钥匙”,weak_ptr 是“只能看不能拿的访客牌”。从此,写代码的人再也不用半夜爬起来数自己到底 new 了多少次、delete 了几回。

岗位关联

对 AI 从业者来说,掌握智能指针是驾驭现代 C++ 推理引擎、训练框架和跨语言绑定的基本功。它能帮你写出不泄漏、不崩溃、可长期运行的服务端代码,这是从“能跑”跨越到“能上线”的关键一步。

目录
  1. 可视化演示
  2. 内存管理痛点
  3. RAII 思想
  4. unique_ptr 独占
  5. shared_ptr 共享
  6. weak_ptr 观察
  7. make 工厂函数
  8. 自定义删除器
  9. 使用陷阱
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

交互展示C++智能指针的所有权语义:unique_ptr独占、shared_ptr共享和weak_ptr观察机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

独占指针:unique_ptr 独占资源所有权不可复制 移动语义转移所有权 unique_ptr = exclusive RAII自动释放避免内存泄漏

内存管理痛点

合租公寓的第一天,宿管发给你一张手写规则:借钥匙要登记,还钥匙要划掉,钥匙丢了要立刻补办。这套规则看似严谨,却全靠“人的自觉”来执行。C++ 早期正是如此,程序员自己负责 new 和 delete 的配对。忙起来的时候,少写一次 delete、多写一次 delete、或者提前 delete 后又继续使用,几乎是每个项目都会踩的坑。

你写下的每一行 new,都是在向操作系统租借一块内存;每一次 delete,才是归还。问题在于,这两件事之间隔着无数异常分支、提前 return 和同事改过的代码。只要有一条路径漏掉归还,这块内存就永远被困在程序里,服务器跑得越久,可用内存越少。

技术上讲,这些事故可以分成三类:泄漏(该还的没还)、双重释放(同一块空间还了两次)、悬空指针(还了之后继续访问)。它们共同指向一个本质——资源的所有权转移和释放时机,完全依赖人的记忆。

回到公寓的场景,宿管发现手写规则永远追不上人的遗忘曲线,他决定换一套系统:所有钥匙统一管理,谁拿了、谁还了、该不该清理,都由系统自动判断。这套想法,在 C++ 里就是智能指针的起点。

内存事故={new 没有 delete⇒泄漏delete 执行两次⇒未定义行为delete 后再访问⇒悬空指针\displaystyle \text{内存事故} = \begin{cases} \text{new 没有 delete} \Rightarrow \text{泄漏} \\ \text{delete 执行两次} \Rightarrow \text{未定义行为} \\ \text{delete 后再访问} \Rightarrow \text{悬空指针} \end{cases}
公式说明:左边是“内存事故”这个总集合,右边三个分支分别对应三种最常见的手动管理失误。
  1. 泄漏分支new 的次数大于 delete 的次数时,多出来的内存块永远无法归还给操作系统。
    new 没有 delete→内存泄漏\text{new 没有 delete} \to \text{内存泄漏}
  2. 双重释放分支同一指针被 delete 两次,而它指向的空间可能已被重新分配给别的对象,第二次释放会引发未知崩溃。
    delete 同一指针两次→未定义行为\text{delete 同一指针两次} \to \text{未定义行为}
  3. 悬空分支delete 之后指针变量仍然保存着旧地址,但它指向的空间已经不属于本程序,再访问就是“踩野地”。
    先 delete 后访问→悬空指针\text{先 delete 后访问} \to \text{悬空指针}
  4. 本质总结所有事故都源于释放路径不唯一、不自动,因此真正可靠的方案是让“释放”这件事不再依赖人。
    可靠性=每项资源都有唯一且自动的释放路径\text{可靠性} = \text{每项资源都有唯一且自动的释放路径}

RAII 思想

宿管的新系统是这样运作的:每件物品放进柜子时自动生成一张“入住卡”,物品被取走时“入住卡”自动注销,不需要任何人手工登记。这条铁律的名字叫 RAII——资源获取即初始化(Resource Acquisition Is Initialization)。它把“资源的生命周期”和“对象的生命周期”强行绑定在一起。

在 C++ 中,每个栈上创建的对象都有明确的作用域。对象构造时,它把资源“接管”过来;对象析构时,它把资源“释放”掉。编译器保证,无论函数正常返回还是抛出异常,栈对象析构都会被执行。这意味着,只要把资源放进一个栈对象里,释放代码就一定会运行。

RAII 的妙处在于它不是一条新语法,而是一种设计准则。C++ 标准库里的锁(std::lock_guard)、文件流(std::fstream)、智能指针,全都是 RAII 的化身。你只需要在变量声明处安排好资源,剩下的交给编译器。

合租公寓从此不再依赖住户的自觉,宿管系统在后台默默完成了所有收尾工作。但对象和资源怎么绑定?绑定之后是独占还是共享?这就要看下面三位“智能管家”的各自分工了。

资源↔ 1:1 对象,构造→获取资源,析构→释放资源\displaystyle \text{资源} \xleftrightarrow{\ 1:1\ } \text{对象}, \qquad \text{构造} \to \text{获取资源}, \quad \text{析构} \to \text{释放资源}
公式说明:资源与对象是一一对应的双箭头关系,对象出生时资源被接管,对象死亡时资源被释放。
  1. 一一绑定资源 \xleftrightarrow{1:1} 对象 表示一个资源只由一个栈对象管理,不允许多头负责。
    资源↔ 1:1 对象\text{资源} \xleftrightarrow{\ 1:1\ } \text{对象}
  2. 获取时机对象的构造函数中完成资源获取,相当于拿到钥匙的那一刻就自动登记。
    构造→获取资源\text{构造} \to \text{获取资源}
  3. 释放时机对象的析构函数中完成资源释放,相当于对象销毁时自动还钥匙。
    析构→释放资源\text{析构} \to \text{释放资源}
  4. 作用域保证对象离开作用域时,析构函数由编译器强制调用,这是 RAII 可靠的根基。
    作用域结束⇒析构自动执行\text{作用域结束} \Rightarrow \text{析构自动执行}

unique_ptr 独占

公寓里有一类物品非常私密,比如你的日记本。宿管规定:这类物品只能有一把钥匙,且必须登记在一个人名下。这就是 unique_ptr——独占所有权智能指针。它保证同一时刻只有一个 unique_ptr 能指向某个堆对象,没有第二把钥匙存在。

从名字就能看出它的性格:unique,独一无二。它的拷贝构造和拷贝赋值被删除了,你想写 p2 = p1 复制所有权?编译直接报错。但所有权可以转移,用 std::move(p1) 就能把对象交给 p2,此时 p1 变成空指针。这就像把唯一钥匙从左手递到右手,中间没有丢失的窗口。

unique_ptr 的开销几乎为零,它内部只保存一个裸指针,不维护引用计数,没有额外的控制块。因此它是智能指针里的“默认选项”:只要场景不需要共享,优先用 unique_ptr。

回到公寓,日记本这种私人物品交给 unique 管家最省心:他攥着唯一钥匙,你永远不用担心钥匙被复制、丢失或冒领。但公共物品就没这么简单了,比如客厅共用的投影仪,好几个人都要用——这就轮到 shared 管家登场。

unique_ptr<T> pA(new T);pB=std::move(pA);pA⇒∅\displaystyle unique\_ptr<T> \ p_A(\text{new } T); \qquad p_B = std::move(p_A); \qquad p_A \Rightarrow \varnothing
公式说明:第一行创建独占指针,第二行通过移动转移所有权,第三行表示转移后原指针变为空。
  1. 独占创建p_A(new T) 让 p_A 成为对象唯一的持有者,此时所有权归 p_A。
    pA=new T,唯一持有p_A = \text{new } T, \quad \text{唯一持有}
  2. 拷贝被禁p_B = p_A 在编译期就被拒绝,因为两把钥匙同时存在违背独占语义。
    pB=pA⇒编译错误p_B = p_A \quad \Rightarrow \quad \text{编译错误}
  3. 移动转移p_B = std::move(p_A) 将所有权从 p_A 转移到 p_B,对象只有一个主人。
    std::move(pA)⇒所有权移交 pBstd::move(p_A) \Rightarrow \text{所有权移交 } p_B
  4. 析构释放p_B 离开作用域时,析构函数自动对原对象执行 delete。
    pB 析构→delete 原对象p_B \text{ 析构} \to \text{delete 原对象}

shared_ptr 共享

客厅的投影仪不是某个人的专属,而是五个人一起用的公共物品。宿管给每件公共物品配了一块计数器:每发一把钥匙,计数加一;每收回来一把,计数减一;计数归零时,说明没人再需要它,宿管才把它收进仓库。shared_ptr 就是这套“计数器+钥匙”机制的实现。

shared_ptr 内部维护一个“控制块”,控制块里存放引用计数。当你拷贝一个 shared_ptr 时,计数加一;当某个 shared_ptr 销毁时,计数减一;当计数从 1 减到 0 的那一刻,它立刻 delete 管理的对象并释放控制块。RVO、容器、函数参数,随便传递,只要还有一个人持有,对象就活着。

需要注意的是,引用计数的增加和减少是原子操作,多个线程拷贝、销毁同一个 shared_ptr 时不会把计数弄乱。但原子性不包括“对象本身的数据读写”,那需要另加锁保护。

共享计数器的思路很优雅,但宿管发现了新问题:如果两个人互相握着对方的钥匙不撒手,比如你拿着我的门禁卡,我拿着你的门禁卡,双方都等着对方先还,计数就永远归不了零。这正是循环引用。破解它,需要一位“只观察、不插手”的访客——weak_ptr。

use_count=∑s∈S[s.ptr=obj],use_count=0⇒delete obj\displaystyle \text{use\_count} = \sum_{s \in S} [s.\text{ptr} = \text{obj}], \qquad \text{use\_count} = 0 \Rightarrow \text{delete obj}
公式说明:S 是所有指向该对象的 shared_ptr 的集合,use_count 是这些指针的个数;当这个数变成 0 时,对象被删除。
  1. 计数定义[s.ptr = obj] 是一个判定式,值为 1 表示该指针确实指向当前对象。
    use_count=∑s∈S[s.ptr=obj]\text{use\_count} = \sum_{s \in S} [s.\text{ptr} = \text{obj}]
  2. 计数递增每多一个 shared_ptr 指向对象,总和就多一个 1。
    拷贝构造⇒use_count+1\text{拷贝构造} \Rightarrow \text{use\_count} + 1
  3. 计数递减每个 shared_ptr 析构时,总和少一个 1。
    析构⇒use_count−1\text{析构} \Rightarrow \text{use\_count} - 1
  4. 归零释放总和为 0 的时刻,对象被 delete,仓库完成清空。
    use_count=0⇒delete obj\text{use\_count} = 0 \Rightarrow \text{delete obj}

weak_ptr 观察

宿管有时需要了解公共物品的状态,比如“投影仪还在不在仓库”?但他不想因此阻止仓库清空。如果每次查看都要拿一把钥匙,计数就会被卡住,物件永远无法回收。于是他发明了访客牌 weak_ptr:可以随时查看物品在不在,但发牌时不动计数器。

weak_ptr 只能从 shared_ptr 构造,它像一个“弱引用”,不参与引用计数。当所有 shared_ptr 都销毁后,控制块会被释放,weak_ptr 观察的对象也就消失了。好在它很谨慎,不会直接访问对象,而是调用 lock() 尝试临时“升级”成一个真正的 shared_ptr 再使用。

lock() 的行为像一个安全检查:如果对象还活着,返回一个新的 shared_ptr(计数加一);如果对象已经没了,返回一个空的 shared_ptr。因此在多线程环境下,lock() 可以安全地避免“使用已释放对象”的悬空风险。

在循环引用场景里,只要把其中一个方向上的 shared_ptr 换成 weak_ptr,链环就被打断了。公寓里,访客牌让宿管既能掌握全局状态,又不会因为多看一眼而让物品永远滞留仓库。

lock():use_count>0⇒有效 shared_ptr;use_count=0⇒空指针\displaystyle lock(): \quad \text{use\_count} > 0 \Rightarrow \text{有效 shared\_ptr}; \qquad \text{use\_count} = 0 \Rightarrow \text{空指针}
公式说明:lock() 根据当前强引用计数决定返回结果,count 大于 0 时升级成功,等于 0 时返回空。
  1. 条件判断lock() 先读取当前对象的 use_count。
    use_count>0?\text{use\_count} > 0?
  2. 升级成功计数大于 0,说明对象仍存活,返回一个新的 shared_ptr,计数加一。
    use_count>0⇒新 shared_ptr\text{use\_count} > 0 \Rightarrow \text{新 shared\_ptr}
  3. 升级失败计数等于 0,对象已释放,返回空指针。
    use_count=0⇒nullptr\text{use\_count} = 0 \Rightarrow \text{nullptr}
  4. 观察不干预weak_ptr 本身的存在不改变 use_count,这正是它能旁观而又不碍事的根本。
    weak_ptr 构造 ⇒use_count 不变\text{weak\_ptr} \text{ 构造 } \Rightarrow \text{use\_count 不变}

make 工厂函数

宿管每天要处理大量入住登记。他发现有住户喜欢先手工创建物品、再委托管家管理,中间容易出岔子:有时物品建好了,管家却在半路被异常打断,导致物品没人接管。于是他开了两个固定窗口:make_unique 和 make_shared,住户必须通过窗口一次性完成“创建+托管”。

make_shared 还有一个隐藏优势:内存分配优化。使用传统写法 shared_ptr<T>(new T) 需要两次堆分配,一次给对象 T,一次给控制块引用计数。而 make_shared 把对象和控制块分配在同一块内存里,只需一次堆分配,既省时间又省空间,缓存友好性也更好。

异常安全是另一个关键点。假设你写 f(shared_ptr<A>(new A), shared_ptr<B>(new B)),如果 B 的构造抛出异常,先创建好的 A 就可能没人负责,直接泄漏。用 make_shared 则每个对象创建后立刻进入托管状态,异常产生也能安全释放。

窗口开办之后,住户再也不用担心“东西建好了却没人接”的尴尬局面。不过,有的东西不是普通垃圾,比如冰柜里的活鱼、仓库的化学试剂,扔的时候需要特殊处理——这就要用到自定义删除器。

make_shared:1 次堆分配(对象 + 控制块);new + shared_ptr:2 次堆分配\displaystyle \text{make\_shared}: 1 \text{ 次堆分配(对象 + 控制块)}; \qquad \text{new + shared\_ptr}: 2 \text{ 次堆分配}
公式说明:公式对比了两种创建路径的堆分配次数,make_shared 把对象和控制块合并成一次分配。
  1. 传统路径先 new 出对象,再拷贝给 shared_ptr 导致控制块分配,共两次。
    new T→堆分配①,控制块→堆分配②\text{new T} \to \text{堆分配①}, \quad \text{控制块} \to \text{堆分配②}
  2. make 路径对象和控制块在同一块内存中相邻布局,一次完成。
    make_shared⇒一次分配{对象+控制块}\text{make\_shared} \Rightarrow \text{一次分配} \left\{ \text{对象} + \text{控制块} \right\}
  3. 异常安全任何时刻对象都已被智能指针持有,中途异常不会造成无人接管。
    创建即持有⇒异常也不泄漏\text{创建即持有} \Rightarrow \text{异常也不泄漏}
  4. 内存布局对象和控制块相邻,数据访问缓存命中率更高。
    相邻布局⇒更好的局部性\text{相邻布局} \Rightarrow \text{更好的局部性}

自定义删除器

大多数时候,堆对象用 delete 释放就够了。但公寓里偶尔会有“特殊物品”:文件句柄要调用 fclose,网络 socket 要调用 close,CUDA 显存要调用 cudaFree。这些资源不是普通堆内存,释放方式各不相同。智能指针允许你传入一个“自定义删除器”,告诉宿管:这东西该怎么处理。

unique_ptr 的删除器类型作为模板参数存在,意味着不同删除器会产生不同的类型;而 shared_ptr 的删除器在构造时传入即可,内部通过控制块存储,删除器类型被“擦除”了,所以 shared_ptr 的声明永远简洁统一。

删除器可以是一个函数指针、lambda 表达式,或任意可调用对象。当引用计数归零时,智能指针调用的不是 delete,而是你传入的删除器。这让你能用统一的智能指针 API 管理所有类型的资源,从堆内存到文件句柄再到设备显存。

有了自定义删除器,宿管终于能处理公寓里的所有特殊物品。但就算规则再完善,总有住户会犯迷糊:手里同时攥着宿管钥匙和私配钥匙,互相纠缠不清——这就是智能指针使用的常见陷阱。

shared_ptr<T> p(ptr,D);析构触发 D(ptr);删除器存于控制块\displaystyle shared\_ptr<T> \ p(\text{ptr}, D); \qquad \text{析构触发 } D(\text{ptr}); \qquad \text{删除器存于控制块}
公式说明:shared_ptr 构造时额外传入删除器 D,对象释放时执行 D(ptr) 代替默认 delete。
  1. 传入删除器构造函数的第二个参数 D 决定资源如何被释放。
    p(ptr,D)⇒D 接管释放逻辑p(\text{ptr}, D) \Rightarrow \text{D 接管释放逻辑}
  2. 释放时机引用计数归零时,不是 delete,而是调用 D(ptr)。
    count=0⇒D(ptr)\text{count} = 0 \Rightarrow D(\text{ptr})
  3. 类型擦除shared_ptr 不把 D 暴露在类型里,只存在控制块内部。
    shared_ptr<T> 与 D 无关⇒类型统一\text{shared\_ptr<T> 与 D 无关} \Rightarrow \text{类型统一}
  4. unique_ptr 差异unique_ptr 的删除器编码在类型中,不同删除器是不同的类型。
    unique_ptr<T, D1>≠unique_ptr<T, D2>\text{unique\_ptr<T, D1>} \neq \text{unique\_ptr<T, D2>}

使用陷阱

公寓到现在已经运转得不错,但宿管在月末盘点时发现了几个反复出现的违规模式。最典型的是循环引用:A 和 B 互相持有对方的 shared_ptr,就像两个住户互相攥着对方家的门禁卡,谁都不肯先还,最后两件物品都永远清不掉。

另一类是裸指针混用。有些老住户总爱说“我直接拿裸指针操作快一点”,结果私下把智能指针里的地址复制给裸指针,再用裸指针 delete,计数就乱了。还有更隐蔽的:get() 函数把内部地址暴露出来,如果不小心对 get() 的结果执行 delete,对象会先被裸指针删除,随后又被智能指针再次释放,造成双重释放。

最后,性能也不是免费的。shared_ptr 的引用计数是原子操作,拷贝和销毁都有额外开销;控制块的分配也占据内存。如果对象根本不需要共享,却强行套上 shared_ptr,就是让宿管白白多花精力记账。

宿管把这些违规模式张贴在公告栏上,提醒住户:钥匙不要私配、不要互攥、更不要撕了门禁卡又拿卡刷门。记好这三句话,合租生活才算真正安全。

循环引用: A→B, B→A;use_countA=use_countB=1;永不归零⇒泄漏\displaystyle \text{循环引用: } A \to B, \ B \to A; \qquad \text{use\_count}_A = \text{use\_count}_B = 1; \qquad \text{永不归零} \Rightarrow \text{泄漏}
公式说明:A 和 B 互持 shared_ptr,双方计数都被对方保住,外部的释放动作无法让任何一方归零。
  1. 互相钳制A 持有指向 B 的 shared_ptr,B 持有指向 A 的 shared_ptr。
    A→B,B→AA \to B, \quad B \to A
  2. 计数卡死A 的计数中包含 B 的一份,B 的计数中包含 A 的一份,各自至少为 1。
    use_countA≥1,use_countB≥1\text{use\_count}_A \ge 1, \quad \text{use\_count}_B \ge 1
  3. 外部释放入场外部变量全部销毁后,两个计数仍然不小于 1。
    外部 shared_ptr 清空⇒计数仍为 1\text{外部 shared\_ptr 清空} \Rightarrow \text{计数仍为 } 1
  4. 泄漏结局没有任何一方能让计数归零,两个对象永远占用内存。
    永不归零⇒内存泄漏\text{永不归零} \Rightarrow \text{内存泄漏}

AI 应用场景

推理引擎模型缓存池

阶段:模型推理部署

解决的问题:推理服务中,同一个模型会被多个请求线程同时使用,传统做法要么让模型常驻内存永不释放,浪费显存;要么人工统计引用,错误率极高。用 shared_ptr 让每个线程拿到模型的一个共享句柄,最后一个线程用完时自动卸载模型,实现“用则留、闲则走”的优雅缓存。

具体案例:某公司部署 TensorRT 推理服务,用 shared_ptr<Engine> 管理多个实例。每个请求从缓存池 lock() 一个弱引用,升级成功则直接复用,失败则说明模型已卸载,由领导线程重新加载。这样当业务高峰过去后,模型自动从显存消失,无需人工运维。

CUDA 显存资源自动回收

阶段:训练框架底层开发

解决的问题:GPU 显存必须用 cudaFree 释放,用普通 delete 会引发未定义行为。通过自定义删除器,把 cudaMalloc 返回的指针交给 shared_ptr,析构时自动调用 cudaFree,让显存泄漏在训练循环中彻底绝迹。

具体案例:某分布式训练框架在实现自定义算子时,用 std::shared_ptr<float> 包装临时显存缓冲区,删除器传入 cudaFree。即使算子在某个设备分支抛异常,缓冲区也会随智能指针析构自动回收,不再出现“跑三天任务,显存被吃光”的事故。

pybind11 跨语言对象生命周期

阶段:模型服务 Python 化封装

解决的问题:Python 调用 C++ 推理库时,C++ 端对象如果被提前释放,Python 侧指针会变成悬空野指针;如果不释放,则内存长期泄漏。shared_ptr 与 pybind11 的转换器结合后,Python 持有引用计数,垃圾回收自动与 C++ 对象生命周期对齐。

具体案例:一个基于 C++ 的自研推理库通过 pybind11 导出 shared_ptr<Detector>,Python 侧用户可以像操作普通对象一样使用 Detector。C++ 端析构逻辑完全由引用计数驱动,Python 的 del 触发计数递减,最后一个引用消失后 Detector 自动析构,两套语言各取所长。

Python 演示

python77 行
import weakrefimport gc  class GpuBlock:    """模拟 GPU 显存里的对象"""    def __init__(self, name):        self.name = name        print(f"[入住] {name}")     def __del__(self):        print(f"[释放] {self.name}")  class SharedPtr:    """模拟 shared_ptr:全局注册表保存引用计数"""    _store = {}     def __init__(self, block):        self._id = id(block)        if self._id not in SharedPtr._store:            SharedPtr._store[self._id] = [block, 0]        SharedPtr._store[self._id][1] += 1     def get(self):        rec = SharedPtr._store.get(self._id)        return rec[0] if rec and rec[1] > 0 else None     @staticmethod    def copy(other):        return SharedPtr(other.get())     def close(self):        rec = SharedPtr._store.get(self._id)        if rec is None:            return        rec[1] -= 1        if rec[1] == 0:            SharedPtr._store.pop(self._id)            del rec[0]  def make_shared(name):    """模拟 make_shared:对象只交给智能指针,不暴露裸指针"""    return SharedPtr(GpuBlock(name))  cached = make_shared("模型权重")worker = SharedPtr.copy(cached)print("引用计数:", SharedPtr._store[worker._id][1])cached.close()print("cached 下线后计数:", SharedPtr._store[worker._id][1])worker.close()print("worker 下线后,无强引用 => 对象自动析构")  # 第二部分:循环引用场景,用 weakref 破解class GraphNode:    def __init__(self, name):        self.name = name        self.peer = None     def __del__(self):        print(f"[释放] 计算节点 {self.name}")  n1, n2 = GraphNode("A"), GraphNode("B")n1.peer = n2n2.peer = n1del n1, n2print("循环引用触发 gc 回收:", gc.collect()) a1, a2 = GraphNode("A"), GraphNode("B")a1.peer = weakref.ref(a2)a2.peer = weakref.ref(a1)del a1, a2print("weakref 弱引用不会形成强环,立即释放")
目的:用 Python 模拟 C++ shared_ptr 的引用计数机制,并演示 weakref 如何打破循环引用。第一部分展示计数 2→1→0 的完整过程;第二部分对比强引用环与弱引用方案。
  1. class SharedPtr:用全局字典模拟 shared_ptr 的控制块,记录对象和引用计数。
  2. self._store.setdefault(self._id, [block, 0])首次出现时登记对象,计数初始为 0。
  3. SharedPtr._store[self._id][1] += 1每次构造智能指针,等价于 shared_ptr 拷贝使引用计数加一。
  4. rec[1] -= 1close() 模拟 shared_ptr 析构,计数减一。
  5. if rec[1] == 0:计数归零,这是释放对象的唯一条件。
  6. SharedPtr._store.pop(self._id)把对象记录从控制块中移除。
  7. del rec[0]删除对对象的最后一个强引用,触发 GpuBlock 的析构。
  8. make_shared("模型权重")模拟 make_shared,对象创建后立刻被智能指针持有,不暴露裸指针。
  9. n1.peer = n2; n2.peer = n1强引用互相指向,形成环,计数循环无法归零。
  10. n1.peer = weakref.ref(n2)弱引用不增加强引用计数,环被打破,对象可被立即回收。

常见误区

误区我先用裸指针指向同一个对象,然后再用 shared_ptr 去管理它,只要我不 delete,应该没问题吧?

正解混用的危险在于所有权不清晰。智能指针认为自己是唯一管家,如果裸指针在某个分支里执行了 delete,智能指针析构时会再次释放同一块内存,造成双重释放;如果裸指针长期保存而智能指针计数归零,对象提前销毁,裸指针就成了悬空指针。正确做法是:对象一旦交给智能指针,就彻底放弃裸指针操作。

为何常见:初学者容易把智能指针理解成“带自动释放功能的裸指针”,而忽略了它更本质的“所有权归属”语义,总觉得可以互相替换。

误区shared_ptr 的引用计数是原子的,那我多个线程同时操作它管理的对象,肯定也是线程安全的。

正解引用计数的增减是原子操作,这只保证“多个线程拷贝或销毁 shared_ptr 时计数不混乱”,不保证“多个线程同时修改对象内部数据是安全的”。对象的数据竞争仍然需要互斥锁或原子变量来保护。

为何常见:人们容易把“计数安全”误认为“整体线程安全”,因为术语里都有“安全”二字,但实际上保护的范围完全不同。

误区有了 weak_ptr,我就可以像 shared_ptr 一样直接访问对象,反正它也是从 shared_ptr 变过来的。

正解weak_ptr 没有 operator-> 和 operator*,因为对象可能已经销毁,直接访问就是悬空。它必须通过 lock() 临时升级为 shared_ptr,确认对象还活着再使用。这是 C++ 设计上的刻意约束,为的是强制你进行安全检查。

为何常见:weak_ptr 名字里带“ptr“,直觉上会以为它和普通指针用法相似,忽略了”弱引用可能悬空”这一核心特性。

误区我想给 shared_ptr 传一个自定义删除器,是不是得像 unique_ptr 那样在尖括号里写明删除器类型?

正解shared_ptr 不需要,删除器类型在构造时传入并从类型中“擦除”,所以 shared_ptr<int> p(new int, 自定义删除器) 完全合法。unique_ptr 才需要把删除器作为模板参数,因为 unique_ptr 没有控制块,必须把删除器编码进类型里。

为何常见:学习者先接触 unique_ptr,习惯性地把 unique_ptr 的模板参数规则套用到 shared_ptr 上,没有意识到两者控制块的差异。

误区make_shared 把对象和控制块分配在一起,那只要还有 weak_ptr 存在,控制块就不会释放,对象内存岂不是永远卡住?

正解weak_ptr 确实会延长控制块的生命周期,但对象本身的生命周期仍由 use_count 决定。当强引用计数归零时,对象的析构函数会立即执行,只是那块合并分配的内存暂时不归还给操作系统,将来控制块释放时一并归还。这属于可接受的实现细节,不是泄漏。

为何常见:看到“对象和控制块一起分配”容易把两者生命周期绑定,但析构时机和内存回收时机其实是两回事。

面试题

Q1. 下列哪一项最符合 RAII 的核心思想?

单选题 · easy

  • A.每次用完手动调用 delete
  • B.资源随对象生命周期自动释放← 正确答案
  • C.用宏定义简化内存操作
  • D.禁止程序使用任何指针

答案:B。RAII 将资源获取放在构造函数、释放放在析构函数,让对象作用域结束时编译器自动调用析构,从而自动释放资源。就像合租公寓的宿管在住户搬走时自动清理房间,不需要住户自己打电话报修。手动 delete、宏操作、禁指针都不是 RAII。

Q2. unique_ptr 的拷贝构造被删除,那如何把一个对象的所有权转交给另一个 unique_ptr?

单选题 · mid

  • A.直接赋值给新变量
  • B.使用 std::move 移动转移← 正确答案
  • C.用 malloc 复制一份
  • D.用 reinterpret_cast 强转

答案:B。unique_ptr 是独占所有权,拷贝被禁止,唯一合法转移路径是移动语义 std::move,转移后原指针变空。这就像只允许把唯一钥匙从左手递到右手,不允许复制一把新钥匙。

Q3. shared_ptr 的引用计数归零时,会发生什么?

单选题 · easy

  • A.对象自动被 delete 释放← 正确答案
  • B.程序立即进入死循环
  • C.引用计数变成负数
  • D.对象被移动到堆外

答案:A。计数归零意味着没有任何 shared_ptr 再指向该对象,控制块会触发 delete 释放对象。这是共享所有权最核心的语义,类似宿管发现公共物品的借书卡全部归还后,就把物品收进仓库。

Q4. 想要安全地检查 weak_ptr 指向的对象是否还活着,应该调用哪个函数?

单选题 · mid

  • A.get()
  • B.lock()← 正确答案
  • C.release()
  • D.reset()

答案:B。weak_ptr 通过 lock() 尝试升级为 shared_ptr,若对象存活则返回有效句柄,否则返回空指针。get() 是返回裸指针,release() 和 reset() 各有不同语义,都不用于弱引用的安全检查。

Q5. 两个对象通过 shared_ptr 互相引用后,外部变量全部销毁,为什么会导致内存泄漏?

单选题 · mid

  • A.shared_ptr 析构函数不执行
  • B.互相持有使计数永不归零← 正确答案
  • C.编译器优化掉了函数调用
  • D.对象被移到全局静态区

答案:B。A 持有指向 B 的 shared_ptr,B 也持有指向 A 的 shared_ptr,双方计数都被对方保住至少为 1,外部释放无法让计数归零,因此两个对象都永远不会被 delete。这正是需要用 weak_ptr 打破的循环引用困局。

Q6. 相比直接 new 后再交给 shared_ptr,make_shared 的最大优势是什么?

单选题 · easy

  • A.生成的代码更短
  • B.一次分配对象和控制块← 正确答案
  • C.不需要包含头文件
  • D.引用计数变成静态的

答案:B。make_shared 将对象和控制块合并到同一次堆分配,减少了分配次数并提升缓存友好性,同时创建即托管,保证异常安全。

Q7. unique_ptr 的自定义删除器类型存放在哪里?

单选题 · hard

  • A.unique_ptr 的模板参数中← 正确答案
  • B.运行时的全局注册表里
  • C.shared_ptr 的控制块里
  • D.操作系统的内存映射中

答案:A。unique_ptr 没有控制块,删除器类型作为模板参数参与类型定义,因此不同删除器会生成不同的 unique_ptr 类型。shared_ptr 则因控制块而实现删除器类型擦除,两者机制完全不同。

Q8. 下列哪些场景适合使用 weak_ptr?

多选题 · mid

  • A.缓存对象,观察是否已被释放← 正确答案
  • B.打破 shared_ptr 循环引用← 正确答案
  • C.长期持有对象延长期命
  • D.临时借用对象不延长生命周期← 正确答案

答案:ABD。weak_ptr 是“观察者”,不增加强引用计数,适合缓存观察、打破循环、临时借用。若想长期持有或延长对象生命,应使用 shared_ptr,而不是 weak_ptr。

Q9. 下列哪些行为会破坏智能指针的所有权纪律?

多选题 · hard

  • A.用裸指针指向同一对象后手动 delete← 正确答案
  • B.两个 shared_ptr 用不同裸指针指向同一对象← 正确答案
  • C.对 get() 返回的裸指针调用 delete← 正确答案
  • D.用 make_unique 创建对象后交给 unique_ptr

答案:ABC。裸指针手动 delete 会造成双重释放;两个 shared_ptr 由不同裸指针创建会让两个控制块都认为自己是唯一所有者;对 get() 结果 delete 更是直接踩中“释放后被再次释放”的雷区。用 make_unique 正常创建则安全合规。

Q10. 关于 shared_ptr 的线程安全,下列哪些说法正确?

多选题 · mid

  • A.引用计数的增减是原子的← 正确答案
  • B.拷贝同一个 shared_ptr 是线程安全的← 正确答案
  • C.对象内部数据仍需加锁保护← 正确答案
  • D.所有操作无需加锁全部安全

答案:ABC。shared_ptr 只保证控制块计数操作原子且安全,多线程拷贝、销毁计数器不会错,但这也只保护“指针本身的操作”,对象数据的并发读写仍要在别处加锁。说“所有操作完全线程安全”是常见误解。