Skip to content

Latest commit

 

History

History
895 lines (674 loc) · 68.8 KB

C++CodingStandards.md

File metadata and controls

895 lines (674 loc) · 68.8 KB

Table of Contents generated with DocToc

C++编程规范:101条规则、准则与最佳实践

书籍:《C++编程规范:101条规则、准则与最佳实践》。

首先:

  • 任何准则都不应该代替自己的思考,遵从好的准则,但一定要有自己的思考,不要盲从。
  • 另外重在理解为什么,不需要死记硬背。
  • 某些规范仅在某些情况下适用,注意使用场景。

组织与策略问题

0. 不要拘泥与小节/了解哪些东西该标准化

  • 摘要:只规定需要规定的东西,不要强制施加个人喜好和过时的东西。
  • 比如强制大括号位置、空格制表符、强制匈牙利命名、强制函数单出口等。

1. 在高警告级别干净利落地编程

  • 摘要:使用编译器的最高警告级别,要求构建干净利落,没有警告。理解所有警告,通过修改代码而不是降低警告级别来排除警告。
  • 但实践时警告等级太高可能会导致报出很多不必要甚至虚假的警告,这时候就需要有一定消除手段。可以通过对不可修改的头文件进行包装消除这种警告,处理警告时需要确保已经完全理解了其含义。
  • 看到这里我立刻去提高了我的Makefile中的g++警告等级,现在它是:-Wall -Wextra -Wfatal-errors -pedantic-errors -Wshadow,基本够用了,如果需要将警告变为错误那么还需要-Werror

2. 使用自动构建系统

  • 摘要:使用自动构建系统,完全自动化操作,无需用户干预便可构建整个项目。
  • 现代C++中,大中型项目应该使用MsBuild、CMake、Xmake等现代化构建工具,小型项目中Makefile依旧可用。
  • 现代C++构建系统中的基本功能:增量构建、完全构建、构建范围选择、选择目标架构、调试模式发布模式选择、直接构建生成安装包等。
  • 自动构建系统应该在项目启动时就引入,大型项目还会需要专门的构建管理员。

3. 使用版本控制系统

  • 摘要:使用版本控制系统(VCS,Version Control System),不要让文件长时间脱离版本控制,不要将工作长时间停留在本地以免丢失,应当保证每一次提交都能够成功构建。
  • 每个程序员都应该会使用git。

4. 做代码审查

  • 摘要:代码提交最好都经过审查,相互审查代码,指出问题,互相学习。
  • 代码审查(Code Review)应该作为软件开发周期中的常规环节。

设计风格

5. 一个实体应该只有一个紧凑的职责

  • 摘要:只给一个实体(变量、类、函数、命名空间、模块和库)赋予一个定义良好的职责。随着实体变大,职责范围会扩大但不应该发散。
  • 一个实体如果具有多个目的,那么除了会增加理解难度、实现复杂度、各部分的错误之外,还会导致很多其他问题。
  • 应该实现目的单一的函数、小而且单一的类以及边界清晰的紧凑模块,使用这些简单的功能单一的实体来实现复杂的行为。
  • 应该使用较小的底层抽象构建更高层次的抽象,避免将多个底层抽象集合成较大的低层抽象聚合体。

6. 正确、简单和清晰第一

  • 摘要:软件简单为美,质量优于速度,简单优于复杂,清晰优于机巧,安全优于不安全。
  • 代码可读性至关重要,不止有一个人阅读你的代码。即使是自己也不一定能完全看懂自己一个月前写得代码。
  • 代码可读性和代码优化很多时候是矛盾的。

7. 编程中应知道何时与如何考虑可伸缩性

  • 摘要:小心算法复杂度的爆炸增长,不要进行不成熟的提前优化,但是任何时候都应该密切关注算法的复杂度。
  • 通过保证复杂度来保证对未来可能面对的更大数据量下的性能。
  • 即使可预见的未来不会有特别大的数据量,也应该避免不能很好应付数据量增加的算法(除非这种算法确实过于清晰、简单、可读性强)。
  • 一些具体做法:
    • 使用灵活的动态分配的数组,而不是固定大小数组。
    • 了解算法的复杂度。
    • 优先使用快的算法,有对数复杂度就不用线性复杂度,比如能二分查找就绝对不按顺序遍历查找。
    • 即使要优化,也应该尝试优化复杂度,而不是浪费精力在节省一个多余加法这种无关紧要不关乎大局的细节上。

8. 不要进行不成熟的优化

  • 摘要:优化的第一原则是不要优化,第二原则还是不要优化,第三原则是经过再三测试(profiling)之后再优化。
  • 在编写开初就需要考虑复杂度,不要过早就进行复杂的优化,都说过早优化是万恶之源。当性能出现问题时,或者功能完成后才进行优化。
  • 优化也应当在严格profiling之后再优化,应当只在必要的情况下优化运行最多的瓶颈代码。
  • 而不是浪费时间搞一些完全不重要的、主观臆想的、增加代码复杂程度、降低可读性、打乱架构的特例性优化。

9. 不要进行不成熟的劣化

  • 摘要:在代码复杂性和可读性相同的情况下,选择高效的设计模式和编程习惯总是更好的。因为并没有以可读性和代码复杂性为代码,所以这是在避免不成熟的劣化,而不是不成熟的优化。
  • 例子:将临时变量从循环中提出来,使用前缀-- ++,引用传递参数等。

10. 尽量减少全局和共享数据

  • 摘要:避免共享数据,尤其是全局数据。共享数据会增加耦合,降低可维护性,通常还会降低性能。
  • 因为使用共享数据的代码片段不仅取决于数据变化的过程,还取决于以后会使用该数据的未知代码区域的机能。
  • 不同编译单元中全局对象的初始化顺序还是不确定的,应尽量避免使用。
  • 全局的数据还会降低多线程和多处理器环境下的并行性。
  • 即使要用也应该使用工厂来注册与维护。

11. 隐藏信息

  • 摘要:不要公开提供抽象实体的内部信息。
  • 信息隐藏限制了变化的影响范围,强化的不变的东西(接口),降低耦合。

