华为C++编程规范

  • 华为C++编程规范

C++语言编程规范

目的

规则并不是完美的,通过禁止在特定情况下有用的特性,可能会对代码实现造成影响。但是我们制定规则的目的“为了大多数程序员可以得到更多的好处”, 如果在团队运作中认为某个规则无法遵循,希望可以共同改进该规则。 参考该规范之前,希望您具有相应的C++语言基础能力,而不是通过该文档来学习C++语言。

  1. 了解C++语言的ISO标准;
  2. 熟知C++语言的基本语言特性,包括C++ 03/11/14/17相关特性;
  3. 了解C++语言的标准库;

总体原则

代码需要在保证功能正确的前提下,满足可读、可维护、安全、可靠、可测试、高效、可移植的特征要求。

重点关注

  1. 约定C++语言的编程风格,比如命名,排版等。
  2. C++语言的模块化设计,如何设计头文件,类,接口和函数。
  3. C++语言相关特性的优秀实践,比如常量,类型转换,资源管理,模板等。
  4. 现代C++语言的优秀实践,包括C++11/14/17中可以提高代码可维护性,提高代码可靠性的相关约定。
  5. 本规范优先适于用C++17版本。

约定

规则:编程时必须遵守的约定(must)

建议:编程时应该遵守的约定(should)

本规范适用通用C++标准, 如果没有特定的标准版本,适用所有的版本(C++03/11/14/17)。

例外

无论是’规则’还是’建议’,都必须理解该条目这么规定的原因,并努力遵守。 但是,有些规则和建议可能会有例外。

在不违背总体原则,经过充分考虑,有充足的理由的前提下,可以适当违背规范中约定。 例外破坏了代码的一致性,请尽量避免。‘规则’的例外应该是极少的。

下列情况,应风格一致性原则优先: 修改外部开源代码、第三方代码时,应该遵守开源代码、第三方代码已有规范,保持风格统一。

2 命名

通用命名

驼峰风格(CamelCase) 大小写字母混用,单词连在一起,不同单词间通过单词首字母大写来分开。 按连接后的首字母是否大写,又分: 大驼峰(UpperCamelCase)和小驼峰(lowerCamelCase)

类型 命名风格
类类型,结构体类型,枚举类型,联合体类型等类型定义, 作用域名称 大驼峰
函数(包括全局函数,作用域函数,成员函数) 大驼峰
全局变量(包括全局和命名空间域下的变量,类静态变量),局部变量,函数参数,类、结构体和联合体中的成员变量 小驼峰
宏,常量(const),枚举值,goto 标签 全大写,下划线分割

注意: 上表中__常量__是指全局作用域、namespace域、类的静态成员域下,以 const或constexpr 修饰的基本数据类型、枚举、字符串类型的变量,不包括数组和其他类型变量。 上表中__变量__是指除常量定义以外的其他变量,均使用小驼峰风格。

文件命名

规则2.2.1 C++文件以.cpp结尾,头文件以.h结尾

我们推荐使用.h作为头文件的后缀,这样头文件可以直接兼容C和C++。 我们推荐使用.cpp作为实现文件的后缀,这样可以直接区分C++代码,而不是C代码。

目前业界还有一些其他的后缀的表示方法:

  • 头文件: .hh, .hpp, .hxx
  • cpp文件:.cc, .cxx, .c

如果当前项目组使用了某种特定的后缀,那么可以继续使用,但是请保持风格统一。 但是对于本文档,我们默认使用.h和.cpp作为后缀。

规则2.2.2 C++文件名和类名保持一致

C++的头文件和cpp文件名和类名保持一致,使用下划线小写风格。

如果有一个类叫DatabaseConnection,那么对应的文件名:

  • database_connection.h
  • database_connection.cpp

结构体,命名空间,枚举等定义的文件名类似。

函数命名

函数命名统一使用大驼峰风格,一般采用动词或者动宾结构。

class List {
public:
	void AddElement(const Element& element);
	Element GetElement(const unsigned int index) const;
	bool IsEmpty() const;
};

namespace Utils {
    void DeleteUser();
}

类型命名

类型命名采用大驼峰命名风格。 所有类型命名——类、结构体、联合体、类型定义(typedef)、枚举——使用相同约定,例如:

// classes, structs and unions
class UrlTable { ...
class UrlTableTester { ...
struct UrlTableProperties { ...
union Packet { ...

// typedefs
typedef std::map<std::string, UrlTableProperties*> PropertiesMap;

// enums
enum UrlTableErrors { ...

对于命名空间的命名,建议使用大驼峰:

// namespace
namespace OsUtils {
 
namespace FileUtils {
     
}
 
}

建议2.4.1 避免滥用 typedef或者#define 对基本类型起别名

除有明确的必要性,否则不要用 typedef/#define 对基本数据类型进行重定义。 优先使用<cstdint>头文件中的基本类型:

有符号类型 无符号类型 描述
int8_t uint8_t 宽度恰为8的有/无符号整数类型
int16_t uint16_t 宽度恰为16的有/无符号整数类型
int32_t uint32_t 宽度恰为32的有/无符号整数类型
int64_t uint64_t 宽度恰为64的有/无符号整数类型
intptr_t uintptr_t 足以保存指针的有/无符号整数类型

变量命名

通用变量命名采用小驼峰,包括全局变量,函数形参,局部变量,成员变量。

std::string tableName;  // Good: 推荐此风格
std::string tablename;  // Bad: 禁止此风格
std::string path;       // Good: 只有一个单词时,小驼峰为全小写

规则2.5.1 全局变量应增加 ‘g_’ 前缀,静态变量命名不需要加特殊前缀

全局变量是应当尽量少使用的,使用时应特别注意,所以加上前缀用于视觉上的突出,促使开发人员对这些变量的使用更加小心。

  • 全局静态变量命名与全局变量相同。
  • 函数内的静态变量命名与普通局部变量相同。
  • 类的静态成员变量和普通成员变量相同。
int g_activeConnectCount;

void Func()
{
    static int packetCount = 0; 
    ...
}

规则2.5.2 类的成员变量命名以小驼峰加后下划线组成

class Foo {
private:
    std::string fileName_;   // 添加_后缀,类似于K&R命名风格
};

对于struct/union的成员变量,仍采用小驼峰不加后缀的命名方式,与局部变量命名风格一致。

宏、常量、枚举命名

宏、枚举值采用全大写,下划线连接的格式。 全局作用域内,有名和匿名namespace内的 const 常量,类的静态成员常量,全大写,下划线连接;函数局部 const 常量和类的普通const成员变量,使用小驼峰命名风格。

#define MAX(a, b)   (((a) < (b)) ? (b) : (a)) // 仅对宏命名举例,并不推荐用宏实现此类功能

enum TintColor {    // 注意,枚举类型名用大驼峰,其下面的取值是全大写,下划线相连
    RED,
    DARK_RED,
    GREEN,
    LIGHT_GREEN
};

int Func(...)
{
    const unsigned int bufferSize = 100;    // 函数局部常量
    char *p = new char[bufferSize];
    ...
}

namespace Utils {
	const unsigned int DEFAULT_FILE_SIZE_KB = 200;        // 全局常量
}

3 格式

行宽

规则3.1.1 行宽不超过 120 个字符

建议每行字符数不要超过 120 个。如果超过120个字符,请选择合理的方式进行换行。

例外:

  • 如果一行注释包含了超过120 个字符的命令或URL,则可以保持一行,以方便复制、粘贴和通过grep查找;
  • 包含长路径的 #include 语句可以超出120 个字符,但是也需要尽量避免;
  • 编译预处理中的error信息可以超出一行。 预处理的 error 信息在一行便于阅读和理解,即使超过 120 个字符。
#ifndef XXX_YYY_ZZZ
#error Header aaaa/bbbb/cccc/abc.h must only be included after xxxx/yyyy/zzzz/xyz.h, because xxxxxxxxxxxxxxxxxxxxxxxxxxxxx
#endif

缩进

规则3.2.1 使用空格进行缩进,每次缩进4个空格

只允许使用空格(space)进行缩进,每次缩进为 4 个空格。不允许使用Tab符进行缩进。 当前几乎所有的集成开发环境(IDE)都支持配置将Tab符自动扩展为4空格输入;请配置你的IDE支持使用空格进行缩进。

大括号

规则3.3.1 使用 K&R 缩进风格

K&R风格 换行时,函数(不包括lambda表达式)左大括号另起一行放行首,并独占一行;其他左大括号跟随语句放行末。 右大括号独占一行,除非后面跟着同一语句的剩余部分,如 do 语句中的 while,或者 if 语句的 else/else if,或者逗号、分号。

如:

struct MyType {     // 跟随语句放行末,前置1空格
    ...
};

int Foo(int a)
{                   // 函数左大括号独占一行,放行首
    if (...) {
        ...
    } else {
        ...
    }
}

推荐这种风格的理由:

  • 代码更紧凑;
  • 相比另起一行,放行末使代码阅读节奏感上更连续;
  • 符合后来语言的习惯,符合业界主流习惯;
  • 现代集成开发环境(IDE)都具有代码缩进对齐显示的辅助功能,大括号放在行尾并不会对缩进和范围产生理解上的影响。

对于空函数体,可以将大括号放在同一行:

class MyClass {
public:
    MyClass() : value_(0) {}
   
private:
    int value_;
};

函数声明和定义

规则3.4.1 函数声明和定义的返回类型和函数名在同一行;函数参数列表超出行宽时要换行并合理对齐

在声明和定义函数的时候,函数的返回值类型应该和函数名在同一行;如果行宽度允许,函数参数也应该放在一行;否则,函数参数应该换行,并进行合理对齐。 参数列表的左圆括号总是和函数名在同一行,不要单独一行;右圆括号总是跟随最后一个参数。

换行举例:

ReturnType FunctionName(ArgType paramName1, ArgType paramName2)   // Good:全在同一行
{
    ...
}

ReturnType VeryVeryVeryLongFunctionName(ArgType paramName1,     // 行宽不满足所有参数,进行换行
                                        ArgType paramName2,     // Good:和上一行参数对齐
                                        ArgType paramName3)
{
    ...
}

ReturnType LongFunctionName(ArgType paramName1, ArgType paramName2, // 行宽限制,进行换行
    ArgType paramName3, ArgType paramName4, ArgType paramName5)     // Good: 换行后 4 空格缩进
{
    ...
}

ReturnType ReallyReallyReallyReallyLongFunctionName(            // 行宽不满足第1个参数,直接换行
    ArgType paramName1, ArgType paramName2, ArgType paramName3) // Good: 换行后 4 空格缩进
{
    ...
}

函数调用

规则3.5.1 函数调用入参列表应放在一行,超出行宽换行时,保持参数进行合理对齐

函数调用时,函数参数列表放在一行。参数列表如果超过行宽,需要换行并进行合理的参数对齐。 左圆括号总是跟函数名,右圆括号总是跟最后一个参数。

换行举例:

ReturnType result = FunctionName(paramName1, paramName2);   // Good:函数参数放在一行

ReturnType result = FunctionName(paramName1,
                                 paramName2,                // Good:保持与上方参数对齐
                                 paramName3);

ReturnType result = FunctionName(paramName1, paramName2,
    paramName3, paramName4, paramName5);                    // Good:参数换行,4 空格缩进

ReturnType result = VeryVeryVeryLongFunctionName(           // 行宽不满足第1个参数,直接换行
    paramName1, paramName2, paramName3);                    // 换行后,4 空格缩进

如果函数调用的参数存在内在关联性,按照可理解性优先于格式排版要求,对参数进行合理分组换行。

// Good:每行的参数代表一组相关性较强的数据结构,放在一行便于理解
int result = DealWithStructureLikeParams(left.x, left.y,     // 表示一组相关参数
                                         right.x, right.y);  // 表示另外一组相关参数

if语句

规则3.6.1 if语句必须要使用大括号

我们要求if语句都需要使用大括号,即便只有一条语句。

理由:

