华为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:
    virtual std::string getVersion() = 0;

    ~Base()
    {
        std::cout << "~Base" << std::endl;
    }
};
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، مما يؤدي إلى تسرب الذاكرة. استثناء: لا حاجة لتعريف دالة تدمير افتراضية أو تعريف final للكلاسات مثل NoCopyable و NoMovable التي لا تحتوي على أي سلوك وتعمل فقط كعلامات.

القاعدة 7.2.2 ممنوع استخدام القيم الافتراضية للدوال الافتراضية

الشرح: في C++، يتم ربط الدوال الافتراضية ديناميكيًا، لكن القيم الافتراضية للدوال يتم ربطها في وقت الترجمة. هذا يعني أن الدالة التي يتم تنفيذها في النهاية هي دالة معرفة في الكلاس المشتق ولكن باستخدام القيم الافتراضية المعرفة في الكلاس الأساسي. لتجنب الارتباك الناتج عن إعلانات غير متسقة للدوال الافتراضية وإعادة تحميلها، ممنوع إعلان القيم الافتراضية للدوال الافتراضية. مثال: القيمة الافتراضية text للدالة الافتراضية display تُحدد في وقت الترجمة، وليس في وقت التشغيل، وبالتالي لا تحقق الهدف من التعددية:

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 استخدام الوراثة المتعددة لتحقيق فصل الواجهة وتجميع الأدوار المتعددة

إذا كان كلاس ما يحتاج إلى تنفيذ واجهات متعددة، يمكن من خلال الوراثة المتعددة دمج واجهات متعددة منفصلة معًا، مشابهةً لـ traits في لغة scala.

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 {

};

إعادة التحميل

إعادة تحميل العوامل يجب أن يكون لها سبب كافٍ، ولا يجب تغيير المعنى الأصلي للعامل، على سبيل المثال لا يجب استخدام عامل ‘+’ للقيام بعملية الطرح. إعادة تحميل العوامل تجعل الكود أكثر وضوحًا، ولكن لها بعض العيوب:

  • قد تخلط بين البديهة، وتجعل المرء يعتقد أن العملية مثل الأنواع المضمنة عالية الأداء، وتجاهل إمكانية انخفاض الأداء.
  • لا تكون عملية تحديد المشكلة واضحة، حيث يكون البحث حسب اسم الدالة أسهل من البحث حسب العامل.
  • إذا كان سلوك عامل معاد التحميل غير واضح (مثلاً استخدام ‘+’ للطرح)، قد يؤدي ذلك إلى إرباك الكود.
  • يؤدي إعادة تحميل عامل الإسناد إلى تحويلات ضمنية قد تخفي أخطاء عميقة. يمكن تعريف دوال مثل Equals() أو CopyFrom() كبديل للعوامل = و ==.

8 الدوال

تصميم الدوال

القاعدة 8.1.1 تجنب الدوال الطويلة، لا تتجاوز الدالة 50 سطرًا (غير فارغة وغير معلّقة)

يجب أن تكون الدالة قابلة للعرض في شاشة واحدة (50 سطرًا أو أقل)، وتقوم بعمل واحد فقط، وتؤديه بشكل جيد.

الدوال الطويلة غالبًا ما تعني أن وظيفة الدالة ليست وحيدة، معقدة جدًا، أو تعرض التفاصيل بشكل مفرط، دون إجراء مزيد من التجريديّة.

استثناء: بعض دوال تنفيذ الخوارزميات، نظرًا لتماسك الخوارزمية وشموليتها، قد تتجاوز 50 سطرًا.

حتى لو كانت الدالة الطويلة تعمل بشكل ممتاز الآن، بمجرد أن يقوم شخص ما بتعديلها، قد تظهر مشاكل جديدة، بل وقد تؤدي إلى أخطاء يصعب اكتشافها. يوصى بتفكيكها إلى دوال أقصر وأسهل في الإدارة لتسهيل قراءة الآخرين وتعديل الكود.

الدوال المضمنة

اقتراح 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    // سيء

// يجب استخدام ثوابت const في C++
const int MAX_MSISDN_LEN = 20; // جيد

// بالنسبة لإصدارات C++11 وما فوق، يمكن استخدام constexpr
constexpr int MAX_MSISDN_LEN = 20;