12. 懂得如何和何时进行并发编程

  • 摘要:如果应用程序使用了多个线程或者进程,应当知道如何尽量减少共享对象,以及如何安全地访问必须共享的对象。
  • 具体做法:
    • 了解平台的多线程接口(当然C++11标准已经提供了跨平台的多线程),了解同步原语:原子操作、内存栅栏、互斥体等。
    • 最好将平台原语包装起来自己设计抽象,益于跨平台移植。
    • 确保正在使用的类型是多线程安全的。

13. 确保资源为对象所拥有,使用显式的RAII和智能指针

  • 摘要:RAII是惯用的正确处理资源的手段。分配原始资源时应当立即将它传递给其管理对象,永远不要在一条语句中分配一个以上对象。
  • 实现RAII类时,小心拷贝构造与赋值操作。
  • 最好使用智能指针来管理内存。
  • 不要在一条语句中分配一个以上对象,因为C++标准对求值顺序的规定很弱,可能申请了一个资源,但是还没有被管理就去申请另一资源,此时抛异常导致第一个资源没有释放(比如在函数调用中)。
  • 不要滥用智能指针,如果原始指针够用,那么也没有必要用智能指针。

编程风格

讨论更具体的编程问题。

14. 宁可编译时链接时错误,也不要运行时错误

  • 摘要:能在编译期做的事情,就不要推迟到运行时。能够在编译期检查不变式就应该在编译期做。
  • 例子:
    • 编译期条件就在编译期检查。
    • 考虑在合适的场景使用编译期多态代替运行时多态。
    • 使用枚举。
    • 如果经常使用dynamic_cast,说明基类提供功能太少了,可以重新设计接口。

15. 积极使用const

  • 摘要:应尽量使用常量,会带来编译期类型检查。
  • 当需要合法在const函数中修改变量时,声明为变量为mutable(应该用在这种修改不影响对象可观察状态的情况下,比如缓存数据:不影响正确性,只提供更快的性能,数据本身并没有改变)。
  • const具有传染性。
  • 不要强制转换const,这通常意味着设计哪里出现了问题。
  • 应避免将值传递的参数设置为const,避免在参数中使用顶层const:在函数声明层面它会被忽略,但是语义约束还在。

16. 避免使用宏

  • 摘要:避免使用宏。
  • C++提供的const enum inline template namespace已经取代了宏的大部分功能,并且提供的更安全的语法。
  • 宏目前唯二无法替代的地方在于代码片段复用和跨平台,即使要用也应该谨慎使用。包含守卫都已经可以使用#pragma once代替了。
  • 不要搞那些让人迷惑的宏元编程,大多数人都看不懂。
  • 宏的问题在于不“卫生”,它仅仅是一种文本替换,忽略了作用域,忽略了类型系统,忽略了其他所有语言特性,天生是割裂的显得格格不入。
  • 即使要用也应该尽快#undef取消其定义。

17. 避免使用魔法数字

  • 摘要:避免显式使用魔法数字,即使它有意义,也应该使用符号名称来替换它。
  • 字符串字面量应该使用符号常量来代替,并集中存放方便查找修改和国际化。

18. 尽可能局部地声明变量

  • 摘要:变量将引入状态,状态的存在时间越短越好,最好只作用于用到它的作用域内。
  • 避免污染上下文。
  • 常量不引入状态,不适用于本条。
  • C++中鼓励即用即声明,有了足够的数据初始化时才声明是一个好选择。
  • 将循环内的局部变量提出循环属于特例,可以自行判别该如何选择。

19. 总是初始化变量

  • 摘要:总是再定义变量定义时初始化,避免使用未初始化的变量导致的错误。
  • 一般而言安全性总是优于不必要的性能考虑。

20. 避免函数过长,避免嵌套过深

  • 摘要:应避免过长的函数与过深的嵌套层次出现。
  • 过长的函数与逻辑可能会使其难以维护、错误频出。
  • 过深的嵌套层次要求我们在读代码时就维护脑子里面的栈,不利于可读性。
  • 例外:如果一个长函数无法拆分,那么最好不要强行拆分。

21. 避免跨编译单元的初始化依赖

  • 摘要:不同编译单元的命名空间作用域的对象不应该在初始化上相互依赖,因为他们的初始化顺序是未定义的。
  • 应避免使用全局或者命名空间作用域的对象,如果一定要用,可以用单例模式代替。单例模式也应该在第一次获取时初始化(一般通过static局部变量来做)。

22. 尽量减少定义性依赖,避免循环依赖

  • 摘要:如果使用完整声明能够实现,就不要包含完整定义。
  • 模块之间不要相互依赖,双向的依赖代表这他们应该是一个模块。

23. 头文件应该自给自足

  • 摘要:每一个头文件应该要能够独立通过编译,它应该包含它所依赖的所有头文件。
  • 但是不要包含不需要的头文件。

24. 总是编写内部包含守卫,不要编写外部包含守卫

  • 摘要:在所有头文件中使用包含守卫,而不要在其外部使用该头文件的包含守卫。
  • 包含守卫的宏应该定义为唯一名称。
  • 外部包含守卫已经过时了,不要再使用了。
  • 现代C++中最好使用#pragma once

函数与操作符

25. 正确选择通过值、引用还是(智能)指针传递参数

  • 摘要:正确传递参数,分清输入参数、输出参数、输入输出参数。
  • 不要使用C语言风格的变长参数。

26. 保持重载操作符的自然语义

  • 摘要:只有在有充分理由时才重载操作符,而且应当保持其自然语义。
  • 如果无法做到这一点,那么大概率不应该使用运算符重载。
  • 如果一定要使用运算符重载设计一门DSL,那么最好谨慎设计、让他们保持自洽并且不与现有运算符冲突。

27. 优先使用算术操作符和赋值操作符的标准形式

  • 摘要:如果要定义以算术运算符,那么最好定义其复合赋值运算符,并且最好是通过复合赋值来实现算术运算符。
  • 算术运算版本应该返回临时变量。
  • 最好将运算符定义为非成员版本以提供转换。
  • 某些情况下用算术运算符版本来实现复合赋值可能更好。