  • 代码逻辑直观,易读;
  • 在已有条件语句代码上增加新代码时不容易出错;
  • 对于在if语句中使用函数式宏时,有大括号保护不易出错(如果宏定义时遗漏了大括号)。
if (objectIsNotExist) {         // Good:单行条件语句也加大括号
    return CreateNewObject();
}

规则3.6.2 禁止 if/else/else if 写在同一行

条件语句中,若有多个分支,应该写在不同行。

如下是正确的写法:

if (someConditions) {
    DoSomething();
    ...
} else {  // Good: else 与 if 在不同行
    ...
}

下面是不符合规范的案例:

if (someConditions) { ... } else { ... } // Bad: else 与 if 在同一行

循环语句

规则3.7.1 循环语句必须使用大括号

和条件表达式类似,我们要求for/while循环语句必须加上大括号,即便循环体是空的,或循环语句只有一条。

for (int i = 0; i < someRange; i++) {   // Good: 使用了大括号
    DoSomething();
}
while (condition) { }   // Good:循环体是空,使用大括号
while (condition) {
    continue;           // Good:continue 表示空逻辑,使用大括号
}

坏的例子:

for (int i = 0; i < someRange; i++)
    DoSomething();      // Bad: 应该加上括号
while (condition);      // Bad:使用分号容易让人误解是while语句中的一部分

switch语句

规则3.8.1 switch 语句的 case/default 要缩进一层

switch 语句的缩进风格如下:

switch (var) {
    case 0:             // Good: 缩进
        DoSomething1(); // Good: 缩进
        break;
    case 1: {           // Good: 带大括号格式
        DoSomething2();
        break;
    }
    default:
        break;
}
switch (var) {
case 0:                 // Bad: case 未缩进
    DoSomething();
    break;
default:                // Bad: default 未缩进
    break;
}

表达式

建议3.9.1 表达式换行要保持换行的一致性,运算符放行末

较长的表达式,不满足行宽要求的时候,需要在适当的地方换行。一般在较低优先级运算符或连接符后面截断,运算符或连接符放在行末。 运算符、连接符放在行末,表示“未结束,后续还有”。 例:

// 假设下面第一行已经不满足行宽要求

if ((currentValue > threshold) &&  // Good:换行后,逻辑操作符放在行尾
    someCondition) {
    DoSomething();
    ...
}

int result = reallyReallyLongVariableName1 +    // Good
             reallyReallyLongVariableName2;

表达式换行后,注意保持合理对齐,或者4空格缩进。参考下面例子

int sum = longVariableName1 + longVariableName2 + longVariableName3 +
    longVariableName4 + longVariableName5 + longVariableName6;         // Good: 4空格缩进

int sum = longVariableName1 + longVariableName2 + longVariableName3 +
          longVariableName4 + longVariableName5 + longVariableName6;   // Good: 保持对齐

变量赋值

规则3.10.1 多个变量定义和赋值语句不允许写在一行

每行只有一个变量初始化的语句,更容易阅读和理解。

int maxCount = 10;
bool isCompleted = false;

下面是不符合规范的示例:

int maxCount = 10; bool isCompleted = false; // Bad:多个变量初始化需要分开放在多行,每行一个变量初始化
int x, y = 0;  // Bad:多个变量定义需要分行,每行一个

int pointX;
int pointY;
...
pointX = 1; pointY = 2;  // Bad:多个变量赋值语句放同一行

例外:for 循环头、if 初始化语句(C++17)、结构化绑定语句(C++17)中可以声明和初始化多个变量。这些语句中的多个变量声明有较强关联,如果强行分成多行会带来作用域不一致,声明和初始化割裂等问题。

初始化

初始化包括结构体、联合体、及数组的初始化

规则3.11.1 初始化换行时要有缩进,并进行合理对齐

结构体或数组初始化时,如果换行应保持4空格缩进。 从可读性角度出发,选择换行点和对齐位置。

const int rank[] = {
    16, 16, 16, 16, 32, 32, 32, 32,
    64, 64, 64, 64, 32, 32, 32, 32
};

指针与引用

建议3.12.1 指针类型"*“跟随变量名或者类型,不要两边都留有或者都没有空格

指针命名: *靠左靠右都可以,但是不要两边都有或者都没有空格。

int* p = nullptr;  // Good
int *p = nullptr;  // Good

int*p = nullptr;   // Bad
int * p = nullptr; // Bad

例外:当变量被 const 修饰时,"*” 无法跟随变量,此时也不要跟随类型。

const char * const VERSION = "V100";

建议3.12.2 引用类型"&“跟随变量名或者类型,不要两边都留有或者都没有空格

引用命名:&靠左靠右都可以,但是不要两边都有或者都没有空格。

int i = 8;

int& p = i;     // Good
int &p = i;     // Good
int*& rp = pi;  // Good,指针的引用,*& 一起跟随类型
int *&rp = pi;  // Good,指针的引用,*& 一起跟随变量名
int* &rp = pi;  // Good,指针的引用,* 跟随类型,& 跟随变量名

int & p = i;    // Bad
int&p = i;      // Bad

编译预处理

规则3.13.1 编译预处理的”#“统一放在行首,嵌套编译预处理语句时,”#“可以进行缩进

编译预处理的”#“统一放在行首,即使编译预处理的代码是嵌入在函数体中的,”#“也应该放在行首。

规则3.13.2 避免使用宏

宏会忽略作用域,类型系统以及各种规则,容易引发问题。应尽量避免使用宏定义,如果必须使用宏,要保证证宏名的唯一性。 在C++中,有许多方式来避免使用宏:

  • 用const或enum定义易于理解的常量
  • 用namespace避免名字冲突
  • 用inline函数避免函数调用的开销
  • 用template函数来处理多种类型

在文件头保护宏、条件编译、日志记录等必要场景中可以使用宏。

规则3.13.3 禁止使用宏来表示常量

宏是简单的文本替换,在预处理阶段完成,运行报错时直接报相应的值;跟踪调试时也是显示值,而不是宏名; 宏没有类型检查,不安全; 宏没有作用域。

规则3.13.4 禁止使用函数式宏

宏义函数式宏前,应考虑能否用函数替代。对于可替代场景,建议用函数替代宏。 函数式宏的缺点如下:

  • 函数式宏缺乏类型检查,不如函数调用检查严格
  • 宏展开时宏参数不求值,可能会产生非预期结果
  • 宏没有独立的作用域
  • 宏的技巧性太强,例如#的用法和无处不在的括号,影响可读性
  • 在特定场景中必须用编译器对宏的扩展语法,如GCC的statement expression,影响可移植性
  • 宏在预编译阶段展开后,在期后编译、链接和调试时都不可见;而且包含多行的宏会展开为一行。函数式宏难以调试、难以打断点,不利于定位问题
  • 对于包含大量语句的宏,在每个调用点都要展开。如果调用点很多,会造成代码空间的膨胀

函数没有宏的上述缺点。但是,函数相比宏,最大的劣势是执行效率不高(增加函数调用的开销和编译器优化的难度)。 为此,可以在必要时使用内联函数。内联函数跟宏类似,也是在调用点展开。不同之处在于内联函数是在编译时展开。

内联函数兼具函数和宏的优点:

  • 内联函数执行严格的类型检查
  • 内联函数的参数求值只会进行一次
  • 内联函数就地展开,没有函数调用的开销
  • 内联函数比函数优化得更好

对于性能要求高的产品代码,可以考虑用内联函数代替函数。

例外: 在日志记录场景中,需要通过函数式宏保持调用点的文件名(FILE)、行号(LINE)等信息。

空格和空行

规则3.14.1 水平空格应该突出关键字和重要信息,避免不必要的留白

水平空格应该突出关键字和重要信息,每行代码尾部不要加空格。总体规则如下:

  • if, switch, case, do, while, for等关键字之后加空格;
  • 小括号内部的两侧,不要加空格;
  • 大括号内部两侧有无空格,左右必须保持一致;
  • 一元操作符(& * + ‐ ~ !)之后不要加空格;
  • 二元操作符(= + ‐ < > * / % | & ^ <= >= == != )左右两侧加空格
  • 三目运算符(? :)符号两侧均需要空格
  • 前置和后置的自增、自减(++ –)和变量之间不加空格
  • 结构体成员操作符(. ->)前后不加空格
  • 逗号(,)前面不加空格,后面增加空格
  • 对于模板和类型转换(<>)和类型之间不要添加空格
  • 域操作符(::)前后不要添加空格
  • 冒号(:)前后根据情况来判断是否要添加空格

常规情况:

void Foo(int b) {  // Good:大括号前应该留空格

int i = 0;  // Good:变量初始化时,=前后应该有空格,分号前面不要留空格

int buf[BUF_SIZE] = {0};    // Good:大括号内两侧都无空格

函数定义和函数调用:

int result = Foo(arg1,arg2);
                    ^    // Bad: 逗号后面需要增加空格

int result = Foo( arg1, arg2 );
                 ^          ^  // Bad: 函数参数列表的左括号后面不应该有空格,右括号前面不应该有空格

指针和取地址

x = *p;     // Good:*操作符和指针p之间不加空格
p = &x;     // Good:&操作符和变量x之间不加空格
x = r.y;    // Good:通过.访问成员变量时不加空格
x = r->y;   // Good:通过->访问成员变量时不加空格

操作符:

x = 0;   // Good:赋值操作的=前后都要加空格
x = -5;  // Good:负数的符号和数值之前不要加空格
++x;     // Good:前置和后置的++/--和变量之间不要加空格
x--;

if (x && !y)  // Good:布尔操作符前后要加上空格,!操作和变量之间不要空格
v = w * x + y / z;  // Good:二元操作符前后要加空格
v = w * (x + z);    // Good:括号内的表达式前后不需要加空格

int a = (x < y) ? x : y;  // Good: 三目运算符, ?和:前后需要添加空格

循环和条件语句:

if (condition) {  // Good:if关键字和括号之间加空格,括号内条件语句前后不加空格
    ...
} else {           // Good:else关键字和大括号之间加空格
    ...
}

while (condition) {}   // Good:while关键字和括号之间加空格,括号内条件语句前后不加空格

for (int i = 0; i < someRange; ++i) {  // Good:for关键字和括号之间加空格,分号之后加空格
    ...
}

switch (condition) {  // Good: switch 关键字后面有1空格
    case 0:     // Good:case语句条件和冒号之间不加空格
        ...
        break;
    ...
    default:
        ...
        break;
}

模板和转换

// 尖括号(< and >) 不与空格紧邻, < 前没有空格, > 和 ( 之间也没有.
vector<string> x;
y = static_cast<char*>(x);

// 在类型与指针操作符之间留空格也可以, 但要保持一致.
vector<char *> x;

域操作符

std::cout;    // Good: 命名空间访问,不要留空格

int MyClass::GetValue() const {}  // Good: 对于成员函数定义,不要留空格

冒号

// 添加空格的场景

// Good: 类的派生需要留有空格
class Sub : public Base {
   
};

// 构造函数初始化列表需要留有空格
MyClass::MyClass(int var) : someVar_(var)
{
    DoSomething();
}

// 位域表示也留有空格
struct XX {
    char a : 4;    
    char b : 5;    
    char c : 4;
};
// 不添加空格的场景

// Good: 对于public:, private:这种类访问权限的冒号不用添加空格
class MyClass {
public:
    MyClass(int var);
private:
    int someVar_;
};

// 对于switch-case的case和default后面的冒号不用添加空格
switch (value)
{
    case 1:
        DoSomething();
        break;
    default:
        break;
}

注意:当前的集成开发环境(IDE)可以设置删除行尾的空格,请正确配置。

建议3.14.1 合理安排空行,保持代码紧凑

减少不必要的空行,可以显示更多的代码,方便代码阅读。下面有一些建议遵守的规则:

  • 根据上下内容的相关程度,合理安排空行;
  • 函数内部、类型定义内部、宏内部、初始化表达式内部,不使用连续空行
  • 不使用连续 3 个空行,或更多
  • 大括号内的代码块行首之前和行尾之后不要加空行,但namespace的大括号内不作要求。
int Foo()
{
    ...
}



int Bar()  // Bad:最多使用连续2个空行。
{
    ...
}


if (...) {
        // Bad:大括号内的代码块行首不要加入空行
    ...
        // Bad:大括号内的代码块行尾不要加入空行
}

int Foo(...)
{
        // Bad:函数体内行首不要加空行
    ...
}

规则3.15.1 类访问控制块的声明依次序是 public:, protected:, private:,缩进和 class 关键字对齐

class MyClass : public BaseClass {
public:      // 注意没有缩进
    MyClass();  // 标准的4空格缩进
    explicit MyClass(int var);
    ~MyClass() {}

    void SomeFunction();
    void SomeFunctionThatDoesNothing()
    {
    }

    void SetVar(int var) { someVar_ = var; }
    int GetVar() const { return someVar_; }

private:
    bool SomeInternalFunction();

    int someVar_;
    int someOtherVar_;
};

在各个部分中,建议将类似的声明放在一起, 并且建议以如下的顺序: 类型 (包括 typedef, using 和嵌套的结构体与类), 常量, 工厂函数, 构造函数, 赋值运算符, 析构函数, 其它成员函数, 数据成员。

规则3.15.2 构造函数初始化列表放在同一行或按四格缩进并排多行

// 如果所有变量能放在同一行:
MyClass::MyClass(int var) : someVar_(var)
{
    DoSomething();
}

// 如果不能放在同一行,
// 必须置于冒号后, 并缩进4个空格
MyClass::MyClass(int var)
    : someVar_(var), someOtherVar_(var + 1)  // Good: 逗号后面留有空格
{
    DoSomething();
}

// 如果初始化列表需要置于多行, 需要逐行对齐
MyClass::MyClass(int var)
    : someVar_(var),             // 缩进4个空格
      someOtherVar_(var + 1)
{ 
    DoSomething();
}

4 注释

一般的,尽量通过清晰的架构逻辑,好的符号命名来提高代码可读性;需要的时候,才辅以注释说明。 注释是为了帮助阅读者快速读懂代码,所以要从读者的角度出发,按需注释

注释内容要简洁、明了、无二义性,信息全面且不冗余。

注释跟代码一样重要。 写注释时要换位思考,用注释去表达此时读者真正需要的信息。在代码的功能、意图层次上进行注释,即注释解释代码难以表达的意图,不要重复代码信息。 修改代码时,也要保证其相关注释的一致性。只改代码,不改注释是一种不文明行为,破坏了代码与注释的一致性,让阅读者迷惑、费解,甚至误解。

使用英文进行注释。

注释风格

在 C++ 代码中,使用 /* */// 都是可以的。 按注释的目的和位置,注释可分为不同的类型,如文件头注释、函数头注释、代码注释等等; 同一类型的注释应该保持统一的风格。

注意:本文示例代码中,大量使用 ‘//’ 后置注释只是为了更精确的描述问题,并不代表这种注释风格更好。

文件头注释

规则3.1 文件头注释必须包含版权许可

/*

  • Copyright (c) 2020 XXX
  • Licensed under the Apache License, Version 2.0 (the “License”);
  • you may not use this file except in compliance with the License.
  • You may obtain a copy of the License at *
  • http://www.apache.org/licenses/LICENSE-2.0
    

*

  • Unless required by applicable law or agreed to in writing, software
  • distributed under the License is distributed on an “AS IS” BASIS,
  • WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  • See the License for the specific language governing permissions and
  • limitations under the License. */

函数头注释

规则4.3.1 公有(public)函数必须编写函数头注释

公有函数属于类对外提供的接口,调用者需要了解函数的功能、参数的取值范围、返回的结果、注意事项等信息才能正常使用。 特别是参数的取值范围、返回的结果、注意事项等都无法做到自注示,需要编写函数头注释辅助说明。

规则4.3.2 禁止空有格式的函数头注释

并不是所有的函数都需要函数头注释; 函数签名无法表达的信息,加函数头注释辅助说明;

函数头注释统一放在函数声明或定义上方,使用如下风格之一: 使用//写函数头

// 单行函数头
int Func1(void);

// 多行函数头
// 第二行
int Func2(void);

使用/* */写函数头

/* 单行函数头 */
int Func1(void);

/*
 * 另一种单行函数头
 */
int Func2(void);

/*
 * 多行函数头
 * 第二行
 */
int Func3(void);

函数尽量通过函数名自注释,按需写函数头注释。 不要写无用、信息冗余的函数头;不要写空有格式的函数头。

函数头注释内容可选,但不限于:功能说明、返回值,性能约束、用法、内存约定、算法实现、可重入的要求等等。 模块对外头文件中的函数接口声明,其函数头注释,应当将重要、有用的信息表达清楚。

例:

/*
 * 返回实际写入的字节数,-1表示写入失败
 * 注意,内存 buf 由调用者负责释放
 */
int WriteString(const char *buf, int len);

坏的例子:

/*
 * 函数名:WriteString
 * 功能:写入字符串
 * 参数:
 * 返回值:
 */
int WriteString(const char *buf, int len);

上面例子中的问题:

  • 参数、返回值,空有格式没内容
  • 函数名信息冗余
  • 关键的 buf 由谁释放没有说清楚

代码注释

规则4.4.1 代码注释放于对应代码的上方或右边

规则4.4.2 注释符与注释内容间要有1空格;右置注释与前面代码至少1空格

代码上方的注释,应该保持对应代码一样的缩进。 选择并统一使用如下风格之一: 使用//


// 这是单行注释
DoSomething();

// 这是多行注释
// 第二行
DoSomething();

使用/*' '*/

/* 这是单行注释 */
DoSomething();

/*
 * 另一种方式的多行注释
 * 第二行
 */
DoSomething();

代码右边的注释,与代码之间,至少留1空格,建议不超过4空格。 通常使用扩展后的 TAB 键即可实现 1-4 空格的缩进。

选择并统一使用如下风格之一:

int foo = 100;  // 放右边的注释
int bar = 200;  /* 放右边的注释 */

右置格式在适当的时候,上下对齐会更美观。 对齐后的注释,离左边代码最近的那一行,保证1-4空格的间隔。 例:

const int A_CONST = 100;         /* 相关的同类注释,可以考虑上下对齐 */
const int ANOTHER_CONST = 200;   /* 上下对齐时,与左侧代码保持间隔 */

当右置的注释超过行宽时,请考虑将注释置于代码上方。

规则4.4.3 不用的代码段直接删除,不要注释掉

被注释掉的代码,无法被正常维护;当企图恢复使用这段代码时,极有可能引入易被忽略的缺陷。 正确的做法是,不需要的代码直接删除掉。若再需要时,考虑移植或重写这段代码。

这里说的注释掉代码,包括用 /* */ 和 //,还包括 #if 0, #ifdef NEVER_DEFINED 等等。

5 头文件

头文件职责

头文件是模块或文件的对外接口,头文件的设计体现了大部分的系统设计。 头文件中适合放置接口的声明,不适合放置实现(内联函数除外)。对于cpp文件中内部才需要使用的函数、宏、枚举、结构定义等不要放在头文件中。 头文件应当职责单一。头文件过于复杂,依赖过于复杂还是导致编译时间过长的主要原因。

建议5.1.1 每一个.cpp文件应有一个对应的.h文件,用于声明需要对外公开的类与接口

通常情况下,每个.cpp文件都有一个相应的.h,用于放置对外提供的函数声明、宏定义、类型定义等。 如果一个.cpp文件不需要对外公布任何接口,则其就不应当存在。 例外:程序的入口(如main函数所在的文件),单元测试代码,动态库代码。

示例:

// Foo.h

#ifndef FOO_H
#define FOO_H

class Foo {
public:
    Foo();
    void Fun();
   
private:
    int value_;
};

#endif
// Foo.cpp
#include "Foo.h"

namespace { // Good: 对内函数的声明放在.cpp文件的头部,并声明为匿名namespace或者static限制其作用域
    void Bar()
    {
    }
}

...

void Foo::Fun()
{
    Bar();
}

头文件依赖

规则5.2.1 禁止头文件循环依赖

头文件循环依赖,指 a.h 包含 b.h,b.h 包含 c.h,c.h 包含 a.h, 导致任何一个头文件修改,都导致所有包含了a.h/b.h/c.h的代码全部重新编译一遍。 而如果是单向依赖,如a.h包含b.h,b.h包含c.h,而c.h不包含任何头文件,则修改a.h不会导致包含了b.h/c.h的源代码重新编译。

头文件循环依赖直接体现了架构设计上的不合理,可通过优化架构去避免。

规则5.2.2 头文件必须编写#define保护,防止重复包含

为防止头文件被重复包含,所有头文件都应当使用 #define 保护;不要使用 #pragma once

定义包含保护符时,应该遵守如下规则: 1)保护符使用唯一名称; 2)不要在受保护部分的前后放置代码或者注释,文件头注释除外。

示例:假定timer模块的timer.h,其目录为timer/include/timer.h,应按如下方式保护:

#ifndef TIMER_INCLUDE_TIMER_H
#define TIMER_INCLUDE_TIMER_H
...
#endif

规则5.2.3 禁止通过声明的方式引用外部函数接口、变量

只能通过包含头文件的方式使用其他模块或文件提供的接口。 通过 extern 声明的方式使用外部函数接口、变量,容易在外部接口改变时可能导致声明和定义不一致。 同时这种隐式依赖,容易导致架构腐化。

不符合规范的案例:

// a.cpp内容

extern int Fun();   // Bad: 通过extern的方式使用外部函数

void Bar()
{
    int i = Fun();
    ...
}

// b.cpp内容

int Fun()
{
    // Do something
}

应该改为:

// a.cpp内容

#include "b.h"   // Good: 通过包含头文件的方式使用其他.cpp提供的接口

void Bar()
{
    int i = Fun();
    ...
}

// b.h内容

int Fun();

// b.cpp内容

int Fun()
{
    // Do something
}

例外,有些场景需要引用其内部函数,但并不想侵入代码时,可以 extern 声明方式引用。 如: 针对某一内部函数进行单元测试时,可以通过 extern 声明来引用被测函数; 当需要对某一函数进行打桩、打补丁处理时,允许 extern 声明该函数。

规则5.2.4 禁止在extern “C"中包含头文件

在 extern “C” 中包含头文件,有可能会导致 extern “C” 嵌套,部分编译器对 extern “C” 嵌套层次有限制,嵌套层次太多会编译错误。

在C,C++混合编程的情况下,在extern “C"中包含头文件,可能会导致被包含头文件的原有意图遭到破坏,比如链接规范被不正确地更改。

示例,存在a.h和b.h两个头文件:

// a.h内容

...
#ifdef __cplusplus
void Foo(int);
#define A(value) Foo(value)
#else
void A(int)
#endif

// b.h内容

...
#ifdef __cplusplus
extern "C" {
#endif

#include "a.h"
void B();

#ifdef __cplusplus
}
#endif

使用C++预处理器展开b.h,将会得到

extern "C" {
    void Foo(int);
    void B();
}

按照 a.h 作者的本意,函数 Foo 是一个 C++ 自由函数,其链接规范为 “C++"。 但在 b.h 中,由于 #include "a.h" 被放到了 extern "C" 的内部,函数 Foo 的链接规范被不正确地更改了。

例外: 如果在 C++ 编译环境中,想引用纯C的头文件,这些C头文件并没有 extern "C" 修饰。非侵入式的做法是,在 extern "C" 中去包含C头文件。

建议5.2.1尽量避免使用前置声明,而是通过#include来包含头文件

前置声明(forward declaration)通常指类、模板的纯粹声明,没伴随着其定义。

