动态内存
掌握C++动态内存管理——shared_ptr/unique_ptr/weak_ptr三种智能指针、new/delete、动态数组,告别裸指针和内存泄漏
学习目标
- 能实现用 shared_ptr 管理共享所有权的动态对象——用 make_shared 创建、解释强引用计数的增减,并判断何时调用删除器
- 能比较并选择所有权模型——共享用 shared_ptr、独占用 unique_ptr、观察用 weak_ptr,并写出不带手动 delete 的异常安全代码
- 能回答:下面这段代码结束时,A 和 B 两个对象是否都会被释放?如果不会,怎么修?
struct B; struct A { std::shared_ptr<B> b; }; struct B { std::shared_ptr<A> a; }; auto a = std::make_shared<A>(); auto b = std::make_shared<B>(); a->b = b; b->a = a;
机制总览
动态内存:机制路径
- 1
直觉:为什么你需要操心“谁负责清理”
在之前的章节里,你写的所有变量——int、string、vector——在离开大括号时都会自动消失。这就是"栈"上的变量:创建和销毁都由编译器管,你不用操心。
- 2
智能指针:自动帮你打扫的三个兄弟
C++11 引入了三种 智能指针(smart pointer) ——它们把清理动作绑定到所有者对象的生命周期,减少手写释放路径,但不会自动替你判断所有权图是否正确。
- 3
官方 Chapter 12 的完整所有权地图
原书从智能指针继续向下追到直接管理内存、动态数组、 allocator ,最后用文本查询程序证明共享所有权的价值。关键问题始终相同:谁拥有资源、谁只观察、何时对象被构造、何时删除器可安全运行。
章级决策实验
动态内存:机制与证据
切换《动态内存》的三个关键教学阶段,先解释机制,再用运行与失败证据验证结论。
选择推理阶段
当前阶段 · 直觉:为什么你需要操心“谁负责清理”
在之前的章节里,你写的所有变量——int、string、vector——在离开大括号时都会自动消失。这就是"栈"上的变量:创建和销毁都由编译器管,你不用操心。
可核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:为什么你需要操心“谁负责清理”」的契约。
学完《动态内存》后,应能从输入和前置条件推导状态变化,并用可重复的构建、运行或边界测试证明结果。
失效—证据矩阵
动态内存:失效与核验
直觉:为什么你需要操心“谁负责清理”
典型失效
若把「直觉:为什么你需要操心“谁负责清理”」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「直觉:为什么你需要操心“谁负责清理”」的契约。
智能指针:自动帮你打扫的三个兄弟
典型失效
若把「智能指针:自动帮你打扫的三个兄弟」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「智能指针:自动帮你打扫的三个兄弟」的契约。
官方 Chapter 12 的完整所有权地图
典型失效
若把「官方 Chapter 12 的完整所有权地图」当作孤立语法点,忽略类型约束、对象生命周期或库契约,代码即使通过编译也可能破坏不变量。
核验证据
保留编译诊断,运行本节最小示例,并用边界断言、对象计数或 sanitizer 复核「官方 Chapter 12 的完整所有权地图」的契约。
直觉:为什么你需要操心“谁负责清理”
在之前的章节里,你写的所有变量——int、string、vector——在离开大括号时都会自动消失。这就是"栈"上的变量:创建和销毁都由编译器管,你不用操心。
自动对象的生命周期通常受作用域控制,而有些数据大小到运行时才知道,或需要被多个对象按独立于当前调用栈的生命周期共享。返回值、移动和容器已经能解决很多场景;确实需要动态分配时,关键不是“堆比栈活得久”,而是明确谁拥有、谁观察、何时释放。
这一章解决什么问题? 给你三样"自动保洁"工具——一个专门共享的、一个坚决独享的、一个只看着不干的——让你用规则代替记忆,从此不必手写 delete。没有它们?漏一次 delete 就漏一块内存,程序跑久了就吃光你的内存。
智能指针:自动帮你打扫的三个兄弟
C++11 引入了三种 ↡封装指针与所有权规则的类模板。unique_ptr 表达独占,shared_ptr 通过控制块表达共享,weak_ptr 只观察;拥有者结束时按删除器释放资源,但循环所有权仍需设计者处理。——它们把清理动作绑定到所有者对象的生命周期,减少手写释放路径,但不会自动替你判断所有权图是否正确。
三兄弟各有各的本事:
-
shared_ptr:允许多个指针共享同一个对象。控制块维护↡shared_ptr 控制块中的强所有者计数。最后一个强引用消失时调用所保存的删除器;weak_ptr 使用另一个弱计数,不延长对象生命周期。——强计数降到零时调用删除器;若所有权成环,计数不会自然归零。
-
unique_ptr:独占一个堆对象。不能拷贝(拷贝构造函数和赋值运算符被显式删除了),只能把所有权↡C++11 引入的语义——不拷贝资源本身,只转移所有权。用于 unique_ptr 的转移(std::move)。转移后源指针变空(nullptr)。给另一个 unique_ptr。就像一件传家宝——同一时刻只能一个人戴着,可以传给下一个,但不可能凭空变出一份。
-
weak_ptr:不拥有堆对象,只是一个"旁观者"。它不增加引用计数,所以不阻止对象被销毁。主要用于打破两个 shared_ptr 之间的↡对象 A 持有对象 B 的 shared_ptr,同时对象 B 也持有对象 A 的 shared_ptr。两者互相持有导致引用计数永远不会降到 0,两个对象都无法释放。用 weak_ptr 拆掉一边即可解决。。就像你远远看着一座房子——你能知道它还在不在,但它没了也不影响你。
一句话选型口诀:共享用 shared,独占用 unique,旁观用 weak。
官方 Chapter 12 的完整所有权地图
原书从智能指针继续向下追到直接管理内存、动态数组、allocator,最后用文本查询程序证明共享所有权的价值。关键问题始终相同:谁拥有资源、谁只观察、何时对象被构造、何时删除器可安全运行。
shared_ptr:最常用的共享管家
shared_ptr 是日常用得最多的智能指针。它背后有三个部分在协作:
make_shared<T>() 一次分配控制块和堆对象在连续内存,引用计数初始为 1。优于先 new T 再传 shared_ptr 的两步分配。如何创建 shared_ptr
推荐用 make_shared<T>()——实现通常把控制块和对象放进一次分配,减少分配开销,并避免在复杂表达式中让裸指针处于尚未被接管的中间态:
auto sp = std::make_shared<std::string>("hello");
// sp 现在持有堆上的 string("hello"),引用计数 = 1make_shared 接收构造参数并转发给 T。若资源由 C API 或其他分配器创建,不能默认套用 delete,必须按 API 契约提供匹配删除器:
std::shared_ptr<int> sp(new int(42)); // new 创建的 int,可由默认 delete 管理引用计数怎么变
shared_ptr 的引用计数是自动增减的,你可以用 use_count() 查看。下面这个例子展示了整个生命周期:
auto sp1 = std::make_shared<int>(10); // use_count = 1
{
auto sp2 = sp1; // use_count = 2 (拷贝构造)
auto sp3 = sp1; // use_count = 3
// sp3 离开作用域 → use_count = 2
}
// sp2 离开作用域 → use_count = 1
// sp1 离开调用函数时会降到 0 → 堆对象被 delete要点:use_count 只在"有几个 shared_ptr 指向它"这个意义上可靠,不要用它做任何业务逻辑——它的值只是随着别处 shared_ptr 的存活/离开而变化。你只需信任它——最后一个离开的人会关门。
拷贝 vs 重置 vs 析构
把 shared_ptr 比一块共享内存上的多个"钥匙"。每种操作对引用计数和堆对象的影响见下——你用 Stepper 一步步看:
先预测:
sp1与sp2共享对象时先sp1.reset(),对象会不会立刻析构?再让sp2离开作用域,强计数、对象和控制块分别发生什么?写下答案后再逐步验证。
创建
auto sp1 = std::make_shared<Widget>();make_shared<T>() 一次分配控制块和堆对象在连续内存,引用计数初始为 1。优于先 new T 再传 shared_ptr 的两步分配。unique_ptr:独享且无共享计数
unique_ptr 没有共享引用计数,空删除器常可借助空基类优化做到接近裸指针大小;但状态化删除器会增加对象大小,标准不保证固定为一个指针。代价是不能拷贝,只能移动。
创建和使用
#include <memory>
std::unique_ptr<int> up(new int(42)); // 本书 C++11 写法
std::cout << *up << '\n'; // 用 * 解引用,和裸指针一样
up.reset(); // 显式释放,等价于 up = nullptr
up.reset(new int(100)); // 释放旧对象并接管新对象make_unique 到 C++14 才进入标准库。C++11 项目可把裸 new 直接放进 unique_ptr 构造表达式,或提供经过评审的 make_unique 辅助函数;不要先把裸指针存进临时变量再跨多条可能抛异常的语句传递。
release 和 reset 的区别
reset(p):释放当前所有对象(如果有),接管新的裸指针prelease():放弃当前对象的所有权,返回裸指针——但不 delete。调用方从此负责手动 delete
std::unique_ptr<int> up(new int(42));
int* raw = up.release(); // up 现在是空,raw 指着那块内存
// 你现在必须手动 delete raw,否则内存泄漏
delete raw;几乎永远优先用 reset——它按删除器释放旧对象。release 只在所有权确实转交给另一接口时使用,并必须确认接收方采用匹配的释放机制;C API 未必使用 C++ delete。
weak_ptr:破掉循环引用这个死结
两个 shared_ptr 互相持有是动态内存管理中最经典的陷阱。看这个例子:
在没有 weak_ptr 的世界里(左图),A 和 B 互相持有对方的 shared_ptr:spA/spB 离开作用域后各自的 use_count 从 2 变成 1(对方手里还拿着),没有谁降到 0——两块内存永远挂在堆上。
公式:把回看的一边换成 weak_ptr。A → B 用 shared_ptr(正常父子关系),B → A 用 weak_ptr(只观察、不增加计数)。这样 spA 离开时 use_count 降到 0,A 正常析构,析构过程中 A 持有的 B 的 shared_ptr 也被析构 → B 的计数变 0 → B 也正常析构。
从 weak_ptr 获取 shared_ptr:lock()
weak_ptr 不能直接解引用——它不拥有对象。必须通过 lock() 获取一个临时的 shared_ptr:
std::shared_ptr<int> sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp; // wp 不会增加引用计数
if (auto locked = wp.lock()) { // lock() 返回 shared_ptr
*locked = 100; // 可以安全使用了
} else {
// 对象已被销毁
}lock() 返回空表示对象的最后一个 shared_ptr 已离开——你已经帮不上什么忙了,优雅地走开。用 expired() 可以提前判断,但判断后到 lock() 之间可能对象已被销毁——所以直接用 lock() + 判空才是正确姿势。
new/delete:现代 C++ 应该少用,但必须懂
在智能指针出现之前,new/delete 是程序员唯一的堆内存工具。如今你仍会在以下场景遇到它们:遗留代码、C API 返回裸指针、"placement new" 这类高级用法。了解它们才能理解智能指针在替你做什么。
new 做了什么
new 做两件事——不要混在一起理解:
- 分配内存:调用
operator new在堆上申请足够放一个T的空间 - 构造对象:在那块内存上调用
T的构造函数
int *pi = new int; // 分配 + 默认初始化(值未定义)
int *pi2 = new int(42); // 分配 + 值初始化
std::string *ps = new std::string("hello"); // 分配 + 调用构造
auto *ps2 = new auto(*ps); // 用 *ps 的类型和值复制一份(C++11)delete 则反过来:
- 析构对象:调用
T的析构函数 - 释放内存:调用
operator delete把空间还回堆
忘了 delete = 内存泄漏。delete 了又用 = 使用后释放(undefined behavior)。
new[] / delete[]:动态数组
分配数组用 new[],释放必须配套用 delete[]——不是 delete!
int *arr = new int[100]; // 堆上分配 100 个 int
// ... 使用 arr[0..99] ...
delete[] arr; // 必须 delete[]!写成 delete arr 是未定义行为"忘记 delete[]"是多容易翻车? 函数里一提前 return、一抛异常——delete[] 就被跳过去了。这就是为什么 ↡Resource Acquisition Is Initialization,把资源的有效期绑定到对象生命周期。构造成功后对象负责清理,析构通过匹配的资源释放操作完成收尾。 是现代 C++ 的核心思想:
delete[](提前 return / 抛异常)→ 内存泄漏。vector 方案:自动管理堆数组,RAII 保证离开作用域时自动 delete[];支持 push_back 自动扩容、范围 for 遍历;异常安全。一句话总结:需要可变长度对象序列时优先 vector。 它用分配器管理连续存储,自动处理元素构造、销毁与异常回滚,并提供容量和迭代器接口。new[] 仍可能出现在 ABI、底层库、定制布局或遗留接口边界,但它不是简单的“更省几个字节”,也不会自动提供长度与所有权信息。
allocator:把分配原始存储与构造对象分开
new T[n] 会同时分配存储并默认构造 n 个对象;有时程序只想先取得空间,随后按实际读入数量逐个构造。↡C++11 中用于分配未构造原始存储并显式构造、销毁对象的标准工具。allocate 与 deallocate 管存储,construct 与 destroy 管对象生命周期。 把这两个阶段分开:
std::allocator<std::string> alloc;
const std::size_t n = 100;
std::string *const begin = alloc.allocate(n);
std::string *current = begin;
try {
std::string value;
while (current != begin + n && std::cin >> value)
alloc.construct(current++, value);
} catch (...) {
while (current != begin) alloc.destroy(--current);
alloc.deallocate(begin, n);
throw;
}
while (current != begin) alloc.destroy(--current);
alloc.deallocate(begin, n);只有 [begin,current) 中的对象已构造,才能解引用或调用 destroy;未构造存储不是 T 对象数组。deallocate 前必须先销毁全部已构造对象,并传回与 allocate 相同的元素数量。实际业务优先使用 vector,这里只用 allocator 看清存储与对象生命周期是两件事。
TextQuery:共享文本而不复制结果
↡C++ Primer 第十二章的文本查询设计。TextQuery 拥有输入行与单词到行号集合的索引,QueryResult 用 shared_ptr 共享这些数据,使查询结果离开查询函数后仍然有效。 是本章的综合案例。TextQuery 读取文件并拥有文本;每个 QueryResult 只保存查询词、匹配行号集合和原文本的 shared_ptr,不复制整份文件:
class QueryResult;
class TextQuery {
public:
typedef std::vector<std::string>::size_type line_no;
explicit TextQuery(std::ifstream &);
QueryResult query(const std::string &) const;
private:
std::shared_ptr<std::vector<std::string> > file;
std::map<std::string, std::shared_ptr<std::set<line_no> > > wm;
};
class QueryResult {
public:
QueryResult(std::string s,
std::shared_ptr<std::set<TextQuery::line_no> > p,
std::shared_ptr<std::vector<std::string> > f)
: sought(s), lines(p), file(f) {}
friend std::ostream &print(std::ostream &, const QueryResult &);
private:
std::string sought;
std::shared_ptr<std::set<TextQuery::line_no> > lines;
std::shared_ptr<std::vector<std::string> > file;
};构造时,file 由 make_shared<vector<string>>() 创建;读取每行后,索引通过 wm[word] 取得对应的 shared_ptr<set<line_no>>,首次遇到单词时再创建集合。query 用 find 做纯查询,命中则把索引中的 shared_ptr 连同 file 传给结果:
QueryResult TextQuery::query(const std::string &word) const {
static std::shared_ptr<std::set<line_no> > noData(
new std::set<line_no>);
std::map<std::string,
std::shared_ptr<std::set<line_no> > >::const_iterator found =
wm.find(word);
if (found == wm.end())
return QueryResult(word, noData, file);
return QueryResult(word, found->second, file);
}即使原 TextQuery 对象先离开作用域,只要 QueryResult 仍持有 file 与 lines,底层数据就继续存活。这个设计把“结果借用查询器内部引用”的悬空风险,转换成明确的共享所有权成本。
实战:用智能指针写一个安全的二叉树节点
下面是本章最重要的一个完整例子:一个二叉树节点类,展示 shared_ptr 和 weak_ptr 各自该在什么位置出现。
#include <memory>
#include <string>
#include <iostream>
struct TreeNode {
std::string value;
std::shared_ptr<TreeNode> left; // 父→子:用 shared_ptr
std::shared_ptr<TreeNode> right;
std::weak_ptr<TreeNode> parent; // 子→父:用 weak_ptr,避免循环引用
TreeNode(const std::string& v) : value(v) {}
~TreeNode() { std::cout << "释放 " << value << '\n'; }
};父子关系是天然的"所有权":父亲创建孩子(make_shared)、父亲被销毁后孩子应该跟着消失。所以 left / right 用 shared_ptr 没问题——父亲持有孩子的所有权。
这里选择 shared_ptr 是为了允许树外代码长期共享某个子节点。若模型保证严格树形且父节点独占孩子,unique_ptr 子节点通常表达得更准确,parent 可使用生命周期受树约束的非拥有原始指针;不要为了“看起来安全”默认把所有边都改成 shared_ptr。
但孩子回看父亲如果用 shared_ptr,就会形成循环:父亲持有孩子 → 孩子持有父亲 → 谁都不释放。孩子对父亲用 weak_ptr——只观察、不拥有。
auto root = std::make_shared<TreeNode>("根");
root->left = std::make_shared<TreeNode>("左孩子");
root->right = std::make_shared<TreeNode>("右孩子");
root->left->parent = root; // weak_ptr = shared_ptr,合法
root->right->parent = root;遍历时通过 lock() 临时拿到 shared_ptr 来访问父节点:
void print_value(const std::shared_ptr<TreeNode>& node) {
std::cout << node->value;
}
// 从任意节点打印从根到当前节点的路径
void print_path(const std::shared_ptr<TreeNode>& node) {
if (auto p = node->parent.lock()) // lock() 拿到临时的 shared_ptr
print_path(p); // 递归向根走
print_value(node); // 到达根后自上而下打印
}这个所有权图没有强引用环,因此 root 以及所有外部强引用离开后,节点能按引用计数释放。没有手写 delete 不等于任意 shared_ptr 图都不会泄漏;正确性来自明确的强边与弱边,而不是指针类型名字本身。
删除器:让 shared_ptr 不只是 delete
shared_ptr 默认用 delete 释放堆对象。但如果你要管理一个不是 new 出来的资源(比如 fopen 打开的文件),可以指定一个↡shared_ptr/unique_ptr 析构时调用的自定义释放函数,替代默认的 delete。可以是函数指针、lambda、函数对象。用于管理非 new 分配的资源(如 fopen 的文件、malloc 的内存)。:
// 管理一个 FILE*,析构时自动 fclose
auto closer = [](FILE* f) { if (f) fclose(f); };
std::shared_ptr<FILE> fp(fopen("data.txt", "r"), closer);
// 离开作用域时调用 closer;打开与关闭错误仍需业务代码检查unique_ptr 也支持删除器,但需要把它写在模板参数里(std::unique_ptr<T, Deleter>),语法稍重;shared_ptr 的删除器是运行时存下来的,语法更轻。
常见误区
小结
- 三兄弟各司其职:shared_ptr 共享陪伴、unique_ptr 独占传承、weak_ptr 旁观破环——选对就是安全
- make_shared 优先:实现通常合并控制块与对象分配,并避免裸指针中间态;永远不要从同一裸指针建立两个独立控制块
- unique_ptr 不能拷贝、只能 move:这是编译期硬约束;它没有共享计数,但状态化删除器可能增加对象大小
- 循环引用用 weak_ptr 破:A 持有 B 用 shared,B 回看 A 用 weak——单向 shared + 反向 weak = 安全公式
- 对象与存储分开看:vector 是动态序列默认选择;allocator 用于显式控制构造区间,new[]/delete[] 必须严格配对
练习
问题 1(代码填空) 下面的 Widget 类有一个子 Widget 列表和一个父 Widget。子用 shared_ptr、父用 weak_ptr——把 ??? 替换成正确的类型,并写一行创建父子关联的代码。
struct Widget {
std::string name;
??? children; // Widget 的孩子们
??? parent; // 指向父 Widget(只观察)
Widget(const std::string& n) : name(n) {}
};
int main() {
auto root = std::make_shared<Widget>("root");
auto child = std::make_shared<Widget>("child");
// 写下让 root 成为 child 的父、child 成为 root 的子 的一行代码:
}问题 2(改代码) 改写下面这段代码,让它不再手动 new/delete,而使用合适的智能指针。然后回答:改写后 get_widget() 返回的是什么所有权?
Widget* get_widget() {
return new Widget("factory made");
}
void use_widget() {
Widget* w = get_widget();
w->do_something();
delete w; // 这里如果抛异常就会跳过 delete!
}问题 3(判断与修复) 下面每一段代码,判断它是否安全释放资源;若不安全或不能编译,说明原因并给修复:
a)
auto sp = std::make_shared<int>(10);
sp.reset();b)
int* raw = new int(10);
auto sp1 = std::shared_ptr<int>(raw);
auto sp2 = std::shared_ptr<int>(raw);c)
std::unique_ptr<int> up1(new int(10));
auto up2 = up1; // 编译错误!名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 智能指针(smart pointer)
- 封装指针与所有权规则的类模板。unique_ptr 表达独占,shared_ptr 通过控制块表达共享,weak_ptr 只观察。拥有者结束时按删除器清理,但强引用环仍需设计者用弱边打破。
- 引用计数(reference count / use_count)
- shared_ptr 控制块中的强所有者计数。最后一个强引用消失时调用保存的删除器并销毁对象;weak_ptr 不增加强计数,控制块可能继续存在到最后一个弱引用消失。
- 移动(std::move)
- C++11 引入的语义——不拷贝资源本身,只把所有权从一个对象转给另一个。unique_ptr 只能移动不能拷贝。移动后源指针变空(nullptr)。类比:把一件传家宝交给下一个人——你不能凭空复制一件,但可以传出去。
- 循环引用(circular reference)
- 两个对象各自持有对方的 shared_ptr:A 有 B 的 shared_ptr、B 也有 A 的 shared_ptr。各自的引用计数永远大于等于 1,谁都不会被 delete——两块内存在堆上永远挂着。解法:把回看的那一边从 shared_ptr 改成 weak_ptr(不增加引用计数)。
- RAII(Resource Acquisition Is Initialization)
- 把资源有效期绑定到对象生命周期。构造成功后对象负责收尾,析构调用与资源匹配的释放操作;异常展开时局部拥有者也会析构。它减少遗漏路径,但不能修复错误的所有权图。
- 删除器(deleter)
- shared_ptr 或 unique_ptr 析构时调用的自定义释放函数。默认是
delete,但你可以改成fclose、free或任何你需要的清理逻辑,让智能指针管理任何资源(文件、socket、GPU 纹理等)。 - allocator(allocator)
- C++11 中用于分开管理原始存储和对象生命周期的工具。allocate/deallocate 管存储,construct/destroy 管已构造对象;释放前必须只销毁实际构造过的区间。
- 文本查询共享模型(TextQuery/QueryResult)
- 本章综合案例。TextQuery 拥有原文与单词索引,QueryResult 通过 shared_ptr 共享文本和匹配行集合,使结果离开查询函数后仍有效且无需深拷贝整份文件。