28. 优先使用前缀版本的++和--

  • 摘要:重载++--应当重载前缀后缀两个版本,并且行为模仿内置运算符。并且在不使用原值时最好使用前缀版本。
  • 前缀版本性能更好一些,不过这点性能真的重要吗,并且编译器会优化。当然这也是避免不成熟的劣化。

29. 考虑重载以避免隐式转换

  • 摘要:隐式类型转换提供了语法便利,但如果创建临时对象的工作并不必要并且使用原类型更适合优化,那么可以重载提供精确匹配的版本。

30. 避免重载&&、||和,

  • 摘要:内置的&& || ,具有求值顺序规定,而重载的版本则没有,无法实现和内置版本完全相同的语义,应当避免重载重载这几个运算符。
  • 没有了确定求值顺序,就无法保证&& ||的短路求值了。那么p && p->something这种代码可能就会出现错误,这种代码是不健壮的。
  • 表达式模板是例外,因为表达式模板的模板就是用来捕获操作符。

31. 不要编写依赖于函数参数求值顺序的代码

  • 摘要:函数参数的求值顺序是不确定的,不要依赖于此编程。
  • 可以通过使用命名对象控制求值顺序。

类的设计与继承

32. 弄清楚要编写的是哪一种类

  • 摘要:不同种类的类适用于不通过用途,因此遵循不同规则,弄清楚要编写的是哪一种。
  • 值类:
    • 拥有公有析构、拷贝构造、带有值语义的赋值。
    • 没有虚函数,包括析构。
    • 用作具体类,而不是基类。
    • 总是在栈中实例化,或者作为另一个类直接包含的成员实例化。
  • 基类:
    • 存在一个公有且虚拟、或者保护而且非虚拟的析构,和一个非公有的拷贝构造与赋值运算符。
    • 通过虚函数建立接口。
    • 总是动态在堆中实例化为具体派生类对象,并通过指针来管理。
  • 特征类:
    • 只包含嵌套类型声明与静态数据或者函数,没有可改变状态或者虚函数。
    • 通常不实例化。
  • 异常类:
    • 有一个公有析构函数和不会失败的构造函数(特别是拷贝构造)。
    • 有虚函数,经常实现克隆和访问。
    • std::exception比较好。
  • 还有比如RAII类等。

33. 用小类代替巨类

  • 摘要:小类更容易编写,更容易保证正确、测试和使用。小类更可能适用于各种场合,应该使用小类体现简单概念,而不是大杂烩式的类。
  • 小类更易编写与重用,大类更加难以编写和使用。
  • 巨类更难以保证正确性。
  • 人的需求总在变,尝试提供完整解决方案几乎总会失败。

34. 用组合代替继承

  • 摘要:继承的耦合非常紧密,仅次于友元。如果能够使用组合代替继承,那么最好使用组合代替继承,除非继承有明显的设计好处。
  • 有了继承之后,人们经常拿着锤子看什么都是钉子,继承很容易被滥用。
  • 除非要用到继承的东西(重写虚函数、派生类替换基类),否则不要使用继承。

35. 避免从并非要设计成基类的类中继承

  • 摘要:本意就不是作为基类来设计的类不应作为基类被继承,这是一种严重的设计错误。如果要添加行为,应该添加非成员函数而不是成员函数,要添加数据应该使用组合而不是继承。

36. 优先提供抽象接口

  • 摘要:抽象接口有助于集中精力保证抽象的正确性,不至于受到实现或者状态管理细节的干扰。优先采用实现了抽象接口的设计层级结构。
  • 抽象接口是完全由纯虚函数组成的抽象类,没有状态(数据成员),通常也没有成员函数实现。
  • 遵循依赖倒置原则(DIP, Dependency Inversion Principle):
    • 高层模块不应该依赖低层模块,两者都应该依赖抽象。
    • 抽象不应该依赖细节,细节应该依赖抽象。
  • DIP具有三个优点:更强健壮性、更大灵活性、更好模块性。

37. 公有继承即可替换性:继承,不是为了重用,而是为了被重用

  • 摘要:公有继承能够使基类指针或者引用指向派生类对象。不要通过公有继承重用基类代码(指通过派类对象或者指针引用),而是为了被已经多态使用的基对象的已有代码重用的。
  • 继承的使用应该遵循里氏替换原则,即派生类指针引用能够完美替换基类指针引用,满足Is-A关系。
  • 所以派生类必须正确实现基类接口该有的功能,这种语义约定必须被遵守,不然就是误用。
  • 并且Is-A的关系并非简单的“是一个”,而更类似于“行为像一个”(或者说“可以用作一个”),比如正方形是一个矩形,但是正方形从矩形继承却是怎么看都不合适的。
  • 公有继承的目的并非重用,而是为了实现可替换性
  • 当然现实中实践时可能某些情况下完全不会使用其可替换性,而仅仅是为了重用,这种情况按照书中描述应该使用组合或者非公有继承来实现。
  • 通过添加新派生类添加功能时,不需要修改现有使用基类指针引用的代码,这满足开闭原则(对扩展开放,对修改关闭)。
  • 特例:策略类和混入类(MixIn)通过公有继承添加行为,这不是误用。

38. 实现安全的覆盖

  • 摘要:重写一个虚函数时,要保持其可替换性。更具体一点,要保持基类虚函数的前后置条件,不要改变虚函数默认参数。应该显式声明为vritual override
  • 可替换性在于多个方面:
    • 重写的函数可以要求更少提供更多,但不可以要求更多承诺更少。
    • 如果基类虚函数承诺不会失败,那么派生类重写后不应抛出异常。
    • 重写虚函数永远不应该修改其默认参数,它们不是函数签名一部分,修改默认参数可能导致奇怪的错误,通过基类指针引用多态调用使用的总是基类的默认参数。
  • 应该显式声明重写的虚函数为virtualoverride,借由编译器检查保证正确性。
  • 谨防在派生类中隐藏基类虚函数,这可能会发生在虚函数本身有重载的情况下,只重写了一个那么另一个由于作用域嵌套关系会先找到派生类函数。解决方法是使用using引入基类函数。

