大型软件项目通常是由大的开发团队负责的。为使大型团队编写的代码具有项目范围的可测精度,必须根据某个标准编写并评价代码。因此,对于大型项目团队,建立编程标准或准则是非常重要的。
编程标准还具有以下用途:
本文旨在提供 C++ 编程规则、准则和提示(这些通称为准则),为您制定编程标准打下基础。本文面向大型项目团队中的软件工程师。
当前版本侧重于编程(尽管有时很难明确区分编程和设计);以后还将增加设计准则。
提供的准则涵盖了 C++ 开发的以下方面:
我们是从大量的行业知识库中收集到这些信息的。(请参阅参考书目以了解具体出处:作者和书名。)这些信息均基于:
大多数信息少量基于第一个类别,大量基于第二和第三个类别。但仍有些信息是基于最后一个类别的;这主要因为编程是一项非常主观的活动:没有一种大家普遍接受的“最佳”或“正确”的方法能够用于编写所有代码。
编写出清晰易懂的 C++ 源代码是应用大多数规则和准则的主要目标:清晰易懂的源代码能够最有效地确保软件的可靠性和可维护性。以下三个简单的基本原则 [Kruchten, 94] 讲述了为什么要编写清晰易懂的代码。
最小意外 - 在源代码的整个使用期限内,读取次数通常多于编写次数(特别是规范)。理想情况是,代码应该类似于一种英语描述,说明要完成哪些操作。程序主要是为人编写的,而不是为计算机编写的。读取代码是一个复杂的思维过程,统一代码之后就可以简化此过程,这在本指南中也称为最小意外原则。软件开发人员使用编程标准主要是为了统一整个项目的风格,不应将其理解为是某种处罚或阻碍创造力和生产力的阻碍。
单一维护点 - 应尽可能在源代码中的一处表达设计决策,而大多数结果都应是从该点有计划地派生出的。违背这一原则将大大地有损可维护性、可靠性以及可读性。
最少干扰 - 最后,应使用最少干扰原则来增强易读性。即,尽力避免将源代码与“干扰”信息(信息量很少或所含信息无助于理解软件用途的栏、框和其他文本)混在一起。
这些准则在此想表达的意思不是要过度限制;而是希望提供正确且安全地使用语言功能的方法。好软件的关键在于:
此处给出的准则具有以下几条基本假设:
准则的重要性并不都相同;它们的重要性按以下标准划分:
用以上符号标识的准则是一种提示,即是可以遵循也可以完全忽略的小提议。
用以上符号标识的准则是通常基于其他技术领域的建议。不遵守此类准则,封装、内聚性、耦合、可移植性、可复用性以及某种实施性能可能会受到影响。 除非有合理的理由证明不需这样做,否则必须遵循这些建议。
用以上符号标识的准则代表需求或限制;若有违反可能直接导致代码损坏、变得不可靠或不可移植。 除非有合理的理由证明不需这样做,否则必须遵守这些需求或限制
找不到适用规则或准则时、规则明显不适用时或者所有办法都不灵验时,请使用常识并查看基本原则。此规则优先于其他所有规则。即使存在适用规则和准则时,常识有时也是必需的。
本章提供了关于程序结构和布局的指导信息。
大型系统通常是作为几个小型的功能子系统开发的。 一般,子系统本身是根据一些代码模块构造的。 在 C++ 中,一个模块通常包含一个抽象(类)的实施,只在极少数情况下包含一组紧密相关的抽象(类)的实施。在 C++ 中,通常作为类来实施抽象。一个类具有两个不同的组成部分:一个是类客户端可见的接口,提供类功能和职责的声明或规范;另一个是声明规范(类定义)的实施。
与类相似的是,模块也有一个接口和一个实施:模块接口具有所含模块抽象类的规范(类声明);模块实施包含抽象类的实际实施(类定义)。
在构造系统时;也可将子系统组织到协作组或层中,从而使依赖性最小化并加以控制。
应该将模块规范放置在与模块实施不同的文件中 - 规范文件指的是头文件。 可以将模块的实施放置在一个或多个实施文件中。
如果模块实施包含大量的内联函数、公用的实施私有声明、测试代码或特定于平台的代码,请将这些内容单独放置在各自的文件中,并在放置好每部分内容后为每个文件命名。
如果可执行程序大小是特定的,则很少使用的函数也应放置在各自的文件中。
使用以下方法构造每部分的文件名:
File_Name::=
<Module_Name> [<Separator> <Part_Name>] '.' <File_Extension>
以下是模块分区和命名方案的示例:
inlines
文件中(请参阅“将模块内联函数定义放置在单独的文件中”)。Module.Test
。将测试代码声明为友元类能够促进模块及其测试代码的独立开发;并允许在不需更改源的情况下,从最终的模块对象代码中省略测试代码。SymaNetwork.hh // 包含名为“SymaNetwork”的类的声明。 SymaNetwork.Inlines.cc // 内联定义子单元 SymaNetwork.cc // 模块的主实施单元 SymaNetwork.Private.cc // 私有实施子单元 SymaNetwork.Test.cc // 测试代码子单元
将模块的规范与实施分开能够促进用户和供应商代码的独立开发。
将模块的实施分散到多个转换单元中能够进一步减少对象代码,从而减小可执行文件的大小。
使用常规和可预测的文件命名和分区约定,能够在不查看模块实际内容的情况下理解模块的内容和组织。
将名称从代码传递到文件名,能够在不需要复杂名称映射的情况下加强可预测性并促进基于文件的工具的构建 [Ellemtel, 1993]。
常用的文件扩展名为:.h、.H、.hh、.hpp
和
.hxx
(用于头文件);以及 .c、.C、.cc、.cpp
和
.cxx
(用于实施)。选择一组扩展名并始终使用它们。
SymaNetwork.hh // 扩展名“.hh”用于表示“SymaNetwork”模块头。 SymaNetwork.cc // 扩展名“.cc”用于表示“SymaNetwork”模块实施。
C++ 草稿标准草案还在名称空间包括的头中使用扩展名“.ns”。
只在极少数情况下才将多个类一起放置在一个模块中;而且这些类还必须密切关联(例如,容器及其迭代器)。 如果所有的类始终需要对客户端模块是可见的,则可以将模块的主类及其支持的类放置在同一个头文件中。
减少模块的接口以及它与其他接口的依赖性。
除类私有成员之外,模块的实施私有声明(例如,实施类型和支持类)不应该出现在模块的规范中。 应在需要的实施文件中放置这些声明,除非有多个实施文件需要这些声明;在那种情况下,应该将这些声明放置在次要的私有头文件中。 其他的实施文件应按需包含这个次要的私有头文件。
这一做法将确保:
// Specification of module foo, contained in file "foo.hh" // class foo { .. declarations }; // End of "foo.hh" // Private declarations for module foo, contained in file // "foo.private.hh" and used by all foo implementation files. ... private declarations // End of "foo.private.hh" // Module foo implementation, contained in multiple files // "foo.x.cc" and "foo.y.cc" // File "foo.x.cc" // #include "foo.hh" // Include module's own header #include "foo.private.hh" // Include implementation // required declarations. ... definitions // End of "foo.x.cc" // File "foo.y.cc" // #include "foo.hh" #include "foo.private.hh" ... definitions // End of "foo.y.cc"
#include
获取模块规范的访问权使用另一个模块的模块必须使用预处理器 #include
伪指令才能访问供应商模块规范。相应地,模块不应该重新声明供应商模块规范的任何部分。
包含文件时,仅使用 #include <header>
语法处理“标准”头文件;使用
#include
“头”语法处理剩余部分。
#include
伪指令还适用于模块自身的实施文件:模块实施必须包含自身的规范和私有次要头文件(请参阅“在单独的文件中放置模块规范和实施”)。
// The specification of module foo in its header file // "foo.hh" // class foo { ... declarations }; // End of "foo.hh" // The implementation of module foo in file "foo.cc" // #include "foo.hh" // The implementation includes its own // specification ... definitions for members of foo // End of "foo.cc"
#include
规则的例外:当模块仅使用或包含供应商模块类型(类)引用(使用指针或引用类型声明)时;引用用法或包含是使用先前的声明(另请参阅“最小化编译依赖关系”)而不是
#include
伪指令指定的。
避免包含的头个数超过实际需要:即模块头不应该包含仅用于模块实施的其他头。
#include "a_supplier.hh" class needed_only_by_reference;// Use a forward declaration // for a class if we only need // a pointer or a reference // access to it. void operation_requiring_object(a_supplier required_supplier, ...); // // Operation requiring an actual supplier object; thus the // supplier specification has to be #included. void some_operation(needed_only_by_reference& a_reference, ...); // // Some operation needing only a reference to an object; thus // should use a forward declaration for the supplier.
此规则将确保:
当模块具有多个内联函数时,应该将它们的定义放置在一个单独的仅包含内联函数的文件中。 内联函数文件应该放在模块头文件的结尾处。
另请参阅“使用 No_Inline
条件编译符号取消内联编译”。
此项技术确保实施详细信息不会破坏模块的头;从而使规范清晰易读。它还有助于在没有编译内联函数时减少代码重复:使用条件编译时,可将内联函数编译到一个对象文件中,而不是静态编译进使用模块的所有文件中。 相应地,内联函数定义应该在类定义中定义,除非它们一点都不重要。
将大型模块分散在多个转换单元中有助于在程序链接时除去未引用的代码。应该将很少引用的成员函数放置不常使用的文件中。 甚至可将个别成员函数放在它们自己的文件中 [Ellemtel, 1993]。
链接程序有时不能除去对象文件中未引用的代码。将大型模块分散在多个文件中可使这些链接程序通过断开整个对象文件的链接来减小可执行程序的大小 [Ellemtel, 1993]。
首先值得考虑的是:是否应该将模块分散到较小的抽象对象中。
分离与平台相关的代码与独立于平台的代码;这将促进端口移植。与平台相关的模块的文件名应受限于其平台名称,从而突出平台的依赖性。
SymaLowLevelStuff.hh // "LowLevelStuff" // specification SymaLowLevelStuff.SunOS54.cc // SunOS 5.4 implementation SymaLowLevelStuff.HPUX.cc // HP-UX implementation SymaLowLevelStuff.AIX.cc // AIX implementation
从体系结构和维护角度来说,在少量低级别的子系统中包含平台依赖性是一种好的做法。
采用标准文件内容结构并始终应用它
建议的文件内容结构由以下部分并按以下顺序组成:
1. 防止包含重复文件(仅限规范)。
2. 可选文件和版本控制标识。
3. 该单元需要的文件包含。
4. 模块文档(仅限规范)。
5. 声明(类、类型、常量、对象和函数)和其他文本规范(前置条件和后置条件,以及不变量)。
6. 包含此模块的内联函数定义。
7. 定义(对象和函数)和实施私有声明。
8. 版权声明。
9. 可选版本控制历史记录。
以上文件内容排序最先显示客户端相关的信息;并且与类的 public、protected 和 private 部分的排序原理一致。
根据公司策略,可以将版权信息放在文件的顶部。
应该在每个头文件中使用以下结构来防止重复的文件和编译:
#if !defined(module_name) // Use preprocessor symbols to #define module_name // protect against repeated // inclusions... // Declarations go here #include "module_name.inlines.cc" // Optional inline // inclusion goes here. // No more declarations after inclusion of module's // inline functions. #endif // End of module_name.hh
将模块文件名用于包含保护符号。符号使用的字母大小写与模块名相同。
No_Inline
”条件编译符号取消内联编译使用以下条件编译结构控制可内联函数的内联(与外部相对)编译:
// At the top of module_name.inlines.hh #if !defined(module_name_inlines) #define module_name_inlines #if defined(No_Inline) #define inline // Nullify inline keyword #endif ... // Inline definitions go here #endif // End of module_name.inlines.hh // At the end of module_name.hh // #if !defined(No_Inline) #include "module_name.inlines.hh" #endif // At the top of module_name.cc after inclusion of // module_name.hh // #if defined(No_Inline) #include "module_name.inlines.hh" #endif
条件编译结构类似于多重包含保护结构。如果未定义 No_Inline
符号,将使用模块规范编译内联函数,并将其从模块实施中自动排除。如果定义了
No_Inline
符号,将从模块规范中排除内联定义,但是会将其包含在模块实施中,并且关键字
inline
无效。
以上技术能够在外部编译内联函数时减少代码重复。通过使用条件编译,内联函数的一个副本会编译到定义模块中;相对而言,当编译器开关切换为外部编译时,复制的代码会在每个使用的模块中编译为“静态”(内部链接)函数。
使用条件编译会增加维护构建依赖关系的复杂度。此复杂性的管理方式是将头和内联函数定义视为一个逻辑单元:实施文件因此与头和内联函数定义文件都相关。
一致缩进应该用于直观描述嵌套语句;经过验证,缩进 2 至 4 个空格的可视效果最好。 建议使用 2 个空格的常规缩进。
复合语句或块语句定界符({}
)的缩进度应该与周围语句的缩进度相同(这暗示
{}
是纵向对齐的)。应该按照选择的空格数缩进块内的语句。
switch
语句的 case 标签的缩进度应该与 switch
语句相同;switch
语句内的语句可以比 switch
语句本身及其 case 标签多缩进一级。
if (true) { // New block foo(); // Statement(s) within block // indented by 2 spaces. } else { bar(); } while (expression) { statement(); } switch (i) { case 1: do_something();// Statements indented by // 1 indentation level from break; // the switch statement itself. case 2: //... default: //... }
缩进 2 个空格,既可轻松识别块又可为嵌套的块提供足够空间,而不致代码偏离出显示屏或打印页面的右边缘。
如果函数声明在一行上放不下,则将第一个参数与函数名放在一行上;后面的每个参数各占一行,缩进度与第一个参数相同。 下面显示的此类声明和缩进在函数返回类型和名称下面留有空格;因此更容易阅读。
void foo::function_decl( some_type first_parameter, some_other_type second_parameter, status_type and_subsequent);
如果遵循上述指示信息会导致回行或参数缩进过多,则根据函数名或作用域名(类、名称空间)缩进所有参数,一个参数占一行:
void foo::function_with_a_long_name( // function name is much less visible some_type first_parameter, some_other_type second_parameter, status_type and_subsequent);
另请参阅下面的对齐规则。
应该限制程序行的最大长度,以防止在标准(letter)或缺省打印输出纸张上打印时丢失信息。
如果缩进度导致语句嵌套过深以至于太接近右边缘,并且语句超出右页边距过多,则应考虑将代码分成较小的易于管理的函数。
当参数列于函数声明、定义和调用中,或者枚举声明中的枚举符在一行上放不下时,在每个列表元素后断行并将每个元素单独放在一行上(另请参阅“根据函数名或作用域名缩进函数参数”)。
enum color { red, orange, yellow, green, //... violet };
如果类或函数模板声明过长,请将声明断成连续的几行并放在模板参数列表后面。例如(标准迭代器库的声明 [X3J16, 95]):
template <class InputIterator, class Distance> void advance(InputIterator& i, Distance n);
本章提供了关于在代码中使用注释的指导信息。
注释应该用于补充源代码而不是解释它:
对于每个注释,程序员都应能轻松回答以下问题:“此注释有什么价值?”通常,如果名称选得恰当便不需要注释。 编译器不检查不在某些正式程序设计语言(PDL)中的注释;因此,依照单点维护原则,即使需要增加几个声明,也应在源代码中而不是注释中表达设计决策。
C++ 样式“//
”注释定界符应优先于 C 样式“/*...*/
”使用。
C++ 样式注释更清晰,减少了因缺少注释结尾定界符而意外地注释掉大量代码的风险。
/* start of comment with missing end-of-comment delimiter do_something(); do_something_else(); /* Comment about do_something_else */ // End of comment is here ---> */ // Both do_something and // do_something_else // are accidentally commented out! Do_further();
注释的放置位置应该接近所注释的代码;缩进度相同,并且使用空白注释行与代码相连。
应用于多个连续的源代码语句的注释应该放在充当语句简介的语句之上。同样,与个别语句关联的注释应该放在语句之下。
// A pre-statements comment applicable // to a number of following statements // ... void function(); // // A post-statement comment for // the preceding statement.
避免在源代码所在的行上加注释,这些注释经常不对齐。然而,对于较长声明中的元素描述(例如枚举声明中的枚举符),此类注释是可以容许的。
避免使用包含诸如作者、电话号码以及创建和修改日期之类信息的标题:作者和电话号码很快就会失效;而创建日期和修改日期,以及修改原因则由配置管理工具(或其他形式的版本历史文件)很好地维护。
避免使用垂直栏、封闭的框架或框,即使对主要结构(函数和类)也如此;它们只会影响可视效果且很难保持一致。 使用空行(而不是大量注释行)分隔源代码的相关块。使用一个空行分隔函数或类内的结构。使用两个空白行分隔各函数。
框架或表单可能具有相同的外观,并能提示程序员记录代码,但是它们经常导致出现解释样式 [Kruchten, 94]。
在一个注释块中使用空注释而不是空行分隔段
// Some explanation here needs to be continued // in a subsequent paragraph. // // The empty comment line above makes it // clear that this is another // paragraph of the same comment block.
避免在注释中重复程序标识,并避免重复在其他位置已有的信息 - 而应提供一个指向这些信息的指针。否则,只要更改程序,便需在多处维护信息。 未能更改所有需更改的注释将导致注释错误或者误导他人:这比不加注释更糟糕。
始终以编写自说明代码而不是提供注释为目标。 这可以通过选择更好的名称来实施;使用额外的临时变量或者重新构造代码。请谨慎对待注释中的样式、语法和拼写。使用自然语言注释,而不应使用电报或加密样式。
do { ... } while (string_utility.locate(ch, str) != 0); // Exit search loop when found it.替换为:
do { ... found_it = (string_utility.locate(ch, str) == 0); } while (!found_it);
尽管自说明代码比注释更可取;但通常除代码复杂部分的说明之外,还需另外提供信息。需要说明的信息至少包含以下方面:
客户端必须具有足够的代码文档和声明才能使用代码;文档是必需的,因为仅仅使用
C++ 不能充分表达类、函数、类型和对象的语义。
本章提供了关于为各种 C++ 实体选择名称的指导信息。
为程序实体(类、函数、类型、对象、字面值、异常和名称空间)选择一个恰当的名称不是件容易的事。 对于大中型应用程序,这个问题更为严峻:在这些应用程序中,存在命名冲突且缺乏同义词供指定即不同又相似的概念,这些都增加了命名的难度。
使用命名约定可以减少创建合适名称所需的精力。除此之外,命名约定还加强了代码的连贯性。 要提供帮助,命名约定应该对以下方面提供指导:印刷样式(或者如何编写名称);以及名称构造(或者如何选择名称)。
使用哪个命名约定并不是非常重要,重要的是要始终应用它。命名的一致性远比实际的约定重要:一致性支持最小意外原则。
由于 C++ 是区分大小写的语言,并且 C++ 团体普遍使用大量不同的命名约定,所以几乎不可能达到绝对的命名一致性。 建议根据主机环境(例如,UNIX 或 Windows)和原则库为项目选择命名约定,从而最大限度的使代码保持一致:
细心的读者会发现本文中的示例目前未遵循所有的准则。一方面是由于这些示例是从不同出处获得的,另一方面是为了节省纸张,还未仔细应用编排好格式的准则。但您应该“按我说的做,而不是按我做的做”。
以一个下划线(“_”)开头的名称常用于库函数(“_main
”和“_exit
”)。
以两个下划线(“__”)开头或以一个下划线开头且随后带一个大写字母的名称供编译器内部使用。
还要避免名称旁边有下划线,因为这通常很难确定下划线的准确数目。
很难记住仅字母大小写不同的类型名称之间的差别,因此容易造成混淆。
如果缩写在应用程序域中是很常用的(例如,FFT 指的是 Fast Fourier Transform,快速傅立叶变换)或者缩写定义在项目公认的缩写列表中,才可以使用缩写。 否则,相似但不完全相同的缩写很可能会到处出现,给今后的使用造成混淆和错误(例如,track_identification 可以缩写为 trid、trck_id、tr_iden、tid 和 tr_ident 等)。
使用后缀对实体进行分类(例如 type 用于类型,error 用于异常)对理解代码通常没有太大帮助。 诸如 array 和 struct 之类的后缀还暗指特定实施;在实施被更改时(更改构造或数组的说明时),此类后缀能够对所有客户端代码造成负面影响或造成误导。
但是,后缀在一些限定情况下很有用:
根据用途选择名称;并将形容词和名词一起使用以使含义更具体(特定于环境)。还要确保名称与其类型相符。
选择名称,使如下所示的构造:
object_name.function_name(...); object_name->function_name(...);
易读并有一定意义。
输入速度不是使用简短名称或缩写名称的可接受的正当理由。一个字母和简短标识通常是没有认真选择或懒惰的结果。 不过容易识别的实例除外,例如使用 E 作为自然对数的基数;又例如 Pi。
不幸的是,编译器和支持工具有时限制名称的长度;因此,应该小心确保较长名称不只是结尾字符不同:因为不同的字符可能被这些工具截掉。
void set_color(color new_color) { ... the_color = new_color; ... }上述语句比以下两个语句更好:
void set_foreground_color(color fg)和
void set_foreground_color(color foreground);{ ... the_foreground_color = foreground; ... }
第一个示例中的命名好于其他两个名称:new_color
是限定名称并且与其类型相符;因此增强了函数的语义。
在第二个示例中,直觉较强的读者可能推断出 fg
代表前景;但是好的编程样式是应该将所有内容都向读者解释清楚,而不是让读者自己推断。
在第三个示例中,当使用参数 foreground
(与其声明不同)时,会引导读者认为
foreground
实际表示前景颜色。这可能会想得到;但已经变成隐含转变为 color
的任何类型。
用名词和形容词构成名称,并确保名称符合其类型并遵守自然语言,增强代码可读性和语义。
名称中的英语单词必须拼写正确并符合项目要求的形式,即符合英国英语或美国英语(但不能混合使用)。这对于注释也适用。
对于布尔型对象、函数和函数自变量,以肯定形式使用谓词子句,例如 found_it
和
is_available
,而不是 is_not_available
。
使用否定谓词时,双重否定很难理解。
如果将系统分解为子系统,请将子系统名称用作名称空间名称以区分并最小化系统的全局名称空间。 如果系统是库,则对整个库使用同一个最外层的名称空间。
为每个子系统或库名称空间指定一个有意义的名称;并为它指定一个缩写或首字母缩写别名。
选择不冲突的缩写或首字母缩写别名,例如,ANSI C++ 草稿标准库 [Plauger, 95]
将 std
定义为 iso_standard_library
的别名。
如果编译器仍不支持名称空间构造,请使用名称前缀模拟名称空间。例如,系统管理子系统接口中的公共名称不能带有前缀 syma(系统管理的缩写)。
使用空间名称包含潜在的全局名,这有助于在由子项目团队或供应商独立开发代码时避免名称冲突。结论就是只有名称空间是全局的。
使用单数形式的常用名词或名词短语,从而为类指定一个表达其抽象含义的名称。对基类使用更为通用的名称,对派生类使用更为专用的名称。
typedef ... reference; // From the standard library typedef ... pointer; // From the standard library typedef ... iterator; // From the standard library class bank_account {...}; class savings_account : public bank_account {...}; class checking_account : public bank_account {...};
当对象和类型缺少适用名称或者适用名称冲突时,对对象使用该简单名称,并为类型名称添加一个后缀(例如,mode、kind 和 code
等)。
使用复数形式表达代表对象集合的抽象。
typedef some_container<...> yellow_pages;
当除对象集合外还需要其他语义时,请将标准库中的以下各项用作行为模式和名称后缀:
对没有返回值(具有 void 返回类型的函数声明)的函数或者用指针或引用参数返回值的函数使用动词或动词短语。
对仅返回一个值(非 void 函数返回类型)的函数使用名词或实词。
对于具有常见操作(一种行为模式)的类,请使用从项目列表选择的操作名。例如:begin、end、insert 和 erase(标准库的容器操作)。
避免使用“get”和“set”命名(向函数添加前缀“get”和“set”),尤其是用于获取和设置对象属性的公共操作。 操作命名应该停留在服务的类抽象和供应级别;获取和设置对象属性是级别较低的实施细节,如果将其设置为公共的,则会对封装有影响。
对返回布尔值(谓词)的函数使用形容词(或过去分词)。对于谓词,通常在名词前面添加前缀 is 或 has,用肯定形式表达名称。当已将简单名称用于对象、类型名称或枚举字面值时,这也很有用。请保证时态的准确和一致。
void insert(...); void erase(...); Name first_name(); bool has_first_name(); bool is_found(); bool is_available();
不要使用否定名称,因为这可能产生双重否定的表达(例如,!is_not_found
),使得代码更难理解。
有时使用反义词即可使否定谓词变为肯定的,而无需更改其语义,例如使用“is_invalid
”而不是“is_not_valid
”。
bool is_not_valid(...); void find_client(name with_the_name, bool& not_found);应该重新定义为:
bool is_valid(...); void find_client(name with_the_name, bool& found);
当操作具有相同的用途时,请使用重载而不是尝试查找同义词:这能最大程度地减少系统中操作的概念和变体,从而降低总体复杂度。
当重载运算符时,请确保保留了运算符的语义;如果无法保留运算符的常规含义,则为函数选择另一个名称而不是重载该运算符。
要表示唯一性或者操作主要侧重于该实体,请向对象或参数名添加前缀“the
”或“this
”。
要表示次要、临时和辅助对象,请添加前缀“a
”或“current
”:
void change_name( subscriber& the_subscriber, const subscriber::name new_name) { ... the_subscriber.name = new_name; ... } void update(subscriber_list& the_list, const subscriber::identification with_id, structure& on_structure, const value for_value); void change( object& the_object, const object using_object);
由于异常仅可用于处理错误情况,所以请使用能够明确传达否定含义的名词或名词短语:
overflow、threshold_exceeded 和 bad_initial_value
使用项目认可的列表中的某个单词(例如 bad、incomplete、invalid、wrong、missing 或 illegal 构成名称,而不是笼统地使用不能表达特定信息的 error 或 exception。
浮点型字面值中的字母“E”和十六进制数字“A”到“F”应该总是大写的。
本章提供了关于各种 C++ 声明的用法和构成的指导信息。
在出现 C++ 语言中的名称空间功能之前,管理名称作用域的方法十分有限;因此,全局名称空间的使用变得十分普遍,并因而导致了冲突,造成一些库不能在同一个程序中一起使用。 新的名称空间语言功能解决了全局名称空间污染问题。
即只有名称空间名称才能是全局的;其他所有声明都应在某个名称空间的作用域中。
忽略此规则可能最终导致名称冲突。
对于非类功能(例如类类别)或作用域远大于类(例如库或子系统)的功能的逻辑分组,请使用名称空间在逻辑上统一声明(请参阅“使用名称空间按子系统或库区分潜在的全局名称”)。
在名称中表达功能的逻辑分组。
namespace transport_layer_interface { /* ... */ }; namespace math_definitions { /* ... */ };
使用全局和名称空间作用域数据是与封装原则恰好相反的。
类是 C++ 的基本设计和实施单元。类应该用于捕获域和设计抽象,并用作实施抽象数据类型(ADT)的封装机制。
class
而不是 struct
实施抽象数据类型使用 class
类关键字而不是 struct
实施类(抽象数据类型)
使用 struct
类关键字将普通旧数据(plain-old-data,POD)结构定义为 C 语言样式,尤其是与 C
代码交互时。
尽管 class
和 struct
是等价的并且可互换使用,但是 class
更强调缺省访问控制(私有)以供更好地封装。
在区分 class
和 struct
时采取统一操作会引入超出语言规则的语义差异:class
是捕获抽象和封装的最重要结构;而 struct
代表可在混合编程语言程序中交换的纯数据结构。
类声明中的访问说明符应该按 public、protected 和 private 的顺序显示。
成员声明的 public、protected 和 private 顺序确保类用户最感兴趣的信息最先显示,因此类用户可更少浏览无关信息或实施细节。
使用公共或受保护的数据成员将减少类封装并影响系统对更改的恢复能力:公共数据成员将类的实施展现给其用户;受保护的数据成员将类的实施展现给其派生类。 对类的公共或受保护的数据成员的任何更改都将影响用户和派生类。
第一次接触本准则时可能感觉与想像的不同:友元关系会使某些对象的私有部分被其友元看到,那么如何保留封装呢? 当类之间关联性很强并且需要互知内部信息时,最好建立友元关系,而不是通过类接口导出内部详细信息。
作为公共成员导出内部详细信息将对不恰当的类客户端授予访问权。导出受保护的成员将会向潜在的子成员授予访问权,由此导致不期望出现的设计分层化。 友元关系将授予可选的私有访问权而无需强加划分子类约束,因此保护封装不受这些需要的访问权的限制。
使用友元关系保留封装的一个很好的示例是向友元测试类授予友元关系。通过查看类内部信息,友元测试类可以实施适当的测试代码,但稍后,可以从交付的代码删除该友元测试类。 因此,没有封装丢失,也没有在可交付代码中添加有关封装的代码。
类声明只应该包含函数声明而不应包含函数定义(实施)。
在类声明中提供函数定义会在类规范中混淆进实施详细信息,从而使类接口不好识别并更难读取,还会增加编译依赖关系。
在类声明中提供函数定义还会使函数内联更难控制(另请参阅“使用 No_Inline
条件编译符号取消内联编译”)。
为了允许在数组中使用类或者任何 STL 容器,类必须提供公共缺省构造函数或允许编译器生成一个缺省构造函数。
当类具有引用类型的非静态数据成员时,若出现以上规则之外的例外情况,则通常不能创建有意义的缺省构造函数。 这是有问题的,因此请对对象数据成员使用引用。
如果需要并且没有显式声明,编译器将为类隐含生成一个副本构造函数和赋值运算符。在 Smalltalk 术语中,编译器所定义的副本构造函数和赋值运算符实施通常称为“浅拷贝”:对于指针而言,就是成员逐位拷贝和逐位拷贝。使用编译器生成的副本构造函数和缺省赋值运算符可保证泄漏内存。
// Adapted from [Meyers, 92]. void f() { String hello("Hello");// Assume String is implemented // with a pointer to a char // array. { // Enter new scope (block) String world("World"); world = hello; // Assignment loses world's // original memory } // Destruct world upon exit from // block; // also indirectly hello String hello2 = hello; // Assign destructed hello to // hello2 }
在以上代码中,保留字符串“World
”的内存在赋值后丢失。在退出内部块后,world
被破坏;因此也丢失了由 hello
引用的内存。将已破坏的 hello
赋值给 hello2
。
// Adapted from [Meyers, 1992]. void foo(String bar) {}; void f() { String lost = "String that will be lost!"; foo(lost); }
在以上代码中,当使用参数 lost
调用 foo
时,将会使用编译器定义的副本构造函数将
lost
复制到 foo
中。由于 lost
是通过指针的位副本复制到
"String that will be lost!"
中的,在从 foo
退出时,lost
的副本将被破坏(假设正确实施析构函数以释放内存),同时保存“String that
will be lost!
”的内存也将丢失
// Example from [X3J16, 95; section 12.8] class X { public: X(const X&, int); // int parameter is not // initialized // No user-declared copy constructor, thus // compiler implicitly declares one. }; // Deferred initialization of the int parameter mutates // constructor into a copy constructor. // X::X(const X& x, int i = 0) { ... }
没有在类声明中看到“标准”副本构造函数签名的编译器将暗含声明副本构造函数。 然而,延迟缺省参数的初始化可将构造函数转换为副本构造函数:导致在使用副本构造函数时发生混淆。 由于混淆,使用的副本构造函数的格式将不正确 [X3J16, 95; section 12.8]。
除非将类显式指定为不可交付,否则其析构函数应该始终声明为虚拟的。
通过指向基类类型的指针或引用删除派生类对象将导致出现不可定义的行为,除非已将基类构造函数声明为虚拟的。
// Bad style used for brevity class B { public: B(size_t size) { tp = new T[size]; } ~B() { delete [] tp; tp = 0; } //... private: T* tp; }; class D : public B { public: D(size_t size) : B(size) {} ~D() {} //... }; void f() { B* bp = new D(10); delete bp; // Undefined behavior due to // non-virtual base class // destructor }
还可通过使用 explicit
说明符声明单个参数构造函数从而防止在隐式转换中使用它们。
非虚拟函数实施的行为不变并且不专用于派生类。违反这一原则可能产生意外行为:同一个对象可能在不同时间产生不同的行为。
非虚拟函数是静态绑定的,因此对对象调用的函数受控于变量(分别引用以下示例中的对象 - 指向 A 的指针和指向 B 的指针)的静态类型,而不是对象的实际类型。
// Adapted from [Meyers, 92]. class A { public: oid f(); // Non-virtual: statically bound }; class B : public A { public: void f(); // Non-virtual: statically bound }; void g() { B x; A* pA = &x; // Static type: pointer-to-A B* pB = &x; // Static type: pointer-to-B pA->f(); // Calls A::f pB->f(); // Calls B::f }
由于非虚拟函数通过限制专门化和多态性来约束子类,所以使用时应该仔细确保操作对于所有子类均为不变的,然后再将函数声明为非虚拟的。
在构造时,对象状态的初始化应该由构造函数初始化程序(初始化程序列表中的成员)而不是构造函数体中的赋值运算符执行。
class X { public: X(); private Y the_y; }; X::X() : the_y(some_y_expression) { } // // "the_y" initialized by a constructor-initializer而不应使用以下语句:
X::X() { the_y = some_y_expression; } // // "the_y" initialized by an assignment operator.
对象构造包括在执行构造函数体之前构造所有基类和数据成员。对于数据成员的初始化,如果在构造函数体内执行,需要两个操作(构造和赋值);如果由构造函数初始化程序执行,需要一个操作(用初始值构造)。
对于大型的嵌套聚集类(嵌套多层类的类),多重操作(构造和成员赋值)的性能开销可能非常重要。
class A { public: A(int an_int); }; class B : public A { public: int f(); B(); }; B::B() : A(f()) {} // undefined: calls member function but A bas // not yet been initialized [X3J16, 95].
如果在基类的所有成员初始化程序完成之前,从构造函数初始化程序直接或间接地调用了成员函数,则操作的结果是未定义的 [X3J16, 95]。
在构造函数中调用成员函数时请小心,注意即使调用了虚拟函数,执行的函数仍然是在构造函数或析构函数的类中或该类的某个基类中定义的函数。
static const
定义整数类常量时,请使用 static const
数据成员而不是
#define
或全局常量。如果编译器不支持 static const
,请代而使用
enum
。
class X { static const buffer_size = 100; char buffer[buffer_size]; }; static const buffer_size;或使用以下语句:
class C { enum { buffer_size = 100 }; char buffer[buffer_size]; };但不要使用以下语句:
#define BUFFER_SIZE 100 class C { char buffer[BUFFER_SIZE]; };
在编译器反映声明的函数缺少返回类型(无显式返回类型)时,这将避免产生混淆。
在函数声明和定义中使用相同的名称;这可最大程度地减少意外。提供参数名称将改进代码文档和可读性。
随意分布在函数体中的返回语句类似于 goto
语句,使代码很难读取和维护。
仅在小型程序中,所有的 return
都可同时看到并且代码结构很常见时,才允许出现多个返回语句。
type_t foo() { if (this_condition) return this_value; else return some_other_value; }
具有 void 返回类型的函数不应该有返回语句。
应该尽量少创建对全局有副作用(更改不建议更改的数据而不是其内部对象状态:如全局和名称空间数据)的函数(另请参阅“最低限度地使用全局和名称空间作用域数据”)。 但如果不可避免,则应该明确地将任何副作用记录进函数规范。
将必需的对象作为参数传递减少了代码的环境依赖性、使代码更健壮并易于读取。
在调用者看来,参数的声明顺序非常重要:
此顺序允许利用缺省值减少函数调用中参数的数量。
不能检查参数数量可变的函数的参数类型。
避免在函数的进一步重新声明中向函数添加缺省值:除了转发声明,一个函数只能声明一次。 否则,可能导致那些没有留意后续声明的读者看不懂。
检查函数是否具有任何不变行为(返回常量值;接受常量参数;或在不产生副作用的情况下操作)并使用
const
说明符声明行为。
const T f(...); // Function returning a constant // object. T f(T* const arg); // Function taking a constant // pointer. // The pointed-to object can be // changed but not the pointer. T f(const T* arg); // Function taking a pointer to, and T f(const T& arg); // function taking a reference to a // constant object. The pointer can // change but not the pointed-to // object. T f(const T* const arg); // Function taking a constant // pointer to a constant object. // Neither the pointer nor pointed- // to object may change. T f(...) const; // Function without side-effect: // does not change its object state; // so can be applied to constant // objects.
用值传递和返回对象可能导致构造函数和析构函数开销过大。可以通过由引用传递和返回对象来避免构造函数和析构函数的开销。
Const
引用可用于指定由引用传递的参数不能修改。通常用于副本构造函数和赋值运算符:
C::C(const C& aC); C& C::operator=(const C& aC);
请看以下语句:
the_class the_class::return_by_value(the_class a_copy) { return a_copy; } the_class an_object; return_by_value(an_object);
使用 an_object
将 return_by_value
作为参数调用时,将会调用
the_class
副本构造函数以将 an_object
复制到 a_copy
中。
将会再次调用 the_class
副本构造函数以将 a_copy
复制到函数返回临时对象中。
调用 the_class
析构函数,从而在从函数返回时破坏 a_copy
。稍后,the_class
析构函数将会被再次调用以破坏由 return_by_value
返回的对象。以上未执行任何操作的函数调用需要两个构造函数和两个析构函数。
如果 the_class
是派生类并且包含其他类的成员数据,则情况更糟;基类和包含的类的构造函数和析构函数也将被调用,从而增加函数调用引起的构造函数和析构函数的调用次数。
以上原则用于建议开发人员始终按引用传递和返回对象,但是应该注意,在需要对象时,不要将引用返回给局部对象或引用。 将引用返回给局部对象会造成灾难,因为在函数返回时,返回的引用将绑定到已破坏的对象!
局部对象在超出函数作用域时将遭破坏;使用已破坏的对象会造成灾难。
违反此原则将导致内存泄漏
class C { public: ... friend C& operator+( const C& left, const C& right); }; C& operator+(const C& left, const C& right) { C* new_c = new C(left..., right...); return *new_c; } C a, b, c, d; C sum; sum = a + b + c + d;
由于计算总和时没有存储 operator+ 的中间结果,所以中间对象不能删除,从而导致内存泄漏。
违反这一原则将破坏数据封装并可能导致意外结果。
#define
不过应适当地使用内联:对很小的函数使用内联是有效的,对大型函数使用内联可能导致代码膨胀。
内联函数也会增加模块之间的编译依赖关系,因为内联函数的实施需要可用于客户端代码的编译。
[Meyers, 1992] 详细地论述了宏的错误用法的极端示例:
不要按如下定义:
#define MAX(a, b) ((a) > (b) ? (a) : (b))
而应按如下定义:
inline int max(int a, int b) { return a > b ? a : b; }
宏 MAX
存在许多问题:它的类型不是安全的;并且它的行为具有不确定性:
int a = 1, b = 0; MAX(a++, b); // a is incremented twice MAX(a++, b+10); // a is incremented once MAX(a, "Hello"); // comparing ints and pointers
当可以利用一种算法并且该算法可由少量参数实施参数化时,使用缺省参数而不是函数重载。
使用缺省参数有助于减少重载函数的数量、增强可维护性、减少函数调用所需的参数数量并且提高代码可读性。
当同一个语义操作需要多个实施(参数类型不同)时,使用函数重载。
重载运算符时保留常规含义。记得定义相关运算符,例如 operator==
和 operator!=
。
避免用带有一个整数参数的函数来重载带有一个指针参数的函数:
void f(char* p); void f(int i);
以下调用可能导致异常:
f(NULL); f(0);
重载解决方案会解析为 f(int)
而不是 f(char*)
。
operator=
返回 *this
的引用C++ 允许连用赋值运算符:
String x, y, z; x = y = z = "A string";
由于赋值运算符是右关联的,所以字符串“A string
”会指定给 z,再由 z
指定给 y,最后由 y 指定给 x。会对 = 右侧的每个表达式有效地调用一次 operator=
,顺序为从右至左。这也说明每个 operator=
的结果是一个对象,但是选择返回左侧对象或右侧对象均可。
由于实践证明赋值运算符的签名最好为如下形式:
C& C::operator=(const C&);
所以,只有左侧对象是可返回的(rhs 是 const 引用,lhs 是非 const 引用),因此应返回 *this
。请参阅 [Meyers,
1992] 获取详细论述。
operator=
检查自我赋值执行这一检查的原因有两点:第一,派生类对象的赋值涉及调用继承层次结构上每个基类的赋值运算符,跳过这些操作可大大地节省运行时。 第二,赋值涉及在复制“rvalue”对象之前销毁“lvalue”对象。在自我赋值时,rvalue 对象在被赋值之前就遭破坏,从而导致赋值的结果未定义。
不要编写过长的函数,例如 60 行以上的代码。
使返回语句的个数最少化,最理想的个数是 1。
尽量将圈复杂度降低到 10 以下(对于单一出口语句函数,为决策语句的总和 + 1)。
尽量将扩展圈复杂度降低到 15 以下(对于单一出口语句函数,为决策语句和逻辑运算符的总和 + 1)。
最小化引用的平均最大作用域(局部对象的声明与使用该对象的第一个实例之间的行距)。
在大型项目中通常有一组系统中常用的类型,在这种情况下,将这些类型集中在一个或多个低级别的全局实用程序名称空间中是一种明智的做法(请参阅“避免使用基本类型”的示例)。
当要实施高级可移植性、需要控制数字对象占用的内存空间或者需要特定范围的值时,不应该使用基本类型。 在这些情况下,最好使用适当的基本类型声明具有大小约束的显式类型名称。
确保基本类型没有通过循环计数和数组下标等隐含地包含进代码。
namespace system_types { typedef unsigned char byte; typedef short int integer16; // 16-bit signed integer typedef int integer32; // 32-bit signed integer typedef unsigned short int natural16; // 16-bit unsigned integer typedef unsigned int natural32; // 32-bit unsigned integer ... }
基本类型的说明是与实施相关的。
typedef
创建同义词以加强本意使用 typedef
为现有名称创建同义词、给出更有意义的局部名称并提高可读性(这么做不会消耗运行时)。
typedef
还可用于简化表示限定名。
// vector declaration from standard library // namespace std { template <class T, class Alloc = allocator> class vector { public: typedef typename Alloc::types<T>reference reference; typedef typename Alloc::types<T>const_reference const_reference; typedef typename Alloc::types<T>pointer iterator; typedef typename Alloc::types<T>const_pointer const_iterator; ... } }
使用 typedef
创建的名称时,不要在同一段代码中混合使用原始名和同义词。
优先使用命名常量。
而应使用 const
或 enum
。
不要按如下定义:
#define LIGHT_SPEED 3E8而应按如下定义:
const int light_speed = 3E8;或按如下调整数组大小:
enum { small_buffer_size = 100, large_buffer_size = 1000 };
使用 #defines 会大大增加调试难度,因为 #defines
引入的名称将在编译处理期间被替换,并且不出现在符号表中。
未声明为 extern
的 const
对象有内部链接,在声明时初始化这些常量对象允许在编译时使用初始化程序。
常量对象可存在于只读内存中。
在对象定义中指定初始值,除非该对象是自初始化的。如果不能指定有意义的初始值,则指定“nil”值或者考虑稍后声明对象。
对于大型对象,通常不建议构造对象并在稍后通过赋值进行初始化,因为这样做成本非常高(另请参阅“使用构造函数初始化程序而不是在构造函数中赋值”)。
如果不能在构造时正确初始化对象,则使用常规的“nil”值(表示“未初始化”)初始化对象。“nil”值仅用于初始化以声明“不可用但已知的值”(在受控方式下可被算法拒绝),从而表明在正确初始化对象之前使用该对象会发生未初始化变量错误。
请注意,有时不能为所有类型(尤其是模类型,如角度)声明“nil”值。在这种情况下,请选择最不可能的值。
本章提供了关于各种类型的 C++ 表达式和语句的用法和构成的指导信息。
表达式的嵌套层数定义为:在忽略运算符优先级规则的情况下,从左到右求表达式的值所需圆括号对的嵌套数。
嵌套层次过多会使表达式难以理解。
除非运算符(逗号运算符、三重表达式以及“与”和“或”)指定了求值顺序,否则不要假设任何特定的求值顺序;假设可能导致错误结果和不可移植。
例如,不要在同一个语句中使用一个变量作为作为该变量的增量或减量。
foo(i, i++); array[i] = i--;
NULL
表示空指针使用 0 或 NULL
表示空指针是一个很有争议的话题。
C 和 C++ 均定义用空指针解释任何零值的常量表达式。由于 0 很难读取并且强烈建议不使用字面值,所以程序员仍然会按惯例使用宏
NULL
作为空指针。不幸的是,还没有对 NULL
进行可移植的定义。
某些 ANSI C 编译器使用 (void *)0,但是事实证明这不适用于 C++:
char* cp = (void*)0; /* Legal C but not C++ */
因为 (T*)0 格式的 NULL
(而不是简单的 0) 的任何定义在 C++
中都需要强制类型转换。过去,提倡使用 0 表示空指针的原则试图减少数据类型转换需求并使代码更容易移植。
但是,许多 C++ 开发人员发现使用 NULL
(而不是 0)更顺手些,并建议当今的大多数编译器(更确切的说是大多数头文件)将 NULL
按 0 实施。
本准则规则建议使用 0,因为使用 0 可以不考虑 NULL
的值;但是经过讨论,将这一原则降为提示,可以根据需要遵守或忽略它。
使用新的数据类型转换运算符(dynamic_cast、static_cast、reinterpret_cast
和 const_cast
)而不是老式的强制类型转换。
如果您没有新的强制类型转换运算符,请完全避免使用数据类型转换,尤其是向下数据类型转换(将基类对象转换为派生类对象)。
按如下使用数据类型转换运算符:
dynamic_cast
- 在使用运行时类型信息(运行时类型信息可用于具有虚拟函数的类)的相同类层次结构(子类型)的成员之间强制类型转换。
此类之间的数据类型转换是安全的。static_cast
- 在没有使用运行时类型信息的相同类层次结构的成员之间强制类型转换,这种转换不保证是安全的。
如果程序员不能保证类型的安全性,请使用 dynamic_cast
。reinterpret_cast
- 在无关的指针类型和整数类型之间强制类型转换;转换是不安全的并且应该仅用于提及的类型。const_cast
- 改变指定为 const
参数的函数参数的“不变性”。注意,const_cast
不侧重于改变真正定义为 const 类型的对象(可保存在只读内存中)的“不变性”。不要使用 typeid
实施类型转换逻辑:允许数据类型转换运算符自动执行类型检查和转换,请参阅
[Stroustrup, 1994] 获取更详细的论述。
请不要使用以下语句:
void foo (const base& b) { if (typeid(b) == typeid(derived1)) { do_derived1_stuff(); else if (typeid(b) == typeid(derived2)) { do_derived2_stuff(); else if () { } }
老式的数据类型转换将损坏类型系统,并产生编译器很难检测到的错误:内存管理系统可能被损坏、虚拟函数表可能被破坏,并且将某不相关的对象作为派生类对象访问时,该对象可能被毁坏。 请注意,即使在进行读访问时也可能造成破坏,因为可能会引用不存在的指针或字段。
新式数据类型转换运算符使类型转换更为安全(在大多数情况下)并且更明确。
不要使用老式布尔宏或常量:没有表示“true”的标准布尔值,请代而使用新的 bool
类型。
因为通常没有表示“true”(1 或 !0)的标准值;将非零表达式与“true”进行比较可能会失败。
请代而使用布尔表达式。
请避免使用以下语句:
if (someNonZeroExpression == true) // May not evaluate to true最好使用以下语句:
if (someNonZeroExpression) // Always evaluates as a true condition.
此类操作的结果通常是没有意义的。
将指向删除的对象的指针设置为空可避免灾难的发生:重复删除非空指针是有害的,但是重复删除空指针是没关系的。
始终在删除对象后(甚至在函数返回前)为其指定 null 指针值,因为稍后可能会添加新代码。
当分支条件是离散值时,使用 switch 语句而不是一组“else if”。
缺省
分支以捕获错误switch 语句应该始终包含 default
分支,并且应该使用 default
分支捕获错误。
此策略确保在引入新切换值且省略句柄新值的分支时,现有缺省分支将捕获到错误。
当迭代或循环终止是基于循环计数时,优先使用 for 语句(而不是 while 语句)。
避免(使用 break、return
或 goto
)而不是循环终止条件从循环退出,并且避免使用
continue
过早跳至下一个迭代。这会减少控制路径的流数量,从而使代码更易理解。
goto
语句这好像是普遍通用的原则。
这可能使读者看不懂,并给维护带来潜在的风险。
本章提供了关于内存管理和错误报告的指导信息。
C 库函数 malloc
、calloc
和 realloc
不应该用于分配对象空间:因此应该使用 C++ 运算符 new。
仅当将内存传递给 C 库函数以供处理时才使用 C 函数分配内存。
不要使用 delete 释放 C 函数分配的内存,或者释放用 new 创建的对象。
对未使用空方括号(“[]”)表示法的数组对象使用 delete 将导致仅删除第一个数组元素,因此发生内存泄漏。
由于使用 C++ 异常机制的经验还不是很丰富,此处提供的准则可能在将来会有重大修订。
C++ 草稿标准在广义上定义了两类错误:逻辑错误和运行时错误。逻辑错误是可以防止的编程错误。 运行时错误是由于事件超出程序作用域而引发的。
使用异常的常规规则就是正常的系统和没有发生超负荷或硬件故障的系统不应该发生任何异常。
在开发时使用函数前置条件和后置条件声明提供“有效”错误检测。
在实施最终的错误处理代码之前,声明将提供简单而有效的临时错误检测机制。声明中新增了使用“NDEBUG”预处理器符号进行编译的功能(请参阅“使用特定值定义 NDEBUG 符号”)。
声明宏在传统意义上用于此目的;但 [Stroustrup, 1994] 将会提供一个备用模板,请参阅下面示例。
template<class T, class Exception> inline void assert ( T a_boolean_expression, Exception the_exception) { if (! NDEBUG) if (! a_boolean_expression) throw the_exception; }
不要对常见的可预见的事件使用异常:异常会破坏控制代码的正常流程,从而使其更难理解和维护。
应该在控制代码的正常流程中处理可预见的事件,根据需要使用函数返回值或“out”参数状态代码。
也不应该使用异常实施控制构造:这将是另一种形式的“goto”语句。
这将确保所有的异常都支持常规操作的最小集合,并且可由一小组高级别处理程序处理异常。
逻辑错误(域错误、无效参数错误、长度错误和超出范围错误)应该用来指示应用程序域错误、传递给函数调用的无效参数、超出允许大小的对象的构造,以及超出允许范围的参数值。
运行时错误(范围错误和溢出错误)应该用来指示算法和配置错误、损坏的数据,或仅在运行时可检测的资源耗尽错误。
在大型系统中,需要在每个级别处理大量异常从而使代码很难读取和维护。异常处理可能阻碍正常处理。
使异常减到最少的方法有:
通过使用少量的异常类别共享抽象对象之间的异常。
抛出由标准异常派生的特殊异常,但处理更常见的异常。
向对象添加“异常”状态,并提供原语以显式检查对象的有效性。
产生异常(而非受异常影响)的函数应该在异常规范中声明所有抛出的异常:不应该在不警告其客户端的情况下静默生成异常。
在开发时,要尽早(包括在“抛出点”)根据适当的日志记录机制报告异常。
应该按照“派生程度最高”到“最基本”的类顺序定义异常处理程序,从而避免编写不可访问的处理程序;请参阅下面的执行示例。 这也确保用最合适的处理程序捕获异常,因为处理程序符合声明顺序。
不要按如下定义:
class base { ... }; class derived : public base { ... }; ... try { ... throw derived(...); // // Throw a derived class exception } catch (base& a_base_failure) // // But base class handler "catches" because // it matches first! { ... } catch (derived& a_derived_failure) // // This handler is unreachable! { ... }
避免使用捕获所有异常的处理程序(处理程序声明使用 ...),除非重新抛出异常。
捕获所有异常的处理程序应该仅用于局部日常管理,然后应重新抛出异常以防掩盖异常在此级别上无法处理的事实:
try { ... } catch (...) { if (io.is_open(local_file)) { io.close(local_file); } throw; }
将状态码作为函数参数返回时,始终向参数赋予一个值并将该操作指定为函数体中的第一个可执行语句。 缺省情况下,系统化地将所有状态设置为“成功”或“失败”。 考虑函数所有可能的退出情况,包括异常处理程序。
如果函数可能生成错误的输出(除非给定正确的输入),请在该函数中添加代码以受控方式检测和报告无效输入。不要依赖注释来告知客户机传递正确的值。如果没有检测到那些无效参数,则几乎可以断定该注释迟早会被忽略掉,从而导致难以调试的错误。
本章说明先前不可移植的语言功能。
在操作系统中,路径名的表示没有标准的方式。 使用这些表示方法将造成平台的依赖性。
#include "somePath/filename.hh" // Unix #include "somePath\filename.hh" // MSDOS
类型的说明和统一与机器体系结构是密切相关的。对说明和统一作假设可能导致错误结果并降低可移植性。
尤其不要尝试存储 int
、long 或其他任何数字类型的指针,这基本上是不可移植的。
值可扩展的常量可避免在改变单词大小时出现问题。
const int all_ones = ~0; const int last_3_bits = ~0x7;
机器体系结构可能指示特定类型的统一。从统一要求不高的类型转换为要求严格统一的类型可能导致程序故障。
本章提供了关于复用 C++ 代码的指导信息。
如果标准库不可用,则请根据标准库接口创建类:这将有助于将来的迁移。
当行为与特定数据类型无关时,请使用模板来复用行为。
使用公共
继承表达“isa”关系并复用基类接口,还可以选择复用它们的实施。
复用实施或对“部分/整体”关系建模时,避免使用私有继承。使用包含而非私有继承来实施无需重新定义的实施复用。
当需要重新定义基类操作时,请使用私有继承。
应该谨慎地使用多重继承,因为它会大大增加复杂性。[Meyers, 1992] 详细地论述了由潜在的名称不明确性和重复的继承造成的复杂性。造成复杂性的根源是:
不明确性,当多个类使用相同名称时,对这些名称的任何非限定引用本质上都有不明确性。 可以通过使用类名限定成员名来消除不明确性。但是,这样做的负面影响是会损害多态性,并将虚拟函数转变为静态绑定的函数。
重复继承同一个基类的多组数据成员(派生类通过继承层次结构中的不同路径多次继承基类)会引出一个问题,就是应该使用这些数据成员组中的哪一组。
可以通过使用虚拟继承(继承虚拟基类)防止继承的数据成员数量的增加。那么,为什么不始终使用虚拟继承呢? 因为虚拟继承有一定的负面影响,会改变底层对象说明并降低访问效率。
制定一条策略要求所有继承均为虚拟的,同时强制何时何处均需执行的惩罚措施的做法是有些过分的。
因此,多重继承要求类设计人员能够预见将来类的使用情况,以决定是使用虚拟继承还是非虚拟继承。
本章提供了关于编译问题的指导信息
不要在一个模块规范中包含只用于模块实施的其他头文件。
避免在规范中包含头文件以便其他类可看到头文件;但仅需要看见指针或引用时,代而使用转发声明。
// Module A specification, contained in file "A.hh" #include "B.hh" // Don't include when only required by // the implementation. #include "C.hh" // Don't include when only required by // reference; use a forward declaration instead. class C; class A { C* a_c_by_reference; // Has-a by reference. }; // End of "A.hh"
最小化编译依赖关系是某些设计习惯或模式的基本理念,存在各种命名方式:Handle、Envelope [Meyers, 1992] 或 Bridge [Gamma] 类。 对两个相关类之间的类抽象的职责划分如下:一个提供类接口,另一个提供实施;由于对实施(实施类)的任何更改不再会引起客户端的重新编译问题,所以类及其客户端之间的依赖关系减到最低。
// Module A specification, contained in file "A.hh" class A_implementation; class A { A_implementation* the_implementation; }; // End of "A.hh"
此方法还允许将接口类和实施类专用作两个单独的类层次结构。
NDEBUG
符号NDEBUG
符号通常用于编译使用声明宏实施的声明代码。
传统的用法就是在想要除去声明时定义该符号,但是开发人员常常未注意到声明的存在,因而没有定义符号。
我们提倡使用声明的模板版本,这样在需要声明代码时,就明确指定 NDEBUG
符号的值为 0;反之为非零值。
随后在未为 NDEBUG
符号提供特定值的情况下编译的任何声明代码都将生成编译错误;因此会使开发人员注意到声明代码的存在。
以下概述了本手册中包含的所有准则。
使用常识
始终使用 #include
获取模块规范的访问权
声明名称不要以一个或多个下划线(“_”)开头
仅限对名称空间使用全局声明
始终向具有显式声明构造函数的类提供缺省构造函数
始终为具有指针类型数据成员的类声明副本构造函数和赋值运算符
不要重新声明具有缺省值的构造函数参数
始终将析构函数声明为虚拟的
不要重新定义非虚拟函数
不要从构造函数初始化程序调用成员函数
不要将引用返回给局部对象
不要返回用 new 初始化的取消引用的指针
不要将非 const 型引用或指针返回给成员数据
让 operator=
返回 *this
的引用
让 operator=
检查自我赋值
始终保持常量对象的“不变性”
不要假设任何特定的表达式求值顺序
不要使用老式数据类型转换
对布尔表达式使用新 bool
类型
不要直接比较布尔值 true
不要将指针与不在同一数组中的对象作比较
始终为删除的对象指针赋予 null
指针值
始终为 switch 语句提供缺省分支以捕获错误
不要使用 goto 语句
避免将 C 和 C++ 内存操作混在一起
始终使用 delete[] 删除用 new 创建的数组对象
不要使用硬编码的文件路径名
不要假设类型的说明
不要假设类型的统一
不要依赖特殊的下溢或溢出行为
不要从“较短”类型转换为“较长”类型
使用特定值定义 NDEBUG
符号
在单独的文件中放置模块规范和实施
选择一组文件扩展名以区分头文件和实施文件
避免为一个模块规范定义多个类
避免将实施私有声明放置到模块规范中
在单独的文件中放置模块内联函数定义
如果程序大小是特定的,则将大型模块分散在多个转换单元中
隔离平台依赖关系
防止文件重复
使用“No_Inline
”条件编译符号取消内联编译
对嵌套的语句使用较小的一致缩进样式
根据函数名或作用域名缩进函数参数
使用适合标准打印输出纸张的最大行长度
使用一致的回行
使用 C++ 样式注释而不是 C 样式注释
使注释最大限度地邻近源代码
避免以行注释结尾
避免注释头
使用空注释行分隔注释段
避免冗余
编写自说明代码而不是注释
文档类和函数
选择命名约定并始终使用它
避免使用仅字母大小写不同的类型名称
避免使用缩写
避免使用后缀表示语言构造
选择清晰易懂且有意义的名称
在名称中使用正确的拼写
对布尔值使用肯定的谓词子句
使用名称空间通过子系统或库区分潜在的全局名
使用名词或名词短语表示类名
使用动词表示过程-类型函数名
当表达相同的一般含义时使用函数重载
使用合乎语法的元素扩增名称以强调含义
选择具有否定含义的异常名称
使用项目定义的形容词表示异常名称
使用大写字母表示浮点指数和十六进制数字。
使用名称空间对非类功能分组
最低限度地使用全局和名称空间作用域数据
使用 class 而不是 struct 实施抽象数据类型
声明类成员以减少可访问性
避免为抽象数据类型声明公共或受保护的数据成员
使用友元保留封装
避免在类声明中提供函数定义
避免声明过多转换运算符和单个参数构造函数
审慎地使用非虚拟函数
使用构造函数初始化程序而不是在构造函数中赋值
在构造函数和析构函数中调用成员函数时要谨慎
对整数类常量使用 static const
始终显式声明函数返回类型
始终在函数声明中提供形参名称
尽量使用具有一个返回点的函数
避免创建对全局有副作用的函数
声明函数参数以降低重要性和易失性
避免声明参数数量可变的函数
避免重新声明具有缺省参数的函数
在函数声明中尽量多使用 const
避免由值传递对象
对宏扩展优先使用内联函数而不是 #define
使用缺省参数而不是函数重载
使用函数重载表达一般语义
避免重载带有指针和整数的函数
最大限度地降低复杂性
避免使用基本类型
避免使用字面值
避免使用预处理器 #define
伪指令定义常量
对象的声明位置应邻近初次使用的位置
始终在声明时初始化 const 对象
在定义时初始化对象
在对布尔表达式建立分支时使用 if 语句
在划分离散值时使用 switch 语句
当循环中需要预迭代测试时,使用 for 语句或 while 语句
当循环中需要后迭代测试时,使用 do-while 语句
避免在循环中使用跳转语句
避免在嵌套的作用域中隐藏标识
在开发期间可自由地使用声明检测错误
仅将异常用于真实的异常情况
从标准异常派生项目异常
使给定抽象使用的异常数最小化
声明抛出的所有异常
按从“派生程度最高”到“最基本”的类顺序定义异常处理程序
避免使用捕获所有异常的处理程序
确保函数状态代码具有适当的值
局部执行安全检查,不要让客户端执行此操作
尽量使用“值可扩展的”常量
尽量使用标准库组件
定义项目范围的全局系统类型
使用 typedef 创建同义词以加强本意
多使用几个括号以更清楚地表达复合表达式
避免表达式嵌套过深
使用 0 而不是 NULL
表示空指针
在首次发生异常时报告