اقتراح 9.1.1 يجب تعريف مجموعة من الثوابت الصحيحة ذات الصلة كتعداد

الشرح: التعداد أأمن من #define أو const 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، لتحديد نوع الجهاز
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 يجب أن تكون الثوابت ذات مسؤولية واحدة

الشرح: يجب أن يستخدم الثابت لتمثيل وظيفة محددة فقط، أي أن الثابت لا يمكن أن يكون له أكثر من استخدام واحد.

// مثال جيد: طول MSISDN في البروتوكول A و B هو 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، الإصدار الأول، 1998-09-01)، وتشمل أنواع POD بشكل أساسي int و char و float و double و enumeration و void و المؤشرات وأنواع البيانات الأولية والبيانات التجميعية، ولا يمكن استخدام التغليف وسمات البرمجة الكائنية (مثل دوال البناء/الإحالة/التدمير المعرفة من قبل المستخدم، الكلاسات الأساسية، الدوال الافتراضية، إلخ).

بما أن كائنات غير POD، مثل كائنات class غير تجميعية، قد تحتوي على دوال افتراضية، وترتيب الذاكرة غير محدد، ويعتمد على المُجمّع، فإن الاستخدام المفرط لنسخ الذاكرة قد يؤدي إلى مشاكل خطيرة.

حتى بالنسبة لكائنات class تجميعية، فإن استخدام نسخ الذاكرة والمطابقة مباشرة يدمر تأثير إخفاء المعلومات وحماية البيانات، ولا يُشجع على استخدام عمليات memcpy_s و memset_s.

للاطلاع على شرح مفصل لأنواع POD، يُرجى الرجوع إلى الملحق.

اقتراح 9.1.2 يجب إعلان المتغير واستخدامه عند الحاجة وتتم تهيئته

الشرح: عدم تهيئة المتغير قبل استخدامه هو خطأ برمجي من الدرجة الدنيا. إعلان المتغير واستخدامه عند الحاجة، مع التهيئة في نفس الوقت، يجنب هذا الخطأ البسيط بشكل مريح.

إعلان جميع المتغيرات في بداية الدالة، ثم استخدامها لاحقًا، يغطي فترة صلاحيتها على الدالة بأكملها، مما يؤدي إلى مشاكل مثل:

  • يصعب فهم البرنامج وصيانته: فصل تعريف المتغير عن استخدامه.
  • يصعب تهيئة المتغير بشكل مناسب: غالبًا ما لا يكون هناك معلومات كافية لتهيئة المتغير في بداية الدالة، وغالبًا ما يتم استخدام قيمة افتراضية فارغة (مثل الصفر) لتهيئته، وهذا غالبًا ما يكون مضيعة، وإذا تم استخدام المتغير قبل إعطائه قيمة فعالة، قد يؤدي إلى خطأ.

باتباع مبدأ صغر نطاق المتغير ومبدأ الإعلان القريب، يصبح من الأسهل قراءة الكود وفهم نوع المتغير وقيمة التهيئة. خاصةً، يجب استخدام طريقة التهيئة لاستبدال الإعلان ثم الإحالة.

// مثال سيء: إعلان وتهيئة منفصلان
string name;        // لم يتم تهيئته عند الإعلان: استدعاء دالة البناء الافتراضية
name = "zhangsan";  // استدعاء دالة الإحالة مرة أخرى؛ الإعلان والتعريف في أماكن مختلفة، يصعب فهمه نسبيًا

// مثال جيد: إعلان وتهيئة متكاملان، أسهل نسبيًا للفهم
string name("zhangsan");  // استدعاء دالة البناء

التعبيرات

القاعدة 9.2.1 ممنوع استخدام المتغير مرة أخرى في التعبير الذي يحتوي على عملية زيادة أو نقصان ذاتية

التعبير الذي يحتوي على عملية زيادة أو نقصان ذاتية للمتغير، إذا تم استخدام المتغير مرة أخرى، فإن النتيجة غير محددة في معيار C++. قد تختلف بين مُجمّعات مختلفة أو إصدارات مختلفة لنفس المُجمّع. لتحقيق قابلية نقل أفضل، لا يجب افتراض أي ترتيب حسابي غير معرف في المعيار.

لاحظ أن مشكلة ترتيب العمليات لا يمكن حلها باستخدام الأقواس، لأنها ليست مسألة أولوية.

