从裸指针到 RAII:资源为什么会泄漏
写 C 时我们手动 new、手动 delete,一旦中途提前 return 或抛异常,delete 那行就被跳过,内存就此泄漏。C++ 的解法不是再催你记得释放,而是换个思路:把资源的生命周期交给一个栈上对象——对象离开作用域时,它的析构函数自动释放资源。这套机制叫 RAII(资源获取即初始化)。
智能指针就是 RAII 的标准实现:它长得像指针,可以解引用、可以指向堆对象,但它本身是个栈对象,出作用域时自动 delete。于是"忘记释放"在结构上就不再可能——异常、提前 return、多分支路径都由析构函数兜底。
RAII 的精髓一句话:用对象的生命周期去管理你申请到的每一样资源——内存、文件句柄、锁、网络连接,都适用。
- RAII栈对象出作用域自动析构,把资源释放绑进对象生命周期。
- unique_ptr独占所有权、轻量、可移动不可复制,是默认首选。
- shared_ptr/weak_ptr引用计数共享所有权,用 weak_ptr 打破循环引用。
- STL 容器按"读多写少、要不要排序、查找频率"选 vector 还是 map。
unique_ptr:独占所有权的默认选择
std::unique_ptr<T> 表示"这块对象只归我一个人管"。它体积几乎和裸指针一样大,没有额外开销,但禁止拷贝、只允许移动——这正好从类型上保证了"独占"。创建它最推荐用 std::make_unique,一行搞定,且天然异常安全。
#include <memory>
#include <vector>
#include <iostream>
struct File {
File(const char *n) { std::cout << "open " << n << "\n"; }
~File() { std::cout << "close\n"; }
};
int main() {
auto p = std::make_unique<File>("data.txt"); // 独占
// auto q = p; // 错:不能拷贝
auto r = std::move(p); // 对:所有权移交,p 变空
std::vector<std::unique_ptr<File>> box;
box.push_back(std::make_unique<File>("a.txt"));
box.push_back(std::move(r)); // 容器里也只存所有权
return 0; // 离开作用域,全部自动 close
}
现代 C++ 的经验法则是:凡是要在堆上分配对象,第一反应就是 unique_ptr。只有当你确实需要"多处共享同一份对象"时,才升级到 shared_ptr。不要因为 shared_ptr 看起来灵活就滥用——它带引用计数开销,还容易制造循环引用。
shared_ptr 与 weak_ptr:共享与打破循环
std::shared_ptr<T> 内部维护一个引用计数:每多一份拷贝,计数加一;每销毁一份,计数减一;归零的那一刻才真正 delete。它适合"一份资源被多个所有者共同持有、谁都不知道该谁最后释放"的场景。
#include <memory>
#include <iostream>
struct Node {
std::shared_ptr<Node> next;
std::weak_ptr<Node> prev; // 关键:反向用 weak,不增加计数
~Node() { std::cout << "~Node\n"; }
};
int main() {
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b; // b 的计数 +1
b->prev = a; // weak_ptr 不增加 a 的计数
return 0; // 计数正常归零,析构被调用
}
最经典的坑就是循环引用:两个对象互相用 shared_ptr 指向对方,计数永远停在 1,谁都不肯先析构,内存就这么漏了。解药是让其中一方改用 std::weak_ptr——它"看着"对象但不算一个持有者,需要用时先 lock() 提升成 shared_ptr,对象还活着才拿得到。
STL 容器怎么选:vector、map 与哈希表
STL 容器不是"随便挑一个",选错了性能差一大截。按使用场景对号入座即可:
| 容器 | 底层 | 典型场景 | 查找/插入 |
|---|---|---|---|
| vector | 连续数组 | 绝大多数动态序列,末尾增删 | 查找慢、尾部快 |
| map | 平衡二叉树 | 需要按键有序遍历 | O(log n) |
| unordered_map | 哈希表 | 只按键查、不关心顺序 | 平均 O(1) |
| set / unordered_set | 树 / 哈希 | 去重、判存在 | 同上 |
#include <vector>
#include <unordered_map>
#include <string>
int main() {
std::vector<int> nums = {5, 2, 9, 1};
nums.push_back(7); // 尾部追加,均摊 O(1)
std::unordered_map<std::string, int> age;
age["张伟"] = 20; // 按键存取,近似常数时间
age["李娜"] = 22;
return 0;
}
三条实战建议:第一,默认就用 vector,它内存连续、缓存友好,往往比你想象的快;第二,只有需要"按 key 排序输出"时才用 map,否则用 unordered_map;第三,提前 reserve 你知道的容量,避免 vector 反复扩容搬迁。
迭代器:别再手动写下标循环
迭代器是容器的"通用游标",把"遍历元素"这件事和具体是哪种容器解耦。现代 C++ 更推荐范围 for 循环,它本质上就是迭代器的语法糖,干净又不易出错。
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {10, 20, 30};
// 范围 for:最常用,读或改都方便
for (auto &x : v) {
x *= 2;
}
// 需要同时拿到位置时才用显式迭代器
for (auto it = v.begin(); it != v.end(); ++it) {
std::cout << *it << " ";
}
return 0; // 输出 20 40 60
}
两个容易踩的坑:一是迭代器失效——对 vector 做插入删除后,旧迭代器可能已经失效,继续用它就是未定义行为;二是 end() 指向"最后一个元素的下一个位置",可以比较、不能解引用。写循环用范围 for,绝大多数情况下能同时避开这两个坑。
算法与 lambda:std::algorithm 的正确打开方式
与其自己写一堆循环做查找、排序、过滤,不如直接用 <algorithm> 里的现成算法,再配一个 lambda 描述"你想要什么"。代码更短、意图更清楚,而且这些算法都经过高度优化。
#include <vector>
#include <algorithm>
#include <iostream>
int main() {
std::vector<int> v = {5, 2, 9, 1, 7};
std::sort(v.begin(), v.end()); // 升序
int cnt = std::count_if(v.begin(), v.end(), // 统计大于5的个数
[](int x) { return x > 5; });
auto it = std::find_if(v.begin(), v.end(), // 找第一个偶数
[](int x) { return x % 2 == 0; });
std::cout << cnt << " " << *it << "\n"; // 2 2
return 0;
}
lambda 的方括号 [] 是"捕获列表":写 [&] 按引用捕获外部变量,写 [=] 按值捕获。规则很简单——你需要在 lambda 里改外部变量就用 &,只读就用 =,别图省事一律 [&],否则容易把不该改的状态也带进异步线程里。
实战:用 RAII 管理一个动态资源缓存
把前面串起来:维护一个按名字存放资源的缓存,资源用 unique_ptr 独占,查找用 unordered_map。无论中途哪里返回,缓存里的资源都会在离开作用域时自动释放,绝不泄漏。
#include <memory>
#include <unordered_map>
#include <string>
#include <iostream>
struct Resource {
std::string name;
explicit Resource(std::string n) : name(std::move(n)) {}
~Resource() { std::cout << "release " << name << "\n"; }
};
int main() {
std::unordered_map<std::string, std::unique_ptr<Resource>> cache;
cache["a"] = std::make_unique<Resource>("config-a");
cache["b"] = std::make_unique<Resource>("config-b");
// 只需要存在性,不转移所有权:用引用
if (auto it = cache.find("a"); it != cache.end()) {
std::cout << "hit " << it->second->name << "\n";
}
return 0; // cache 析构,两个资源依次 release
}
这段代码里没有一个手写的 delete,也不需要 try/finally 去兜底。缓存不管是正常结束、提前 return 还是将来加异常路径,资源都由 unique_ptr 在作用域结束时自动回收。这就是现代 C++ 内存安全的默认姿态。
内存与容器的检查清单
把现代 C++ 的资源管理记成一条主线:优先 unique_ptr,确需共享才用 shared_ptr,共享的反向指针改 weak_ptr;容器默认 vector,按键查用 unordered_map。提交代码前对照这份清单快速自查:
- 代码里还有裸露的 new / delete 吗?能否换成 make_unique / make_shared?
- shared_ptr 之间是否可能形成双向循环?反向那一方是否已是 weak_ptr?
- 需要排序输出才用 map,否则是不是误用了 map 而该用 unordered_map?
- vector 已知大小时,是否提前 reserve 避免反复扩容?
- lambda 捕获用的是
[&]还是精确捕获?有没有把局部引用带进比它活得更久的线程? - 对 vector 插入删除后,旧迭代器是否还在被使用?
智能指针没有让 C++ 变简单,它只是把"谁来释放"这件事从人脑里移交给了编译器和类型系统。你需要做的,只是在写 new 的那一刻多想一秒。
下一篇我们把视角切到 R 语言:当数据已经进了数据框,怎么用它真正拟合一条回归线、再把样本聚成几类,并用图把结论画出来。