39. 考虑将虚函数声明为非公有的,将公有函数声明为非虚函数

  • 摘要:在基类中进行修改代价高昂:可以将公有函数函数设置为非虚的,并且将虚函数设置为私有,如果派生类需要调用基类版本则设置为保护。
  • 这就是NVI(非虚接口,Non Virtual Interface)模式。
  • 公有虚接口其实提供了两个职责:指定接口,指定实现细节。这两件事职责和动机不同,有些时候会冲突。
  • 使用非虚接口后,公有非虚接口只提供接口,虚函数不再提供接口。可以有更高的灵活性,并且能够健壮地适应变化。
  • 特例:
    • 对析构函数不适用。
    • NVI不支持调用者的协变返回类型(即返回基类虚函数指针引用的底层类型的派生类的指针引用)。

40. 避免提供隐式转换

  • 摘要:隐式类型转换通常利大于弊,为自定义类型提供隐式类型转换之前,需要三思。应该依赖显式类型转换(explicit转换构造与转换运算符)。
  • 通常来说,只有非常直观的非常合理的隐式类型转换才应该被使用。单参数的构造如果不确定那么最好都加上explicit
  • 转换运算符可以通过提供命名转换函数替代。

41. 将数据成员设置为成员、无行为的聚集(C语言形式struct)

  • 摘要:将数据设置为私有的。只有表示数据的聚合类才会将数据成员设置为公有。
  • 私有数据成员封装实现,所有修改都通过接口来实现,是可预测的。公有数据成员则是混乱和无法预测的。
  • 考虑使用pImpl惯用法来隐藏类的私有成员(通常只针对提供给外部的SDK这样以做到二进制兼容与隐藏实现细节)。
  • 在没有更好方法的情况下,使用getter/setter都是可以接受的。提供最小的抽象以及健壮的版本管理。
  • getter/setter很好用,但是主要有getter/setter组成的类可能是一种设计不良的表现。这种时候应该要仔细思考一下,是否应该定义为一个聚合类。不提供抽象,仅保存数据。

42. 不要公开内部数据

  • 摘要:避免返回类所管理的内部数据的句柄,这样类的客户就不会不受控制在对象不知情的情况下修改对象的状态。
  • 客户应该通过你提供的接口来进行和内部数据有关的一切操作,你应该将操作封装好以供用户调用。
  • 用户都不应该知道你内部有这样一个东西,也就是接口不应该依赖于实现。
  • 在完全知情的情况下为了方便可以提供,不过不应该直接提供,而应该通过中间层/后门的方式提供给自己用,而不能直接提供给用户。

43. 明智使用pImpl

  • 摘要:C++将私有成员指定为不可访问,但没有指定为不可见。如果要将数据成员变得真正不可见,可以使用pImpl手法。
  • 这样做即使修改了数据成员也能能够保持二进制兼容。
  • 只应该用在确实要隐藏数据成员时:通常用在要提供给用户的SDK中,内部使用则一般不必要。

44. 优先编写非成员非友元函数

  • 摘要:尽可能将函数指定为非成员非友元函数。
  • 如果一个函数没有用到内部数据,只用到公有成员那么就可以这么做,也应该这么做。

45. 总是一起提供new和delete

  • 摘要:每个类重载的operator new都必须要有对应的operator delete
  • 因为构造时如果分配了内存但是构造抛出异常,那么会调用对应operator delete来释放,如果没有则不会调用从而导致内存泄漏。

46. 如果为类提供专门的new,那么应该提供所有标准形式(普通、placement、nothrow版本)

  • 摘要:如果定义了operator new,那么就应该定义普通版本、nothrow版本和placement版本。
  • 因为为一个类定义operator new就会隐藏全局的所有operator new,所以为了能够使用这三种变体,应该提供三个版本的operator new
  • 当然并非说一定要定义3个,这个条款只是为了提醒不要因为疏忽而隐藏他们,定义时要考虑清楚要定义哪些。
  • 数组版本operator new[]同理。

构造、析构和复制

47. 以同样顺序定义和初始化变量

  • 摘要:成员初始化顺序要与类定义中声明顺序始终保持一致。最好做法是构造函数初始化列表中的初始化顺序与声明顺序一致。
  • 以避免初始化的变量之间有依赖造成的问题。
  • g++中开启-Wreorder可以在构造函数初始化列表中顺序与数据成员声明顺序不一致时提供警告。
  • 一般来说还是尽量不要让一个成员的初始化依赖另一个成员最好。

48. 在构造函数中使用初始化代替赋值

  • 摘要:如题。
  • 因为没有在构造函数初始化列表中的数据成员会执行默认初始化,再赋值会导致性能下降。
  • 例外:应该在构造函数体内进行非托管资源获取。

49. 避免在构造函数和析构函数中调用虚函数

  • 摘要:如题。
  • 因为构造函数是先基类后派生类,析构函数是先派生类后基类。
  • 所以在构造函数和析构函数中调用虚函数并不会调用到派生类重写的虚函数,只会调用到自己的或者继承而来的。
  • 如果希望在构造函数中调用虚函数,可以有几种解决方案:
    • 可以使用后构造函数(post-constructor),也就是构造函数执行完后的类似于init这样的初始化函数。这时需要在文档中注明需要这样做,由用户来调用。
    • 可以在第一次调用成员函数时进行初始化,存一个布尔标志做一个判断即可。
    • 使用工厂函数,在其中初始化。

50. 将基类析构函数设置为公有虚函数或者保护非虚函数

  • 摘要:如题。
  • 如果允许通过基类指针引用析构对象,那么析构一定要可见(公用)并且必须是虚函数。
  • 如果不允许通过基类指针析构对象,那么则没有必要定义为虚函数,并且需要设置为保护以避免外部调用。
  • 总是为基类编写析构函数,因为隐式生成的是公有且非虚的。

