用 RAII 取代手写 new / delete
很多人对 C++ 的印象还停留在“手动 new、手动 delete”,但现代 C++(C++11 之后)的核心思想恰恰相反:让资源的生命周期绑定到对象的生命周期上。这个思想叫 RAII(Resource Acquisition Is Initialization)——构造时获取资源,析构时自动释放,函数无论从哪条路径退出,栈对象的析构函数都会被调用。
对照一下两段代码就能看出差别:手写指针时,一旦中途 return 或抛异常,delete 就容易被跳过;而用 std::vector 或智能指针,释放由编译器保证。这正是现代 C++ 能在保留性能的同时兼顾安全的根本原因。
不要问“这段代码在哪释放”,而要问“这个对象在哪析构”。把释放权交给析构函数,异常安全和泄漏问题会同时消失一大半。
- RAII资源随对象构造获取、随析构释放,天然异常安全。
- 智能指针unique_ptr 独占、shared_ptr 共享、weak_ptr 弱引用破环。
- 移动语义右值引用让大对象“搬家”而非深拷贝。
- 并发原语std::thread、mutex、condition_variable 组成标准并发工具箱。
智能指针三兄弟
标准库在 <memory> 中提供了三种智能指针,选错了会带来性能损耗或循环引用。判断的依据只有一个:这块资源到底有几个“主人”。
| 类型 | 所有权 | 拷贝 | 典型用途 |
|---|---|---|---|
| std::unique_ptr | 独占 | 不可拷贝,可移动 | 默认首选,工厂返回值、资源句柄 |
| std::shared_ptr | 共享,引用计数 | 可拷贝,计数加一 | 多处共享同一对象生命周期 |
| std::weak_ptr | 不增加计数 | 可拷贝 | 打破 shared_ptr 循环引用、观察者 |
#include <memory>
#include <iostream>
struct Node { int v; };
int main() {
auto p = std::make_unique<Node>(Node{7}); // 独占所有权
std::cout << p->v << "\n"; // 7
std::shared_ptr<Node> a = std::make_shared<Node>(Node{9});
std::shared_ptr<Node> b = a; // 计数 = 2
std::weak_ptr<Node> w = a; // 不增加计数
std::cout << a.use_count() << "\n"; // 2
return 0; // 全部自动释放
}
两个经验:优先用 make_unique / make_shared 而不是裸 new,既更短也更安全;函数参数在“只借用、不持有”时应传裸指针或引用,而不是 shared_ptr,否则会无意延长生命周期、影响计数语义。
移动语义与右值引用
拷贝一个大容器意味着复制它内部所有元素,代价高。移动语义的思路是:当源对象马上就要销毁时,何必复制?直接把它的内部指针“偷”过来,把源置空即可。右值引用 T&& 就是“可以安全移动的临时对象”的类型标记。
#include <string>
#include <utility>
#include <vector>
std::vector<int> make_big() {
std::vector<int> v(1'000'000, 1);
return v; // 返回值优化 / 移动,不会深拷贝
}
int main() {
std::vector<int> a = make_big(); // 移动构造
std::vector<int> b = std::move(a); // 显式移动后,a 处于有效但未指定状态
// 此后不要再假定 a 里还有数据
return 0;
}
要点:std::move 并不移动任何东西,它只是把表达式转型为右值引用,真正干活的是移动构造函数 / 移动赋值运算符。被移动过的对象仍然有效(可析构、可重新赋值),但内容不再可信,不要继续读取。编写自己的容器类时,一并提供移动构造与移动赋值,是让性能上一个台阶的关键。
Lambda 与标准算法
Lambda 让“就地写一个函数”变得自然,配合标准算法可以把原本十几行的循环压缩成一行。它的完整语法是 [捕获列表](参数){函数体},捕获列表决定它如何使用外部变量:[=] 按值、[&] 按引用、[x, &y] 混合。
#include <algorithm>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v = {5, 2, 8, 1, 9};
std::sort(v.begin(), v.end(),
[](int a, int b) { return a > b; }); // 降序
int threshold = 3;
auto cnt = std::count_if(v.begin(), v.end(),
[threshold](int x) { return x > threshold; });
for (int x : v) std::cout << x << " "; // 9 8 5 2 1
std::cout << "\n>" << threshold << ": " << cnt << "\n"; // >3: 3
return 0;
}
注意“按引用捕获”的悬垂风险:如果 Lambda 逃出当前作用域(被存进容器、交给异步任务),而它捕获的是局部变量的引用,就会访问已销毁的栈内存。跨作用域使用时一律按值捕获或明确用智能指针传递。
auto、范围 for 与结构化绑定
现代 C++ 用 auto 让编译器推导类型,减少冗长的类型书写;范围 for(range-based for)消除了手写下标;结构化绑定(C++17)则让“拆解一组返回值”变得直观。三者合起来,能显著降低代码的噪声。
#include <map>
#include <string>
#include <iostream>
int main() {
std::map<std::string, int> ages = {{"Amy", 20}, {"Bob", 31}};
ages["Cara"] = 25;
for (const auto &[name, age] : ages) { // 结构化绑定
std::cout << name << " = " << age << "\n";
}
return 0;
}
用 auto 时也要有边界感:涉及隐式转换、需要明确宽度的场景(如 size_t 与 int 混用),最好显式写出类型,避免 deduction 出意料之外的窄化或符号问题。const auto& 是遍历容器时最稳妥的写法——既避免拷贝,又防止误改。
并发编程:线程与同步原语
C++11 起,标准库直接提供了 std::thread、std::mutex、std::condition_variable,不再依赖平台 API。掌握三件事就够起步:线程怎么起、共享数据怎么保护、线程之间怎么通知。
- 起线程:
std::thread t(fn, args...);,退出前务必join()(等待结束)或detach()(分离),否则会terminate。 - 保护数据:用
std::mutex配合std::lock_guard/std::unique_lock,让加解锁也走 RAII,避免忘记解锁。 - 线程通信:用
condition_variable做等待唤醒,而不是忙轮询浪费 CPU。
#include <thread>
#include <mutex>
#include <vector>
#include <iostream>
int main() {
std::mutex m;
long total = 0;
auto worker = [&](int lo, int hi) {
long local = 0;
for (int i = lo; i < hi; ++i) local += i;
std::lock_guard<std::mutex> lk(m); // 临界区
total += local; // 保护共享累加
};
std::vector<std::thread> ts;
for (int k = 0; k < 4; ++k) ts.emplace_back(worker, k * 250, (k + 1) * 250);
for (auto &t : ts) t.join(); // 等待全部结束
std::cout << total << "\n"; // 124500
return 0;
}
“每个线程在本地累加、最后一次性加进共享变量”是并发求和的常见优化:把锁的粒度降到最低,既保证正确性,又减少竞争。
实战:线程安全的任务队列
把智能指针、移动语义、Lambda 和并发原语组合起来,写一个生产者-消费者任务队列。它用 std::function 装任务,用 queue 排队,用 mutex 加 condition_variable 做等待唤醒。
#include <queue>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <thread>
#include <iostream>
class TaskQueue {
public:
void push(std::function<void()> job) {
{ std::lock_guard<std::mutex> lk(m_); q_.push(std::move(job)); }
cv_.notify_one(); // 唤醒一个等待者
}
bool pop(std::function<void()> &out) {
std::unique_lock<std::mutex> lk(m_);
cv_.wait(lk, [this] { return !q_.empty(); }); // 条件等待
out = std::move(q_.front());
q_.pop();
return true;
}
private:
std::queue<std::function<void()>> q_;
std::mutex m_;
std::condition_variable cv_;
};
int main() {
TaskQueue tq;
std::thread worker([&] {
std::function<void()> job;
while (tq.pop(job)) job();
});
tq.push([] { std::cout << "job A\n"; });
tq.push([] { std::cout << "job B\n"; });
tq.push([] { std::cout << "job C\n"; });
worker.join(); // 示例:处理完手动结束
return 0;
}
这段代码把“等待条件”写进 cv_.wait 的谓词里,能自动处理虚假唤醒;用 unique_lock 是因为条件变量在等待时需要一个可解锁的锁对象。真实项目里再补一个“关闭标志”,就能实现优雅退出。
工程规范与常见陷阱
现代 C++ 的强大来自“多范式”,但滥用特性会让代码难以维护。几条落地建议:
- 默认用值语义和智能指针,不轻易裸 new;确需裸指针时只用于“非拥有”的观察。
- 遵循“规则之三/五/零”:能靠标准库成员组合完成的类,就不要手写析构、拷贝、移动函数。
const能加就加,接口尽量以const&传参,减少无谓拷贝。- 警惕 shared_ptr 循环引用——两个对象互相持有 shared_ptr 会永不释放,用 weak_ptr 打破。
- 编译时打开
-Wall -Wextra,并用-fsanitize=address,undefined,thread做动态检查。
现代 C++ 的进步不在语法花哨,而在“把容易出错的约定交给类型系统和编译器”。当你能用unique_ptr表达独占、用&&表达可移动,代码的意图就写在了类型里。
下一篇我们转到 C#:它把内存管理整体交给托管的 CLR 与垃圾回收,让你用更少的显式控制换来更高的开发效率,代价是要理解 GC 的行为与对象生命周期。