Effective C++ 条款26:尽可能延后变量定义式的出现时间
只要定义了一个变量而其类型带有一个构造函数或析构函数,那么当程序的控制流到达这个变量定义式时,你便得承受构造成本;当这个变量离开其作用域时,你便得承受析构成本。
一、为什么要延后变量定义?
在 C 语言(特别是 C89)的旧习惯中,开发者往往喜欢在函数开头把所有变量都定义好。这种风格在 C++ 中却可能带来不必要的性能开销。C++ 中的对象往往伴随着构造函数和析构函数的调用,过早定义变量意味着:
- 不必要的构造开销:变量在定义时就会调用构造函数
- 不必要的析构开销:即使变量未被使用,离开作用域时也会调用析构函数
- 代码清晰度下降:读者需要跳过很多行才能看到变量真正被使用的地方
核心原则
不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。
二、代码示例对比
反例:过早定义变量
cpp
#include <string>
#include <stdexcept>
// 加密密码的函数
std::string encryptPassword(const std::string& password) {
using namespace std;
// ❌ 过早定义:如果下面抛出异常,这个对象就白构造了
string encrypted;
if (password.length() < 8) {
throw logic_error("Password is too short");
// encrypted 在这里被构造后又立即被析构,完全浪费!
}
// 真正的加密操作
const char* key = "MySecretKey";
encrypted = performEncryption(password, key);
return encrypted;
}
正例:延后到需要使用时才定义
cpp
#include <string>
#include <stdexcept>
std::string encryptPassword(const std::string& password) {
using namespace std;
if (password.length() < 8) {
throw logic_error("Password is too short");
// 没有任何对象被无意义地构造和析构
}
// ✅ 延后定义:此时确定会用到 encrypted
const char* key = "MySecretKey";
string encrypted = performEncryption(password, key);
return encrypted;
}
更进一步:直接以初值定义
cpp
// ✅✅ 最佳实践:定义时直接初始化
std::string encryptPassword(const std::string& password) {
if (password.length() < 8) {
throw std::logic_error("Password is too short");
}
// 直接以初值定义,避免 default 构造后再赋值
std::string encrypted(performEncryption(password, "MySecretKey"));
return encrypted;
}
三、性能差异分析
| 方式 | 构造函数调用 | 析构函数调用 | 赋值操作 | 效率评级 |
|---|---|---|---|---|
| 过早定义 + 后续赋值 | 1 次 default | 1 次 | 1 次 | ⭐⭐ |
| 延后定义 + 后续赋值 | 1 次 default | 1 次 | 1 次 | ⭐⭐⭐ |
| 延后定义 + 直接初始化 | 1 次 copy/移动 | 1 次 | 0 次 | ⭐⭐⭐⭐⭐ |
对于
std::string这类带有动态内存分配的类型,default 构造后再赋值的成本远高于直接以初值构造!
循环中的变量定义
cpp
// 方式A:变量定义在循环外
Widget w;
for (int i = 0; i < n; ++i) {
w = 取决于i的某个值;
// 使用 w
}
// 1 次构造 + 1 次析构 + n 次赋值
// 方式B:变量定义在循环内
for (int i = 0; i < n; ++i) {
Widget w(取决于i的某个值);
// 使用 w
}
// n 次构造 + n 次析构
如何选择?
- 如果赋值成本低于 构造+析构 成本 → 选择方式A
- 如果构造+析构成本低于赋值成本 → 选择方式B
- 对于大部分 C++ 类型(尤其是 STL 容器、string 等)→ 通常方式B更优
四、实际应用场景
场景1:文件处理
cpp
#include <fstream>
#include <string>
void processFile(const std::string& filepath) {
// ❌ 不好的做法
std::ifstream file;
std::string line;
std::string content;
if (filepath.empty()) {
return; // 三个对象都被无意义地构造和析构了
}
file.open(filepath);
// ...
// ✅ 好的做法
if (filepath.empty()) {
return; // 没有任何对象被创建
}
std::ifstream file(filepath); // 需要时才创建
std::string line;
std::string content;
// ...
}
场景2:数据库查询
cpp
#include <mysql/mysql.h>
class QueryResult {
public:
QueryResult(MYSQL_RES* res) : result(res) {}
~QueryResult() { if (result) mysql_free_result(result); }
// ...
private:
MYSQL_RES* result;
};
void fetchUserData(int userId) {
// ❌ 过早定义
QueryResult result(nullptr);
MYSQL* conn = getConnection();
if (userId <= 0) {
return; // result 被无意义地构造和析构
}
// ... 执行查询
result = QueryResult(mysql_store_result(conn));
// ✅ 延后定义
if (userId <= 0) {
return;
}
MYSQL* conn = getConnection();
// ... 执行查询
QueryResult result(mysql_store_result(conn)); // 需要时才创建
}
场景3:复杂对象的构造
cpp
#include <vector>
#include <map>
class DataProcessor {
public:
DataProcessor(const std::vector<int>& data) : data_(data) {
// 复杂的预处理
preprocess();
}
// ...
private:
std::vector<int> data_;
std::map<int, int> index_;
void preprocess() { /* 耗时操作 */ }
};
void processRequest(const std::vector<int>& input, bool needProcess) {
// ❌ 不好的做法
DataProcessor processor(input);
if (!needProcess) {
return; // processor 被无意义地构造(包含复杂的预处理!)
}
processor.run();
// ✅ 好的做法
if (!needProcess) {
return;
}
DataProcessor processor(input); // 确定需要时才构造
processor.run();
}
五、原理深度解析
5.1 C++ 对象生命周期
定义点 ──→ 构造函数调用 ──→ 使用期 ──→ 离开作用域 ──→ 析构函数调用
延后定义的核心思想就是:缩短"定义点到使用点"之间的距离,避免在异常路径或提前返回路径上产生不必要的构造/析构开销。
5.2 编译器优化视角
现代编译器虽然可以进行一些优化(如 RVO/NRVO),但对于以下情况优化能力有限:
- 带有副作用的构造函数/析构函数
- 涉及动态内存分配的类型
- 虚函数调用
cpp
class Widget {
public:
Widget() { std::cout << "Construct\n"; }
Widget(const Widget&) { std::cout << "Copy\n"; }
Widget& operator=(const Widget&) {
std::cout << "Assign\n";
return *this;
}
~Widget() { std::cout << "Destruct\n"; }
};
void demo() {
Widget w; // 输出: Construct
w = Widget(); // 输出: Construct -> Assign -> Destruct
} // 输出: Destruct
// 总共:2 次构造、1 次赋值、2 次析构
void demo2() {
Widget w = Widget(); // 输出: Construct (可能被优化)
} // 输出: Destruct
// 总共:1 次构造、1 次析构
5.3 异常安全角度
延后定义还能提升异常安全性。考虑以下代码:
cpp
void riskyFunction() {
ResourceA a; // 获取资源A
ResourceB b; // 获取资源B - 可能抛出异常!
if (someCondition) {
throw std::runtime_error("Error");
}
ResourceC c; // 获取资源C
// ...
}
如果 ResourceB 的构造抛出异常,ResourceA 已经被构造了,需要确保它能正确释放。将变量定义延后到真正需要的位置,可以减少这种异常安全问题的影响范围。
六、注意事项与例外
6.1 内置类型无需过度担心
对于 int、double、char* 等内置类型,定义成本极低,延后定义带来的收益不大:
cpp
// 对于内置类型,两种写法差异不大
void func() {
int i; // 成本极低
// ... 很多代码
i = 42;
// vs
// ... 很多代码
int i = 42; // 稍微清晰一点
}
6.2 避免过度延后导致代码混乱
cpp
// ❌ 过度延后:代码难以阅读
void badExample() {
// ... 50 行代码
{
std::string s = getString();
process(s);
} // s 在这里销毁
// ... 又 50 行代码
{
std::vector<int> v = getVector();
process(v);
} // v 在这里销毁
}
// ✅ 适度延后:在合理的作用域内定义
void goodExample() {
// ... 一些代码
std::string s = getString();
process(s);
// ... 一些代码
std::vector<int> v = getVector();
process(v);
}
6.3 成员变量无法延后
类的成员变量必须在构造函数初始化列表中初始化,无法在成员函数中"延后定义"。对于这种情况,可以考虑使用 std::optional(C++17)或指针:
cpp
#include <optional>
class LazyInit {
public:
void init() {
// 延后初始化成员
data_.emplace(100); // 真正需要时才构造
}
private:
std::optional<std::vector<int>> data_; // 延后初始化
};
七、总结
| 要点 | 说明 |
|---|---|
| 核心原则 | 延后变量定义到真正需要使用的时刻 |
| 最佳实践 | 定义时直接以初值初始化,避免 default 构造后再赋值 |
| 性能收益 | 避免不必要的构造/析构,尤其在异常路径上 |
| 代码清晰度 | 变量定义靠近使用点,代码更易读 |
| 循环中的选择 | 根据构造+析构 vs 赋值的成本权衡 |
请记住:
- 尽可能延后变量定义式的出现时间。这样做可增加程序的清晰度并改善程序效率。
- 不只应该延后变量的定义,直到非得使用该变量的前一刻为止,甚至应该尝试延后这份定义直到能够给它初值实参为止。
参考阅读:
- 《Effective C++》第三版,条款26
- 《C++ Primer》关于变量作用域和生命周期的章节
- C++ Core Guidelines: ES.21
如果这篇文章对你有帮助,欢迎点赞、收藏和转发!有任何问题欢迎在评论区留言讨论。