مثال:

x = b[i] + i++; // سيء: ترتيب b[i] و i++ غير واضح.

الطريقة الصحيحة للكتابة هي وضع عملية الزيادة أو النقصان في سطر منفصل:

x = b[i] + i;
i++;            // جيد: في سطر منفصل

معاملات الدالة

Func(i++, i);   // سيء: عند تمرير المعامل الثاني، غير متأكد من حدوث عملية الزيادة أم لا

الطريقة الصحيحة للكتابة

i++;            // جيد: في سطر منفصل
x = Func(i, i);

القاعدة 9.2.2 يجب أن تحتوي عبارات switch على فرع default

في معظم الحالات، يجب أن تحتوي عبارة switch على فرع default لضمان وجود معالجة افتراضية عند تفويت معالجة علامة case.

استثناء: إذا كان متغير شرط switch من نوع تعدادي، وتغطي فروع case جميع القيم، فإن إضافة فرع default لمعالجة شيء ما قد يكون مفرطًا. تحتوي المُجمّعات الحديثة على القدرة على التحقق مما إذا كان هناك فرع case مفقود لقيمة تعدادية معينة في عبارة switch، وستظهر تحذيرات متناظرة.

enum Color {
    RED = 0,
    BLUE
};

// يمكن عدم إضافة فرع معالجة default لأن متغير شرط switch هو قيمة تعدادية
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++ هي لغة برمجة موجهة نحو الجهاز، تنطوي على مؤشرات وعناوين، وسوف نتفاعل مع واجهات برمجة التطبيقات المختلفة من الطرف الثالث أو من الطبقة السفلية. تصميم أنواعها قد لا يكون معقولًا، وسيظهر تحويل الأنواع بسهولة عند التكيف مع هذا التصميم.