  • 优点:
    1. 前置声明能够节省编译时间,多余的 #include 会迫使编译器展开更多的文件,处理更多的输入。
    2. 前置声明能够节省不必要的重新编译的时间。 #include 使代码因为头文件中无关的改动而被重新编译多次。
  • 缺点:
    1. 前置声明隐藏了依赖关系,头文件改动时,用户的代码会跳过必要的重新编译过程。
    2. 前置声明可能会被库的后续更改所破坏。前置声明模板有时会妨碍头文件开发者变动其 API. 例如扩大形参类型,加个自带默认参数的模板形参等等。
    3. 前置声明来自命名空间 std:: 的 symbol 时,其行为未定义(在C++11标准规范中明确说明)。
    4. 前置声明了不少来自头文件的 symbol 时,就会比单单一行的 include 冗长。
    5. 仅仅为了能前置声明而重构代码(比如用指针成员代替对象成员)会使代码变得更慢更复杂。
    6. 很难判断什么时候该用前置声明,什么时候该用#include,某些场景下面前置声明和#include互换以后会导致意想不到的结果。

所以我们尽可能避免使用前置声明,而是使用#include头文件来保证依赖关系。

6 作用域

命名空间

建议6.1.1 对于cpp文件中不需要导出的变量,常量或者函数,请使用匿名namespace封装或者用static修饰

在C++ 2003标准规范中,使用static修饰文件作用域的变量,函数等被标记为deprecated特性,所以更推荐使用匿名namespace。

主要原因如下:

  1. static在C++中已经赋予了太多的含义,静态函数成员变量,静态成员函数,静态全局变量,静态函数局部变量,每一种都有特殊的处理。
  2. static只能保证变量,常量和函数的文件作用域,但是namespace还可以封装类型等。
  3. 统一namespace来处理C++的作用域,而不需要同时使用static和namespace来管理。
  4. static修饰的函数不能用来实例化模板,而匿名namespace可以。

但是不要在 .h 中使用中使用匿名namespace或者static。

// Foo.cpp

namespace {
    const int MAX_COUNT = 20;
    void InternalFun() {};
}

void Foo::Fun()
{
    int i = MAX_COUNT;
   
    InternalFun();
}

规则6.1.1 不要在头文件中或者#include之前使用using导入命名空间

说明:使用using导入命名空间会影响后续代码,易造成符号冲突,所以不要在头文件以及源文件中的#include之前使用using导入命名空间。 示例:

// 头文件a.h
namespace NamespaceA {
    int Fun(int);
}
// 头文件b.h
namespace NamespaceB {
    int Fun(int);
}

using namespace NamespaceB;

void G()
{
    Fun(1);
}
// 源代码a.cpp
#include "a.h"
using namespace NamespaceA;
#include "b.h"

void main()
{
    G(); // using namespace NamespaceA在#include “b.h”之前,引发歧义:NamespaceA::Fun,NamespaceB::Fun调用不明确
}

对于在头文件中使用using导入单个符号或定义别名,允许在模块自定义名字空间中使用,但禁止在全局名字空间中使用。

// foo.h

#include <fancy/string>
using fancy::string;  // Bad,禁止向全局名字空间导入符号

namespace Foo {
    using fancy::string;  // Good,可以在模块自定义名字空间中导入符号
    using MyVector = fancy::vector<int>;  // Good,C++11可在自定义名字空间中定义别名
}

全局函数和静态成员函数

建议6.2.1 优先使用命名空间来管理全局函数,如果和某个class有直接关系的,可以使用静态成员函数

说明:非成员函数放在名字空间内可避免污染全局作用域, 也不要用类+静态成员方法来简单管理全局函数。 如果某个全局函数和某个类有紧密联系, 那么可以作为类的静态成员函数。

如果你需要定义一些全局函数,给某个cpp文件使用,那么请使用匿名namespace来管理。

namespace MyNamespace {
    int Add(int a, int b);
}

class File {
public:
    static File CreateTempFile(const std::string& fileName);
};

全局常量和静态成员常量

建议6.3.1 优先使用命名空间来管理全局常量,如果和某个class有直接关系的,可以使用静态成员常量

说明:全局常量放在命名空间内可避免污染全局作用域, 也不要用类+静态成员常量来简单管理全局常量。 如果某个全局常量和某个类有紧密联系, 那么可以作为类的静态成员常量。

如果你需要定义一些全局常量,只给某个cpp文件使用,那么请使用匿名namespace来管理。

namespace MyNamespace {
    const int MAX_SIZE = 100;
}

class File {
public:
    static const std::string SEPARATOR;
};

全局变量

建议6.4.1 尽量避免使用全局变量,考虑使用单例模式

说明:全局变量是可以修改和读取的,那么这样会导致业务代码和这个全局变量产生数据耦合。

int g_counter = 0;

// a.cpp
g_counter++;

// b.cpp
g_counter++;

// c.cpp
cout << g_counter << endl;

使用单实例模式

class Counter {
public:
    static Counter& GetInstance()
    {
        static Counter counter;
        return counter;
    }  // 单实例实现简单举例
   
    void Increase()
    {
        value_++;
    }
   
    void Print() const
    {
        std::cout << value_ << std::endl;
    }

private:
    Counter() : value_(0) {}

private:
    int value_;
};

// a.cpp
Counter::GetInstance().Increase();

// b.cpp
Counter::GetInstance().Increase();

// c.cpp
Counter::GetInstance().Print();

实现单例模式以后,实现了全局唯一一个实例,和全局变量同样的效果,并且单实例提供了更好的封装性。

例外:有的时候全局变量的作用域仅仅是模块内部,这样进程空间里面就会有多个全局变量实例,每个模块持有一份,这种场景下是无法使用单例模式解决的。

7 类

构造,拷贝构造,赋值和析构函数

构造,拷贝,移动和析构函数提供了对象的生命周期管理方法:

  • 构造函数(constructor): X()
  • 拷贝构造函数(copy constructor):X(const X&)
  • 拷贝赋值操作符(copy assignment):operator=(const X&)
  • 移动构造函数(move constructor):X(X&&) C++11以后提供
  • 移动赋值操作符(move assignment):operator=(X&&) C++11以后提供
  • 析构函数(destructor):~X()

规则7.1.1 类的成员变量必须显式初始化

说明:如果类有成员变量,没有定义构造函数,又没有定义默认构造函数,编译器将自动生成一个构造函数,但编译器生成的构造函数并不会对成员变量进行初始化,对象状态处于一种不确定性。

例外:

  • 如果类的成员变量具有默认构造函数,那么可以不需要显式初始化。

示例:如下代码没有构造函数,私有数据成员无法初始化:

class Message {
public:
    void ProcessOutMsg()
    {
        //…
    }

private:
    unsigned int msgID_;
    unsigned int msgLength_;
    unsigned char* msgBuffer_;
    std::string someIdentifier_;
};

Message message;   // message成员变量没有初始化
message.ProcessOutMsg();   // 后续使用存在隐患

// 因此,有必要定义默认构造函数,如下:
class Message {
public:
    Message() : msgID_(0), msgLength_(0), msgBuffer_(nullptr)
    {
    }

    void ProcessOutMsg()
    {
        // …
    }

private:
    unsigned int msgID_;
    unsigned int msgLength_;
    unsigned char* msgBuffer_;
    std::string someIdentifier_; // 具有默认构造函数,不需要显式初始化
};

建议7.1.1 成员变量优先使用声明时初始化(C++11)和构造函数初始化列表初始化

说明:C++11的声明时初始化可以一目了然的看出成员初始值,应当优先使用。如果成员初始化值和构造函数相关,或者不支持C++11,则应当优先使用构造函数初始化列表来初始化成员。相比起在构造函数体中对成员赋值,初始化列表的代码更简洁,执行性能更好,而且可以对const成员和引用成员初始化。

class Message {
public:
    Message() : msgLength_(0)  // Good,优先使用初始化列表
    {
        msgBuffer_ = nullptr;  // Bad,不推荐在构造函数中赋值
    }
   
private:
    unsigned int msgID_{0};  // Good,C++11中使用
    unsigned int msgLength_;
    unsigned char* msgBuffer_;
};

规则7.1.2 为避免隐式转换,将单参数构造函数声明为explicit

说明:单参数构造函数如果没有用explicit声明,则会成为隐式转换函数。 示例:

class Foo {
public:
    explicit Foo(const string& name): name_(name)
    {
    }
private:
    string name_;
};


void ProcessFoo(const Foo& foo){}

int main(void)
{
    std::string test = "test";
    ProcessFoo(test);  // 编译不通过
    return 0;
}

上面的代码编译不通过,因为ProcessFoo需要的参数是Foo类型,传入的string类型不匹配。

如果将Foo构造函数的explicit关键字移除,那么调用ProcessFoo传入的string就会触发隐式转换,生成一个临时的Foo对象。往往这种隐式转换是让人迷惑的,并且容易隐藏Bug,得到了一个不期望的类型转换。所以对于单参数的构造函数是要求explicit声明。

规则7.1.3 如果不需要拷贝构造函数、赋值操作符 / 移动构造函数、赋值操作符,请明确禁止

说明:如果用户不定义,编译器默认会生成拷贝构造函数和拷贝赋值操作符, 移动构造和移动赋值操作符(移动语义的函数C++11以后才有)。 如果我们不要使用拷贝构造函数,或者赋值操作符,请明确拒绝:

  1. 将拷贝构造函数或者赋值操作符设置为private,并且不实现:
class Foo {
private:
    Foo(const Foo&);
    Foo& operator=(const Foo&);
};
  1. 使用C++11提供的delete, 请参见后面现代C++的相关章节。