51. 析构、释放和交换绝对不能失败

  • 摘要:决不允许析构函数、资源释放函数、交换函数报告错误。
  • 如果无法安全的析构、释放资源、交换,那么无法安全的撤销与回滚(这常见与RAII对象的析构中),那么也就无法实现不会失败的提交。
  • 在捕获到异常时,会对已经构造的对象调用析构,配合RAII就保证了资源在异常发生时也能够正确释放。如果析构不保证不会失败,那么就可能发生抛异常时的析构处理再抛异常,此时程序会直接终止(std::terminate)。

52. 一致地进行复制和销毁

  • 摘要:如果定义了拷贝构造、拷贝赋值、析构中的任何一个,那么可能也需要定义另外两个。
  • 定义以就意味着要做默认行为之外的事情,而这三个函数是不对称相关的。

53. 显式启用或者禁止复制

  • 摘要:在以下三种行为中进行选择——使用编译器生成的拷贝构造和拷贝赋值、编写自己的版本、如果不允许赋值那么显式禁用前两者。
  • 对于值语义的类,编译器生成的往往符合要求。但对于需要自己管理资源的类,则常常不符合。
  • 根据需要声明为=default =delete比直接用默认行为要更好,仔细思考类的行为,如果不需要则应该禁用,如果可使用默认生成的版本,则最好显式声明为=default

54. 避免切片,在基类中考虑使用克隆代替复制

  • 摘要:多态复制的话,考虑禁用拷贝构造与拷贝赋值,而改用克隆函数复制对象。
  • 因为拷贝构造和拷贝赋值都是值语义的,派生类对象对基类对象赋值会导致切片。
  • 标准做法是在基类声明clone虚函数,每个派生类根据自己的类型重写。

55. 使用赋值的标准形式

  • 摘要:实现赋值运算符时,应该使用标准形式——具有特定签名的非虚形式。
  • 即:
T& operator=(const T&);
T& operator=(T);
  • 通常定义为第一个形式,如果通过交换实现,那么则选用第二个(在引入移动语义后第二个还能统一移动语义和拷贝语义)。
  • 不要将赋值运算符定义为虚函数,如果需要这么做,那么定义一个这种功能的其他函数(命名为比如assign)。
  • 需要确保自赋值是安全的。基于交换的版本是天然自赋值安全的。

56. 只要可行,正确提供不会失败的swap

  • 摘要:考虑提供一个安全的不会失败的swap以实现高效的交换。
  • 并同时对std::swap提供特化,调用成员版的swap即可。
  • 对于许多标准库算法,提供swap会提升效率。不提供则会使用标准库版本的通过拷贝构造和拷贝赋值(移动构造、移动赋值)来实现的版本。
  • 对于值语义的类来说,提供交换是有用的,对于基类来说往往就没什么用了(一般通过指针使用)。

命名空间与模块

57. 将类型和非成员函数接口置于同一命名空间中

  • 摘要:如果将非成员函数(特别是操作符与辅助函数)设计为类的接口的一部分,那么必须在类相同的命名空间中定义他们,以便正确调用。
  • 公有成员函数和非成员函数都是类公有接口的一部分。
  • 定义在相同命名空间则允许用户使用时不显式声明出函数的命名空间,此时使用ADL查找到函数名称。
  • 通常用于操作符,这样就不必为每一个操作符显式using或者通过命名空间以函数形式调用,函数的话还是显式写出名字空间比较好。

58. 应该将类型和函数分别置于不同名字空间中,除非有意想让他们一起工作

  • 摘要:如果想让类型和函数分别独立工作,而不是作为类的公有接口。那么应该将他们置于不同的命名空间,防止ADL发生作用。
  • 这一建议主要为了规避ADL带来的可能的问题。
  • ADL的规则比较复杂,特别是在涉及到模板时。
  • 详细了解ADL的规则并以此作为编程基础是没有必要和晦涩的,最好是显式声明名称空间,仅对运算符使用ADL查找。

59. 不要在头文件#include之前using命名空间

  • 摘要:不要在包含头文件之前using命名空间或者使用using指令。
  • 这可能导致头文件中的符号的含义发生改变,产生诡异的错误。
  • 另外:在头文件中也不应该using命名空间或者使用using指令(局部作用域是可以的),相反应该显式限定符号的命名空间。
  • 应该在实现文件的#include之后using命名空间或者使用using指令。
  • 在源文件中using命名空间或者使用using指令是很自然的,出现名称冲突时通过限定命名空间即可解决。
  • 当然不能让using命名空间和using指令的使用限制其他人的代码。也就是说不能在任何可能跟有其他人代码的地方是用它们(其实通常也只有头文件中是这样)。
  • 在命名空间中使用using命名空间或者使用using指令是同样危险的。

60. 避免在不同的模块中分配和释放内存

  • 摘要:在一个模块中分配内存而在另一个模块中释放,会让这两个模块间产生轻微的远距离依赖,使程序变得脆弱。
  • 如果要这么做,必须要使用相同的编译器版本、同样的编译选项和相同的标准库实现来编译他们。
  • 实践中,释放内存时,用来分配内存的模块最好仍在内存中。
  • 为了确保删除由合适的函数进行,一个很好的方式是使用智能指针。
  • 当然一切的前提都是用来释放内存的模块(也就是释放内存的函数位于的那个模块)仍在内存中,也就是说动态链接才会有这个问题,静态链接则不需要烦恼。

61. 不要在头文件中定义具有链接的实体

  • 摘要:具有链接属性的实体,包括命名空间级的变量和函数,都需要分配内存。不应该在头文件中这样定义,将具有链接的实体放入实现文件。
  • 特别地,不要在头文件中定义静态全局变量或者函数。
  • 例外:内联函数、内联变量、函数模板、类模板的静态数据成员定义可以放在头文件中,编译器和链接器负责对他们去重。
  • 所以如果要编写仅头文件的库的话,要做的就是将所有非模板的定义声明为内联即可。