استثناء: عند استدعاء دالة ما، إذا كنت متأكدًا من عدم رغبتك في معالجة نتيجة الدالة، يجب أولاً التفكير فيما إذا كان هذا أفضل خيار لك. إذا كنت بالفعل لا تريد معالجة نتيجة الدالة، يمكن استخدام التحويل (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 يعتمد على RTTI في C++، مما يسمح للمبرمج بالتعرف على نوع كائن C++ في وقت التشغيل.
  2. ظهور dynamic_cast يشير عادةً إلى وجود مشكلة في تصميم الكلاس الأساسي والكلاس المشتق، حيث يدمر الكلاس المشتق عقد الكلاس الأساسي، ويضطر إلى تحويل إلى الكلاس المشتق عبر dynamic_cast للتعامل مع معالجة خاصة، وفي هذه الحالة نأمل في تحسين تصميم الكلاس بدلاً من حل المشكلة عبر dynamic_cast.

اقتراح 9.3.2 تجنب استخدام reinterpret_cast

الشرح: reinterpret_cast تُستخدم لتحويل الأنواع غير المرتبطة. محاولة استخدام reinterpret_cast لفرض تحويل نوع إلى نوع آخر يدمر أمان وموثوقية النوع، وهو تحويل غير آمن. يجب تجنب التحويل بين أنواع مختلفة قدر الإمكان.

اقتراح 9.3.3 تجنب استخدام const_cast

الشرح: const_cast تُستخدم لإزالة خاصية const و volatile للكائن.

استخدام مؤشر أو مرجع محوّل عبر 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 في المنتصف، مما يؤدي إلى تعديل الـ 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، وهذا يجلب مشكلتين، الأولى هي أن بعض إصدارات استراتيجية نسخ عند الكتابة لا تنفذ بشكل آمن للخيوط، مما قد يؤدي إلى تعطل البرنامج في بيئة متعددة الخيوط؛ والثانية هي أنه عند تمرير string القائم على استراتيجية نسخ عند الكتابة بين المكتبات الديناميكية، قد يؤدي عدم تقليل العداد المرجعي عند إلغاء تحميل المكتبة الديناميكية إلى مؤشر معلق. لذلك، فإن اختيار تنفيذ STL موثوق به لضمان استقرار البرنامج أمر مهم جدًا.

استثناء: عند استدعاء واجهة برمجة تطبيقات النظام أو مكتبة الطرف الثالث، بالنسبة للواجهة المحددة مسبقًا، يمكن فقط استخدام char*. ولكن قبل استدعاء الواجهة، يمكن استخدام string، واستخدام string::c_str() للحصول على مؤشر حرف عند استدعاء الواجهة. عند تخصيص مصفوفة حروف على المكدس كمجال للاستخدام، يمكن تعريف مصفوفة الحروف مباشرة، ولا داعي لاستخدام string، ولا داعي لاستخدام حاويات مشابهة vector<char>.

القاعدة 9.5.2 ممنوع استخدام auto_ptr

الشرح: في مكتبة std::auto_ptr في stl، هناك سلوك نقل ملكية ضمني، كما في الكود التالي:

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

بعد تنفيذ السطر الثاني، لم يعد p1 يشير إلى الكائن الذي تم تخصيصه في السطر الأول، بل يصبح nullptr. بسبب هذا السبب، لا يمكن وضع auto_ptr في مختلف الحاويات القياسية. سلوك نقل الملكية غالبًا ما لا يكون النتيجة المرجوة. بالنسبة للسيناريوهات التي يجب فيها نقل الملكية، لا ينبغي استخدام طريقة النقل الضمنية. غالبًا ما يتطلب ذلك من المبرمج الحذر من استخدام الكود الذي يستخدم auto_ptr، وإلا فقد يحدث الوصول إلى مؤشر فارغ. هناك نوعان شائعان من استخدام auto_ptr، أحدهما هو نقل auto_ptr إلى خارج الدالة التي تُنتج auto_ptr، والآخر هو استخدام auto_ptr كفئة RAII لإدارة الموارد، حيث يتم تحرير الموارد عند انتهاء عمر auto_ptr. بالنسبة للسيناريو الأول، يمكن استخدام std::shared_ptr كبديل. بالنسبة للسيناريو الثاني، يمكن استخدام std::unique_ptr من معيار C++11 كبديل. حيث أن 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 في أي حالة ممكنة. أحيانًا، قد يكون من الأفضل استخدام constexpr من C++11 لتعريف الثوابت الحقيقية.

القاعدة 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، إذا كانت الدالة لا تطرح استثناء، ف_DECLARE_ كـ noexcept

السبب

  1. إذا لم تطرح الدالة استثناء، فإن الإعلان كـ noexcept يسمح للمُجمّع بتحسين الدالة إلى أقصى حد، مثل تقليل مسارات التنفيذ، وتحسين كفاءة الإنهاء من الأخطاء.
  2. بالنسبة لحاويات STL مثل vector، من أجل ضمان متانة الواجهة، إذا لم تُعلَن دالة 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;    // قد تطرح استثناء
    // افعل شيئًا
    return res;
}

مثال

RetType Function(Type params) noexcept;   // أقصى تحسين
RetType Function(Type params);            // تحسين أقل

// دالة move لـ std::vector تحتاج إلى إعلان noexcept
class Foo1 {
public:
    Foo1(Foo1&& other);  // لا 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++ آلية قوية للبرمجة العامة، ويمكن تحقيق واجهات آمنة من حيث النوع وموجزة للغاية، مما يحقق إعادة استخدام الكود مع سلوك مختلف ولكن متطابق من حيث النوع.

ومع ذلك، فإن برمجة القوالب لها العيوب التالية:

