一段动态内存代码最危险的时刻,往往不是写下 new 的时候,而是几周后有人在中间加了一条 return。
void load_preview(bool cache_hit) {
int* pixels = new int[4096]{};
if (cache_hit) {
return; // pixels 从此没有机会被释放
}
delete[] pixels;
}这段代码没有复杂算法。它只是把“释放责任”藏在了控制流末尾。提前返回、异常、后续重构,任何一项都可能把 delete[] 绕过去。
动态内存管理的主线因此不是记忆更多指针语法,而是持续回答三个问题:对象现在是否还活着,谁拥有它,谁只是在临时借用它。现代 C++ 会尽量把答案写进类型,让作用域和析构函数完成清理。
资源泄漏指程序已经失去释放某项资源的可靠路径。内存只是最常见的一种资源;文件、锁、套接字和线程句柄也会泄漏。
上例中的 pixels 是唯一保存动态数组地址的变量。一旦函数提前返回,指针变量被销毁,但数组没有被销毁。裸指针离开作用域不会自动释放它指向的对象。
指针还可能在赋值时丢掉原地址:
int* data = new int[128]{};
data = new int[256]{}; // 第一块分配的地址已经丢失
delete[] data; // 这里只能释放第二块这里没有“少写一条 delete[]”那么简单。第一块地址已不可达,后面再补清理语句也找不回它。
如果需求只是“一组运行时才知道数量的元素”,std::vector 已经把分配、构造、扩容、析构和释放绑在一个对象里:
#include <iostream>
#include <vector>
void load_preview(bool cache_hit) {
std::vector<int> pixels(4096, 0);
if (cache_hit) {
std::cout << "cache hit, size=" << pixels.size() << '\n';
return;
}
std::cout << "decoded, size="
输出:
cache hit, size=4096
decoded, size=4096两条路径都会离开 pixels 的作用域,因此都会调用它的析构函数。代码不再需要为每条路径复制清理逻辑。
处理动态数量的普通元素时,先问能否使用 vector 或 string。让容器拥有资源,通常比给裸指针补更多检查更可靠。
程序中的对象并不都以同一种方式存在。理解动态内存前,先把几类常见存储期摆在一起:
“栈”和“堆”是常用的实现模型;写代码时更值得关注的是存储期。局部指针通常是自动对象,而它指向的对象可能具有动态存储期,两者的生命周期完全可以不同。
内存可以看作连续的字节位置。地址用来定位某个位置,T* 则表示“这里应当有一个 T 对象”。
int score = 86;
int* address = &score;
*address = 90;&score 取得地址,*address 访问那个地址上的 int 对象。指针类型仍然重要:它决定解引用时按什么类型解释存储,也决定指针递增时跨过多大的元素。
一个地址可以被复制很多次:
int* first = &score;
int* second = first;此时有两个地址副本,却仍然只有一个 score 对象。类似地,复制一个指向动态对象的裸指针,并不会创建第二个对象,也不会自动建立可靠的共享释放协议。
#include <iostream>
#include <memory>
#include <string>
#include <utility>
class Trace {
public:
explicit Trace(std::string name) : name_(std::move(name)) {
std::cout << "construct " << name_ << '\n';
}
~Trace() {
std
输出:
construct outer
construct dynamic
construct inner
destroy inner
destroy dynamic
after inner block
destroy outer同一作用域内,已完成构造的局部对象按相反顺序析构。dynamic 自身是局部所有者;它析构时再销毁所拥有的动态对象。
对象的生命周期从初始化或构造完成后开始。只有一段存储并不等于其中已经存在可访问的对象;这个区别在 allocator 部分会再次出现。

审查动态对象时,可以先不看具体类名,只画关系:
owner ──拥有──> object
reader - -借用- -> object
view - -借用- -> object拥有边决定对象何时销毁;借用边只允许在对象存活期间访问。对象可以有很多借用者,但释放责任必须能从拥有关系中明确推出。
常见接口可以直接表达这种差异:
struct Report;
void inspect(const Report& report); // 必须存在,只借用
void inspect_if_any(const Report* report); // 可以为空,只借用引用适合“调用期间一定有对象”的参数。裸指针适合“可以没有对象”或需要与旧接口交互的非拥有参数。两者都不应在函数内部执行 delete,除非接口明确约定了所有权转移;现代接口通常会改用智能指针表达转移。
new 与 delete 各自做两件事对类对象而言,new T(args...) 先取得足够的存储,再在其中构造 T。delete p 先调用析构函数,再归还存储。
数组有自己的配对规则:
#include <iostream>
#include <string>
struct Token {
std::string text;
~Token() {
std::cout << "destroy " << text << '\n';
}
};
int main() {
Token* one = new Token{"one"};
Token* batch = new Token[
输出:
destroy one
destroy right
destroy leftnew T 必须配 delete,new T[n] 必须配 delete[]。也不能把 malloc/free 与 new/delete 交叉搭配,因为前一组只处理字节存储,后一组还承担对象构造和析构。
int* unknown = new int; // 未给出可依赖的初值
int* zero = new int{}; // 值初始化为 0
int* answer = new int{42}; // 初始化为 42
delete unknown;
delete zero;
delete answer;不要读取 *unknown 来观察所谓“垃圾值”。未初始化读取没有可靠语义,某次运行看到的数字不能变成程序约定。
下面是仅供审查的危险反例,不要把它当成可运行实验:
void unsafe_examples() {
int* owner = new int{7};
int* alias = owner;
delete owner;
owner = nullptr;
// std::cout << *alias; // 未定义行为:alias 已悬空
// delete alias; // 未定义行为:同一对象再次释放
}把 owner 设为 nullptr 只改变这个变量。alias 并不会同步变空,因此“释放后置空”不能代替所有权设计。
悬空访问、越界和重复释放都属于未定义行为。不要记录一次偶然输出并据此推断规律;应从生命周期规则判断代码错误。
RAII 的做法很直接:构造函数取得资源,析构函数释放资源。使用者管理的是一个正常对象,而不是一对可能被控制流拆散的“获取/释放”语句。
vector 管理动态数组,fstream 管理文件,智能指针管理动态对象。它们的共同点是:资源所有者本身具有明确作用域。
下面用输出展示异常发生时的析构顺序:
#include <iostream>
#include <stdexcept>
#include <string>
#include <utility>
class Session {
public:
explicit Session(std::string name) : name_(std::move(name)) {
std::cout << "open " << name_ << '\n';
}
~Session() {
std
输出:
open network
open cache
close cache
close network
caught: sync failed寻找匹配处理器的过程中,已经完成构造的局部对象会按规则析构。这个过程让早返回和异常不再需要各写一份清理代码。
异常安全只讨论合法操作失败时如何收尾。越界写、空指针解引用、释放后访问并不会自动变成可捕获异常。
析构正是异常展开期间依赖的清理机制。如果清理本身又让异常逃出,程序很难建立一致的处理规则。因此,资源类的析构函数通常应完成有限、可靠且不抛出的清理。
在函数里到处写 catch (...) 再手动清理,通常说明资源还没有交给合适的 RAII 对象。捕获异常应服务于恢复或转换错误,而不是弥补所有权缺失。

unique_ptr 表达独占所有权std::unique_ptr<T> 表示独占所有权。它不能复制,因为复制会制造两个都准备销毁同一对象的所有者;它可以移动,因为移动会把责任从旧对象转给新对象。
移动前:first ──拥有──> Task
移动后:first (空) Task <──拥有── second优先用 std::make_unique 创建对象,使动态对象从创建完成起就处在所有者中:
#include <iostream>
#include <memory>
#include <string>
#include <utility>
struct Task {
explicit Task(std::string name) : name(std::move(name)) {
std::cout << "create " << this->name << '\n';
}
~Task() {
std
输出:
create compile
observe compile
first owns: false
take compile
destroy compile
second owns: false移动后的 unique_ptr 仍是有效对象,但通常为空。可以赋新值、reset() 或销毁它;不能在未检查时解引用。
void inspect(const Task& task); // 借用,不接管
void inspect_if_any(const Task* task); // 可空借用,不接管
void consume(std::unique_ptr<Task> task); // 按值接管
std::unique_ptr<Task> create_task(); // 返回所有权如果函数只是读取对象,传引用比传 const unique_ptr& 更聚焦,因为被调用者并不关心调用者使用哪种所有者。只有函数真的要接管或更换所有权时,接口才应出现 unique_ptr。
get、reset 与 releaseget() 返回底层裸指针,所有权仍在 unique_ptr,适合临时调用借用型旧接口。reset() 销毁当前对象,并可选择接管一个新指针。release() 交出底层指针但不销毁对象,原 unique_ptr 变空。void legacy_read(const Task* task);
void bridge() {
auto task = std::make_unique<Task>("index");
legacy_read(task.get()); // 只借用
Task* raw = task.release();
std::unique_ptr<Task> owner_again(raw); // 立刻重新接入 RAII
}release() 会把释放责任重新暴露给人,除非协议确实要求转交裸指针,否则不要使用。尤其不能对 get() 返回的指针手动 delete。
需要动态对象时,默认从 unique_ptr 开始。只有对象确实没有单一所有者,才考虑共享所有权。
shared_ptr 共享生命周期,weak_ptr 只观察std::shared_ptr<T> 用于多个所有者共同延长同一对象生命周期的场景。复制 shared_ptr 会共享同一个控制块,其中维护拥有者计数和删除方式;最后一个拥有者消失时,对象才被销毁。
优先使用 std::make_shared 创建共享对象。不要从同一个裸指针分别构造两个 shared_ptr,那会建立两个互不知情的控制块,最终可能重复释放。
#include <iostream>
#include <memory>
#include <string>
#include <utility>
struct Asset {
explicit Asset(std::string name) : name(std::move(name)) {
std::cout << "construct " << this->name << '\n';
}
~Asset() {
std
输出:
construct map
owners after copy: 2
owners during lock: 3
owners after block: 1
destroy map
expired: trueweak_ptr 不增加拥有者计数。lock() 会尝试取得一个临时 shared_ptr:对象还活着时成功并暂时增加计数,对象已销毁时得到空指针。
如果两个对象都用 shared_ptr 拥有对方,即使外部变量都离开作用域,双方的计数仍至少为 1:
left ──shared──> right
left <──shared── right这不是悬空,而是对象永远无法到达计数为零的条件。应把不承担生命周期责任的反向关系改成 weak_ptr:
#include <iostream>
#include <memory>
struct Child;
struct Parent {
~Parent() { std::cout << "destroy parent\n"; }
std::shared_ptr<Child> child;
};
struct Child {
~Child() { std::cout << "destroy child\n"; }
std::weak_ptr<
输出:
destroy parent
destroy child这里父对象拥有子对象,子对象只观察父对象。所有权图中没有闭合的拥有环。
use_count() 写进业务判断引用计数会随着临时复制、参数传递和 lock() 改变。use_count() 适合教学和调试,不应拿来决定“现在是否可以修改对象”之类的业务行为。类型和同步协议才应表达真正约束。
shared_ptr 不是“更安全的可空指针”。它声明共享生命周期,还带来控制块、计数维护和循环引用风险。只借用时仍应使用引用或非拥有指针。

unique_ptr 转移唯一责任,shared_ptr 共同延长生命周期,weak_ptr 则保留可检查的观察关系而不增加拥有者计数。vectornew T[n] 返回首元素指针,指针本身不记录 n。调用另一个函数时若只传 T*,接收方无法从指针恢复数组边界。
这会让长度、初始化状态和释放方式都落到额外约定上。数组越界访问不会因为使用了动态分配就变得安全;它仍然是未定义行为。
对普通数据集合,vector 同时保存元素、长度和容量:
#include <iostream>
#include <vector>
int main() {
std::vector<int> values;
values.reserve(8);
std::cout << std::boolalpha;
std::cout << "after reserve: size=" << values.size()
<< ", capacity>=8=" << (values.capacity() >= 8) << '\n'
输出:
after reserve: size=0, capacity>=8=true
values: 10 20 30
after clear: size=0size 与 capacity 回答不同问题size() 是当前已经构造并可访问的元素数量。capacity() 是不重新分配时最多能容纳的元素数量。reserve(n) 只保证容量至少为 n,不创建 n 个元素。clear() 销毁全部元素,使 size() 变为 0,但不承诺立即归还容量。扩容可能把元素搬到新存储,因此指向旧元素的指针、引用和迭代器可能失效。不要在 push_back 可能触发扩容后继续使用先前保存的元素地址。
unique_ptr<T[]> 的位置std::unique_ptr<T[]> 能独占一段动态数组,并在析构时正确执行 delete[]:
#include <cstddef>
#include <iostream>
#include <memory>
int main() {
constexpr std::size_t count = 4;
auto samples = std::make_unique<int[]>(count);
for (std::size_t i = 0; i < count; ++i) {
samples[i] = static_cast<int>
输出:
5 10 15 20它适合“长度固定但运行时才知道,并且接口确实需要连续数组”的低层边界。它不保存长度,也没有 vector 的迭代器、容量和插入操作,所以日常数据集合仍优先 vector。
数组长度在编译期固定时还可以考虑 std::array<T, N>。选择容器时,先让类型保存边界,再考虑是否真的需要裸数组接口。
allocator 把存储与对象拆开容器需要区分“已经取得的一块存储”和“其中已经构造的元素”。std::allocator 的模型把工作拆成四步:
allocate 取得足以容纳若干 T 的原始存储,此时其中还没有这些 T 对象。
construct 在指定位置构造对象,只有构造完成的位置才进入对象生命周期。
destroy 结束对象生命周期,把位置恢复为未构造存储。
deallocate 归还整块原始存储;传入的容量必须与分配协议匹配。
这正是 vector 中 size 与 capacity 分离的底层理由:容量可以是 8,但活对象可能只有前 3 个。
下面使用 C++17 的 allocator_traits,只在三格存储中构造两个字符串:
#include <cstddef>
#include <iostream>
#include <memory>
#include <string>
int main() {
std::allocator<std::string> allocator;
using Traits = std::allocator_traits<decltype(allocator)>;
constexpr std::size_t capacity = 3;
std::string* storage = Traits
输出:
capacity=3, size=2
0: alpha
1: beta
destroy beta
destroy alpha
storage released第三个位置只有可容纳 string 的存储,没有 string 对象,因此不能读取,也不能对它调用销毁操作。
例子中的 try 分支已经暴露出手工管理原始存储的成本:必须准确追踪已完成构造的数量,并在部分构造失败时只清理那一部分。真正的容器实现会再用一个小型 RAII 句柄保护临时存储。
业务代码通常应直接使用标准容器。只有实现容器、内存池或受严格约束的底层组件时,才需要操作 allocator;此时也应把四步封装在经过测试的资源类中。
allocate 成功不代表对象已经存在。把未构造存储当成 T 读取,或销毁从未构造的位置,都会越过对象生命周期规则。

reserve 只准备容量;只有完成构造的前 size 个位置含有活对象。allocator 的存储层与对象层必须分别建立、分别清理。面对一项资源,可以按下面的顺序选择工具:
vector、string 或其他标准容器。unique_ptr。shared_ptr。weak_ptr。不要因为“以后可能共享”提前使用 shared_ptr。所有权模型应反映当前设计,而不是模糊的未来可能性。
下面的代码把一个对象放到自由存储,只为在同一作用域末尾销毁:
Report* report = new Report("month");
render(*report);
delete report;如果 Report 不需要超出作用域,直接使用局部对象:
Report report("month");
render(report);如果必须动态创建并在之后转交所有权:
auto report = std::make_unique<Report>("month");
render(*report);
archive(std::move(report));可运行的安全示例可以在严格警告和 sanitizer 下检查:
c++ -std=c++17 -Wall -Wextra -Wpedantic \
-fsanitize=address,undefined -fno-omit-frame-pointer \
demo.cpp -o demo
./demo在支持相应 sanitizer 的平台上,安全版本应没有越界、释放后访问、重复释放或泄漏诊断。危险示例要单独构建、单独运行,并把 sanitizer 报告视为错误证据;不要把崩溃与否或某个打印值当成保证。
std::move 明确表达?shared_ptr 是否真的表示共同生命周期,是否存在拥有环?vector,边界是否随数据一起传递?如果这些问题都能从局部代码和函数签名中回答,动态内存就不再依赖“大家记得约定”。所有权已经成为程序结构的一部分。