  2. 推荐继承NoCopyable、NoMovable,禁止使用DISALLOW_COPY_AND_MOVE,DISALLOW_COPY,DISALLOW_MOVE等宏。

class Foo : public NoCopyable, public NoMovable {
};

NoCopyable和NoMovable的实现:

class NoCopyable {
public:
    NoCopyable() = default;
    NoCopyable(const NoCopyable&) = delete;
    NoCopyable& operator = (NoCopyable&) = delete;
};

class NoMovable {
public:
    NoMovable() = default;
    NoMovable(NoMovable&&) noexcept = delete;
    NoMovable& operator = (NoMovable&&) noexcept = delete;
};

规则7.1.4 拷贝构造和拷贝赋值操作符应该是成对出现或者禁止

拷贝构造函数和拷贝赋值操作符都是具有拷贝语义的,应该同时出现或者禁止。

// 同时出现
class Foo {
public:
    ...
    Foo(const Foo&);
    Foo& operator=(const Foo&);
    ...
};

// 同时default, C++11支持
class Foo {
public:
    Foo(const Foo&) = default;
    Foo& operator=(const Foo&) = default;
};

// 同时禁止, C++11可以使用delete
class Foo {
private:
    Foo(const Foo&);
    Foo& operator=(const Foo&);
};

规则7.1.5 移动构造和移动赋值操作符应该是成对出现或者禁止

在C++11中增加了move操作,如果需要某个类支持移动操作,那么需要实现移动构造和移动赋值操作符。

移动构造函数和移动赋值操作符都是具有移动语义的,应该同时出现或者禁止。

// 同时出现
class Foo {
public:
    ...
    Foo(Foo&&);
    Foo& operator=(Foo&&);
    ...
};

// 同时default, C++11支持
class Foo {
public:
    Foo(Foo&&) = default;
    Foo& operator=(Foo&&) = default;
};

// 同时禁止, 使用C++11的delete
class Foo {
public:
    Foo(Foo&&) = delete;
    Foo& operator=(Foo&&) = delete;
};

规则7.1.6 禁止在构造函数和析构函数中调用虚函数

说明:在构造函数和析构函数中调用当前对象的虚函数,会导致未实现多态的行为。 在C++中,一个基类一次只构造一个完整的对象。

示例:类Base是基类,Sub是派生类

class Base {                      
public:               
    Base();
    virtual void Log() = 0;    // 不同的派生类调用不同的日志文件
};

Base::Base()         // 基类构造函数
{
    Log();           // 调用虚函数Log
}                                                 

class Sub : public Base {      
public:
    virtual void Log();         
};

当执行如下语句: Sub sub; 会先执行Sub的构造函数,但首先调用Base的构造函数,由于Base的构造函数调用虚函数Log,此时Log还是基类的版本,只有基类构造完成后,才会完成派生类的构造,从而导致未实现多态的行为。 同样的道理也适用于析构函数。

规则7.1.7 多态基类中的拷贝构造函数、拷贝赋值操作符、移动构造函数、移动赋值操作符必须为非public函数或者为delete函数

如果报一个派生类对象直接赋值给基类对象,会发生切片,只拷贝或者移动了基类部分,损害了多态行为。 【反例】 如下代码中,基类没有定义拷贝构造函数或拷贝赋值操作符,编译器会自动生成这两个特殊成员函数, 如果派生类对象赋值给基类对象时就发生切片。可以将此例中的拷贝构造函数和拷贝赋值操作符声明为delete,编译器可检查出此类赋值行为。

class Base {                      
public:               
    Base() = default;
    virtual ~Base() = default;
    ...
    virtual void Fun() { std::cout << "Base" << std::endl;}
};

class Derived : public Base {
    ...
    void Fun() override { std::cout << "Derived" << std::endl; }
};

void Foo(const Base &base)
{
    Base other = base; // 不符合:发生切片
    other.Fun(); // 调用的时Base类的Fun函数
}
Derived d;
Foo(d); // 传入的是派生类对象
  1. 将拷贝构造函数或者赋值操作符设置为private,并且不实现:

继承

规则7.2.1 基类的析构函数应该声明为virtual,不准备被继承的类需要声明为final

说明:只有基类析构函数是virtual,通过多态调用的时候才能保证派生类的析构函数被调用。

示例:基类的析构函数没有声明为virtual导致了内存泄漏。

class Base {
public:
```cpp
class Sub : public Base {
public:
    Sub() : numbers_(nullptr)
    { 
    }
   
    ~Sub()
    {
        delete[] numbers_;
        std::cout << "~Sub" << std::endl;
    }
   
    int Init()
    {
        const size_t numberCount = 100;
        numbers_ = new (std::nothrow) int[numberCount];
        if (numbers_ == nullptr) {
            return -1;
        }
       
        ...
    }

    std::string getVersion()
    {
        return std::string("hello!");
    }
private:
    int* numbers_;
};
int main(int argc, char* args[])
{
    Base* b = new Sub();

    delete b;
    return 0;
}

由於基類Base的析構函數沒有聲明為virtual,當對象被銷毀時,只會調用基類的析構函數,不會調用派生類Sub的析構函數,導致內存泄漏。 例外: NoCopyable、NoMovable這種沒有任何行為,僅僅用來做標識符的類,可以不定義虛析構也不定義final。

規則7.2.2 禁止虛函數使用缺省參數值

說明:在C++中,虛函數是動態綁定的,但函數的缺省參數卻是在編譯時就靜態綁定的。這意味著你最終執行的函數是一個定義在派生類,但使用了基類中的缺省參數值的虛函數。為了避免虛函數重載時,因參數聲明不一致給使用者帶來的困惑和由此導致的問題,規定所有虛函數均不允許聲明缺省參數值。 示例:虛函數display缺省參數值text是由編譯時刻決定的,而非運行時刻,沒有達到多態的目的:

class Base {
public:
    virtual void Display(const std::string& text = "Base!")
    {
        std::cout << text << std::endl;
    }
   
    virtual ~Base(){}
};

class Sub : public Base {
public:
    virtual void Display(const std::string& text  = "Sub!")
    {
        std::cout << text << std::endl;
    }
   
    virtual ~Sub(){}
};

int main()
{
    Base* base = new Sub();
    Sub* sub = new Sub();
  
    ...
   
    base->Display();  // 程序輸出結果: Base! 而期望輸出:Sub!
    sub->Display();   // 程序輸出結果: Sub!
   
    delete base;
    delete sub;
    return 0;
};

規則7.2.3 禁止重新定義繼承而來的非虛函數

說明:因為非虛函數無法實現動態綁定,只有虛函數才能實現動態綁定:只要操作基類的指針,即可獲得正確的結果。

示例:

class Base {
public:
    void Fun();
};

class Sub : public Base {
public:
    void Fun();
};

Sub* sub = new Sub();                    
Base* base = sub;

sub->Fun();    // 調用子類的Fun                 
base->Fun();   // 調用父類的Fun
//...

多重繼承

在實際開發過程中使用多重繼承的場景是比較少的,因為多重繼承使用過程中有下面的典型問題:

  1. 菱形繼承所帶來的數據重復,以及名字二義性。因此,C++引入了virtual繼承來解決這類問題;
  2. 即便不是菱形繼承,多個父類之間的名字也可能存在衝突,從而導致的二義性;
  3. 如果子類需要擴展或改寫多個父類的方法時,造成子類的職責不明,語義混亂;
  4. 相對於委托,繼承是一種白盒復用,即子類可以訪問父類的protected成員, 這會導致更強的耦合。而多重繼承,由於耦合了多個父類,相對於單根繼承,這會產生更強的耦合關係。

多重繼承具有下面的優點: 多重繼承提供了一種更簡單的組合來實現多種接口或者類的組裝與復用。

所以,對於多重繼承的只有下面幾種情況下面才允許使用多重繼承。

建議7.3.1 使用多重繼承來實現接口分離與多角色組合

如果某個類需要實現多重接口,可以通過多重繼承把多個分離的接口組合起來,類似 scala 語言的 traits 混入。

class Role1 {};
class Role2 {};
class Role3 {};

class Object1 : public Role1, public Role2 {
    // ...
};

class Object2 : public Role2, public Role3 {
    // ...
};

在C++標準庫中也有類似的實現樣例:

class basic_istream {};
class basic_ostream {};

class basic_iostream : public basic_istream, public basic_ostream {
 
};

重載

重載操作符要有充分理由,而且不要改變操作符原有語義,例如不要使用 ‘+’ 操作符來做減運算。 操作符重載令代碼更加直觀,但也有一些不足:

  • 混淆直覺,誤以為該操作和內建類型一樣是高性能的,忽略了性能降低的可能;
  • 問題定位時不夠直觀,按函數名查找比按操作符顯然更方便。
  • 重載操作符如果行為定義不直觀(例如將‘+’ 操作符來做減運算),會讓代碼產生混淆。
  • 賦值操作符的重載引入的隱式轉換會隱藏很深的bug。可以定義類似Equals()、CopyFrom()等函數來替代=,==操作符。

8 函數

函數設計

規則8.1.1 避免函數過長,函數不超過50行(非空非注釋)

函數應該可以一屏顯示完 (50行以內),只做一件事情,而且把它做好。

過長的函數往往意味著函數功能不單一,過於復雜,或過分呈現細節,未進行進一步抽象。

例外:某些實現算法的函數,由於算法的聚合性與功能的全面性,可能會超過50行。

即使一個長函數現在工作的非常好, 一旦有人對其修改, 有可能出現新的問題, 甚至導致難以發現的bug。 建議將其拆分為更加簡短並易於管理的若干函數,以便於他人閱讀和修改代碼。

內聯函數

建議8.2.1 內聯函數不超過10行(非空非注釋)

說明:內聯函數具有一般函數的特性,它與一般函數不同之處只在於函數調用的處理。一般函數進行調用時,要將程序執行權轉到被調用函數中,然後再返回到調用它的函數中;而內聯函數在調用時,是將調用表達式用內聯函數體來替換。

內聯函數只適合於只有 1~10 行的小函數。對一個含有許多語句的大函數,函數調用和返回的開銷相對來說微不足道,也沒有必要用內聯函數實現,一般的編譯器會放棄內聯方式,而采用普通的方式調用函數。

如果內聯函數包含復雜的控制結構,如循環、分支(switch)、try-catch 等語句,一般編譯器將該函數視同普通函數。 虛函數、遞歸函數不能被用來做內聯函數

函數參數

建議8.3.1 函數參數使用引用取代指針

說明:引用比指針更安全,因為它一定非空,且一定不再指向其他目標;引用不需要檢查非法的NULL指針。

如果是基於老平台開發的產品,則優先順從原有平台的處理方式。 選擇 const 避免參數被修改,讓代碼閱讀者清晰地知道該參數不被修改,可大大增強代碼可讀性。

例外:當傳入參數為編譯期長度未知的數組時,可以使用指針而不是引用。

建議8.3.2 使用強類型參數,避免使用void*

儘管不同的語言對待強類型和弱類型有自己的觀點,但是一般認為c/c++是強類型語言,既然我們使用的語言是強類型的,就應該保持這樣的風格。 好處是盡量讓編譯器在編譯階段就檢查出類型不匹配的問題。

使用強類型便於編譯器幫我們發現錯誤,如下代碼中注意函數 FooListAddNode 的使用:

struct FooNode {
    struct List link;
    int foo;
};

struct BarNode {
    struct List link;
    int bar;
}

void FooListAddNode(void *node) // Bad: 這裡用 void * 類型傳遞參數
{
    FooNode *foo = (FooNode *)node;
    ListAppend(&g_FooList, &foo->link);
}

void MakeTheList()
{
    FooNode *foo = nullptr;
    BarNode *bar = nullptr;
    ...

    FooListAddNode(bar);        // Wrong: 這裡本意是想傳遞參數 foo,但錯傳了 bar,卻沒有報錯
}
  1. 可以使用模板函數來實現參數類型的變化。
  2. 可以使用基類指針來實現多態。

建議8.3.3 函數的參數個數不超過5個

函數的參數過多,會使得該函數易於受外部變化的影响,從而影響維護工作。函數的參數過多同時也會增大測試的工作量。

如果超過可以考慮:

  • 看能否拆分函數
  • 看能否將相關參數合在一起,定義結構體

9 C++其他特性

常量與初始化

不變的值更易於理解、跟蹤和分析,所以應該盡可能地使用常量代替變量,定義值的時候,應該把const作為默認的選項。

規則9.1.1 不允許使用宏來表示常量

說明:宏是簡單的文本替換,在預處理階段時完成,運行報錯時直接報相應的值;跟蹤調試時也是顯示值,而不是宏名;宏沒有類型檢查,不安全;宏沒有作用域。

#define MAX_MSISDN_LEN 20    // 不好

// C++請使用const常量
const int MAX_MSISDN_LEN = 20; // 好

// 對於C++11以上版本,可以使用constexpr
constexpr int MAX_MSISDN_LEN = 20;

建議9.1.1 一組相關的整型常量應定義為枚舉

說明:枚舉比#defineconst int更安全。編譯器會檢查參數值是否位於枚舉取值範圍內,避免錯誤發生。

// 好的例子:
enum Week {
    SUNDAY,
    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY
};

enum Color {
    RED,
    BLACK,
    BLUE
};

void ColorizeCalendar(Week today, Color color);

ColorizeCalendar(BLUE, SUNDAY); // 編譯報錯,參數類型錯誤

// 不好的例子:
const int SUNDAY = 0;
const int MONDAY = 1;

const int BLACK  = 0;
const int BLUE   = 1;

bool ColorizeCalendar(int today, int color);
ColorizeCalendar(BLUE, SUNDAY); // 不會報錯

當枚舉值需要對應到具體數值時,須在聲明時顯式賦值。否則不需要顯式賦值,以避免重復賦值,降低維護(增加、刪除成員)工作量。

// 好的例子:S協議裡定義的設備ID值,用於標識設備類型
enum DeviceType {
    DEV_UNKNOWN = -1,
    DEV_DSMP = 0,
    DEV_ISMG = 1,
    DEV_WAPPORTAL = 2
};

程序內部使用,僅用於分類的情況,不應該進行顯式的賦值。

// 好的例子:程序中用來標識會話狀態的枚舉定義
enum SessionState {
    INIT,
    CLOSED,
    WAITING_FOR_RESPONSE
};

應當盡量避免枚舉值重復,如必須重復也要用已定義的枚舉來修飾

enum RTCPType {
    RTCP_SR = 200,
    RTCP_MIN_TYPE = RTCP_SR,       
    RTCP_RR    = 201,
    RTCP_SDES  = 202,
    RTCP_BYE   = 203,
    RTCP_APP   = 204,
    RTCP_RTPFB = 205,
    RTCP_PSFB  = 206,
    RTCP_XR  = 207,
    RTCP_RSI = 208,
    RTCP_PUBPORTS = 209,
    RTCP_MAX_TYPE = RTCP_PUBPORTS 
};

規則9.1.2 不允許使用魔鬼數字

所謂魔鬼數字即看不懂、難以理解的數字。

魔鬼數字並非一個非黑即白的概念,看不懂也有程度,需要自行判斷。 例如數字 12,在不同的上下文中情況是不一樣的: type = 12; 就看不懂,但 monthsCount = yearsCount * 12; 就能看懂。 數字 0 有時候也是魔鬼數字,比如 status = 0; 並不能表達是什麼狀態。

解決途徑: 對於局部使用的數字,可以增加註釋說明 對於多處使用的數字,必須定義 const 常量,並通過符號命名自註釋。

禁止出現下列情況: 沒有通過符號來解釋數字含義,如 const int ZERO = 0 符號命名限制了其取值,如 const int XX_TIMER_INTERVAL_300MS = 300,直接使用XX_TIMER_INTERVAL_MS來表示該常量是定時器的時間間隔。

規則9.1.3 常量應該保證單一職責

說明:一個常量只用來表示一個特定功能,即一個常量不能有多種用途。

// 好的例子:協議A和協議B,手機號(MSISDN)的長度都是20。
const unsigned int A_MAX_MSISDN_LEN = 20;
const unsigned int B_MAX_MSISDN_LEN = 20;

// 或者使用不同的名字空間:
namespace Namespace1 {
    const unsigned int MAX_MSISDN_LEN = 20;
}

namespace Namespace2 {
    const unsigned int MAX_MSISDN_LEN = 20;
}

規則9.1.4 禁止用memcpy_s、memset_s初始化非POD對象

說明POD全稱是Plain Old Data,是C++ 98標準(ISO/IEC 14882, first edition, 1998-09-01)中引入的一個概念,POD類型主要包括int, char, floatdoubleenumerationvoid,指針等原始類型以及聚合類型,不能使用封裝和面向對象特性(如用戶定義的構造/賦值/析構函數、基類、虛函數等)。

由於非POD類型比如非聚合類型的class對象,可能存在虛函數,內存布局不確定,跟編譯器有關,濫用內存拷貝可能會導致嚴重的問題。

即使對聚合類型的class,使用直接的內存拷貝和比較,破壞了信息隱蔽和數據保護的作用,也不提倡memcpy_smemset_s操作。

對於POD類型的詳細說明請參見附錄。

建議9.1.2 變量使用時才聲明並初始化

說明:變量在使用前未賦初值,是常見的低級編程錯誤。使用前才聲明變量並同時初始化,非常方便地避免了此類低級錯誤。

在函數開始位置聲明所有變量,後面才使用變量,作用域覆蓋整個函數實現,容易導致如下問題:

  • 程序難以理解和維護:變量的定義與使用分離。
  • 變量難以合理初始化:在函數開始時,經常沒有足夠的信息進行變量初始化,往往用某個默認的空值(比如零)來初始化,這通常是一種浪費,如果變量在被賦於有效值以前使用,還會導致錯誤。

遵循變量作用域最小化原則與就近聲明原則, 使得代碼更容易閱讀,方便了解變量的類型和初始值。特別是,應使用初始化的方式替代聲明再賦值。

// 不好的例子:聲明與初始化分離
string name;        // 聲明時未初始化:調用缺省構造函數
name = "zhangsan";  // 再次調用賦值操作符函數;聲明與定義在不同的地方,理解相對困難

// 好的例子:聲明與初始化一體,理解相對容易
string name("zhangsan");  // 調用構造函數

表達式

規則9.2.1 含有變量自增或自減運算的表達式中禁止再次引用該變量

含有變量自增或自減運算的表達式中,如果再引用該變量,其結果在C++標準中未明確定義。各個編譯器或者同一個編譯器不同版本實現可能會不一致。 為了更好的可移植性,不應該對標準未定義的運算次序做任何假設。

注意,運算次序的問題不能使用括號來解決,因為這不是優先級的問題。

示例:

x = b[i] + i++; // Bad: b[i]運算跟 i++,先後順序並不明確。

正確的寫法是將自增或自減運算單獨放一行:

x = b[i] + i;
i++;            // Good: 單獨一行

函數參數

Func(i++, i);   // Bad: 傳遞第2個參數時,不確定自增運算有沒有發生

正確的寫法

i++;            // Good: 單獨一行
x = Func(i, i);

規則9.2.2 switch語句要有default分支

大部分情況下,switch語句中要有default分支,保證在遺漏case標簽處理時能夠有一個默認的處理行為。

特例: 如果switch條件變量是枚舉類型,並且 case 分支覆蓋了所有取值,則加上default處理分支有些多余。 現代編譯器都具備檢查是否在switch語句中遺漏了某些枚舉值的case分支的能力,會有相應的warning提示。

enum Color {
    RED = 0,
    BLUE
};

// 因為switch條件變量是枚舉值,這裡可以不用加default處理分支
switch (color) {
    case RED:
        DoRedThing();
        break;
    case BLUE:
        DoBlueThing();
        ...
        break;
}

建議9.2.1 表達式的比較,應當遵循左側傾向於變化、右側傾向於不變的原則

當變量與常量比較時,如果常量放左邊,如 if (MAX == v) 不符合閱讀習慣,而 if (MAX > v) 更是難於理解。 應當按人的正常閱讀、表達習慣,將常量放右邊。寫成如下方式:

if (value == MAX) {
 
}

if (value < MAX) {
 
}

也有特殊情況,如:if (MIN < value && value < MAX) 用來描述區間時,前半段是常量在左的。

不用擔心將 ‘==’ 誤寫成 ‘=’,因為 if (value = MAX) 會有編譯告警,其他靜態檢查工具也會報錯。讓工具去解決筆誤問題,代碼要符合可讀性第一。

建議9.2.2 使用括號明確操作符的優先級

使用括號明確操作符的優先級,防止因默認的優先級與設計思想不符而導致程序出錯;同時使得代碼更為清晰可讀,然而過多的括號會分散代碼使其降低了可讀性。下面是如何使用括號的建議。

  • 二元及以上操作符, 如果涉及多種操作符,則應該使用括號
x = a + b + c;         /* 操作符相同,可以不加括號 */
x = Foo(a + b, c);     /* 逗號兩邊的表達式,不需要括號 */
x = 1 << (2 + 3);      /* 操作符不同,需要括號 */
x = a + (b / 5);       /* 操作符不同,需要括號 */
x = (a == b) ? a : (a  b);    /* 操作符不同,需要括號 */

類型轉換

避免使用類型分支來定制行為:類型分支來定制行為容易出錯,是企圖用C++編寫C代碼的明顯標志。這是一種很不靈活的技術,要添加新類型時,如果忘記修改所有分支,編譯器也不會告知。使用模板和虛函數,讓類型自己而不是調用它們的代碼來決定行為。

建議避免類型轉換,我們在代碼的類型設計上應該考慮到每種數據的數據類型是什麼,而不是應該過度使用類型轉換來解決問題。在設計某個基本類型的時候,請考慮:

  • 是無符號還是有符號的
  • 是適合float還是double
  • 是使用int8,int16,int32還是int64,確定整形的長度

但是我們無法禁止使用類型轉換,因為C++語言是一門面向機器編程的語言,涉及到指針地址,並且我們會與各種第三方或者底層API交互,他們的類型設計不一定是合理的,在這個適配的過程中很容易出現類型轉換。

例外:在調用某個函數的時候,如果我們不想處理函數結果,首先要考慮這個是否是你的最好的選擇。如果確實不想處理函數的返回值,那麼可以使用(void)轉換來解決。

規則9.3.1 如果確定要使用類型轉換,請使用由C++提供的類型轉換,而不是C風格的類型轉換

說明

C++提供的類型轉換操作比C風格更有針對性,更易讀,也更加安全,C++提供的轉換有:

  • 類型轉換:
  1. dynamic_cast:主要用於繼承體系下行轉換,dynamic_cast具有類型檢查的功能,請做好基類和派生類的設計,避免使用dynamic_cast來進行轉換。
  2. static_cast:和C風格轉換相似可做值的強制轉換,或上行轉換(把派生類的指針或引用轉換成基類的指針或引用)。該轉換經常用於消除多重繼承帶來的類型歧義,是相對安全的。如果是純粹的算數轉換,那麼請使用後面的大括號轉換方式。
  3. reinterpret_cast:用於轉換不相關類型。reinterpret_cast強制編譯器將某個類型對象的內存重新解釋成另一種類型,這是一種不安全的轉換,建議盡可能少用reinterpret_cast
  4. const_cast:用於移除對象的const屬性,使對象變得可修改,這樣會破壞數據的不變性,建議盡可能少用。
  • 算數轉換: (C++11開始支持) 對於那種算數轉換,並且類型信息沒有丟失的,比如float到double, int32到int64的轉換,推薦使用大括號的初始方式。
  double d{ someFloat };
  int64_t i{ someInt32 };

建議9.3.1 避免使用dynamic_cast

  1. dynamic_cast依賴於C++的RTTI, 讓程序員在運行時識別C++類對象的類型。
  2. dynamic_cast的出現一般說明我們的基類和派生類設計出現了問題,派生類破壞了基類的契約,不得不通過dynamic_cast轉換到子類進行特殊處理,這個時候更希望來改善類的設計,而不是通過dynamic_cast來解決問題。

建議9.3.2 避免使用reinterpret_cast

說明reinterpret_cast用於轉換不相關類型。嘗試用reinterpret_cast將一種類型強制轉換另一種類型,這破壞了類型的安全性與可靠性,是一種不安全的轉換。不同類型之間盡量避免轉換。

建議9.3.3 避免使用const_cast

說明const_cast用於移除對象的constvolatile性質。

使用const_cast轉換後的指針或者引用來修改const對象,行為是未定義的。

// 不好的例子
const int i = 1024;
int* p = const_cast<int*>(&i);
*p = 2048;      // 未定義行為
// 不好的例子
class Foo {
public:
    Foo() : i(3) {}

    void Fun(int v)
    {
        i = v;
    }

private:
    int i;
};

int main(void)
{
    const Foo f;
    Foo* p = const_cast<Foo*>(&f);
    p->Fun(8);  // 未定義行為
}

資源分配和釋放

規則9.4.1 單個對象釋放使用delete,數組對象釋放使用delete []

說明:單個對象刪除使用delete, 數組對象刪除使用delete [],原因:

  • 調用new所包含的動作:從系統中申請一塊內存,並調用此類型的構造函數。
  • 調用new[n]所包含的動作:申請可容納n個對象的內存,並且對每一個對象調用其構造函數。
  • 調用delete所包含的動作:先調用相應的析構函數,再將內存歸還系統。
  • 調用delete[]所包含的動作:對每一個對象調用析構函數,再釋放所有內存

如果new和delete的格式不匹配,結果是未知的。對於非class類型, new和delete不會調用構造與析構函數。

錯誤寫法:

const int MAX_ARRAY_SIZE = 100;
int* numberArray = new int[MAX_ARRAY_SIZE];
...
delete numberArray;
numberArray = nullptr;

正確寫法:

const int MAX_ARRAY_SIZE = 100;
int* numberArray = new int[MAX_ARRAY_SIZE];
...
delete[] numberArray;
numberArray = nullptr;

建議9.4.1 使用 RAII 特性來幫助追蹤動態分配

說明:RAII是“資源獲取就是初始化”的縮語(Resource Acquisition Is Initialization),是一種利用對象生命周期來控制程序資源(如內存、文件句柄、網絡連接、互斥量等等)的簡單技術。

RAII 的一般做法是這樣的:在對象構造時獲取資源,接著控制對資源的訪問使之在對象的生命週期內始終保持有效,最後在對象析構的時候釋放資源。這種做法有兩大好處:

  • 我們不需要顯式地釋放資源。
  • 對象所需的資源在其生命週期內始終保持有效。這樣,就不必檢查資源有效性的问题,可以簡化邏輯、提高效率。

示例:使用RAII不需要顯式地釋放互斥資源。

class LockGuard {
public:
    LockGuard(const LockType& lockType): lock_(lockType)
    {
        lock_.Acquire();
    }
   
    ~LockGuard()
    {
        lock_.Release();
    }
   
private:
    LockType lock_;
};


bool Update()
{
    LockGuard lockGuard(mutex);
    if (...) {
        return false;
    } else {
        // 操作數據
    }
   
    return true;
}

標準庫

STL標準模板庫在不同產品使用程度不同,這裡列出一些基本規則和建議,供各團隊參考。

規則9.5.1 不要保存std::string的c_str()返回的指針

說明:在C++標準中並未規定string::c_str()指針持久有效,因此特定STL實現完全可以在調用string::c_str()時返回一個臨時存儲區並很快釋放。所以為了保證程序的可移植性,不要保存string::c_str()的結果,而是在每次需要時直接調用。

示例:

void Fun1()
{
    std::string name = "demo";
    const char* text = name.c_str();  // 表達式結束以後,name的生命週期還在,指針有效

    // 如果中間調用了string的非const成員函數,導致string被修改,比如operator[], begin()等
    // 可能會導致text的內容不可用,或者不是原來的字符串
    name = "test";
    name[1] = '2';

    // 後續使用text指針,其字符串內容不再是"demo"
}

void Fun2()
{
    std::string name = "demo";
    std::string test = "test";
    const char* text = (name + test).c_str(); // 表達式結束以後,+號產生的臨時對象被銷毀,指針無效

    // 後續使用text指針,其已不再指向合法內存空間
}

例外:在少數對性能要求非常高的代碼中,為了適配已有的只接受const char*類型入參的函數,可以臨時保存string::c_str()返回的指針。但是必須嚴格保證string對象的生命週期長於所保存指針的生命週期,並且保證在所保存指針的生命週期內,string對象不會被修改。

建議9.5.1 使用std::string代替char*

說明:使用string代替char*有很多優勢,比如:

  1. 不用考慮結尾的’\0’;
  2. 可以直接使用+, =, ==等運算符以及其它字符串操作函數;
  3. 不需要考慮內存分配操作,避免了顯式的new/delete,以及由此導致的錯誤;

需要注意的是某些stl實現中string是基於寫時復制策略的,這會帶來2個問題,一是某些版本的寫時復制策略沒有實現線程安全,在多線程環境下會引起程序崩潰;二是當與動態鏈接庫相互傳遞基於寫時復制策略的string時,由於引用計數在動態鏈接庫被卸載時無法減少可能導致懸掛指針。因此,慎重選擇一個可靠的stl實現對於保證程序穩定是很重要的。

例外: 當調用系統或者其它第三方庫的API時,針對已經定義好的接口,只能使用char*。但是在調用接口之前都可以使用string,在調用接口時使用string::c_str()獲得字符指針。 當在棧上分配字符數組當作緩衝區使用時,可以直接定義字符數組,不要使用string,也沒有必要使用類似vector<char>等容器。

規則9.5.2 禁止使用auto_ptr

說明:在stl庫中的std::auto_ptr具有一個隱式的所有權轉移行為,如下代碼:

auto_ptr<T> p1(new T);
auto_ptr<T> p2 = p1;

當執行完第2行語句後,p1已經不再指向第1行中分配的對象,而是變為nullptr。正因為如此,auto_ptr不能被置於各種標準容器中。 轉移所有權的行為通常不是期望的結果。對於必須轉移所有權的場景,也不應該使用隱式轉移的方式。這往往需要程序員對使用auto_ptr的代碼保持額外的謹慎,否則出現對空指針的訪問。 使用auto_ptr常見的有兩種場景,一是作為智能指針傳遞到產生auto_ptr的函數外部,二是使用auto_ptr作為RAII管理類,在超出auto_ptr的生命週期時自動釋放資源。 對於第1種場景,可以使用std::shared_ptr來代替。 對於第2種場景,可以使用C++11標準中的std::unique_ptr來代替。其中std::unique_ptr是std::auto_ptr的代替品,支持顯式的所有權轉移。

例外: 在C++11標準得到普遍使用之前,在一定需要對所有權進行轉移的場景下,可以使用std::auto_ptr,但是建議對std::auto_ptr進行封裝,並禁用封裝類的拷貝構造函數和賦值運算符,以使該封裝類無法用於標準容器。

建議9.5.2 使用新的標準頭文件

說明: 使用C++的標準頭文件時,請使用<cstdlib>這樣的,而不是<stdlib.h>這種的。

const的用法

在聲明的變量或參數前加上關鍵字 const 用於指明變量值不可被篡改 (如 const int foo ). 為類中的函數加上 const 限定符表明該函數不會修改類成員變量的狀態 (如 class Foo { int Bar(char c) const; };)。 const 變量, 數據成員, 函數和參數為編譯時類型檢測增加了一層保障, 便於盡早發現錯誤。因此, 我們強烈建議在任何可能的情況下使用 const。 有時候,使用C++11的constexpr來定義真正的常量可能更好。

規則9.6.1 對於指針和引用類型的形參,如果是不需要修改的,請使用const

不變的值更易於理解/跟蹤和分析,把const作為默認選項,在編譯時會對其進行檢查,使代碼更牢固/更安全。

class Foo;

void PrintFoo(const Foo& foo);

規則9.6.2 對於不會修改成員變量的成員函數請使用const修飾

盡可能將成員函數聲明為 const。 訪問函數應該總是 const。只要不修改數據成員的成員函數,都聲明為const。 對於虛函數,應當從設計意圖上考慮繼承鏈上的所有類是否需要在此虛函數中修改數據成員,而不是僅關注單個類的實現。

class Foo {
public:

    // ...

    int PrintValue() const // const修飾成員函數,不會修改成員變量
    {
        std::cout << value_ << std::endl;
    }

    int GetValue() const  // const修飾成員函數,不會修改成員變量
    {
        return value_;
    }

private:
    int value_;
};

建議9.6.1 初始化後再也不會修改的成員變量定義為const

class Foo {
public:
    Foo(int length) : dataLength_(length) {}
private:
    const int dataLength_; 
};

異常

建議9.7.1 C++11中,如果函數不會拋出異常,聲明為noexcept

理由

  1. 如果函數不會拋出異常,聲明為noexcept可以讓編譯器最大程度的優化函數,如減少執行路徑,提高錯誤退出的效率。
  2. vector等STL容器,為了保證接口的健壯性,如果保存元素的move運算符沒有聲明為noexcept,則在容器擴張搬移元素時不會使用move機制,而使用copy機制,帶來性能損失的風險。如果一個函數不能拋出異常,或者一個程序並沒有截獲某個函數所拋出的異常並進行處理,那麼這個函數可以用新的noexcept關鍵字對其進行修飾,表示這個函數不會拋出異常或者拋出的異常不會被截獲並處理。例如:
extern "C" double sqrt(double) noexcept;  // 永遠不會拋出異常

// 即使可能拋出異常,也可以使用 noexcept
// 這裡不準備處理內存耗盡的異常,簡單地將函數聲明為noexcept
std::vector<int> MyComputation(const std::vector<int>& v) noexcept
{
    std::vector<int> res = v;    // 可能會拋出異常
    // do something
    return res;
}

示例

RetType Function(Type params) noexcept;   // 最大的優化
RetType Function(Type params);            // 更少的優化

// std::vector 的 move 操作需要聲明 noexcept
class Foo1 {
public:
    Foo1(Foo1&& other);  // no noexcept
};

std::vector<Foo1> a1;
a1.push_back(Foo1());
a1.push_back(Foo1());  // 觸發容器擴張,搬移已有元素時調用copy constructor

class Foo2 {
public:
    Foo2(Foo2&& other) noexcept;
};

std::vector<Foo2> a2;
a2.push_back(Foo2());
a2.push_back(Foo2());  // 觸發容器擴張,搬移已有元素時調用move constructor

注意 默認構造函數、析構函數、swap函數,move操作符都不應該拋出異常。

模板與泛型編程

規則9.8.1 禁止在OpenHarmony項目中進行泛型編程

泛型編程和面向對象編程的思想、理念以及技巧完全不同,OpenHarmony項目主流使用面向對象的思想。

C++提供了強大的泛型編程的機制,能夠實現非常靈活簡潔的類型安全的接口,實現類型不同但是行為相同的代碼復用。

但是C++泛型編程存在以下缺點:

  1. 對泛型編程不很熟練的人,常常會將面向對象的邏輯寫成模板、將不依賴模板參數的成員寫在模板中等等導致邏輯混亂代碼膨脹諸多問題。
  2. 模板編程所使用的技巧對於使用c++不是很熟練的人是比較晦澀難懂的。在復雜的地方使用模板的代碼讓人更不容易讀懂,並且debug 和維護起來都很麻煩。
  3. 模板如果使用不當,會導致運行時代碼過度膨脹。
  4. 模板代碼難以修改和重構。模板的代碼會在很多上下文裡面擴展開來, 所以很難確認重構對所有的這些展開的代碼有用。

所以,OpenHarmony大部分部件禁止模板編程,僅有 少數部件 可以使用泛型編程,並且開發的模板要有詳細的註釋。 例外:

  1. stl適配層可以使用模板

在C++語言中,我們強烈建議盡可能少使用復雜的宏

  • 對於常量定義,請按照前面章節所述,使用const或者枚舉;
  • 對於宏函數,盡可能簡單,並且遵循下面的原則,並且優先使用內聯函數,模板函數等進行替換。
// 不推薦使用宏函數
#define SQUARE(a, b) ((a) * (b))

// 請使用模板函數,內聯函數等來替換。
template<typename T> T Square(T a, T b) { return a * b; }

如果需要使用宏,請參考C語言規範的相關章節。 例外:一些通用且成熟的應用,如:對 new, delete 的封裝處理,可以保留對宏的使用。

10 現代C++特性

隨著 ISO 在2011年發布 C++11 語言標準,以及2017年3月發布 C++17 ,現代C++(C++11/14/17等)增加了大量提高編程效率、代碼質量的新語言特性和標準庫。 本章節描述了一些可以幫助團隊更有效率的使用現代C++,規避語言陷阱的指導意見。

代碼簡潔性和安全性提升

建議10.1.1 合理使用auto

理由

  • auto可以避免編寫冗長、重復的類型名,也可以保證定義變量時初始化。
  • auto類型推導規則復雜,需要仔細理解。
  • 如果能夠使代碼更清晰,繼續使用明確的類型,且只在局部變量使用auto

示例

// 避免冗長的類型名
std::map<string, int>::iterator iter = m.find(val);
auto iter = m.find(val);

// 避免重復類型名
class Foo {...};
Foo* p = new Foo;
auto p = new Foo;

// 保證初始化
int x;    // 編譯正確,沒有初始化
auto x;   // 編譯失敗,必須初始化

auto 的類型推導可能導致困惑:

auto a = 3;           // int
const auto ca = a;    // const int
const auto& ra = a;   // const int&
auto aa = ca;         // int, 忽略 const 和 reference
auto ila1 = { 10 };   // std::initializer_list<int>
auto ila2{ 10 };      // std::initializer_list<int>

auto&& ura1 = x;      // int&
auto&& ura2 = ca;     // const int&
auto&& ura3 = 10;     // int&&

const int b[10];
auto arr1 = b;        // const int*
auto& arr2 = b;       // const int(&)[10]

如果沒有注意 auto 類型推導時忽略引用,可能引入難以發現的性能問題:

std::vector<std::string> v;
auto s1 = v[0];  // auto 推導為 std::string,拷貝 v[0]

如果使用auto定義接口,如頭文件中的常量,可能因為開發人員修改了值,而導致類型發生變化。

規則10.1.1 在重寫虛函數時請使用overridefinal關鍵字

理由 overridefinal關鍵字都能保證函數是虛函數,且重寫了基類的虛函數。如果子類函數與基類函數原型不一致,則產生編譯告警。final還保證虛函數不會再被子類重寫。

使用overridefinal關鍵字後,如果修改了基類虛函數原型,但忘記修改子類重寫的虛函數,在編譯期就可以發現。也可以避免有多個子類時,重寫虛函數的修改遺漏。

示例

class Base {
public:
    virtual void Foo();
    virtual void Foo(int var);
    void Bar();
};

class Derived : public Base {
public:
    void Foo() const override; // 編譯失敗: Derived::Foo 和 Base::Foo 原型不一致,不是重寫
    void Foo() override;       // 正確: Derived::Foo 重寫 Base::Foo
    void Foo(int var) final;   // 正確: Derived::Foo(int) 重寫 Base::Foo(int),且Derived的派生類不能再重寫此函數
    void Bar() override;       // 編譯失敗: Base::Bar 不是虛函數
};

總結

  1. 基類首次定義虛函數,使用virtual關鍵字
  2. 子類重寫基類虛函數(包括析構函數),使用overridefinal關鍵字(但不要兩者一起使用),並且不使用virtual關鍵字
  3. 非虛函數,virtualoverridefinal都不使用

規則10.1.2 使用delete關鍵字刪除函數

理由 相比於將類成員函數聲明為private但不實現,delete關鍵字更明確,且適用範圍更廣。

示例

class Foo {
private:
    // 只看頭文件不知道拷貝構造是否被刪除
    Foo(const Foo&);
};

class Foo {
public:
    // 明確刪除拷貝賦值函數
    Foo& operator=(const Foo&) = delete;
};

delete關鍵字還支持刪除非成員函數

template<typename T>
void Process(T value);

template<>
void Process<void>(void) = delete;

規則10.1.3 使用nullptr,而不是NULL0

理由 長期以來,C++沒有一個代表空指針的關鍵字,這是一件很尷尬的事:

#define NULL ((void *)0)

char* str = NULL;   // 錯誤: void* 不能自動轉換為 char*

void(C::*pmf)() = &C::Func;
if (pmf == NULL) {} // 錯誤: void* 不能自動轉換為指向成員函數的指針

如果把NULL被定義為00L。可以解決上面的問題。

或者在需要空指針的地方直接使用0。但這引入另一個問題,代碼不清晰,特別是使用auto自動推導:

auto result = Find(id);
if (result == 0) {  // Find() 返回的是 指針 還是 整數?
    // do something
}

0字面上是int類型(0Llong),所以NULL0都不是指針類型。 當重載指針和整數類型的函數時,傳遞NULL0都調用到整數類型重載的函數:

void F(int);
void F(int*);

F(0);      // 調用 F(int),而非 F(int*)
F(NULL);   // 調用 F(int),而非 F(int*)

另外,sizeof(NULL) == sizeof(void*)並不一定總是成立的,這也是一個潛在的風險。

總結: 直接使用00L,代碼不清晰,且無法做到類型安全;使用NULL無法做到類型安全。這些都是潛在的風險。

nullptr的優勢不僅僅是在字面上代表了空指針,使代碼清晰,而且它不再是一個整數類型。

nullptrstd::nullptr_t類型,而std::nullptr_t可以隱式的轉換為所有的原始指針類型,這使得nullptr可以表現成指向任意類型的空指針。

void F(int);
void F(int*);
F(nullptr);   // 調用 F(int*)

auto result = Find(id);
if (result == nullptr) {  // Find() 返回的是 指針
    // do something
}

規則10.1.4 使用using而非typedef

C++11之前,可以通過typedef定義類型的別名。沒人願意多次重復std::map<uint32_t, std::vector<int>>這樣的代碼。

typedef std::map<uint32_t, std::vector<int>> SomeType;

類型的別名實際是對類型的封裝。而通過封裝,可以讓代碼更清晰,同時在很大程度上避免類型變化帶來的散彈式修改。 在C++11之後,提供using,實現聲明別名(alias declarations):

using SomeType = std::map<uint32_t, std::vector<int>>;

對比兩者的格式:

typedef Type Alias;   // Type 在前,還是 Alias 在前
using Alias = Type;   // 符合'賦值'的用法,容易理解,不易出錯

如果覺得這點還不足以切換到using,我們接著看看模板別名(alias template):

// 定義模板的別名,一行代碼
template<class T>
using MyAllocatorVector = std::vector<T, MyAllocator<T>>;

MyAllocatorVector<int> data;       // 使用 using 定義的別名

template<class T>
class MyClass {
private:
    MyAllocatorVector<int> data_;   // 模板類中使用 using 定義的別名
};

typedef不支持帶模板參數的別名,只能"曲線救國”:

// 通過模板包裝 typedef,需要實現一個模板類
template<class T>
struct MyAllocatorVector {
    typedef std::vector<T, MyAllocator<T>> type;
};

MyAllocatorVector<int>::type data;  // 使用 typedef 定義的別名,多寫 ::type

template<class T>
class MyClass {
private:
    typename MyAllocatorVector<int>::type data_;  // 模板類中使用,除了 ::type,還需要加上 typename
};

規則10.1.5 禁止使用std::move操作const對象

從字面上看,std::move的意思是要移動一個對象。而const對象是不允許修改的,自然也無法移動。因此用std::move操作const對象會給代碼閱讀者帶來困惑。 在實際功能上,std::move會把對象轉換成右值引用類型;對於const對象,會將其轉換成const的右值引用。由於極少有類型會定義以const右值引用為參數的移動構造函數和移動賦值操作符,因此代碼實際功能往往退化成了對象拷貝而不是對象移動,帶來了性能上的損失。

錯誤示例:

std::string g_string;
std::vector<std::string> g_stringList;

void func()
{
    const std::string myString = "String content";
    g_string = std::move(myString); // bad:並沒有移動myString,而是進行了復制
    const std::string anotherString = "Another string content";
    g_stringList.push_back(std::move(anotherString));    // bad:並沒有移動anotherString,而是進行了復制
}

智能指針

規則10.2.1 單例、類的成員等所有權不會被多方持有的優先使用原始指針而不是智能指針

理由 智能指針會自動釋放對象資源避免資源泄露,但會帶額外的資源開銷。如:智能指針自动生成的類、構造和析構的開銷、內存占用多等。

單例、類的成員等對象的所有權不會被多方持有的情況,僅在類析構中釋放資源即可。不應該使用智能指針增加額外的開銷。

示例

class Foo;
class Base {
public:
    Base() {}
    virtual ~Base()
    {
        delete foo_;
    }
private:
    Foo* foo_ = nullptr;
};

例外

  1. 返回創建的對象時,需要指針銷毀函數的可以使用智能指針。
class User;
class Foo {
public:
    std::unique_ptr<User, void(User *)> CreateUniqueUser() // 可使用unique_ptr保證對象的創建和釋放在同一runtime
    {
        sptr<User> ipcUser = iface_cast<User>(remoter);
        return std::unique_ptr<User, void(User *)>(::new User(ipcUser), [](User *user) {
            user->Close();
            ::delete user;
        });
    }

    std::shared_ptr<User> CreateSharedUser() // 可使用shared_ptr保證對象的創建和釋放在同一runtime中
    {
        sptr<User> ipcUser = iface_cast<User>(remoter);
        return std::shared_ptr<User>(ipcUser.GetRefPtr(), [ipcUser](User *user) mutable {
            ipcUser = nullptr;
        });
    }
};
  1. 返回創建的對象且對象需要被多方引用時,可以使用shared_ptr。

規則10.2.2 使用std::make_unique而不是new創建unique_ptr

理由

  1. make_unique提供了更簡潔的創建方式
  2. 保證了復雜表達式的異常安全

示例

// 不好:兩次出現 MyClass,重復導致不一致風險
std::unique_ptr<MyClass> ptr(new MyClass(0, 1));
// 好:只出現一次 MyClass,不存在不一致的可能
auto ptr = std::make_unique<MyClass>(0, 1);

重復出現類型可能導致非常嚴重的問題,且很難發現:

// 編譯正確,但new和delete不配套
std::unique_ptr<uint8_t> ptr(new uint8_t[10]);
std::unique_ptr<uint8_t[]> ptr(new uint8_t);
// 非異常安全: 編譯器可能按如下順序計算參數:
// 1. 分配 Foo 的內存,
// 2. 構造 Foo,
// 3. 調用 Bar,
// 4. 構造 unique_ptr<Foo>.
// 如果 Bar 拋出異常, Foo 不會被銷毀,產生內存泄露。
F(unique_ptr<Foo>(new Foo()), Bar());

// 異常安全: 調用函數不會被打斷.
F(make_unique<Foo>(), Bar());

例外 std::make_unique不支持自定義deleter。 在需要自定義deleter的場景,建議在自己的命名空間實現定制版本的make_unique。 使用new創建自定義deleterunique_ptr是最後的選擇。

規則10.2.4 使用std::make_shared而不是new創建shared_ptr

理由 使用std::make_shared除了類似std::make_unique一致性等原因外,還有性能的因素。 std::shared_ptr管理兩個實體:

  • 控制塊(存儲引用計數,deleter等)
  • 管理對象

std::make_shared創建std::shared_ptr,會一次性在堆上分配足夠容納控制塊和管理對象的內存。而使用std::shared_ptr<MyClass>(new MyClass)創建std::shared_ptr,除了new MyClass會觸發一次堆分配外,std::shard_ptr的構造函數還會觸發第二次堆分配,產生額外的開銷。

例外 類似std::make_uniquestd::make_shared不支持定制deleter

Lambda

建議10.3.1 當函數不能工作時選擇使用lambda(捕獲局部變量,或編寫局部函數)

理由 函數無法捕獲局部變量或在局部範圍內聲明;如果需要這些東西,盡可能選擇lambda,而不是手寫的functor。 另一方面,lambdafunctor不會重載;如果需要重載,則使用函數。 如果lambda和函數都可以的場景,則優先使用函數;盡可能使用最簡單的工具。

示例

// 編寫一個只接受 int 或 string 的函數
// -- 重載是自然的選擇
void F(int);
void F(const string&);

// 需要捕獲局部狀態,或出現在語句或表達式範圍
// -- lambda 是自然的選擇
[Response interrupted by a tool use result. Only one tool may be used at a time and should be placed at the end of the message.]