  1. غالبًا ما يكتب الأشخاص غير المتمرسين في برمجة القوالب منطق面向对象 كقوالب، أو يكتبون أعضاء لا يعتمدون على معلمة القالب في القوالب، مما يؤدي إلى ارتباك منطقي وتضخم الكود وغير ذلك من المشكلات.
  2. غالبًا ما تكون تقنيات برمجة القوالب مربكة وصعبة الفهم للأشخاص غير المتمرسين في C++. غالبًا ما يكون الكود المكتوب باستخدام القوالب في الأماكن المعقدة صعب القراءة، وصعوبة التصحيح والصيانة.
  3. غالبًا ما تكون رسائل الخطأ الناتجة عن القوالب غير ودية: عند حدوث خطأ في الكود، حتى لو كان الواجهة بسيطة جدًا، فإن تفاصيل تنفيذ القالب المعقدة ستظهر في رسالة الخطأ. مما يجعل رسالة الخطأ صعبة الفهم.
  4. إذا تم استخدام القوالب بشكل غير صحيح، فقد يؤدي إلى تضخم الكود في وقت التشغيل.
  5. من الصعب تعديل وإعادة هيكلة كود القوالب. كود القالب يتم توسيعه في العديد من السياقات، لذلك من الصعب التأكد من أن إعادة الهيكلة مفيدة لجميع كود القالب الموسّع.

لذلك، من الممنوع استخدام برمجة القوالب في معظم مكونات 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 لمعايير لغة C++11 في عام 2011 وإصدار C++17 في مارس 2017، أضاف 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 والإشارة
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 عند إعادة كتابة الدالة الافتراضية، استخدم override أو final keyword

السبب كل من كلمتي override و final يمكن أن تضمن أن الدالة افتراضية وأنها أعادت كتابة الدالة الافتراضية للكلاس الأساسي. إذا كان شكل الدالة الفرعية لا يطابق شكل الدالة الأساسية، فسيتم إصدار تحذير الترجمة. كما تضمن final أن الدالة الافتراضية لن يتم إعادة كتابتها من قبل الكلاس الفرعي.

عند استخدام كلمات override أو final، إذا تم تعديل شكل الدالة الافتراضية للكلاس الأساسي ولكن نسيت تعديل الدالة الفرعية التي أعادت الكتابة، يمكن اكتشاف ذلك في وقت الترجمة. كما يمكن تجنب نسيان إعادة كتابة الدالة الافتراضية عند وجود عدة كلاسات فرعية.

مثال

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)، ولا يمكن للكلاس الفرعي إعادة كتابة هذه الدالة بعد الآن
    void Bar() override;       // فشل الترجمة: Base::Bar ليست دالة افتراضية
};

الخلاصة

  1. استخدم الكلمة المفتاحية virtual عند تعريف الدالة الافتراضية لأول مرة في الكلاس الأساسي.
  2. استخدم الكلمة المفتاحية override أو final عند إعادة كتابة الدالة الافتراضية للكلاس الأساسي (بما في ذلك دالة التدمير) في الكلاس الفرعي (ولكن لا تستخدم الكلمتين معًا)، ولا تستخدم الكلمة المفتاحية virtual.
  3. لا تستخدم virtual، override و final للدوال غير الافتراضية.

القاعدة 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، وليس NULL أو 0

السبب منذ فترة طويلة، لم يكن لدى C++ كلمة مفتاحية تمثل مؤشر فارغ، وهذا أمر محرج جدًا:

#define NULL ((void *)0)

char* str = NULL;   // خطأ: void* لا يمكن التحويل تلقائيًا إلى char*

void(C::*pmf)() = &C::Func;
if (pmf == NULL) {} // خطأ: void* لا يمكن التحويل تلقائيًا إلى مؤشر عضو دالة

إذا تم تعريف NULL كـ 0 أو 0L. يمكن حل المشكلة أعلاه.

أو استخدام 0 مباشرةً في الأماكن التي تحتاج إلى مؤشر فارغ. لكن هذا يجلب مشكلة أخرى، الكود غير واضح، خاصةً عند استخدام auto للحصول على النوع تلقائيًا:

auto result = Find(id);
if (result == 0) {  // هل Find() تُرجع مؤشر أم عددًا صحيحًا؟
    // افعل شيئًا
}

ظاهريًا، 0 هو نوع int (0L هو long)، لذا NULL و 0 ليسا من نوع المؤشر. عند تحميل الدوال ذات الوظائف المتعددة للمؤشرات والأعداد الصحيحة، يتم استدعاء الدالة ذات الوظائف المتعددة للعدد الصحيح عند تمرير NULL أو 0:

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

F(0);      // استدعاء F(int)، وليس F(int*)
F(NULL);   // استدعاء F(int)، وليس F(int*)

بالإضافة إلى ذلك، sizeof(NULL) == sizeof(void*) ليس بالضرورة صحيحًا دائمًا، وهذا أيضًا خطر محتمل.

الخلاصة: استخدام 0 أو 0L مباشرةً يجعل الكود غير واضح ولا يكون آمنًا من حيث النوع؛ استخدام NULL لا يكون آمنًا من حيث النوع. كل هذه الأمور تمثل مخاطر محتملة.

ميزة nullptr لا تكمن فقط في كونها تمثل مؤشرًا فارغًا في النص، مما يجعل الكود واضحًا، بل أيضًا في كونها ليست من نوع عدد صحيح.

