Effective C++ 条款17:以独立语句将 newed 对象置入智能指针
🎯 核心观点:以独立语句将 newed 对象存储于(置入)智能指针内。如果不这样做,一旦异常被抛出,可能导致难以察觉的资源泄漏。
一、一个看似无害的陷阱
让我们从一个常见的函数调用开始:
cpp
#include <memory>
#include <iostream>
class Widget {
public:
Widget() { std::cout << "Widget 构造\n"; }
~Widget() { std::cout << "Widget 析构\n"; }
};
void processWidget(std::shared_ptr<Widget> pw, int priority);
int computePriority();
// 看似合理的调用方式
void doWork() {
processWidget(std::shared_ptr<Widget>(new Widget()), computePriority());
}
这段代码有问题吗?表面上看,new Widget() 的结果被立即传入了 shared_ptr 的构造函数,然后传给 processWidget。但这里隐藏着一个致命的异常安全隐患。
二、编译器视角:操作序列的不确定性
2.1 C++ 的求值顺序
在深入问题之前,我们需要理解一个关键事实:
⚠️ C++ 标准不保证函数参数之间的求值顺序(直到 C++17 才对某些情况做出规定)。
在 C++17 之前,编译器在编译 processWidget(std::shared_ptr<Widget>(new Widget()), computePriority()) 时,以下操作的执行顺序是不确定的:
- 执行
new Widget() - 调用
std::shared_ptr<Widget>的构造函数 - 调用
computePriority()
编译器可能按照以下顺序执行(这是合法的):
1. new Widget() → 返回裸指针
2. computePriority() → 可能抛出异常!
3. shared_ptr 构造 → 如果步骤2抛异常,这一步永远不会执行
2.2 灾难场景还原
假设 computePriority() 在运行时抛出了异常:
cpp
int computePriority() {
// 某些条件下抛出异常
throw std::runtime_error("优先级计算失败");
return 42;
}
void doWork() {
// 潜在的资源泄漏!
processWidget(std::shared_ptr<Widget>(new Widget()), computePriority());
}
执行流程可能是:
| 步骤 | 操作 | 结果 |
|---|---|---|
| 1 | new Widget() |
分配内存,调用构造函数,返回裸指针 |
| 2 | computePriority() |
抛出异常 |
| 3 | shared_ptr 构造 |
永远不会执行 |
此时,new Widget() 返回的裸指针永远丢失了 ------没有智能指针接管它,也没有代码能够 delete 它。这就是资源泄漏。
💀 这种泄漏特别危险,因为:
- 代码看起来"正确"
- 编译器不会报错
- 只有在特定异常触发时才会发生
- 极难在测试中发现
三、解决方案:独立语句
3.1 正确的写法
将 new 和智能指针的构造放在一条独立的语句中:
cpp
void doWorkSafe() {
// ✅ 独立语句:newed 对象立即被置入智能指针
std::shared_ptr<Widget> pw(new Widget());
// 现在即使 computePriority() 抛异常,pw 的析构函数也会正确释放 Widget
processWidget(pw, computePriority());
}
为什么这样更安全?
因为 C++ 保证:在一条语句执行完毕之前,编译器不会插入其他操作 。一旦 std::shared_ptr<Widget> pw(new Widget()); 这条语句完成,Widget 对象就已经被智能指针安全接管了。
3.2 C++11 的更佳方案:std::make_shared
在 C++11 及以后,最佳实践 是使用 std::make_shared:
cpp
void doWorkBest() {
// ✅ 最佳实践:使用 make_shared,更安全、更高效
auto pw = std::make_shared<Widget>();
processWidget(pw, computePriority());
}
std::make_shared 的优势:
| 优势 | 说明 |
|---|---|
| 异常安全 | Widget 的创建和 shared_ptr 的管理是原子操作 |
| 性能更好 | 只需一次内存分配(控制块 + 对象在同一内存块中) |
| 代码简洁 | 不需要显式 new |
| 防止泄漏 | 彻底杜绝裸指针暴露的机会 |
3.3 对比总结
cpp
// ❌ 危险:参数内部完成 new 和智能指针构造
processWidget(std::shared_ptr<Widget>(new Widget()), computePriority());
// ✅ 安全:独立语句
std::shared_ptr<Widget> pw(new Widget());
processWidget(pw, computePriority());
// ✅✅ 最佳:使用 make_shared
auto pw = std::make_shared<Widget>();
processWidget(pw, computePriority());
四、实际应用场景
4.1 场景:GUI 框架中的控件创建
cpp
#include <memory>
#include <stdexcept>
class Button {
public:
explicit Button(const std::string& label) { /* ... */ }
};
class Window {
public:
void addButton(std::shared_ptr<Button> btn, int zOrder);
};
int calculateZOrder(); // 可能抛出异常
// ❌ 危险的控件添加
void setupWindowDangerous(Window& window) {
window.addButton(std::shared_ptr<Button>(new Button("OK")), calculateZOrder());
}
// ✅ 安全的控件添加
void setupWindowSafe(Window& window) {
auto btn = std::make_shared<Button>("OK");
window.addButton(btn, calculateZOrder());
}
4.2 场景:数据库连接池
cpp
#include <memory>
#include <string>
class DatabaseConnection {
public:
explicit DatabaseConnection(const std::string& connStr) {
// 建立连接,可能分配大量资源
}
~DatabaseConnection() {
// 关闭连接,释放资源
}
};
std::string buildConnectionString(); // 可能因配置错误抛出异常
class ConnectionPool {
public:
void addConnection(std::shared_ptr<DatabaseConnection> conn, int priority);
};
// ❌ 危险:如果 buildConnectionString 抛异常,数据库连接泄漏
void initializePoolDangerous(ConnectionPool& pool) {
pool.addConnection(
std::shared_ptr<DatabaseConnection>(new DatabaseConnection("initial")),
buildConnectionString().length()
);
}
// ✅ 安全:分步执行
void initializePoolSafe(ConnectionPool& pool) {
auto conn = std::make_shared<DatabaseConnection>("initial");
auto connStr = buildConnectionString(); // 即使这里抛异常,conn 也会被正确释放
pool.addConnection(conn, connStr.length());
}
4.3 场景:多线程环境中的资源创建
在多线程环境下,这个问题更加微妙:
cpp
#include <memory>
#include <thread>
#include <vector>
class Task {
public:
void execute();
};
int fetchTaskPriority(); // 可能涉及网络请求,可能抛异常
// ❌ 在多线程中传递参数时尤其危险
void dispatchTaskDangerous() {
std::thread t(
[](std::shared_ptr<Task> task, int priority) {
task->execute();
},
std::shared_ptr<Task>(new Task()), // 潜在的泄漏点
fetchTaskPriority() // 可能抛异常
);
t.detach();
}
// ✅ 安全版本
void dispatchTaskSafe() {
auto task = std::make_shared<Task>();
int priority = fetchTaskPriority();
std::thread t(
[](std::shared_ptr<Task> t, int p) {
t->execute();
},
task,
priority
);
t.detach();
}
五、深入理解:为什么编译器会这样?
5.1 编译器的优化自由
C++ 标准赋予编译器很大的自由度来优化函数参数的求值顺序。这种设计是为了:
- 允许不同架构的最优代码生成
- 支持各种编译器优化策略
- 保持语言的灵活性
但这也意味着程序员必须对序列点(sequence points,C++11 后称为 sequenced-before 关系)有清晰的理解。
5.2 C++17 的改进
好消息是,C++17 引入了更严格的求值顺序规则:
在 C++17 中,函数参数的求值顺序仍然不完全确定,但某些操作被保证为顺序执行。
然而,即使如此,显式使用独立语句仍然是最佳实践,因为它:
- 让代码意图更清晰
- 兼容 C++11/14 代码库
- 避免依赖复杂的求值顺序规则
- 更容易被代码审查者理解
六、unique_ptr 同样适用
虽然条款示例使用了 shared_ptr,但同样的原则完全适用于 unique_ptr:
cpp
#include <memory>
class Resource {
public:
Resource() = default;
void process();
};
void useResource(std::unique_ptr<Resource> res, int config);
int loadConfig(); // 可能抛异常
// ❌ 危险
void prepareDangerous() {
useResource(std::unique_ptr<Resource>(new Resource()), loadConfig());
}
// ✅ 安全
void prepareSafe() {
auto res = std::make_unique<Resource>();
useResource(std::move(res), loadConfig());
}
💡 注意 :
unique_ptr不可拷贝,所以传递时需要std::move。但创建它时仍然应该使用独立语句或std::make_unique(C++14 起可用)。
七、总结
| 要点 | 说明 |
|---|---|
| 核心规则 | 以独立语句将 newed 对象置入智能指针 |
| 根本原因 | C++ 不保证函数参数的求值顺序 |
| 潜在风险 | 异常抛出时,裸指针未被智能指针接管,导致资源泄漏 |
| 最佳实践 | 使用 std::make_shared / std::make_unique,彻底避免裸指针 |
| 兼容性 | 独立语句写法兼容所有 C++ 版本 |
📌 一句话记住 :不要让
new和智能指针的构造之间"夹"着可能抛异常的代码。要么用独立语句,要么直接用make_shared/make_unique。
八、延伸阅读
- Effective C++ 条款13:以对象管理资源
- Effective C++ 条款16:成对使用 new 和 delete 时要采取相同形式
- Effective C++ 条款18:让接口容易被正确使用,不易被误用
- C++ Core Guidelines:ES.60 - Avoid
newanddeleteoutside resource management functions
如果这篇文章对你有帮助,欢迎点赞 👍、收藏 ⭐、评论 💬!你的支持是我持续创作的动力!