62. 不要允许异常跨越模块边界传播

  • 摘要:C++异常处理没有普遍使用的二进制标准,不要在两段代码之间传播异常,除非他们是使用相同的编译器相同的编译选项构建的。更具体地来说:不要允许异常跨越模块或者子系统边界传播。
  • C++标准并没有固定异常传播的实现方式,甚至没有大多数系统遵守的事实标准,就MinGW64来说就有3中异常实现方式:dwarf、sjlj、seh。
  • 结合60条,最好是对整个系统使用相同的编译器相同的编译选项。
  • 实践中,以下位置应该要有用于兜底的捕获所有异常的catch(...)语句,并将其记录与日志系统中:
    • main函数附近,捕获任何其他地方没有捕获到的异常,防止系统终止。
    • 从无法控制的代码中执行回调附近,不要让异常传播到回调函数之外。因为回调函数的代码很有可能使用不同的异常处理机制,甚至不是使用C++编写的。
    • 在线程边界附近,不要跨线程传播异常。
    • 在模块接口边界附近,子系统开放公用接口供外部使用。那么异常应该局限与外部,并使用传播的平凡但可靠的错误代码向外界传播错误。当然如果子系统要求外部代码和子系统使用同样的编译器,那么其实是可以跨边界传播的。
    • 析构函数中不应该抛出异常,如果其中调用了可能抛异常的函数,那么应该在析构中捕获所有可能异常,防止异常向外泄漏。
  • 在这里提到的位置之外使用catch(...)经常是不良设计的征兆。
  • 理想情况下,错误可以在模块内部到处顺畅地传播,在模块边界转换(为异常外的错误处理机制),在按策略设置的边界上进行处理。
  • 比较好的实践是定义一些中枢性的函数,在异常和子系统返回的错误代码之间进行转换,统一并简化错误处理机制的转换。

63. 在模块的接口中使用具有良好可移植性的类型

  • 摘要:在模块的边缘,必须格外小心。不要让客户不能正确理解的类型出现在外部接口中,应该使用客户代码能够理解的最高层抽象。
  • 很遗憾的是,C++没有指定标准的二进制接口,广泛发布的库可能只能依赖于内置类型和外部世界接口(操作系统提供的接口)。即使在相同的环境中使用不同编译选项编译相同的类型,仍然会生成二进制不兼容的版本。
  • 如果能够控制用户用来构建的编译器版本和选项,那么可以使用任何类型,如果不能,那么就只能使用平台提供类型和C++内置类型。如果不是使用完全相同的标准库映象,那么标准库类型都不能在接口中使用。
  • 提供低层次和高层次的抽象是存在冲突的:低层次接口客户使用返回更广,但是也面临着不安全更容易出现错误的问题,高层次接口更安全,但限制更大,必须要编译器编译选项能够控制。应该视具体情况选择。
  • 即使选择在模块外部接口中提供低层次抽象(可移植的类型),也应该始终在内部使用更高层的抽象。

模板与泛型

64. 理智地结合静态多态与动态多态

  • 摘要:静态多态与动态多态是相辅相成的,理解他们的优缺点,善用他们的长处,结合两者以获得两方面的优势。
  • 同一段代码能够用于不同类型,就叫做多态。
  • 通过公有继承实现的动态多态擅长以下方面:
    • 基于超集/子集关系的统一操作。
    • 静态类型检查。
    • 动态绑定和分别编译。
    • 二进制接口兼容。
  • 基于模板的静态多态擅长:
    • 基于语法和语义接口的统一操作。
    • 静态类型检查。
    • 静态绑定(不能或者防止分别编译)。
    • 效率。
  • 结合两种多态后:
    • 用静态多态辅助动态多态:使用静态多态性实现动态多态的接口,典型应用是CRTP。
    • 用动态多态辅助静态多态:提供泛型、易用的静态绑定接口,但内部又是动态分配的。代表是可识别的类型安全的联合。
    • 任何其他结合。

65. 有意地进行显式自定义

  • 摘要:在编写模板时,应该有意地、正确地提供自定义点,并清晰地记入文档。
  • 在模板中提供自定义点主要有三种方式:
    • 要求模板参数提供特定成员(特定名字、语义的函数、嵌套类型、数据成员等)。
    • 要求模板参数具有给定名字、签名、语义的非成员函数接口(通过ADL找到)。
    • 第三种选择是模板使用一个类型特征,对这个类型特征提供特化。例子std::iterator_traits
  • 如果自定义点对于内置类型也必须可以自定义,那么应该使用选择2或者3。
  • 为了避免无意提供自定义点,应该做到:
    • 将模板内部使用的辅助函数放入自己的内嵌命名空间,或者显式限定他们禁用ADL,比如对于模板参数T(bar)(t)将不会进行参数依赖查找(ADL)。
    • 避免使用依赖(非独立)名称,使用this-> Base::限定基类名称。

66. 不要特化函数模板

  • 摘要:在扩展他人的函数模板时(包括std::swap)应该避免编写特化,而是提供重载。将其放在重载所有类型的命名空间中,通过ADL来查找。
  • 原因:
    • 函数模板不能偏特化,只能全特化,用途有限。
    • 函数模板特化不参与重载决议。
  • 这也引出了可能有重载的函数模板的正确用法,引入必要的名称之后,使用非限定名称。比如引入std::swap后,使用swap进行交换。

67. 不要无意编写不通用的代码

  • 摘要:依赖抽象而非细节,使用最抽象、最通用的方法实现一个功能。
  • 例子:
    • 使用!=而不是<对迭代器进行比较,前者范围更广。
    • 使用迭代器代替索引访问。
    • 使用empty()替代size() == 0
    • 使用层次结构中最高层次的类提供需要的功能。
    • 编写常量正确的代码,只读的情况下就使用const

错误处理与异常