nullptr هو من نوع std::nullptr_t، و std::nullptr_t يمكن التحويل الضمني إلى جميع أنواع المؤشرات الأولية، مما يجعل nullptr يمكن أن يتصرف كمؤشر فارغ لأي نوع:

void F(int);
void F(int*);
F(nullptr);   // استدعاء F(int*)

auto result = Find(id);
if (result == nullptr) {  // Find() تُرجع مؤشرًا
    // افعل شيئًا
}

القاعدة 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 على الكائنات الثابتة

من الناحية الحرفية، std::move يعني نقل كائن. أما الكائنات الثابتة فلا يمكن تعديلها، وبالتالي لا يمكن نقلها. لذلك فإن استخدام std::move على الكائنات الثابتة يجعل قارئ الكود في حيرة من أمره. من ناحية الوظيفة الفعلية، std::move يقوم بتحويل الكائن إلى نوع مرجع rvalue؛ بالنسبة للكائنات الثابتة، يتم تحويلها إلى مرجع rvalue ثابت. نظرًا لوجود عدد قليل جدًا من الأنواع التي تعرّف دالة البناء والتعيين للنسخ rvalue كـ const، غالبًا ما يؤدي الكود الفعلي إلى نسخ الكائن بدلاً من نقله، مما يؤدي إلى فقدان في الأداء.

مثال خاطئ:

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

