华为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のように何の振る舞いもなく、単なる識別子として使用するクラスは、virtualデストラクタや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 {
 
};

オーバーロード

オペレータのオーバーロードには十分な理由が必要であり、オペレータの元の意味を変えないでください。たとえば、減算演算に’+‘演算子を使用しないでください。 オペレータのオーバーロードはコードをより直感的にしますが、いくつかの欠点もあります:

  • 直感を惑わせ、内蔵型と同様に高性能であると誤解し、性能低下の可能性を無視する。
  • 問題の特定時に直感的ではなく、関数名で検索する方が演算子で検索するより明らかに便利です。
  • オペレータの振る舞いが直感的でない場合(たとえば’+‘演算子を減算に使用する)、コードが混乱します。
  • 代入演算子のオーバーロードで導入された暗黙の変換は、深いバグを隠す可能性があります。=,==演算子の代わりに、Equals()、CopyFrom()などの関数を定義できます。

8 関数

関数設計

ルール8.1.1 関数が長くなりすぎないようにし、関数は50行以内(空行とコメントを除く)にする

関数は1画面で表示できるようにすべきです(50行以内)、一つのことに集中し、それを完璧にこなすべきです。

長すぎる関数は、関数の機能が単一ではなく、過度に複雑であるか、過度に詳細を提示しているか、さらに抽象化されていないことを意味している可能性があります。

例外:アルゴリズムを実装する特定の関数は、アルゴリズムの凝集性と機能の包括性のために、50行を超える可能性があります。

長い関数が現在非常にうまく動作していても、誰かが変更したときに新しい問題が発生し、発見しにくいバグを引き起こす可能性があります。 それをより短く、より管理しやすいいくつかの関数に分割して、他人がコードを読んだり変更したりしやすくすることを強くお勧めします。

インライン関数

提案8.2.1 インライン関数は10行以内(空行とコメントを除く)にする

説明:インライン関数は通常の関数の特性を持ち、関数呼び出しの処理に関してのみ通常の関数と異なります。通常の関数を呼び出すときは、プログラムの実行権を呼び出された関数に移し、その後呼び出し元の関数に戻ります。一方、インライン関数は呼び出し時に、呼び出し式をインライン関数本体で置き換えます。

インライン関数は1〜10行程度の小さな関数に適しています。多くの文を含む大きな関数の場合、関数呼び出しと返却のオーバーヘッドは比較的小さく、インライン関数を使用する必要はありません。通常のコンパイラはインライン化を放棄し、通常の方法で関数を呼び出します。

インライン関数にループ、分岐(switch)、try-catchなどの複雑な制御構造が含まれる場合、通常のコンパイラはその関数を通常の関数として扱います。

仮想関数と再帰関数はインライン関数として使用できません

関数パラメータ

提案8.3.1 参照を使用してポインタを置き換える

説明:参照はポインタよりも安全です。なぜなら、参照は必ずnullではなく、必ず他の対象を指すことはなく、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つ以下にする

関数のパラメータが多すぎると、外部の変化に対して関数が敏感になり、保守作業に影響を与えます。関数のパラメータが多すぎると、テスト作業も増大します。

もし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 PODオブジェクト以外にmemcpy_s、memset_sを使用して初期化しないこと

説明PODPlain 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とやり取りするからです。それらの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は「リソース取得は初期化である」の略で、オブジェクトのライフサイクルを使用してプログラムリソース(メモリ、ファイルハンドル、ネットワーク接続、ミューテックスなど)を制御するシンプルな技術です。

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のライフサイクルはまだ存続しているため、ポインタは有効

    // 文字列の非constメンバー関数を呼び出すと、文字列が変更され、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つの問題が生じます。1つは、あるバージョンのコピー時書き込み戦略がスレッドセーフを実装していないため、マルチスレッド環境でプログラムがクラッシュする可能性があることです。2つ目は、動的リンクライブラリ間でコピー時書き込み戦略に基づくstringを相互に受け渡すと、動的リンクライブラリがアンロードされたときに参照カウントが減少しないため、ぶら下がりポインタが発生する可能性があることです。したがって、プログラムの安定性を保証するには、信頼できるstl実装を選択することが重要です。

例外: システムや他のサードパーティライブラリのAPIを呼び出す際には、すでに定義されたインターフェースに対してはchar*を使用する必要があります。しかし、インターフェースを呼び出す前まではstringを使用でき、インターフェースを呼び出すときにstring::c_str()を使用して文字列ポインタを取得します。 スタック上に文字列配列をバッファとして使用する場合は、直接文字列配列を定義し、stringを使用する必要はありません。また、vectorなどのコンテナを使用する必要もありません。

ルール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を使用する一般的な2つのシナリオがあります。1つ目は、auto_ptrをauto_ptrを生成する関数の外部に渡すために使用すること、2つ目は、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を使用することができますが、auto_ptrをカプセル化し、カプセル化クラスのコピーコンストラクタと代入演算子を無効にして、このカプセル化クラスが標準コンテナで使用できないようにすることをお勧めします。

提案9.5.2 新しい標準ヘッダファイルを使用する

説明: C++の標準ヘッダファイルを使用する際は、<cstdlib>のように使用し、<stdlib.h>のようには使用しないでください。

constの使用方法

変数やパラメータの前にconstキーワードを付けることで、変数の値が変更されないことを示すことができます(例:const int foo)。クラスの関数にconst修飾子を付けることで、その関数がクラスメンバー変数の状態を変更しないことを示すことができます(例:class Foo { int Bar(char c) const; };)。const変数、データメンバー、関数、パラメータはコンパイル時の型検査に追加の保証を提供し、エラーを早期に発見するのに役立ちます。そのため、可能な限りconstを使用することを強くお勧めします。 時には、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;    // 例外を投げる可能性がある
    // 何かをする
    return res;
}