68. 广泛使用断言记录内部假设和不变式

  • 摘要:广泛使用断言assert或者类似等价物记录模块内部的各种假设,这些假设时必须成立的,否则就说明存在错误。当然,要确保断言无任何副作用。
  • 断言一般只会在调试模式下生效(在NDEBUG宏没有定义时),在发型版中是不存在的,不会对性能造成任何影响。
  • 但千万不要在断言中使用具有副作用的表达式,这会导致调试版和发行版行为不同,是绝对的错误。
  • assert中使用字符串表示输出信息。
  • 标准的assert宏比较简单粗暴,可能需要实现自己的断言。并在发型版本中保留大多数断言(不要处于性能原因禁止检查,除非确实需要)。
  • 自定义的断言可以提供不同级别,在发行版中也可以保留一部分高级别的断言。
  • 不要使用断言报告运行时错误:比如内存分配失败、窗口创建失败、线程启动失败等,这些并不是绝对不应该发生的错误,应该交给异常来做。
  • 总而言之,断言应该用在绝对不应该发生的错误上。发生了,那就是程序员的过错。

69. 建立合理的错误处理策略,并严格遵守

  • 摘要:应该在设计早期开发实际、一致、合理的错误处理策略,并严格遵守。
  • 策略应当包含以下内容:
    • 鉴别:哪些情况属于错误。
    • 严重程度:每个错误的严重性或紧急性。
    • 检查:哪些代码负责错误检查。
    • 传递:用什么机制在模块中报告和传递错误。
    • 处理:哪些代码负责处理错误。
    • 报告:怎样将错误记入日志或者通知用户。
  • 只在模块边界改变错误处理机制。

70. 区分错误与非错误

  • 摘要:违反约定就是错误。
  • 违反函数的前置条件、后置条件、不变式是错误。任何其他情况都不是错误。

71. 设计和编写错误安全代码

  • 摘要:如果可以应该提供强保证,至少提供基本保证。
  • 基本保证:出现错误时保证程序会处于有效状态。
  • 强保证:最终状态要么是最初状态、要么是目标状态。
  • 不失败保证:保证操作永远不会失败。

72. 优先使用异常报告错误

  • 摘要:应该使用异常而不是错误码来报告错误。但不能使用异常时,可以使用错误码来报告错误已经不是错误的情况。当不可能从错误恢复时,可以使用其他方法,比如终止程序(正常终止或者不正常终止)。
  • 异常的好处:
    • 异常不能不加修改地忽略。而错误码可以。
    • 异常是自动传播的。
    • 有了异常处理,就不必在控制流主线中加入错误处理和恢复了。异常处理使错误处理变得清晰。
    • 从构造函数和运算符报告错误,异常要优于其他方案。
  • 异常处理存在一些潜在缺点,它要求程序员熟悉一些反复遇到的惯用法:
    • 比如析构函数和释放函数决不能失败。
    • 出现异常是必须保证中间代码是正确的。
  • 异常如果没有被抛出,那么所带来的性能开销是可以忽略的。
  • 当异常被抛出时,会有一定性能损耗,但异常处理绝不会是程序执行的热点代码。频繁的异常抛出与捕获通常意味着程序存在严重问题,将不应该视为错误的情况视为错误抛了出来,此时程序可能会存在严重的性能问题。
  • 例外,在极其罕见的情况下,使用异常可能不是很好:
    • 异常的优点不适用:比如调用代码几乎总是必须马上处理错误,这意味着调用方能够知道被会发生的所有错误,且都能正确处理,那么是没有必要使用异常的,因为不需要向上传播,丧失了优点还会带来性能损耗。
    • 抛出异常与使用错误码性能存在显著差距:前面提过,这通常意味着异常被频繁抛出和捕获,也意味着设计存在问题。
  • 不要关闭异常处理,除了极其性能敏感的模块或程序,基本上没有关闭异常的必要。g++选项-fno-exceptions
  • 严格意义来说,异常不是零开销抽象。不抛异常时程序性能一般不会有太大损耗,但是通常来说会有额外的内存空间消耗。(不准确地凭借经验来说)启用异常的代码相比禁用异常的代码二进制大概会增加30%左右

73. 通过值抛出,通过引用捕获

  • 摘要:异常的最佳使用方式是通过值(而不是指针)抛出,通过引用(通常是const)捕获。重新抛出时优先选用throw;,避免使用throw e;
  • 值抛出的异常对象会由编译器负责管理其生命周期,不需要程序员操心。
  • 而通过指针抛出则需要程序员对内存的分配和释放负责,如果认为确实有必要这样做,那么可以抛出异常的智能指针。
  • 捕获异常时最好通过引用不会,通过值捕获会有切片问题,这会使异常对象丢失多态性。
  • 重新抛出时应该使用throw;而不是throw e;,第一种形式是抛出源对象,第二种是重新以值抛出,会丢失多态性。

74. 正确地报告、处理和转换错误

  • 摘要:在检查并确认是错误时报告错误。在能够正确处理错误的最近一层处理或者转换错误。
  • 函数检查出一个它自己无法解决的错误而且会使函数无法继续执行时,应该报告错误(比如throw)。
  • 以下情况需要转换错误:
    • 要添加高层语义。
    • 要改变错误处理机制,比如在模块边界将异常转换为错误码。
  • 如果没有对错误做有用处理的上下文,代码就不应该接收错误。如果函数不准备处理错误,那么它应该允许或者使错误向上传播到能够处理它的调用代码。
  • 例外:
    • 接收异常并添加额外信息再重新抛出是有用的,虽然并没有处理错误。

75. 避免使用异常规范

  • 摘要:如题。
  • 目前C++标准已经废弃异常规范。

STL:容器

76. 默认使用vector,否则选择其他合适的容器

  • 摘要:如果有理由选择某个容器,那么就选择它。如果没有什么特别理由,那么直接选择vector即可。
  • vector具有很多优点:
    • 容器中最低空间开销。
    • 所有容器中对存放元素存取最快。
    • 与生俱来的数据局部性。
    • C兼容内存布局。
    • 迭代器最灵活:随机访问。
    • 性能最高迭代器:指针或性能相当的类。
  • 如果有了理由选择其他容器,那么也没有必要考虑vector了,否则无脑vector即可。

77. 用vecotr和string代替数组

  • 摘要:如题。
  • 原因:几乎同等性能的前提下,提供了更高层次的抽象,自动管理内存,丰富接口,有助于优化。