void func()
{
    const std::string myString = "String content";
    g_string = std::move(myString); // سيء: لم يقم بتحريك myString، بل قام بنسخه
    const std::string anotherString = "Another string content";
    g_stringList.push_back(std::move(anotherString));    // سيء: لم يقم بتحريك 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 لتكوين unique_ptr مع deleter مخصص هو الخيار الأخير.

القاعدة 10.2.4 استخدام std::make_shared بدلًا من new لتكوين shared_ptr

السبب بجانب أسباب الاتساق المشابهة لـ 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_unique، لا يدعم std::make_shared deleter مخصص.

Lambda

اقتراح 10.3.1 عند عدم قدرة الدالة على العمل، اختر استخدام lambda (التقاط المتغيرات المحلية، أو كتابة دالة محلية)

السبب لا يمكن للدالة التقاط المتغيرات المحلية أو الإعلان عنها في نطاق محلي؛ إذا كنت بحاجة إلى هذه الأشياء، اختر lambda قدر الإمكان، بدلًا من كتابة functor يدويًا. من ناحية أخرى، لا يمكن للـ lambda و functor إعادة التحميل؛ إذا كنت بحاجة إلى إعادة التحميل، استخدم الدالة. إذا كان كلا من lambda و الدالة مناسبين، فاُستخدم الدالة قدر الإمكان؛ استخدم أبسط الأدوات قدر الإمكان.

مثال

// كتابة دالة تقبل فقط int أو string
// -- إعادة التحميل هو الخيار الطبيعي
void F(int);
void F(const string&);

// الحاجة إلى التقاط الحالة المحلية، أو الظهور في نطاق العبارة أو التعبير
// -- lambda هو الخيار الطبيعي
vector<Work> v = LotsOfWork();
for (int taskNum = 0; taskNum < max; ++taskNum) {
    pool.Run([=, &v] {...});
}
pool.Join();

القاعدة 10.3.1 تجنب التقاط بالمرجع عند استخدام lambdas خارج النطاق المحلي

السبب يُستخدم lambdas خارج النطاق المحلي بما في ذلك القيم العائدة، والتخزين على الذاكرة المؤقتة، أو تمريرها إلى خيوط أخرى. لا ينبغي أن تكون المؤشرات والمراجع المحلية موجودة خارج عمر متغيراتها المحلية. التقاط بالمرجع في lambdas يعني تخزين مرجع إلى متغير محلي. إذا أدى ذلك إلى وجود مراجع خارج عمر المتغير المحلي، فلا ينبغي التقاط بالمرجع.

مثال

// سيء
void Foo()
{
    int local = 42;
    // التقاط local بالمرجع.
    // بعد عودة الدالة، local لم يعد موجودًا،
    // وبالتالي، سلوك Process() غير محدد!
    threadPool.QueueWork([&]{ Process(local); });
}

// جيد
void Foo()
{
    int local = 42;
    // التقاط local بالقيمة.
    // بسبب النسخ، Process() دائمًا فعال خلال استدعاء Process()؛
    threadPool.QueueWork([=]{ Process(local); });
}

اقتراح 10.3.2 عند التقاط this، يجب التقاط جميع المتغيرات بشكل صريح

السبب يبدو [=] في دالة عضو وكأنه التقاط بالقيمة. لكنه في الحقيقة التقاط this بالقيمة، بسبب كون this مؤشرًا، فإن أعضاء البيانات يتم التقاطها بالمرجع في الحقيقة، ويجب تجنب ذلك عمومًا. إذا كان من الضروري فعل ذلك، يجب التصريح بشكل صريح على التقاط this.

مثال

class MyClass {
public:
    void Foo()
    {
        int i = 0;

        auto Lambda = [=]() { Use(i, data_); };   // سيء: يبدو وكأنه نسخ/التقاط بالقيمة، لكن أعضاء الكلاس في الحقيقة يتم التقاطها بالمرجع

        data_ = 42;
        Lambda(); // يستدعي use(42);
        data_ = 43;
        Lambda(); // يستدعي use(43);

        auto Lambda2 = [i, this]() { Use(i, data_); }; // جيد، التصريح الصريح بالالتقاط بالقيمة، أقل التباسًا
    }

private:
    int data_ = 0;
};

اقتراح 10.3.3 تجنب استخدام نمط التقاط الافتراضي

السبب تُوفر تعبيرات lambda نوعين من أنماط التقاط افتراضية: بالمرجع (&) وبالقيمة (=). التقاط الافتراضي بالمرجع يُخفي التقاط جميع المتغيرات المحلية كمرجع، مما يسهل الوصول إلى المؤشرات المعلقة. بالمقارنة، كتابة المتغيرات التي يحتاجها lambda بشكل صريح يسهل فحص عمر الكائن وتقليل احتمال الخطأ. التقاط الافتراضي بالقيمة يُخفي التقاط مؤشر this، ويصعب على القارئ معرفة المتغيرات التي تعتمد عليها دالة lambda. إذا كانت هناك متغيرات ثابتة، فقد يخطئ القارئ في الاعتقاد أن lambda تقوم بنسخ متغير ثابت. لذلك، يجب عادةً كتابة المتغيرات التي يحتاجها lambda بشكل صريح، بدلًا من استخدام نمط التقاط افتراضي.

مثال خاطئ

auto func()
{
    int addend = 5;
    static int baseValue = 3;

    return [=]() {  // في الحقيقة تقوم فقط بنسخ addend
        ++baseValue;    // التعديل يؤثر على القيمة الثابتة
        return baseValue + addend;
    };
}

مثال صحيح

auto func()
{
    int addend = 5;
    static int baseValue = 3;

    return [addend, baseValue = baseValue]() mutable {  // استخدام تهيئة التقاط C++14 لنسخ نسخة من المتغير
        ++baseValue;    // تعديل النسخة الخاصة بي، لا يؤثر على القيمة الثابتة
        return baseValue + addend;
    };
}

راجع: “Effective Modern C++”: Item 31: Avoid default capture modes.

الواجهة

اقتراح 10.4.1 في السيناريوهات التي لا تنطوي على ملكية، استخدم T* أو T& كمعامل، بدلًا من المؤشر الذكي

السبب

  1. فقط عندما يكون من الضروري تحديد آلية الملكية، يجب نقل أو مشاركة الملكية من خلال المؤشر الذكي.
  2. تمرير المؤشر الذكي يقيد المستدعي على استخدام المؤشر الذكي (مثل رغبة المستدعي في تمرير this).
  3. تمرير المؤشر الذكي الذي يشارك الملكية له تكلفة تشغيلية.

مثال

// قبول أي int*
void F(int*);

// فقط قبول int التي يريد نقل ملكيتها
void G(unique_ptr<int>);

// فقط قبول int التي تريد مشاركة ملكيتها
void G(shared_ptr<int>);

// لا تغير الملكية، ولكن تحتاج إلى ملكية محددة من المستدعي
void H(const unique_ptr<int>&);

// قبول أي int
void H(int&);

// سيء
void F(shared_ptr<Widget>& w)
{
    // ...
    Use(*w); // فقط يستخدم w -- لا يتعلق بالحياة والموت
    // ...
};