RetType Function(Type params) noexcept;   // 最大の最適化
RetType Function(Type params);            // 少ない最適化

// std::vector の move 操作は 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++は非常に強力なジェネリックプログラミングメカニズムを提供しており、非常に柔軟で簡潔なタイプセーフなインターフェースを実装でき、タイプが異なるが振る舞いが同じコードの再利用を実現できます。

しかし、C++のジェネリックプログラミングには以下のような欠点があります:

  1. ジェネリックプログラミングにあまり熟練していない人は、オブジェクト指向のロジックをテンプレートとして書き、テンプレートパラメータに依存しないメンバーをテンプレートに書き込むなどして、ロジックの混乱やコードの肥大化を引き起こすことがよくあります。
  2. テンプレートプログラミングで使用されるテクニックは、C++にあまり熟練していない人にとっては非常に難解で理解しにくいです。複雑な場所でテンプレートを使用したコードは、読むのがさらに難しくなり、デバッグやメンテナンスも非常に面倒になります。
  3. テンプレートはコードが間違っているときに非常に不親切なコンパイルエラーメッセージを表示します:コードにエラーがあるとき、このインターフェースが非常にシンプルでも、テンプレート内部の複雑な実装の詳細がエラーメッセージに表示され、このコンパイルエラーメッセージを理解するのは非常に難しいです。
  4. テンプレートが不適切に使用されると、実行時のコードが過度に肥大化する可能性があります。
  5. テンプレートコードは変更やリファクタリングが困難です。テンプレートコードは多くの文脈で展開されるため、リファクタリングがすべての展開されたコードに有効かどうかを確認するのは非常に困難です。

したがって、OpenHarmonyの大部分のコンポーネントはテンプレートプログラミングを禁止しており、ごく少数のコンポーネントのみがジェネリックプログラミングを使用でき、開発されたテンプレートには詳細なコメントが必要です。 例外:

  1. STLアダプテーションレイヤーはテンプレートを使用できます

マクロ

C++言語では、できるだけ複雑なマクロの使用を避けることを強くお勧めします。

  • 定数定義については、前述の章で述べたように、constまたはenumを使用してください。
  • マクロ関数については、できるだけシンプルにし、以下の原則に従ってください。また、inline関数やtemplate関数などで置き換えることを優先してください。
// マクロ関数の使用はお勧めしません
#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 仮想関数をオーバーライドするときはoverrideまたはfinalキーワードを使用する

理由 overridefinalキーワードは、関数が仮想関数であり、基底クラスの仮想関数をオーバーライドすることを保証します。子クラスの関数と基底クラスの関数のプロトタイプが一致しない場合、コンパイル警告が発生します。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) をオーバーライドし、Derivedの派生クラスはこの関数をオーバーライドできない
    void Bar() override;       // コンパイル失敗: Base::Bar は仮想関数ではない
};

まとめ

  1. 基底クラスで初めて仮想関数を定義するときは、virtualキーワードを使用する
  2. 子クラスが基底クラスの仮想関数(デストラクタを含む)をオーバーライドするときは、overrideまたはfinalキーワードを使用する(両方同時に使用しないこと)、かつ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* はメンバ関数ポインタに自動変換できない

NULL00Lとして定義すれば、上記の問題は解決できます。

または、空ポインタが必要な場所で直接0を使用します。しかし、これには別の問題があり、コードが明確ではなく、特にauto自動推論を使用する場合です:

auto result = Find(id);
if (result == 0) {  // Find() はポインタを返すのか整数を返すのか?
    // 何かをする
}

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() はポインタを返す
    // 何かをする
}

ルール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 constオブジェクトに対してstd::moveを使用しないこと

文字通り見ると、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); // 悪い: 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 newではなくstd::make_uniqueを使用してunique_ptrを作成する

理由

  1. make_uniqueはより簡潔な作成方法を提供します
  2. 複雑な式の例外安全を保証します

// 悪い:MyClassが2回出現、不一致のリスクがある
std::unique_ptr<MyClass> ptr(new MyClass(0, 1));
// 良い:MyClassが1回だけ出現、不一致の可能性がない
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を実装することをお勧めします。 カスタムdeleter付きのunique_ptrを作成するためにnewを使用することは最後の選択肢です。

ルール10.2.4 newではなくstd::make_sharedを使用してshared_ptrを作成する

理由 std::make_sharedを使用してstd::shared_ptrを作成することは、std::make_uniqueと同様に一貫性などの理由だけでなく、パフォーマンスの観点からも重要です。 std::shared_ptrは2つの実体を管理します:

  • 制御ブロック(参照カウント、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は避けます。 一方、lambdafunctorはオーバーロードできません。オーバーロードが必要な場合は、関数を使用します。 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()の呼び出し中、localは常に有効
    threadPool.QueueWork([=]{ Process(local); });
}

提案10.3.2 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式は2種類のデフォルトキャプチャモードを提供しています:参照によるキャプチャ(&)と値によるキャプチャ(=)。 デフォルトの参照キャプチャはすべてのローカル変数の参照を暗黙的にキャプチャするため、ハングアップ参照へのアクセスを簡単に引き起こします。対照的に、必要な変数を明示的に書くことで、オブジェクトのライフサイクルをより簡単に確認でき、間違いを減らすことができます。 デフォルトの値キャプチャは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だけを使用する -- 所有権管理とは全く関係ない
    // ...
};