78. 使用vector和string::c_str与非C++API交互

  • 摘要:vector和string的元素内存保证连续,与非C++API交互时应该使用他们(如C)。
  • 对于vector,可以使用&*v.begin() &v[0] &v.front() vec.data()来获取首元素地址。
  • 对于string可以使用s.c_str() s.data()获取首字符指针。
  • C++17起可以直接统一为std::data(obj)

79. 在容器中只存储值和只能指针

  • 摘要:如题。

80. 用push_back代替其他扩充序列的方式

  • 摘要:尽量使用push_back。
  • 如果不需要顺序,那么就应该使用push_back,如果很关心顺序,那么大概不应该选择vector。
  • 可以通过back_inserter配合标准算法使用push_back
  • 例外:如果插入范围,那么应该使用insert

81. 多用范围操作,少用单元素操作

  • 摘要:调用范围操作通常比循环调用单元素操作更加高效和易读。

82. 使用公用惯用法真正压缩容量,真正删除元素

  • 摘要:压缩容量,可以使用swap惯用法。真正删除元素,可以使用erase-remove惯用法。
  • 压缩容量:现已有shrink_to_fit
    • 去掉多余容量:container<T>(c).swap(c)
    • 去掉全部内容和容量:container<T>(c).swap(c)
  • 删除元素:c.erase(std::remove(c.begin(), c.end(), value), c.end())

STL:算法

83. 使用带检查的STL实现

  • 摘要:即是只在发行前的测试版本中使用,仍然使用带检查的STL实现。
  • 至少保证测试时要使用带检查的STL。

84. 用算法调用代替手工编写的循环

  • 摘要:如题。
  • 因为算法库的算法就是精心编写的循环,使用标准库算法更简洁与不易出错。

85. 使用正确的STL查找算法

  • 摘要:如题。
  • 查找无序范围:find/findif count/countif
  • 查找有序范围:lower_bound upper_bound equal_range binary_search
  • 如果查找关联容器,那么应该使用同名的成员函数,而不是标准库算法。

86. 使用正确的STL排序算法

  • 摘要:如题。
  • 按以下顺序选择排序算法:partition stable_partition nth_element partial_sort/partial_sort_copy sort stable_sort
  • 如果前面的能够达到需求,那么就不需要用更强的版本。
  • 如果不是非用不可,应该不用任何排序算法。比如关联容器和优先队列。

87, 使谓词成为纯函数

  • 摘要:如题。
  • 应该总是将函数对象的operator()声明为const,不在传入算法的函数对象中引入状态。这样才能确保算法的正确性。

88. 算法和比较器的参数应多用函数对象少用函数

  • 摘要:如题。
  • 适配性更好,而且比较反直觉的是,产生的代码一般更快。

89. 正确编写函数对象

  • 摘要:尽量将函数对象设计成复制成本很低的值类型。

类型安全

90. 避免使用类型分支,多使用多态

  • 摘要:避免使用类型分支,多使用多态。使用模板和虚函数,让类型自己而不是调用他们的代码来决定他们的行为。
  • 通过类型分支定制行为既不牢固、容易出错,又不安全,这是使用C++编写C风格代码。
  • 理想情况下,添加新功能只需要添加代码而不需要修改。即满足开闭原则。
  • 通过基于模板实现的编译期多态或者基于虚函数的动态多态来实现不同类型不同逻辑。

91. 依赖类型而不是其表示形式

  • 摘要:不要对对象的内存布局做任何假设。
  • 代码中不应该有任何依赖于对象特定内存布局的逻辑,那通常是不可移植的,甚至不跨编译器版本。

92. 避免使用reinterpret_cast

  • 摘要:不要尝试使用reinterpret_cast,这违反了维护类型安全性的原则。
  • reinterpret_cast伴随着程序员对对象表示方式的最强假设,这通常意味着坏的设计。
  • 某些非常特殊的场景可以使用,但通常要配合std::launder使用。

93. 避免对指针使用static_cast

  • 摘要:不要对动态对象的指针使用static_cast,安全替代方法很多,包括使用dynamic_cast、重构、重新设计。

94. 避免强制转换const

  • 摘要:强制转换const有时会导致未定义行为,即使合法,也是不良的编程风格。

95. 不要使用C风格强制类型转换

  • 摘要:如题。
  • 对于对象构造这种转换,还有另一个层含义是调用转换构造或者类型转换运算符,其实是可以用的。如Object(obj) (Object)(obj)。并且最好用前者,对象构造的形式。
  • 但如果目标类型是复合类型,那么最好还是用C++风格类型转换明确自己要做的是哪一种转换。如(Object*)p

96. 不要对非POD类型进行memcpy或者memcmp操作

  • 摘要:如题,包括所有直接的内存操作,包括memset memmove等。
  • C++20起,POD(简旧数据类型)已经废弃,新的具名要求是平凡复制类型、平凡类型、标准布局类型。

97. 不要使用union重新解释表示方式

  • 摘要:这是比reinterpret_cast还要糟糕的C风格用法。不要在C++中这么用。
  • 它做的假设比reinterpret_cast还要多。

98. 不要使用C风格变长参数(...)

  • 摘要:C风格变长参数是来自C语言的危险遗产,应该避免使用。
  • 缺点:
    • 缺乏类型安全性。
    • 主调和被调紧耦合。
    • 类类型对象行为未知。
    • 参数数量未知。

99. 不要使用失效对象,不要使用不安全函数

  • 摘要:如题。
  • 不要使用失效对象:
    • 已销毁对象。
    • 语义失效对象:悬垂指针、引用,失效迭代器。
    • 从来都有效的对象:包括使用reinterpret_cast伪造指针获得的对象,或者越界访问获得的对象。
  • 不要使用不安全的C遗产:包括不检查长度的缓冲区写入、拷贝、读取等操作,比如strcpy strncpy sprintf等。

100. 不要多态地处理数组

  • 摘要:多态地处理数组是绝对的类型错误。
  • 数组有两个用途:作为对象别名和数组迭代器。
  • 将指针作为数组迭代器使用时,绝对不能多态使用,比如将Derived*转为Base*之后迭代。
  • 在接口中使用引用就表明引用的一个对象,而绝不可能是一个数组。