【C++编程规范-101条规则准则与最佳实践笔记1】
🌹 作者: 云小逸
🤟 个人主页: 云小逸的主页
🤟 motto: 要敢于一个人默默的面对自己,强大自己才是核心。不要等到什么都没有了,才下定决心去做。种一颗树,最好的时间是十年前,其次就是现在!学会自己和解,与过去和解,努力爱自己。希望春天来之前,我们一起面朝大海,春暖花开!
🥇 专栏:
文章目录
-
- 📚 前言
- 一、组织和策略问题
- 二、设计风格
- 三、编程风格
- 四、函数与操作符
📚 前言
在C++编程的世界里,你是否曾遇到过这样的困境:自己写的代码过了几天就看不懂了?和团队成员协作时,因为代码风格差异巨大而频繁产生矛盾?辛辛苦苦写的程序频繁出现bug,排查起来焦头烂额?如果你有过这些经历,那么《C++编程规范-101条规则、准则与最佳实践》这本书绝对是你的“救星”。
这本书由Herb Sutter和Andrei Alexandrescu两位世界顶级的C++专家联袂撰写,凝聚了全球C++界20年的集体智慧和经验。它不是一本空谈理论的书籍,而是将抽象的编程思想转化为了101条具体、可操作的规则。无论你是刚刚入门C++的新手,还是有多年经验的资深程序员,都能从这本书中受益匪浅。
对于新手而言,这本书能帮助你从一开始就养成良好的编程习惯,避开很多新手容易踩的“坑”,建立起正确的编程思维。比如,它会告诉你如何正确初始化变量、如何合理使用const关键字,这些基础知识点的规范运用,能让你少走很多弯路。
对于中级或高级程序员来说,书中详细的规则解释和权威的参考文献列表,能帮助你深入理解C++类型系统、语法和对象模型的底层逻辑,进一步提升你的编程造诣。在团队协作中,这本书还可以作为制定团队编程规范的基础,减少不必要的争论,提高团队的开发效率。
编程规范并非束缚我们创造力的“枷锁”,而是帮助我们写出更高效、更易维护、更可靠代码的“指南”。在实际开发中,遵循规范能让我们的代码更具可读性和可扩展性,尤其是在大型项目中,规范的重要性更是不言而喻。
接下来,就让我们一起走进这本书的世界,逐一学习这101条宝贵的编程规范,开启我们的C++进阶之旅。
一、组织和策略问题
1. 第0条 不要拘泥于小节(又名:了解哪些东西不应该标准化)
1.1 核心要点
这条规则的核心是:编程规范只需要规定那些影响程序正确性、可读性和可维护性的关键内容,不要将个人喜好或者过时的做法强制施加给团队。对于一些纯个人风格的问题,重要的是保持文件内部的一致性,而不是在整个项目或公司范围内强制统一。
1.2 通俗解释
打个比方,就像我们写作文,重要的是文章的内容准确、逻辑清晰,而不是强制要求所有人都用同样的字体、同样的段落间距。编程也是如此,有些细节只是个人习惯问题,不会影响程序的运行和理解,就不需要制定统一的规则。
1.3 具体例子
1.3.1 括号的位置
以下三种括号的写法在可读性上没有本质区别,专业程序员都能轻松理解:
// K&R风格
void using_k_and_r_style() {
// 代码逻辑
}
// 每个括号单独一行
void putting_each_brace_on_its_own_line()
{
// 代码逻辑
}
// 每个括号单独一行且缩进
void or_putting_each_brace_on_its_own_line_indented()
{
// 代码逻辑
}
关键是在同一个文件中保持一种写法,不要随意切换,以免混淆代码的作用域。
1.3.2 缩进的空格数
有些程序员习惯用4个空格缩进,有些习惯用2个空格,这都是可以的。但在同一个源文件中,必须保持一致的缩进方式,这样才能清晰地体现代码的结构层次。比如:
// 一致的4空格缩进
void test() {
if (1) {
cout << "缩进正确" << endl;
}
}
// 不一致的缩进(错误示例)
void bad_test() {
if (1) {
cout << "缩进混乱" << endl;
}
}
1.3.3 命名规范的边界
命名规范中,有两点是必须遵守的:
- 永远不要使用“晦涩的名称”,也就是以下划线开始或者包含双下划线的名称。因为这类名称在C++中可能有特殊用途,容易引发命名冲突。
- 宏名称必须使用全大写字母,比如
#define MAX_SIZE 100,不要用常见的词或缩略语作为宏名,比如#define T 10,这样很容易造成混淆。
除了这两点,团队可以约定统一的命名风格(比如类名首字母大写、变量名首字母小写等),但不必强制要求所有项目都使用同一种命名风格。
1.3.4 注释的编写
不要规定注释的具体体例(除非需要工具提取文档),更重要的是编写有用的注释。避免在注释中重复代码的语义,比如:
// 错误示例:注释重复代码语义
int a = 10; // 给变量a赋值为10
// 正确示例:解释代码的目的和原理
int max_retries = 3; // 最大重试次数,根据服务稳定性测试确定,超过则返回失败
1.4 过时规则的摒弃
有些曾经的编程规范现在已经过时了,不应该再强制实施。
- 匈牙利记法:将类型信息嵌入变量名(比如
iCount表示int类型的计数变量)。这种记法在面向对象和泛型编程中不仅没有帮助,还会增加代码的冗余度,现在已经不推荐使用。 - 单入口单出口:要求每个函数只能有一个return语句。在支持异常和析构函数的现代C++中,这种要求已经过时了。更合理的做法是编写更简单、更短小的函数,这样的函数本身更易于理解和维护。比如:
// 过时的单出口写法
int get_score(char grade) {
int score;
switch (grade) {
case 'A':
score = 90;
break;
case 'B':
score = 80;
break;
default:
score = 60;
break;
}
return score;
}
// 更简洁的多出口写法
int get_score_modern(char grade) {
if (grade == 'A') return 90;
if (grade == 'B') return 80;
return 60;
}
2. 第1条 在高警告级别干净利落地进行编译
2.1 核心要点
编译器是我们编程的“好帮手”,它发出的警告往往提示代码中存在潜在问题。我们应该使用编译器的最高警告级别,并且确保构建过程没有任何警告。遇到警告时,要通过修改代码来消除,而不是降低警告级别。
2.2 通俗解释
就像老师批改作业时,不仅会指出明显的错误(比如计算错误),还会指出一些潜在的问题(比如书写不规范、逻辑不清晰)。我们应该重视这些“警告”,及时修正,而不是让老师“放宽标准”。
2.3 具体例子
2.3.1 第三方头文件的警告处理
有时候,我们使用的第三方库的头文件可能会产生一些良性警告。这时可以将其包装起来,在局部禁用这些警告:
// 文件:my_boost_lambda.h
#pragma warning(push) // 保存当前警告级别
#pragma warning(disable:4512) // 禁用特定警告
#pragma warning(disable:4180)
#include <boost/lambda/lambda.hpp>
#pragma warning(pop) // 恢复警告级别
这样既避免了第三方头文件的警告干扰,又不影响项目其他部分的警告检查。
2.3.2 未使用的函数参数
如果函数参数确实不需要使用,可以删除参数名来消除警告:
// 警告:未使用的参数localityHint
pointer allocate(size_type numObjects, const void* localityHint = 0) {
return static_cast<pointer>(mallocShared(numObjects * sizeof(T)));
}
// 消除警告后的版本
pointer allocate(size_type numObjects, const void* /*localityHint*/ = 0) {
return static_cast<pointer>(mallocShared(numObjects * sizeof(T)));
}
2.3.3 定义了从未使用过的变量
对于确实不需要的变量,直接删除;如果是RAII对象(比如锁),可以通过简单求值来消除警告:
// 警告:变量lock未使用
void Fun() {
Lock lock;
// 代码逻辑
}
// 消除警告的版本
void Fun() {
Lock lock;
lock; // 简单求值,不影响运行时性能
// 代码逻辑
}
2.3.4 变量使用前可能未经初始化
这是非常常见的警告,也是容易引发bug的地方,解决方法就是显式初始化变量:
// 警告:变量a可能未初始化
int calculate() {
int a;
if (some_condition) {
a = 10;
}
return a;
}
// 消除警告的版本
int calculate_modern() {
int a = 0; // 显式初始化
if (some_condition) {
a = 10;
}
return a;
}
2.3.5 遗漏了return语句
即使逻辑上不会执行到函数末尾,也应该添加return语句,避免警告:
// 警告:缺少return语句
int Fun(Color c) {
switch (c) {
case Red: return 2;
case Green: return 0;
case Blue:
case Black: return 1;
}
}
// 消除警告的版本
int Fun_modern(Color c) {
switch (c) {
case Red: return 2;
case Green: return 0;
case Blue:
case Black: return 1;
default:
assert(false && "应该不会执行到这里!");
return -1;
}
}
2.4 例外情况
如果编译器发出的是毫无意义的虚假警告,且没有其他消除方法,团队可以决定局部禁用该警告,但必须添加清晰的注释说明原因。
3. 第2条 使用自动构建系统
3.1 核心要点
使用完全自动化的构建系统,实现“一次按键”构建整个项目,无需人工干预。自动构建系统能可靠、可重复地将源文件转换为可交付的软件包。
3.2 通俗解释
就像工厂的生产线,通过自动化设备实现从原材料到成品的一站式生产,无需人工逐个环节操作。自动构建系统也能帮我们自动完成编译、链接、测试等一系列流程,提高开发效率。
3.3 自动构建的两种模式
- 增量构建:只重新构建上次构建后发生变化的部分。比如你修改了一个源文件,增量构建只会编译这个文件以及依赖它的文件,节省构建时间。
- 完全构建:重新构建整个项目。通常在发布版本、解决复杂依赖问题时使用。
3.4 构建的参数化控制
一个灵活的自动构建系统可以通过参数控制构建过程,比如:
- 目标架构:构建适用于x86、x64等不同架构的版本。
- 构建模式:调试模式(包含调试信息,便于调试)和发布模式(经过优化,运行效率高)。
- 构建范围:只构建核心可执行文件、构建所有文件,或者生成包含安装程序的完整安装包。
3.5 为什么要使用自动构建系统
- 提高效率:避免手动点击编译、复制文件等繁琐操作,节省时间。
- 减少错误:手动操作容易出现遗漏步骤、路径错误等问题,自动构建能避免这些人为错误。
- 便于协作:团队成员使用统一的构建系统,确保每个人构建出的版本一致。
- 支持持续集成:自动构建是持续集成的基础,能及时发现代码集成中的问题。
4. 第3条 使用版本控制系统
4.1 核心要点
版本控制系统(VCS)是管理代码变更的重要工具。无论项目大小,都应该使用它。不要让文件长时间登出,在通过单元测试后,应频繁提交代码,且提交的代码不能破坏构建。
4.2 通俗解释
版本控制系统就像我们写文章时的“保存历史记录”功能,它能记录每次对代码的修改,方便我们回溯历史版本、查看修改内容、解决代码冲突。
4.3 版本控制系统的作用
4.3.1 跟踪代码历史
当代码出现问题时,可以通过版本控制系统查看近期的修改记录,快速定位是哪次修改引入了bug。比如,你今天提交的代码导致程序崩溃,可以对比昨天的版本,找出差异部分。
4.3.2 支持团队协作
多个开发人员同时修改同一个文件时,版本控制系统能自动合并修改内容。如果出现冲突(比如两人修改了同一行代码),也能提示冲突位置,方便手动解决。
4.3.3 备份代码
版本控制系统会将代码存储在服务器上,即使本地电脑出现故障,也能从服务器上恢复代码,避免代码丢失。
4.4 使用建议
- 频繁提交:不要等到代码完全写完才提交,建议每天至少提交一次,每次提交解决一个小问题,这样便于回溯和定位问题。
- 提交前测试:提交代码前,必须通过单元测试和本地构建,确保代码不会破坏项目的正常运行。
- 编写有意义的提交信息:比如“修复用户登录时密码加密错误的bug”,而不是简单写“修改代码”。
- 不要长时间锁定文件:如果长时间锁定文件,会影响其他团队成员的工作,导致协作效率下降。
4.5 例外情况
只有一个程序员且开发周期不超过一周的极小项目,可能不需要使用版本控制系统。但对于大多数项目,版本控制系统都是必不可少的工具。
5. 第4条 在代码审查上投入
5.1 核心要点
代码审查是提高代码质量的有效手段。通过团队成员之间互相审查代码,不仅能发现错误,还能促进知识共享,培养团队成员的编程能力。
5.2 通俗解释
就像学生写作文后,让同学或老师帮忙批改,能发现自己忽略的问题,同时也能学习别人的写作技巧。代码审查也是如此,通过他人的视角,找出代码中的漏洞和可以优化的地方。
5.3 代码审查的好处
- 提高代码质量:通过多人检查,能发现单个开发者难以察觉的错误,比如逻辑漏洞、性能问题、安全隐患等。
- 促进知识共享:团队成员可以了解项目其他部分的代码,学习他人的优秀编程技巧和设计思路。
- 快速培养新人:新人通过审查老员工的代码,能快速熟悉项目规范和业务逻辑;老员工通过审查新人的代码,能及时纠正他们的不良编程习惯。
- 增强团队凝聚力:代码审查过程中的交流和讨论,能让团队成员形成共同的编程价值观,提升团队的整体实力。
5.4 代码审查的实施建议
- 定期进行:将代码审查纳入软件开发周期,比如每周进行一次集中审查,或者在每次重要功能开发完成后进行审查。
- 制定审查标准:以本书的101条编程规范作为审查的核心标准,确保审查有章可循。
- 采用书面形式:通过电子邮件、代码审查工具等书面形式进行审查,便于跟踪审查意见和修改情况。
- 保持积极心态:审查的目的是提高代码质量,而不是批评个人,团队成员要以开放、学习的心态对待审查意见。
二、设计风格
1. 第5条 一个实体应该只有一个紧凑的职责
1.1 核心要点
每个程序实体(变量、类、函数、名称空间、模块和库)都应该只有一个定义良好的职责。实体可以随着功能扩展而增大,但职责不能发散。
1.2 通俗解释
就像一家公司,每个部门都有明确的职责(比如财务部负责财务核算,人力资源部负责人事管理)。如果一个部门同时负责多个不相关的职责,很容易出现管理混乱、效率低下的问题。程序实体也是如此,单一职责能让代码更易于理解、维护和重用。
1.3 反例分析
1.3.1 C语言中的realloc函数
realloc函数承担了太多职责:
- 如果传入的指针为NULL,它会分配内存空间。
- 如果传入的大小参数为0,它会释放内存空间。
- 如果可行,它会就地重新分配内存。
- 如果就地分配不可行,它会在其他地方分配内存并复制数据。
这种设计导致realloc函数难以理解和扩展,是一个典型的反面例子。
1.3.2 C++中的std::basic_string类
std::basic_string类过于庞大,包含了太多不相关的功能:
- 试图成为容器,但又没有完全遵循容器的规范。
- 在使用迭代器和索引访问之间犹豫不决。
- 重复实现了许多标准算法的功能。
这种设计降低了类的灵活性和可维护性,也增加了使用者的学习成本。
1.4 正面示例
// 单一职责的类:只负责日志记录
class Logger {
public:
void log_info(const std::string& message) {
// 记录信息日志
}
void log_error(const std::string& message) {
// 记录错误日志
}
private:
std::ofstream log_file;
};
// 单一职责的函数:只负责计算两个数的和
int add(int a, int b) {
return a + b;
}
2. 第6条 正确、简单和清晰第一
2.1 核心要点
编写代码时,应遵循“正确优于速度、简单优于复杂、清晰优于机巧、安全优于不安全”的原则。优先保证代码的正确性和可读性,再考虑性能优化等其他因素。
2.2 通俗解释
就像盖房子,首先要保证房子的结构安全、功能正常(正确),设计简洁合理(简单清晰),而不是一开始就追求外观的华丽或者建造速度。代码也是如此,正确和清晰是基础。
2.3 具体建议
2.3.1 避免使用冷僻特性
C++中有一些不常用的冷僻特性,虽然在某些情况下能实现特殊功能,但会降低代码的可读性。比如,尽量避免使用复杂的模板元编程技巧,除非有特殊的性能需求。
2.3.2 优先使用简单的技术
实现同一个功能可能有多种方法,应选择最简单、最直观的方法。比如,遍历一个vector容器,使用普通的for循环比使用复杂的算法组合更易于理解:
// 简单清晰的遍历方式
std::vector<int> nums = {1, 2, 3, 4, 5};
for (size_t i = 0; i < nums.size(); ++i) {
std::cout << nums[i] << std::endl;
}
// 相对复杂的遍历方式(无特殊需求时不推荐)
std::for_each(nums.begin(), nums.end(), [](int num) {
std::cout << num << std::endl;
});
2.3.3 平衡清晰性和性能
不要为了追求性能而牺牲代码的清晰性,因为让一个正确的程序变快,比让一个快速的程序正确要容易得多。比如,在没有明确性能瓶颈的情况下,不要手动编写复杂的内存优化代码。
2.4 反面示例
// 反面示例:使用过于机巧的代码计算a和b的最大值
int max_bad(int a, int b) {
return (a + b + abs(a - b)) / 2;
}
// 正面示例:清晰直观的实现
int max_good(int a, int b) {
return a > b ? a : b;
}
3. 第7条 编程中应知道何时和如何考虑可伸缩性
3.1 核心要点
不要进行不成熟的优化,但要关注算法的渐近复杂性。处理用户数据的算法,其时间复杂度应是可预测的,最好不劣于线性关系。当优化必要时,应优先改善算法的时间复杂度,而不是进行微小的代码优化。
3.2 通俗解释
就像设计一条公路,不仅要考虑当前的车流量,还要考虑未来几年的交通增长。如果公路设计得过于狭窄,未来车流量增大时就会出现拥堵。程序也是如此,要保证代码在数据量增长时仍然能高效运行。
3.3 关键概念:算法的时间复杂度
算法的时间复杂度描述了算法执行时间随数据量增长的变化趋势,常用的时间复杂度有:
- O(1):常数时间,算法执行时间不随数据量变化,比如访问数组中的某个元素。
- O(logN):对数时间,算法执行时间随数据量增长而缓慢增长,比如二分查找。
- O(N):线性时间,算法执行时间与数据量成正比,比如遍历数组。
- O(NlogN):线性对数时间,比如快速排序、归并排序。
- O(N²):平方时间,算法执行时间与数据量的平方成正比,比如冒泡排序、插入排序。
- O(2ⁿ):指数时间,算法执行时间随数据量增长呈指数级增长,比如暴力破解密码。
3.4 具体建议
3.4.1 避免使用固定大小的数组
固定大小的数组在数据量超过数组大小时会出现问题,应优先使用vector等动态容器:
// 错误示例:固定大小的数组
int bad_array[100]; // 当数据量超过100时会溢出
// 正确示例:动态容器
std::vector<int> good_vector; // 可以根据数据量动态扩展
3.4.2 了解算法的实际复杂度
有些算法看似是线性时间,但内部调用了其他线性操作,实际复杂度可能是平方级的。比如:
// 反面示例:实际复杂度为O(N²)
void bad_algorithm(std::vector<int>& vec) {
for (size_t i = 0; i < vec.size(); ++i) {
// 每次调用find都是O(N)操作,整体复杂度为O(N²)
auto it = std::find(vec.begin(), vec.end(), vec[i] * 2);
if (it != vec.end()) {
// 处理逻辑
}
}
}
// 正面示例:优化后复杂度为O(N)
void good_algorithm(std::vector<int>& vec) {
std::unordered_set<int> num_set(vec.begin(), vec.end());
for (int num : vec) {
// 哈希表查找为O(1)操作,整体复杂度为O(N)
if (num_set.count(num * 2)) {
// 处理逻辑
}
}
}
3.4.3 优先选择高效的算法和数据结构
根据具体需求选择合适的算法和数据结构:
- 需要快速插入和查找时,优先使用哈希表(unordered_map、unordered_set)。
- 需要有序数据时,优先使用set、map(红黑树实现,O(logN)复杂度)。
- 避免使用指数复杂度的算法,除非没有其他替代方案。
4. 第8条 不要进行不成熟的优化
4.1 核心要点
不成熟的优化是编程中的“万恶之源”。在没有明确的性能测试数据证明需要优化之前,不要为了追求性能而使代码变得复杂,影响可读性和可维护性。
4.2 通俗解释
就像我们写作文,首先要保证内容完整、逻辑清晰,而不是一开始就纠结于个别词语的华丽程度。代码也是如此,先保证代码的正确性和可读性,再根据实际性能需求进行优化。
4.3 不成熟优化的危害
- 降低代码可读性:为了优化性能,可能会使用复杂的算法和数据结构,让代码难以理解和维护。
- 浪费开发时间:很多所谓的“性能瓶颈”其实并不存在,花大量时间进行优化纯属浪费。
- 引入新的bug:优化过程中很容易引入新的错误,而且这些错误往往因为代码复杂而难以排查。
4.4 优化的正确步骤
- 编写清晰、正确的代码。
- 对代码进行性能测试,找出真正的性能瓶颈。
- 针对性能瓶颈进行优化,优化后再次测试,验证优化效果。
- 优化过程中要保持代码的可读性,必要时添加注释说明优化的原因。
4.5 示例
// 反面示例:不成熟的优化
// 为了节省一次加法运算,代码变得难以理解
int calculate_bad(int a, int b, int c) {
return (a + b) * c - a * c; // 等价于b * c,但可读性极差
}
// 正面示例:清晰的代码
int calculate_good(int a, int b, int c) {
return b * c;
}
5. 第9条 不要进行不成熟的劣化
5.1 核心要点
避免不成熟的优化,并不意味着要牺牲必要的性能。在代码复杂性和可读性相同的情况下,应优先选择更高效的设计模式和编程惯用法,这不是优化,而是避免不必要的性能损失。
5.2 通俗解释
就像我们选择交通工具,在距离和成本相同的情况下,优先选择更快的交通工具(比如选高铁而不是普通火车)。编程也是如此,在不增加代码复杂度的前提下,自然地写出更高效的代码。
5.3 具体示例
5.3.1 优先使用引用传递而非值传递
对于非原始类型,值传递会产生对象的副本,增加性能开销,应优先使用const引用传递:
// 反面示例:值传递,产生对象副本
void process_string_bad(std::string str) {
// 处理逻辑
}
// 正面示例:const引用传递,避免副本
void process_string_good(const std::string& str) {
// 处理逻辑
}
5.3.2 优先使用前缀++而非后缀++
对于自定义类型,后缀++会创建临时对象,而前缀++直接修改对象本身,效率更高:
// 反面示例:后缀++
MyClass obj;
for (int i = 0; i < 1000; ++i) {
obj++; // 创建临时对象,效率低
}
// 正面示例:前缀++
MyClass obj;
for (int i = 0; i < 1000; ++i) {
++obj; // 无临时对象,效率高
}
5.3.3 在构造函数中使用初始化列表而非赋值
初始化列表直接初始化成员变量,而赋值操作会先默认初始化成员变量,再进行赋值,效率更低:
// 反面示例:赋值操作
class MyClassBad {
public:
MyClassBad(int a, std::string b) {
num = a; // 先默认初始化,再赋值
str = b;
}
private:
int num;
std::string str;
};
// 正面示例:初始化列表
class MyClassGood {
public:
MyClassGood(int a, std::string b) : num(a), str(b) {
// 直接初始化,效率更高
}
private:
int num;
std::string str;
};
6. 第10条 尽量减少全局和共享数据
6.1 核心要点
共享数据(尤其是全局数据)会增加代码的耦合度,降低可维护性和性能,应尽量避免使用。如果必须使用,要谨慎处理初始化和访问同步问题。
6.2 通俗解释
就像公共区域的物品,每个人都可以使用和修改,很容易出现损坏、丢失或者使用冲突的问题。全局和共享数据也是如此,多个部分的代码都能访问和修改它们,容易引发逻辑错误和并发问题。
6.3 全局和共享数据的危害
- 增加耦合度:使用全局数据的代码片段之间会形成隐式依赖,修改一个地方的代码可能会影响其他多个地方。
- 影响可测试性:含有全局数据的代码,其正确性依赖于全局数据的状态,难以进行独立的单元测试。
- 引发并发问题:在多线程环境中,多个线程同时访问和修改共享数据,容易出现数据竞争、死锁等问题。
- 污染命名空间:全局数据的名称会占用全局命名空间,容易引发命名冲突。
6.4 避免使用的建议
6.4.1 用局部变量代替全局变量
如果变量只在某个函数或代码块中使用,应定义为局部变量:
// 反面示例:全局变量
int global_counter = 0;
void increment() {
global_counter++;
}
// 正面示例:局部变量(如果适用)
void increment_good() {
static int local_counter = 0; // 静态局部变量,作用域局限于函数内部
local_counter++;
}
6.4.2 用类的成员变量代替共享数据
将需要共享的数据封装在类中,通过类的接口进行访问和修改,避免直接暴露数据:
// 正面示例:封装共享数据
class Counter {
public:
void increment() {
// 若涉及多线程,可在此添加同步机制
counter++;
}
int get_value() const {
return counter;
}
private:
int counter = 0;
};
6.4.3 避免跨线程共享数据
在多线程编程中,尽量采用“无共享”的设计模式,通过消息队列等方式进行线程间通信,代替数据共享。
7. 第11条 隐藏信息
7.1 核心要点
信息隐藏是软件工程的重要原则,不要公开程序实体的内部信息。通过隐藏实现细节,减少代码之间的依赖,提高代码的可维护性和可扩展性。
7.2 通俗解释
就像一家公司,会公开其产品和服务,但不会公开其核心技术和商业机密。程序也是如此,公开对外提供的接口,隐藏内部的实现细节,这样在修改内部实现时,不会影响外部代码的使用。
7.3 信息隐藏的好处
- 限制变化的影响范围:修改内部实现细节时,只要接口不变,外部代码就不需要修改。
- 强化不变式:确保只有特定的代码负责维护对象的状态,避免外部代码随意修改导致对象状态不一致。
- 降低耦合度:外部代码只依赖于公开的接口,不依赖于内部实现,减少了代码之间的耦合。
7.4 具体实现方式
7.4.1 将类的数据成员设为私有
通过private访问控制符隐藏类的数据成员,提供public的成员函数来访问和修改数据:
// 正面示例:信息隐藏
class Person {
public:
std::string get_name() const {
return name;
}
void set_name(const std::string& new_name) {
// 可以在此添加数据验证逻辑
if (!new_name.empty()) {
name = new_name;
}
}
private:
std::string name; // 私有数据成员,外部无法直接访问
};
// 反面示例:公开数据成员
class PersonBad {
public:
std::string name; // 公开数据成员,外部可随意修改
};
7.4.2 避免公开内部句柄和指针
不要返回类内部数据的指针或引用,以免外部代码通过这些指针或引用修改内部数据:
// 反面示例:返回内部数据的指针
class StringBad {
public:
char* get_buffer() {
return buffer; // 公开内部缓冲区指针,外部可修改
}
private:
char buffer[1024];
};
// 正面示例:返回const指针或复制数据
class StringGood {
public:
const char* get_buffer() const {
return buffer; // 只能读取,不能修改
}
std::string get_string() const {
return std::string(buffer); // 返回数据的副本
}
private:
char buffer[1024];
};
7.4.3 模块和库的信息隐藏
模块和库也应该通过清晰的接口对外提供服务,隐藏内部的实现细节和依赖关系。
8. 第12条 懂得何时和如何进行并发性编程
8.1 核心要点
在多线程或多进程应用程序中,应尽量减少共享对象的使用。对于必须共享的对象,要采取安全的同步机制,避免出现死锁、活锁和数据竞争等问题。
8.2 通俗解释
就像多个工人在同一个工厂工作,他们需要合理分工,避免同时操作同一台设备(共享资源),否则会导致生产混乱。多线程编程也是如此,多个线程同时访问共享资源时,需要通过同步机制进行协调。
8.3 并发编程的关键问题
8.3.1 数据竞争
当多个线程同时访问同一个共享数据,且至少有一个线程在修改数据时,就可能出现数据竞争,导致数据结果不一致。比如:
// 反面示例:数据竞争
int shared_counter = 0;
void increment_counter() {
for (int i = 0; i < 10000; ++i) {
shared_counter++; // 多个线程同时修改,出现数据竞争
}
}
// 启动两个线程执行increment_counter函数,最终shared_counter的值可能小于20000
8.3.2 死锁
当两个或多个线程互相等待对方释放资源时,就会出现死锁,导致所有线程都无法继续执行。比如:
// 反面示例:死锁
std::mutex mutex1;
std::mutex mutex2;
void thread1_func() {
mutex1.lock();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
mutex2.lock(); // 等待thread2释放mutex2
// 处理逻辑
mutex2.unlock();
mutex1.unlock();
}
void thread2_func() {
mutex2.lock();
std::this_thread::sleep_for(std::chrono::milliseconds(100));
mutex1.lock(); // 等待thread1释放mutex1
// 处理逻辑
mutex1.unlock();
mutex2.unlock();
}
8.4 并发编程的最佳实践
8.4.1 减少共享数据
尽量采用“无共享”的设计,每个线程使用自己的局部数据,通过消息传递等方式进行线程间通信。
8.4.2 使用同步机制
对于必须共享的数据,使用互斥锁(mutex)、原子操作等同步机制:
// 正面示例:使用互斥锁避免数据竞争
std::mutex counter_mutex;
int shared_counter = 0;
void increment_counter_safe() {
for (int i = 0; i < 10000; ++i) {
std::lock_guard<std::mutex> lock(counter_mutex); // 自动加锁和解锁
shared_counter++;
}
}
8.4.3 避免死锁的方法
- 按固定顺序获取锁:所有线程都按照相同的顺序获取多个锁。
- 使用std::lock函数同时获取多个锁:该函数能避免死锁。
- 设置锁的超时时间:如果超时未能获取锁,就放弃并重试。
8.4.4 确保类型的线程安全性
自定义类型时,要明确其线程安全级别,并在文档中说明。比如,有些类型要求外部代码进行同步,有些类型则内部实现了同步机制。
9. 第13条 确保资源为对象所拥有。使用显式的RAII和智能指针
9.1 核心要点
RAII(资源获取即初始化)是C++中管理资源的核心惯用法。通过将资源封装在对象中,利用对象的构造函数获取资源,析构函数释放资源,实现资源的自动管理。动态分配的资源应尽量使用智能指针来管理。
9.2 通俗解释
就像我们租房子,入住时(构造函数)获取房屋的使用权,退房时(析构函数)归还房屋。通过这种方式,确保资源的获取和释放一一对应,不会出现资源泄漏。
9.3 RAII的应用场景
9.3.1 文件操作
// 正面示例:RAII管理文件资源
class FileHandler {
public:
FileHandler(const std::string& filename) {
file = fopen(filename.c_str(), "r");
if (!file) {
throw std::runtime_error("文件打开失败");
}
}
~FileHandler() {
if (file) {
fclose(file); // 自动关闭文件
}
}
// 提供文件操作的接口
size_t read(char* buffer, size_t size) {
return fread(buffer, 1, size, file);
}
private:
FILE* file;
// 禁用复制构造和赋值,避免资源重复释放
FileHandler(const FileHandler&) = delete;
FileHandler& operator=(const FileHandler&) = delete;
};
9.3.2 锁管理
// 正面示例:RAII管理锁资源
class LockGuard {
public:
explicit LockGuard(std::mutex& mtx) : mutex(mtx) {
mutex.lock();
}
~LockGuard() {
mutex.unlock(); // 自动释放锁
}
private:
std::mutex& mutex;
// 禁用复制构造和赋值
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
};
9.4 智能指针的使用
C++标准库提供了三种智能指针:std::unique_ptr、std::shared_ptr和std::weak_ptr,用于管理动态分配的内存。
9.4.1 std::unique_ptr
独占式智能指针,同一时间只能有一个unique_ptr指向同一个对象:
// 示例:std::unique_ptr的使用
void unique_ptr_example() {
std::unique_ptr<int> ptr1 = std::make_unique<int>(10);
// std::unique_ptr<int> ptr2 = ptr1; // 错误:不能复制
std::unique_ptr<int> ptr2 = std::move(ptr1); // 可以移动,ptr1不再拥有对象
if (!ptr1) {
std::cout << "ptr1不再拥有对象" << std::endl;
}
}
9.4.2 std::shared_ptr
共享式智能指针,多个shared_ptr可以指向同一个对象,通过引用计数管理对象的生命周期:
// 示例:std::shared_ptr的使用
void shared_ptr_example() {
std::shared_ptr<int> ptr1 = std::make_shared<int>(20);
std::cout << "引用计数:" << ptr1.use_count() << std::endl; // 输出1
std::shared_ptr<int> ptr2 = ptr1;
std::cout << "引用计数:" << ptr1.use_count() << std::endl; // 输出2
ptr1.reset();
std::cout << "引用计数:" << ptr2.use_count() << std::endl; // 输出1
}
9.4.3 避免在一条语句中分配多个资源
在一条语句中分配多个资源可能会导致资源泄漏:
// 反面示例:可能导致资源泄漏
void bad_resource_allocation() {
// 如果第二个new抛出异常,第一个对象的内存会泄漏
Fun(std::shared_ptr<Widget>(new Widget), std::shared_ptr<Widget>(new Widget));
}
// 正面示例:分开分配资源
void good_resource_allocation() {
std::shared_ptr<Widget> ptr1 = std::make_shared<Widget>();
std::shared_ptr<Widget> ptr2 = std::make_shared<Widget>();
Fun(ptr1, ptr2);
}
三、编程风格
1. 第14条 宁要编译时和连接时错误,也不要运行时错误
1.1 核心要点
编写代码时,应尽量让错误在编译期或链接期被发现,而不是等到程序运行时才暴露。编译时错误通常更容易定位和修复,而运行时错误可能会在程序部署后才出现,造成严重的后果。
1.2 通俗解释
就像我们考试,在做题时就发现自己的错误并改正,比交卷后才发现错误要好得多。编程也是如此,尽早发现错误,能减少后续的修复成本。
1.3 具体实现方法
1.3.1 使用编译时断言
对于编译时就能确定的条件,使用static_assert进行检查:
// 示例:编译时断言
template <typename T>
class FixedSizeArray {
public:
static_assert(sizeof(T) <= 8, "类型T的大小不能超过8字节");
// 类的其他成员
};
// FixedSizeArray<int> arr1; // 正确:int通常为4字节
// FixedSizeArray<double[2]> arr2; // 错误:编译时断言失败
1.3.2 使用编译时多态代替运行时多态
在合适的场景下,使用模板(编译时多态)代替虚拟函数(运行时多态),能在编译期发现类型错误:
// 示例:编译时多态
template <typename T>
void process(T& obj) {
obj.do_something(); // 编译时检查T是否有do_something方法
}
class A {
public:
void do_something() {
std::cout << "A的do_something" << std::endl;
}
};
class B {
// 没有do_something方法
};
// process(A()); // 正确
// process(B()); // 错误:编译时发现B没有do_something方法
1.3.3 使用枚举代替魔法数字
使用枚举定义常量,能在编译期检查类型的正确性:
// 反面示例:魔法数字
void process_color_bad(int color) {
if (color == 0) {
// 处理红色
} else if (color == 1) {
// 处理绿色
}
}
// 正面示例:枚举
enum class Color {
Red,
Green,
Blue
};
void process_color_good(Color color) {
if (color == Color::Red) {
// 处理红色
} else if (color == Color::Green) {
// 处理绿色
}
}
// process_color_good(0); // 错误:编译时类型不匹配
// process_color_good(Color::Red); // 正确
2. 第15条 积极使用const
2.1 核心要点
const关键字是C++中用于表示常量的重要工具。合理使用const能让代码更安全、更易理解,减少意外修改数据的可能性。const对象在编译时会受到检查,而且与C++的类型系统无缝集成。
2.2 通俗解释
就像我们给文件设置“只读”属性,防止误修改。const关键字也能给变量、函数等设置“只读”属性,明确表示这些实体不应该被修改。
2.3 const的具体用法
2.3.1 常量变量
使用const定义的变量,其值不能被修改:
// 示例:const常量变量
const int MAX_AGE = 100;
// MAX_AGE = 200; // 错误:不能修改const变量的值
const std::string DEFAULT_NAME = "unknown";
// DEFAULT_NAME = "test"; // 错误
2.3.2 const指针和指针指向的常量
// 示例:const指针
int num = 10;
const int* ptr1 = # // 指针指向的内容不能修改
// *ptr1 = 20; // 错误
num = 20; // 可以通过原变量修改
int* const ptr2 = # // 指针本身不能修改
// ptr2 = nullptr; // 错误
*ptr2 = 30; // 可以修改指针指向的内容
const int* const ptr3 = # // 指针本身和指向的内容都不能修改
// ptr3 = nullptr; // 错误
// *ptr3 = 40; // 错误
2.3.3 const成员函数
const成员函数不能修改类的非mutable数据成员,也不能调用非const成员函数:
// 示例:const成员函数
class Rectangle {
public:
Rectangle(int w, int h) : width(w), height(h) {}
int get_area() const {
// width = 100; // 错误:不能修改非mutable成员
return width * height;
}
void set_width(int w) {
width = w;
}
private:
int width;
int height;
};
void print_area(const Rectangle& rect) {
std::cout << rect.get_area() << std::endl;
// rect.set_width(20); // 错误:不能调用非const成员函数
}
2.3.4 mutable成员
mutable成员可以在const成员函数中被修改,通常用于存储缓存数据等不影响对象可观察状态的成员:
// 示例:mutable成员
class Calculator {
public:
double get_square_root(double x) const {
if (x != last_x || !cache_valid) {
last_result = sqrt(x);
last_x = x;
cache_valid = true; // 修改mutable成员
}
return last_result;
}
private:
mutable double last_x = 0.0;
mutable double last_result = 0.0;
mutable bool cache_valid = false;
};
3. 第16条 避免使用宏
3.1 核心要点
宏是C和C++中一种低级的文本替换工具,它不考虑作用域、类型系统和语言规则,容易引发各种问题。在C++中,几乎所有宏的用途都可以被更安全的特性替代,应尽量避免使用宏。
3.2 通俗解释
就像用剪刀随意裁剪布料,很容易破坏布料的结构。宏也会随意替换代码文本,破坏C++的语言规则,引发难以预料的问题。
3.3 宏的问题
- 不考虑作用域:宏的作用域是从定义处到文件结束,容易引发命名冲突。
- 不进行类型检查:宏的参数没有类型限制,容易传入错误类型的参数。
- 难以调试:宏在预处理阶段被替换,调试时看到的是替换后的代码,难以定位问题。
- 可能产生意外的副作用:宏参数如果是表达式,可能会被多次求值。
3.4 宏的替代方案
3.4.1 用const或enum代替宏定义的常量
// 反面示例:宏定义常量
#define MAX_SIZE 100
#define PI 3.14159
// 正面示例:const或enum
const int kMaxSize = 100;
const double kPi = 3.14159;
enum class Weekday {
Monday,
Tuesday,
Wednesday
};
3.4.2 用inline函数代替宏函数
// 反面示例:宏函数
#define ADD(a, b) ((a) + (b))
// 正面示例:inline函数
inline int add(int a, int b) {
return a + b;
}
// 宏的副作用示例
int x = 1;
// int y = ADD(x++, 2); // 结果为x先自增再相加,y=4,x=2,可能不符合预期
int y = add(x++, 2); // 结果为2+1=3,x=2,行为明确
3.4.3 用模板代替宏的类型无关代码
// 反面示例:宏的类型无关代码
#define SWAP(a, b, type) do { type temp = a; a = b; b = temp; } while(0)
// 正面示例:模板函数
template <typename T>
void swap(T& a, T& b) {
T temp = a;
a = b;
b = temp;
}
int a = 1, b = 2;
// SWAP(a, b, int); // 宏的用法
swap(a, b); // 模板的用法,更简洁且类型安全
3.5 宏的例外情况
在某些特殊场景下,宏是不可替代的:
- #include保护符:用于防止头文件被重复包含。
- 条件编译:比如#ifdef DEBUG用于区分调试模式和发布模式。
- assert的实现:assert宏用于在调试模式下检查断言条件。
4. 第17条 避免使用“魔数”
4.1 核心要点
“魔数”是指在代码中直接出现的、没有任何说明的数字或字符串常量。使用魔数会降低代码的可读性和可维护性,应将其替换为有意义的符号名称。
4.2 通俗解释
就像在文章中使用一些没有解释的暗号,读者很难理解其含义。代码中的魔数也是如此,其他开发者看到这些数字时,无法知道它们的用途。
4.3 具体示例
4.3.1 数字常量的替换
// 反面示例:魔数
double calculate_circle_area(double radius) {
return 3.14159 * radius * radius; // 3.14159是魔数
}
void process_order(int status) {
if (status == 1) {
// 处理已付款订单
} else if (status == 2) {
// 处理已发货订单
}
}
// 正面示例:符号常量
const double kPi = 3.14159;
enum class OrderStatus {
Paid,
Shipped
};
double calculate_circle_area_good(double radius) {
return kPi * radius * radius;
}
void process_order_good(OrderStatus status) {
if (status == OrderStatus::Paid) {
// 处理已付款订单
} else if (status == OrderStatus::Shipped) {
// 处理已发货订单
}
}
4.3.2 字符串常量的替换
// 反面示例:字符串魔数
void send_request() {
std::string url = "https://api.example.com/login";
// 发送请求到该URL
}
// 正面示例:符号常量
const std::string kLoginUrl = "https://api.example.com/login";
void send_request_good() {
std::string url = kLoginUrl;
// 发送请求到该URL
}
4.3.3 类特定常量的定义
对于只在某个类中使用的常量,应定义为类的静态成员:
// 示例:类特定常量
class Rectangle {
public:
static const int kDefaultWidth = 100;
static const int kDefaultHeight = 50;
Rectangle() : width(kDefaultWidth), height(kDefaultHeight) {}
private:
int width;
int height;
};
5. 第18条 尽可能局部地声明变量
5.1 核心要点
变量的声明应尽可能靠近其首次使用的位置,减少变量的作用域和生存期。这样能提高代码的可读性,减少未初始化变量的问题,同时降低变量被意外修改的风险。
5.2 通俗解释
就像我们整理物品,常用的物品放在手边,不常用的物品放在储物间。变量也是如此,在哪里使用,就在哪里声明,避免变量的作用域过大。
5.3 具体示例
5.3.1 变量声明靠近使用位置
// 反面示例:变量声明过早
void process_data() {
int result = 0;
std::vector<int> data = get_data();
// 大量不使用result的代码
for (int num : data) {
result += num;
}
std::cout << result << std::endl;
}
// 正面示例:变量声明靠近使用位置
void process_data_good() {
std::vector<int> data = get_data();
// 大量代码
int result = 0;
for (int num : data) {
result += num;
}
std::cout << result << std::endl;
}
5.3.2 循环变量的声明
在C++11及以后的版本中,循环变量可以直接在for循环中声明:
// 示例:循环变量的声明
std::vector<int> nums = {1, 2, 3, 4, 5};
// 正面示例:在循环中声明变量
for (int num : nums) {
std::cout << num << std::endl;
}
for (size_t i = 0; i < nums.size(); ++i) {
std::cout << nums[i] << std::endl;
}
5.4 例外情况
在某些性能敏感的场景下,将变量声明在循环外部可能会提高性能,比如循环内部需要创建开销较大的对象:
// 示例:循环外部声明变量
void process_large_data() {
std::vector<LargeObject> data = get_large_data();
LargeObject temp; // 声明在循环外部,避免多次创建和销毁
for (auto& obj : data) {
temp = obj;
// 处理temp
}
}
6. 第19条 总是初始化变量
6.1 核心要点
未初始化的变量是C和C++程序中常见的错误来源。在定义变量时,应显式地对其进行初始化,避免变量包含随机值,引发难以预测的行为。
6.2 通俗解释
就像我们使用笔记本之前,先把笔记本上的旧内容清理干净,避免受到旧内容的干扰。变量初始化也是如此,在使用变量之前,先给它一个明确的值。
6.3 具体示例
6.3.1 基本类型变量的初始化
// 反面示例:未初始化变量
void calculate() {
int a;
if (some_condition) {
a = 10;
}
std::cout << a << std::endl; // 未初始化时,a的值是随机的
}
// 正面示例:显式初始化
void calculate_good() {
int a = 0; // 显式初始化
if (some_condition) {
a = 10;
}
std::cout << a << std::endl;
}
6.3.2 类对象的初始化
类对象应通过构造函数进行初始化,确保对象的状态是有效的:
// 示例:类对象的初始化
class Person {
public:
// 带参数的构造函数,确保对象初始化
Person(const std::string& name, int age) : name_(name), age_(age) {}
private:
std::string name_;
int age_;
};
// Person p; // 错误:没有默认构造函数
Person p("张三", 20); // 正确:通过构造函数初始化
6.3.3 数组的初始化
// 反面示例:未初始化数组
void array_example_bad() {
int arr[5];
for (int i = 0; i < 5; ++i) {
std::cout << arr[i] << std::endl; // 数组元素值随机
}
}
// 正面示例:初始化数组
void array_example_good() {
int arr1[5] = {0}; // 所有元素初始化为0
int arr2[5] = {1, 2, 3, 4, 5}; // 显式初始化每个元素
std::vector<int> arr3(5, 0); // vector容器初始化
}
6.4 例外情况
硬件或其他进程直接写入的输入缓冲区数据和volatile类型数据,不需要程序对其进行初始化。
7. 第20条 避免函数过长,避免嵌套过深
7.1 核心要点
过长的函数和嵌套过深的代码块会降低代码的可读性和可维护性。应将过长的函数拆分为多个小函数,将嵌套过深的代码进行重构,使代码更加清晰。
7.2 通俗解释
就像一篇过长的文章难以阅读,一个过长的函数也难以理解其逻辑。嵌套过深的代码就像迷宫,读者需要不断跟踪代码的分支,容易迷失方向。
7.3 具体建议
7.3.1 拆分过长的函数
一个函数的长度建议不超过50行,超过则应拆分为多个功能单一的函数:
// 反面示例:过长的函数
void process_order_bad() {
// 1. 验证订单数据
if (order_data.empty()) {
return;
}
// 大量验证逻辑
// 2. 计算订单金额
double amount = 0;
// 大量计算逻辑
// 3. 保存订单数据
// 大量保存逻辑
// 4. 发送通知
// 大量发送通知逻辑
}
// 正面示例:拆分后的函数
bool validate_order(const OrderData& data) {
if (data.empty()) {
return false;
}
// 验证逻辑
return true;
}
double calculate_order_amount(const OrderData& data) {
double amount = 0;
// 计算逻辑
return amount;
}
void save_order(const OrderData& data, double amount) {
// 保存逻辑
}
void send_notification(const OrderData& data) {
// 发送通知逻辑
}
void process_order_good() {
if (!validate_order(order_data)) {
return;
}
double amount = calculate_order_amount(order_data);
save_order(order_data, amount);
send_notification(order_data);
}
7.3.2 减少代码嵌套
代码的嵌套深度建议不超过3层,超过则应进行重构:
// 反面示例:嵌套过深的代码
void process_data_bad(const std::vector<int>& data) {
for (size_t i = 0; i < data.size(); ++i) {
if (data[i] > 0) {
if (data[i] % 2 == 0) {
// 处理正偶数
} else {
// 处理正奇数
}
} else {
if (data[i] == 0) {
// 处理零
} else {
// 处理负数
}
}
}
}
// 正面示例:减少嵌套后的代码
void process_positive_even(int num) {
// 处理正偶数
}
void process_positive_odd(int num) {
// 处理正奇数
}
void process_zero(int num) {
// 处理零
}
void process_negative(int num) {
// 处理负数
}
void process_data_good(const std::vector<int>& data) {
for (int num : data) {
if (num > 0) {
if (num % 2 == 0) {
process_positive_even(num);
} else {
process_positive_odd(num);
}
continue;
}
if (num == 0) {
process_zero(num);
} else {
process_negative(num);
}
}
}
8. 第21条 避免跨编译单元的初始化依赖
8.1 核心要点
不同编译单元中的名字空间级对象的初始化顺序是未定义的,因此这些对象不应该在初始化上互相依赖,否则可能导致程序崩溃或出现不可移植的问题。
8.2 通俗解释
就像多个团队同时进行项目的不同模块开发,不知道哪个模块先完成。不同编译单元的对象初始化顺序也是不确定的,依赖这个顺序的代码可能会出错。
8.3 问题示例
// 编译单元A:a.cpp
extern int b_value;
int a_value = b_value + 1; // 依赖编译单元B的b_value初始化
// 编译单元B:b.cpp
int b_value = 10;
在这个例子中,a_value的初始化依赖b_value的值,但a.cpp和b.cpp的初始化顺序不确定。如果a_value先初始化,b_value还未初始化,a_value就会得到一个随机值。
8.4 解决方案
8.4.1 避免使用名字空间级对象
尽量将变量封装在类中,通过静态成员函数获取实例,确保初始化顺序:
// 正面示例:延迟初始化
class Config {
public:
static Config& get_instance() {
static Config instance; // 第一次调用时初始化
return instance;
}
int get_b_value() const {
return b_value;
}
private:
Config() : b_value(10) {}
int b_value;
};
// 使用
int a_value = Config::get_instance().get_b_value() + 1;
8.4.2 使用单例模式
通过单例模式确保对象的初始化顺序,只有在需要时才创建对象。
9. 第22条 尽量减少定义性依赖。避免循环依赖
9.1 核心要点
应尽量使用前向声明代替包含头文件,减少代码之间的定义性依赖。同时,要避免模块之间的循环依赖,循环依赖会破坏模块的独立性,增加代码的维护难度。
9.2 通俗解释
就像两个朋友互相借钱,谁也离不开谁。模块之间的循环依赖也会让模块失去独立性,修改一个模块会影响另一个模块。
9.3 减少定义性依赖的方法
9.3.1 使用前向声明
如果只需要知道某个类的存在,而不需要知道其具体实现,可以使用前向声明:
// 正面示例:前向声明
// A.h
class B; // 前向声明B类
class A {
public:
void set_b(B* b);
private:
B* b_;
};
// A.cpp
#include "B.h"
void A::set_b(B* b) {
b_ = b;
}
9.3.2 避免包含不必要的头文件
在头文件中只包含必要的头文件,对于不需要的头文件,通过前向声明或在实现文件中包含来避免依赖:
// 反面示例:包含不必要的头文件
// A.h
#include "B.h" // 不必要,因为只需要B的前向声明
class A {
public:
void process(B* b);
};
// 正面示例:只包含必要的头文件
// A.h
class B; // 前向声明
class A {
public:
void process(B* b);
};
// A.cpp
#include "B.h"
void A::process(B* b) {
// 处理逻辑
}
9.4 避免循环依赖的方法
9.4.1 重构代码,打破循环
如果两个类之间存在循环依赖,可以将共同的功能提取到第三个类中,或者使用接口分离依赖:
// 反面示例:循环依赖
// A.h
#include "B.h"
class A {
public:
void use_b(B* b);
};
// B.h
#include "A.h"
class B {
public:
void use_a(A* a);
};
// 正面示例:打破循环依赖
// IBase.h
class IBase {
public:
virtual ~IBase() {}
};
// A.h
#include "IBase.h"
class B;
class A
::public IBase {
public:
void use_b(IB* b); // 依赖IB接口,而非具体B类
};
// B.h
#include "IBase.h"
class IA;
class B : public IBase {
public:
void use_a(IA* a); // 依赖IA接口,而非具体A类
};
// IA.h
#include "IBase.h"
class IA : public IBase {
public:
virtual void do_something() = 0;
};
// IB.h
#include "IBase.h"
class IB : public IBase {
public:
virtual void do_another() = 0;
};
// A.cpp
#include "A.h"
#include "IB.h"
void A::use_b(IB* b) {
b->do_another(); // 调用接口方法,不依赖B的具体实现
}
// B.cpp
#include "B.h"
#include "IA.h"
void B::use_a(IA* a) {
a->do_something(); // 调用接口方法,不依赖A的具体实现
}
通过引入抽象接口IA和IB,A和B不再直接依赖对方的具体类,而是依赖稳定的接口,彻底打破了循环依赖。接口的稳定性远高于具体类,后续修改A或B的实现时,只要接口不变,依赖方就无需修改。
9.4.2 检查循环依赖的迹象
如果局部修改代码后,需要重新编译项目中大量无关文件,很可能存在隐藏的循环依赖。此时应梳理模块间的依赖关系,通过前向声明、接口抽象等方式重构代码。
9.5 例外情况
如果两个类紧密耦合、属于同一模块(由同一团队维护、一起发布),且逻辑上确实需要互相引用(如Node和LinkedList),则短暂的循环依赖可接受,但仍建议通过内部前向声明(如在LinkedList.h中前向声明Node)减少编译依赖,而非直接包含头文件。
10. 第23条 头文件应该自给自足
10.1 核心要点
每个头文件都应能独立编译,无需用户额外包含其他头文件。头文件需自行包含其内容依赖的所有头文件,避免给使用者增加不必要的负担。
10.2 通俗解释
就像一份说明书,应该包含所有必要的信息,读者不需要额外查阅其他资料就能看懂。头文件也一样,用户包含它时,不需要手动添加其他头文件就能编译通过。
10.3 为什么需要自给自足
早年有观点认为“头文件不应该包含其他头文件”,担心重复包含增加编译开销。但现代编译器已能自动识别#include保护符(见第24条),甚至支持预编译头文件,无需担心这类问题。反之,依赖用户手动包含其他头文件会导致:
- 交流障碍:用户需记住“包含A.h前必须先包含B.h”,容易遗漏。
- 隐藏依赖:代码移植时,若忘记携带依赖的头文件,会导致编译失败。
10.4 具体示例
10.4.1 基础类型依赖
如果头文件中使用了std::string,必须自行包含<string>,而非让用户手动添加:
// 反面示例:依赖用户包含其他头文件
// Widget.h
// 错误:使用了std::string,但未包含<string>
class Widget {
public:
std::string get_name() const; // 编译失败,std::string未定义
private:
std::string name_;
};
// 正面示例:自给自足的头文件
// Widget.h
#include <string> // 自行包含依赖的头文件
class Widget {
public:
std::string get_name() const;
private:
std::string name_;
};
10.4.2 模板的隐藏依赖
模板类即使未实例化,若使用了其他容器(如std::deque),也需包含对应头文件,否则用户实例化时会报错:
// 反面示例:模板依赖未包含
// MyContainer.h
// 错误:使用std::deque,但未包含<deque>
template <typename T>
class MyContainer {
public:
void add(const T& value);
private:
std::deque<T> data_; // 编译失败(若用户未包含<deque>)
};
// 正面示例:模板头文件自给自足
// MyContainer.h
#include <deque> // 包含模板依赖的头文件
template <typename T>
class MyContainer {
public:
void add(const T& value) {
data_.push_back(value);
}
private:
std::deque<T> data_;
};
10.4.3 测试头文件自给自足
可通过“单独编译头文件”验证其自给自足:创建一个临时.cpp文件,仅包含目标头文件,若能编译通过,则头文件符合要求:
// test_Widget.cpp
#include "Widget.h" // 仅包含目标头文件
int main() {
return 0;
}
// 编译命令:g++ test_Widget.cpp -o test
// 若编译无错误,则Widget.h自给自足
10.5 例外情况
若头文件中仅在极少使用的函数(如某个模板成员函数)中依赖大开销头文件(如<boost/heavy_lib.hpp>),可将该函数重构为非成员函数,放在单独的头文件中,并在新头文件中包含大开销头文件,避免所有用户都承担编译开销。
11. 第24条 总是编写内部#include保护符,决不要编写外部#include保护符
11.1 核心要点
所有头文件必须使用内部包含保护符(#include guard)防止重复包含导致的重定义错误。保护符名称需唯一,且严禁使用过时的外部包含保护符。
11.2 通俗解释
就像给头文件装一把“锁”,确保即使被多次包含,也只会被编译一次。内部保护符是“自带钥匙”的锁,而外部保护符需要用户手动管理钥匙,容易出错。
11.3 内部包含保护符的标准格式
头文件foo.h的标准保护格式如下,关键是保护符名称唯一(如包含项目名、文件名、随机数):
// foo.h
#ifndef FOO_H_INCLUDED_MY_PROJECT // 唯一保护符名称
#define FOO_H_INCLUDED_MY_PROJECT
// 头文件内容(类、函数声明等)
class Foo {
public:
void do_something();
};
#endif // FOO_H_INCLUDED_MY_PROJECT
11.4 保护符命名规则
- 唯一性:避免与其他项目或库的保护符重名,建议格式:
[文件名]_H_[项目名/模块名],或加入随机数(如FOO_H_78A3B2)。 - 避免关键字:不要使用以下划线开头或包含双下划线的名称(如
_FOO_H_),这类名称在C++中属于“保留名称”,可能与编译器内部定义冲突。 - 一致性:团队内统一命名风格,如所有保护符以
[文件名]_H_INCLUDED_开头。
11.5 严禁使用外部包含保护符
外部包含保护符是早年的过时做法,依赖用户在包含头文件前手动检查,耦合度极高,现已被现代编译器淘汰:
// 反面示例:外部包含保护符(不推荐)
#ifndef FOO_H_INCLUDED // 用户需手动检查
#include "foo.h"
#define FOO_H_INCLUDED
#endif
外部保护符的问题:
- 耦合紧:用户必须知道保护符名称,且与头文件内部约定一致。
- 易出错:若用户拼写错误(如
FOO_H_INCLUDE),会导致重复包含。 - 编译器不优化:现代编译器无法识别外部保护符,仍会重复读取头文件,增加编译时间。
11.6 例外情况
极罕见的场景下(如头文件需被多次包含以生成不同代码,如通过宏控制生成不同版本的类),可省略保护符,但必须在文档中明确说明用途和使用方式,且仅限内部临时代码。
四、函数与操作符
1. 第25条 正确地选择通过值、(智能)指针或者引用传递参数
1.1 核心要点
传递参数时需根据参数类型(输入/输出)、大小、所有权等因素,选择值传递、const引用传递或**(智能)指针传递**,平衡安全性和效率。
1.2 通俗解释
就像送礼物:
- 小礼物(如一张卡片)直接递给对方(值传递);
- 贵重且不希望被修改的礼物(如限量版书籍),让对方拿着看但不放手(const引用传递);
- 需要对方长期保管或可能修改的礼物(如一把钥匙),交给对方并允许其处置(指针传递)。
1.3 参数传递的分类与选择准则
根据参数的“用途”(输入/输出),传递方式的选择如下:
| 参数类型 | 适用场景 | 推荐传递方式 | 示例 |
|---|---|---|---|
| 输入参数(只读) | 1. 原始类型(char、int、float等) 2. 小值对象(如 Point、std::complex) |
值传递 | void print(int num)void calc(Point p) |
| 输入参数(只读) | 1. 大对象(如std::string、std::vector)2. 用户自定义类对象 |
const引用传递 | void process(const std::string& str) |
| 输出/输入输出参数 | 1. 参数必需存在(不可为null) 2. 无需传递所有权 |
非const引用传递 | void read_data(std::vector<int>& out_data) |
| 输出/输入输出参数 | 1. 参数可选(可为null) 2. 需要传递所有权 |
智能指针(如std::shared_ptr) |
void set_config(std::shared_ptr<Config> cfg) |
1.4 具体示例
1.4.1 输入参数:值传递 vs const引用传递
-
原始类型/小对象用值传递,无性能损失:
// 正确:int是原始类型,值传递高效 void print_score(int score) { std::cout << "Score: " << score << std::endl; } // 正确:Point是小对象(2个double,16字节),值传递高效 struct Point { double x, y; }; double distance(Point p1, Point p2) { return sqrt((p1.x - p2.x)² + (p1.y - p2.y)²); } -
大对象用const引用传递,避免拷贝开销:
// 反面示例:std::string是大对象,值传递会拷贝整个字符串 void print_string(std::string str) { // 低效:拷贝开销大 std::cout << str << std::endl; } // 正面示例:const引用传递,无拷贝 void print_string(const std::string& str) { // 高效:仅传递引用 std::cout << str << std::endl; }
1.4.2 输出参数:引用 vs 智能指针
-
参数必需存在(不可为null):用非const引用,明确“调用者必须提供有效对象”:
// 正确:out_result是输出参数,必需存在 void calculate_sum(const std::vector<int>& nums, int& out_result) { out_result = 0; for (int num : nums) { out_result += num; } } // 使用:调用者必须提供有效变量 std::vector<int> nums = {1,2,3}; int sum; calculate_sum(nums, sum); // 正确:sum是有效变量 -
参数可选(可为null):用智能指针(如
std::shared_ptr),明确“参数可不存在”:// 正确:cfg是可选参数,可为null void init_app(std::shared_ptr<Config> cfg) { if (cfg) { // 检查是否有有效配置 load_config(*cfg); } else { load_default_config(); } } // 使用:可传递null(表示用默认配置) init_app(nullptr); // 正确:使用默认配置 auto custom_cfg = std::make_shared<Config>(); init_app(custom_cfg); // 正确:使用自定义配置
1.5 常见错误
- 对大对象用值传递:如
void process(std::vector<int> data),会拷贝整个容器,效率极低。 - 对输入参数用非const引用:如
void print(std::string& str),即使不修改str,也会让调用者误以为参数会被修改,且无法传递临时对象(如print("hello")会编译失败)。 - 用原始指针传递所有权:如
void take_ownership(Widget* ptr),调用者无法确定是否需要释放内存,易导致内存泄漏或重复释放。
2. 第26条 保持重载操作符的自然语义
2.1 核心要点
仅在操作符语义与内置类型(如int)一致时才重载,且需保持操作符的“自然行为”。若语义模糊或违反直觉,应改用命名函数,避免让代码变得难以理解。
2.2 通俗解释
就像我们约定“+”表示“加法”,如果有人用“+”表示“减法”,会让人 confusion。重载操作符也一样,必须符合程序员的直觉,比如a + b就应该是“a与b相加”,而不是“a减去b”或“a添加b到自己”。
2.3 操作符重载的核心原则:“像int一样行为”
对值类型(如Complex、Vector)重载操作符时,应模仿int的行为:
a + b:返回新对象,不修改a和b;a += b:修改a(a = a + b),返回a的引用;==:返回bool,判断两个对象是否相等;- 避免重载无直观语义的操作符(如
operator*用于“向量点积”需谨慎,需在文档中明确说明)。
2.4 反面示例:语义混乱的重载
// 反面示例1:operator+实现减法
class BadInt {
public:
BadInt(int val) : value(val) {}
// 错误:+实际是减法,违反直觉
BadInt operator+(const BadInt& rhs) const {
return BadInt(value - rhs.value);
}
private:
int value;
};
// 使用时完全混乱
BadInt a(5), b(3);
BadInt c = a + b; // 预期8,实际2,极易出错
// 反面示例2:operator+=语义不明确
class Tensor {
public:
// 错误:语义模糊:是“元素相加”还是“重置大小”?
Tensor& operator+=(unsigned new_size) {
this->resize(new_size); // 实际是重置大小
return *this;
}
private:
std::vector<double> data;
};
// 使用时误解
Tensor t;
t += 10; // 调用者以为是“加10”,实际是“重置大小为10”
2.5 正面示例:语义清晰的重载
以Complex(复数)类为例,重载+和+=,模仿int的自然语义:
// 正面示例:正确重载+和+=
class Complex {
public:
Complex(double real, double imag) : real_(real), imag_(imag) {}
// +=:修改当前对象,返回引用(模仿int)
Complex& operator+=(const Complex& rhs) {
real_ += rhs.real_;
imag_ += rhs.imag_;
return *this;
}
// 获取实部和虚部(供外部访问)
double real() const { return real_; }
double imag() const { return imag_; }
private:
double real_; // 实部
double imag_; // 虚部
};
// +:返回新对象,不修改原对象(用+=实现,避免代码重复)
Complex operator+(const Complex& lhs, const Complex& rhs) {
Complex temp(lhs); // 拷贝 lhs
temp += rhs; // 调用 += 实现加法
return temp;
}
// 使用:语义清晰,符合直觉
Complex a(1, 2), b(3, 4);
Complex c = a + b; // 正确:c = (4, 6)
a += b; // 正确:a 变为 (4, 6)
2.6 禁止重载的操作符
以下操作符重载后极易违反直觉,除非是特殊领域库(如正则表达式引擎)且有明确文档,否则严禁重载:
&&、||:内置版本有“短路求值”(如a && b中,若a为false则不计算b),重载后会失去该特性;,(逗号操作符):内置版本有“从左到右求值”,重载后求值顺序不确定;&、*(取地址、解引用):语义与内存操作强绑定,重载易导致混淆。
2.7 例外情况
特殊领域库(如数学库、正则表达式库)可定义领域特定的操作符语义,但必须:
- 在文档中明确说明操作符的具体含义(如“
Tensor::operator*表示向量点积”); - 确保同一库内语义一致(如所有数学类型的
*都遵循相同规则); - 避免与内置类型语义冲突(如
int的*仍表示乘法)。
3. 第27条 优先使用算术操作符和赋值操作符的标准形式
3.1 核心要点
若定义二元算术操作符(如+、-、*),应同时提供对应的赋值操作符(如+=、-=、*=),并通过赋值操作符实现算术操作符,避免代码重复,保证语义一致。
3.2 通俗解释
就像我们先学会“5 += 3”(5变成8),再基于它理解“5 + 3”(得到8,5不变)。算术操作符基于赋值操作符实现,既避免写两遍相同逻辑,又能确保两者语义一致(不会出现a + b和a += b结果不同的情况)。
3.3 标准实现模式
以T类的+和+=为例,标准实现步骤如下:
- 实现
operator+=:完成实际的修改逻辑,返回T&(自身引用); - 实现
operator+:通过operator+=实现,避免代码重复,返回新对象T。
具体代码模板:
// 1. 实现 operator+=(成员函数,修改自身)
class T {
public:
T& operator+=(const T& rhs) {
// 实际的加法逻辑(如修改成员变量)
this->data_ += rhs.data_;
return *this; // 返回自身引用
}
private:
int data_; // 示例成员变量
};
// 2. 实现 operator+(非成员函数,通过 += 实现)
T operator+(const T& lhs, const T& rhs) {
T temp(lhs); // 拷贝 lhs 到临时对象
temp += rhs; // 调用 += 完成加法
return temp; // 返回临时对象(编译器会优化拷贝)
}
3.4 关键优势
- 无代码重复:
+的逻辑完全依赖+=,只需维护一份核心代码,减少bug风险; - 语义一致:
a + b和a += b的逻辑完全一致,不会出现矛盾; - 支持隐式转换:若
T有隐式构造函数(如T(int)),非成员函数operator+支持左右参数的隐式转换(如T(5) + 3和3 + T(5)都能生效),而成员函数版本仅支持右参数转换。
3.5 优化:通过值传递减少拷贝
在operator+中,可让第一个参数通过值传递,利用编译器优化减少一次拷贝(适用于支持“返回值优化”的现代编译器):
// 优化版本:operator+ 的第一个参数通过值传递
T operator+(T lhs, const T& rhs) { // lhs 是值传递,已拷贝
lhs += rhs; // 直接修改 lhs
return lhs; // 返回 lhs(编译器优化拷贝)
}
优化原理:传统版本中T temp(lhs)需一次拷贝,优化版本中lhs的传递本身就是一次拷贝,减少了临时对象的创建。
3.6 示例:字符串的+和+=
class MyString {
public:
// 构造函数
MyString(const char* str) : data_(str) {}
// 1. 实现 operator+=:追加字符串
MyString& operator+=(const MyString& rhs) {
// 预分配内存,避免多次扩容(优化)
data_.reserve(data_.size() + rhs.data_.size());
data_.append(rhs.data_);
return *this;
}
// 获取字符串长度(供外部使用)
size_t size() const { return data_.size(); }
private:
std::string data_; // 内部用 std::string 存储
};
// 2. 实现 operator+:通过 += 实现
MyString operator+(MyString lhs, const MyString& rhs) {
lhs += rhs;
return lhs;
}
// 使用
MyString a("Hello"), b(" World");
MyString c = a + b; // c = "Hello World"
a += b; // a = "Hello World"
3.7 例外情况
少数场景下(如Complex的*=),操作符的修改逻辑与算术操作符的逻辑差异较大(如*=需要临时变量存储中间结果),可反过来用operator*实现operator*=,但需确保语义一致,且在文档中说明原因。
4. 第28条 优先使用++和–的标准形式。优先调用前缀形式
4.1 核心要点
++和--有前缀(++a)和后缀(a++)两种形式,需按标准模式实现(前缀修改并返回自身,后缀返回原值再修改);调用时,若无需原值,优先使用前缀形式(效率更高)。
4.2 通俗解释
- 前缀
++a:先把a变大,再用变大后的值(“先加后用”); - 后缀
a++:先用a当前的值,再把a变大(“先用后加”);
就像喝水:前缀是“先倒水再喝”,后缀是“先喝再倒水”。实现时需遵循这个逻辑,调用时优先选前缀(少创建一个临时对象)。
4.3 标准实现模式
以Counter类为例,++的标准实现步骤:
- 前缀
operator++:成员函数,修改自身,返回T&(自身引用); - 后缀
operator++:成员函数,参数为int(区分前缀的标记,无实际意义),返回T(原值的拷贝),通过前缀实现。
具体代码:
class Counter {
public:
Counter(int val) : value_(val) {}
// 1. 前缀 ++:修改自身,返回引用
Counter& operator++() {
++value_; // 先修改
return *this; // 返回修改后的值
}
// 2. 后缀 ++:返回原值,再修改(参数int是标记)
Counter operator++(int) {
Counter old(*this); // 保存原值
++(*this); // 调用前缀 ++ 完成修改
return old; // 返回原值
}
// 获取当前值
int get_value() const { return value_; }
private:
int value_;
};
4.4 调用时优先选择前缀形式
后缀++会创建一个临时对象(保存原值),而前缀++直接返回自身引用,无额外开销。因此,若无需使用原值,必须优先用前缀:
// 正面示例:无需原值,用前缀 ++
Counter c(5);
++c; // 高效:无临时对象,c 变为6
std::cout << c.get_value() << std::endl; // 输出6
// 反面示例:无需原值却用后缀 ++
Counter d(5);
d++; // 低效:创建临时对象(保存5),d 变为6
std::cout << d.get_value() << std::endl; // 输出6(临时对象未被使用)
仅当需要“先用后加”时才用后缀:
// 仅当需要原值时用后缀
Counter e(5);
int old_val = e++; // 先用e的原值(5),再把e变为6
std::cout << old_val << " " << e.get_value() << std::endl; // 输出5 6
4.5 常见错误
- 后缀
++参数错误:忘记加int参数,导致与前缀冲突(如Counter operator++()会与前缀Counter& operator++()重载冲突); - 后缀
++返回引用:如Counter& operator++(int),会返回局部对象old的引用,导致悬垂引用(对象销毁后引用无效); - 手动实现后缀逻辑:不调用前缀
++,而是重复编写修改逻辑(如后缀中再写++value_),导致代码重复和语义不一致。
4.6 例外情况
表达式模板框架(如数学计算库)可能会通过特殊方式实现++和--以优化计算流程,但需在文档中明确说明,且仅限库内部实现,不推荐普通代码使用。
5. 第29条 考虑重载以避免隐含类型转换
5.1 核心要点
隐式类型转换(如String自动转为const char*)虽方便,但可能创建不必要的临时对象。若能通过重载函数/操作符精确匹配常见参数类型,应提供重载版本,避免转换开销。
5.2 通俗解释
就像去咖啡店买咖啡:如果直接说“要一杯美式”(精确匹配),店员直接拿给你;如果说“要一杯黑色的咖啡”(需要转换),店员要先确认“黑色咖啡”是“美式”,再拿给你,多了一步。重载就是提供“直接拿”的选项,避免“确认”的开销。
5.3 问题场景:隐式转换的开销
以String类为例,若String有隐式构造函数String(const char*),且仅提供operator==(const String&, const String&),则比较String和const char*时会创建临时对象:
// 反面示例:仅提供一种 operator==,依赖隐式转换
class String {
public:
// 隐式构造函数:const char* -> String
String(const char* str) : data_(str) {}
private:
std::string data_;
friend bool operator==(const String& lhs, const String& rhs);
};
// 仅提供 String vs String 的比较
bool operator==(const String& lhs, const String& rhs) {
return lhs.data_ == rhs.data_;
}
// 使用时的隐式转换
String s("hello");
if (s == "world") { // 错误:创建临时 String("world"),有拷贝开销
// ...
}
上述代码中,"world"(const char*)会先隐式转换为String临时对象,再与s比较,额外产生一次字符串拷贝,效率较低。
5.4 解决方案:提供重载版本
为常见参数组合(如String vs const char*、const char* vs String)提供重载的operator==,避免隐式转换:
// 正面示例:提供多个重载版本
class String {
public:
String(const char* str) : data_(str) {}
// 暴露 data_ 供重载函数访问(或提供 get_c_str())
const char* c_str() const { return data_.c_str(); }
private:
std::string data_;
};
// 1. String vs String
bool operator==(const String& lhs, const String& rhs) {
return lhs.c_str() == rhs.c_str();
}
// 2. String vs const char*(无转换)
bool operator==(const String& lhs, const char* rhs) {
return lhs.c_str() == std::string(rhs);
}
// 3. const char* vs String(无转换)
bool operator==(const char* lhs, const String& rhs) {
return std::string(lhs) == rhs.c_str();
}
// 使用时无转换开销
String s("hello");
if (s == "world") { // 调用 operator==(s, "world"),无临时对象
// ...
}
if ("hello" == s) { // 调用 operator==("hello", s),无临时对象
// ...
}
5.5 重载的原则
- 覆盖常见场景:仅为高频使用的参数组合提供重载(如
String与const char*的比较),避免过度重载导致代码臃肿; - 复用核心逻辑:重载函数内部调用统一的核心函数,避免代码重复。例如:
// 核心比较逻辑 bool string_equal(const char* a, const char* b) { return std::strcmp(a, b) == 0; } // 重载版本复用核心逻辑 bool operator==(const String& lhs, const char* rhs) { return string_equal(lhs.c_str(), rhs); } bool operator==(const char* lhs, const String& rhs) { return string_equal(lhs, rhs.c_str()); } - 不破坏可读性:重载函数的语义必须与原函数一致(如所有
operator==都表示“相等比较”),避免混淆。
5.6 例外情况
若隐式转换的开销极小(如int转为double),且重载带来的代码复杂度超过收益(如仅偶尔使用),则可依赖隐式转换,无需提供重载。
6. 第30条 避免重载&&、||或,(逗号)
6.1 核心要点
&&、||和,(逗号操作符)的内置版本有特殊语义(短路求值、固定求值顺序),重载后会失去这些特性,导致代码行为与直觉不符,极易引发bug,应坚决避免重载。
6.2 通俗解释
就像交通规则:&&和||内置版本是“绿灯走、红灯停”(短路求值),重载后变成“不管红绿灯都走”,完全打乱预期。逗号操作符内置版本是“按顺序排队”,重载后变成“随机排队”,混乱不堪。
6.3 内置版本的特殊语义
| 操作符 | 内置语义 | 重载后失去的特性 |
|---|---|---|
&& |
短路求值:a && b中,若a为false,则不计算b |
无短路求值,a和b都会计算 |
| ` | ` | |
, |
固定顺序:a, b中,先计算a,再计算b,结果为b的值 |
求值顺序不确定,a和b可能乱序计算 |
6.4 反面示例1:重载&&导致短路求值失效
// 反面示例:重载 &&,失去短路求值
class BoolWrapper {
public:
BoolWrapper(bool val) : value_(val) {}
// 重载 &&
friend bool operator&&(const BoolWrapper& lhs, const BoolWrapper& rhs) {
return lhs.value_ && rhs.value_;
}
// 模拟有副作用的操作(如打印)
BoolWrapper& print(const char* msg) {
std::cout << msg << std::endl;
return *this;
}
private:
bool value_;
};
// 使用时的意外行为
BoolWrapper a(false), b(true);
// 预期:a为false,不执行b.print("b执行了")
// 实际:重载&&无短路,b.print仍会执行,输出"b执行了"
if (a && b.print("b执行了")) {
// ...
}
上述代码中,用户预期a为false时b.print不会执行,但重载&&后失去短路特性,导致b.print被调用,产生意外输出。
6.5 反面示例2:重载,导致求值顺序混乱
// 反面示例:重载逗号操作符,求值顺序不确定
class IntWrapper {
public:
IntWrapper(int val) : value_(val) {}
// 重载逗号操作符
friend IntWrapper operator,(const IntWrapper& lhs, const IntWrapper& rhs) {
return IntWrapper(rhs.value_); // 结果为 rhs 的值
}
int get_value() const { return value_; }
private:
int value_;
};
// 使用时的混乱
IntWrapper i(0);
// 预期:先执行++i(i变为1),再执行i+1(结果2),最终j=2
// 实际:重载后求值顺序不确定,可能先计算i+1(i=0,结果1),再++i(i变为1),j=1
IntWrapper j = (++i, i + 1);
std::cout << j.get_value() << std::endl; // 结果可能是1或2,未定义
内置逗号操作符的顺序是“先左后右”,但重载后编译器可自由选择求值顺序,导致结果不确定,代码行为不可预测。
6.6 替代方案:用命名函数
若需要组合多个操作,应改用命名函数,明确语义和执行顺序:
// 正面示例:用命名函数替代重载 &&
class BoolWrapper {
public:
// ... 其他成员 ...
// 命名函数:明确“不短路,都执行”
static bool and_without_shortcut(const BoolWrapper& a, const BoolWrapper& b) {
return a.value_ && b.value_;
}
};
// 使用时明确意图
if (BoolWrapper::and_without_shortcut(a, b.print("b执行了"))) {
// 用户明确知道b.print会执行,无意外
}
// 正面示例:用命名函数替代重载 ,
IntWrapper compute_sequence(IntWrapper& i) {
++i; // 明确先执行++i
return IntWrapper(i.get_value() + 1); // 再执行i+1
}
// 使用时顺序明确
IntWrapper j = compute_sequence(i);
std::cout << j.get_value() << std::endl; // 确定输出2
6.7 例外情况
仅表达式模板库(如高性能数学计算库)可重载这些操作符,目的是捕获表达式并延迟计算(而非执行传统逻辑),但需在文档中明确说明与内置语义的差异,且仅限库内部使用,不推荐普通代码重载。
7. 第31条 不要编写依赖于函数参数求值顺序的代码
7.1 核心要点
C++标准未定义函数参数的求值顺序(如f(a(), b())中,a()和b()的执行顺序不确定),依赖该顺序的代码会因编译器不同而产生不同行为,必须避免。
7.2 通俗解释
就像餐厅点餐:你点了“汉堡和可乐”,服务员可能先给汉堡,也可能先给可乐,没有固定顺序。函数参数也一样,编译器可自由选择先计算哪个参数,依赖顺序的代码会像“必须先喝可乐再吃汉堡”一样,在某些餐厅(编译器)里无法正常“用餐”。
7.3 问题示例:依赖参数求值顺序
// 反面示例1:参数中修改同一变量
int count = 5;
// 错误:参数 ++count 和 ++count 的求值顺序不确定
void Transmogrify(int a, int b);
Transmogrify(++count, ++count);
// 可能结果:(6,7)、(7,6),甚至编译器特定行为
// 反面示例2:参数中调用有副作用的函数
int Bump(int& x) { return ++x; }
int count = 5;
// 错误:Bump(count) 调用两次,求值顺序不确定
Transmogrify(Bump(count), Bump(count));
// 可能结果:(6,7)、(7,6)
// 反面示例3:智能指针构造的资源泄漏风险
// 错误:两个 shared_ptr 的构造顺序不确定,可能泄漏内存
void Fun(std::shared_ptr<Widget> sp1, std::shared_ptr<Widget> sp2);
Fun(std::shared_ptr<Widget>(new Widget), std::shared_ptr<Widget>(new Widget));
风险分析:编译器可能先执行两个new Widget(分配内存),再调用shared_ptr构造函数。若第二个new Widget成功,但第二个shared_ptr构造函数抛出异常(如内存不足),则第一个new分配的内存会泄漏(未被shared_ptr管理)。
7.4 解决方案:拆分参数计算
通过命名变量明确计算顺序,避免参数中直接包含修改或有副作用的操作:
// 正面示例1:拆分变量计算
int count = 5;
int a = ++count; // 先计算第一个参数
int b = ++count; // 再计算第二个参数
Transmogrify(a, b); // 确定传递 (6,7)
// 正面示例2:拆分智能指针构造
std::shared_ptr<Widget> sp1(new Widget); // 先构造第一个智能指针
std::shared_ptr<Widget> sp2(new Widget); // 再构造第二个智能指针
Fun(sp1, sp2); // 无泄漏风险,即使构造失败也会释放内存
7.5 常见误区
- 认为“从左到右”是标准:很多程序员误以为参数求值顺序是“从左到右”,但C++标准明确规定“未定义”,不同编译器(如GCC、Clang、MSVC)可能选择不同顺序;
- 忽略“隐藏的副作用”:即使参数看起来无副作用(如
f(g(x), h(x))),若g(x)或h(x)修改了全局变量或x的成员,仍会依赖求值顺序; - 依赖调试结果:在某个编译器的调试模式下观察到“从左到右”的顺序,便认为所有情况都如此,忽略了编译器优化或版本变化可能改变顺序。
更多推荐



所有评论(0)