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