华为C++编程规范
Categories:
- 华为C++编程规范
C++语言编程规范
目的
规则并不是完美的,通过禁止在特定情况下有用的特性,可能会对代码实现造成影响。但是我们制定规则的目的“为了大多数程序员可以得到更多的好处”, 如果在团队运作中认为某个规则无法遵循,希望可以共同改进该规则。 参考该规范之前,希望您具有相应的C++语言基础能力,而不是通过该文档来学习C++语言。
- 了解C++语言的ISO标准;
- 熟知C++语言的基本语言特性,包括C++ 03/11/14/17相关特性;
- 了解C++语言的标准库;
总体原则
代码需要在保证功能正确的前提下,满足可读、可维护、安全、可靠、可测试、高效、可移植的特征要求。
重点关注
- 约定C++语言的编程风格,比如命名,排版等。
- C++语言的模块化设计,如何设计头文件,类,接口和函数。
- C++语言相关特性的优秀实践,比如常量,类型转换,资源管理,模板等。
- 现代C++语言的优秀实践,包括C++11/14/17中可以提高代码可维护性,提高代码可靠性的相关约定。
- 本规范优先适于用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)通常指类、模板的纯粹声明,没伴随着其定义。
- 优点:
- 前置声明能够节省编译时间,多余的 #include 会迫使编译器展开更多的文件,处理更多的输入。
- 前置声明能够节省不必要的重新编译的时间。 #include 使代码因为头文件中无关的改动而被重新编译多次。
- 缺点:
- 前置声明隐藏了依赖关系,头文件改动时,用户的代码会跳过必要的重新编译过程。
- 前置声明可能会被库的后续更改所破坏。前置声明模板有时会妨碍头文件开发者变动其 API. 例如扩大形参类型,加个自带默认参数的模板形参等等。
- 前置声明来自命名空间
std::的 symbol 时,其行为未定义(在C++11标准规范中明确说明)。 - 前置声明了不少来自头文件的 symbol 时,就会比单单一行的 include 冗长。
- 仅仅为了能前置声明而重构代码(比如用指针成员代替对象成员)会使代码变得更慢更复杂。
- 很难判断什么时候该用前置声明,什么时候该用
#include,某些场景下面前置声明和#include互换以后会导致意想不到的结果。
所以我们尽可能避免使用前置声明,而是使用#include头文件来保证依赖关系。
6 作用域
命名空间
建议6.1.1 对于cpp文件中不需要导出的变量,常量或者函数,请使用匿名namespace封装或者用static修饰
在C++ 2003标准规范中,使用static修饰文件作用域的变量,函数等被标记为deprecated特性,所以更推荐使用匿名namespace。
主要原因如下:
- static在C++中已经赋予了太多的含义,静态函数成员变量,静态成员函数,静态全局变量,静态函数局部变量,每一种都有特殊的处理。
- static只能保证变量,常量和函数的文件作用域,但是namespace还可以封装类型等。
- 统一namespace来处理C++的作用域,而不需要同时使用static和namespace来管理。
- 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以后才有)。 如果我们不要使用拷贝构造函数,或者赋值操作符,请明确拒绝:
- 将拷贝构造函数或者赋值操作符设置为private,并且不实现:
class Foo {
private:
Foo(const Foo&);
Foo& operator=(const Foo&);
};
-
使用C++11提供的delete, 请参见后面现代C++的相关章节。
-
推荐继承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); // 传入的是派生类对象
- 将拷贝构造函数或者赋值操作符设置为private,并且不实现:
继承
规则7.2.1 基类的析构函数应该声明为virtual,不准备被继承的类需要声明为final
说明:只有基类析构函数是virtual,通过多态调用的时候才能保证派生类的析构函数被调用。
示例:基类的析构函数没有声明为virtual导致了内存泄漏。
class Base {
public:
```cpp
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의 소멸자는 호출되지 않고 기반 클래스의 소멸자만 호출되어 메모리 누수가 발생합니다. 예외: NoCopyable, NoMovable처럼 아무런 동작도 하지 않고 단순히 식별자 역할만 하는 클래스는 가상 소멸자를 정의하지 않아도 되며 final을 정의하지 않아도 됩니다.
규칙 7.2.2 가상 함수에서 기본 매개변수 값을 금지합니다.
설명: C++에서 가상 함수는 동적 바인딩되지만 함수의 기본 매개변수는 컴파일 타임에 정적 바인딩됩니다. 즉, 결국 실행되는 함수는 파생 클래스에서 정의되었지만 기반 클래스의 기본 매개변수 값을 사용하는 가상 함수가 됩니다. 가상 함수를 재정의할 때 매개변수 선언이 일치하지 않아 사용자를 혼란스럽게 하고 문제를 야기하는 것을 피하기 위해 모든 가상 함수는 기본 매개변수 값을 선언할 수 없습니다. 예시: 가상 함수 display의 기본 매개변수 값 text는 컴파일 타임에 결정되며 런타임에 결정되는 것이 아닙니다. 다형성을 달성하지 못했습니다.
class Base {
public:
virtual void Display(const std::string& text = "Base!")
{
std::cout << text << std::endl;
}
virtual ~Base(){}
};
class Sub : public Base {
public:
virtual void Display(const std::string& text = "Sub!")
{
std::cout << text << std::endl;
}
virtual ~Sub(){}
};
int main()
{
Base* base = new Sub();
Sub* sub = new Sub();
...
base->Display(); // 프로그램 출력 결과: Base! 원하는 출력: Sub!
sub->Display(); // 프로그램 출력 결과: Sub!
delete base;
delete sub;
return 0;
};
규칙 7.2.3 상속받은 비가상 함수를 재정의하는 것을 금지합니다.
설명: 비가상 함수는 동적 바인딩이 불가능하기 때문에 가상 함수만이 동적 바인딩을 구현할 수 있습니다. 즉, 기반 클래스의 포인터를 조작하기만 하면 올바른 결과를 얻을 수 있습니다.
예시:
class Base {
public:
void Fun();
};
class Sub : public Base {
public:
void Fun();
};
Sub* sub = new Sub();
Base* base = sub;
sub->Fun(); // 서브 클래스의 Fun 호출
base->Fun(); // 부모 클래스의 Fun 호출
//...
다중 상속
실제 개발 과정에서 다중 상속을 사용하는 경우는 비교적 적습니다. 왜냐하면 다중 상속을 사용하는 과정에서 다음과 같은 전형적인 문제가 있기 때문입니다:
- 다이아몬드 상속으로 인한 데이터 중복과 이름의 모호성. 따라서 C++은 virtual 상속을 도입하여 이러한 문제를 해결했습니다.
- 다이아몬드 상속이 아니더라도 여러 부모 클래스 사이의 이름이 충돌하여 모호성이 발생할 수 있습니다.
- 서브 클래스가 여러 부모 클래스의 메서드를 확장하거나 재작성해야 할 때, 서브 클래스의 책임이 명확하지 않아 의미가 혼란스러워집니다.
- 위임에 비해 상속은 화이트박스 재사용로, 서브 클래스가 부모 클래스의 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행을 초과하지 않아야 합니다(공백 및 주석 제외)
함수는 한 화면에 모두 표시될 수 있어야 하며(50행 이하), 하나의 일만 수행하고 그 일을 잘 해야 합니다.
길이가 너무 긴 함수는 종종 함수 기능이 단일하지 않거나 지나치게 복잡하거나 세부 사항을 과도하게 드러내어 추가적인 추상화가 이루어지지 않은 것을 의미합니다.
예외: 알고리즘을 구현하는 함수 중에는 알고리즘의 집합성과 기능의 포괄성으로 인해 50행을 초과할 수 있습니다.
길이가 긴 함수가 현재 매우 잘 작동하고 있더라도 누군가가 수정할 경우 새로운 문제를 야기할 수 있으며, 발견하기 어려운 버그로 이어질 수 있습니다. 함수를 더 간단하고 관리하기 쉬운 여러 개의 함수로 분할하여 다른 사람이 코드를 읽고 수정할 수 있도록 하는 것이 좋습니다.
인라인 함수
권고 8.2.1 인라인 함수는 10행을 초과하지 않아야 합니다(공백 및 주석 제외)
설명: 인라인 함수는 일반 함수의 특성을 가지고 있으며, 일반 함수와 다른 점은 함수 호출 처리 방식에 있습니다. 일반 함수를 호출할 때는 프로그램 실행 권한을 호출된 함수로 이동한 후 다시 호출 함수로 돌아옵니다. 반면에 인라인 함수는 호출 시 호출 표현식을 인라인 함수 본문으로 대체합니다.
인라인 함수는 1~10개의 작은 함수에 적합합니다. 많은 문장을 포함하는 큰 함수의 경우 함수 호출과 반환의 오버헤드가 상대적으로 미미하기 때문에 인라인 함수로 구현할 필요가 없습니다. 일반적으로 컴파일러는 인라인 방식을 포기하고 일반 함수 방식을 채택합니다.
인라인 함수가 복잡한 제어 구조(예: 루프, 분기(switch), try-catch 등)를 포함하는 경우 일반적으로 컴파일러는 해당 함수를 일반 함수로 간주합니다.
가상 함수, 재귀 함수는 인라인 함수로 사용할 수 없습니다.
함수 매개변수
권고 8.3.1 참조를 사용하여 포인터를 대체합니다.
설명: 참조는 NULL이 아니므로 더 안전하며, 다른 대상을 가리키지 않으며, 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를 전달했고, 오류가 발생하지 않았습니다.
}
- 템플릿 함수를 사용하여 매개변수 타입의 변화를 구현할 수 있습니다.
- 다형성을 구현하기 위해 기본 클래스 포인터를 사용할 수 있습니다.
권고 8.3.3 함수의 매개변수 개수는 5개를 초과하지 않아야 합니다.
함수의 매개변수가 많으면 외부 변화에 의해 함수가 영향을 받기 쉬워지고 유지 관리 작업에 영향을 미칩니다. 함수의 매개변수가 많으면 테스트 작업량도 늘어납니다.
초과하는 경우 다음을 고려할 수 있습니다:
- 함수를 분할할 수 있는지 확인
- 관련 매개변수를 구조체로 정의하여 함께 그룹화할 수 있는지 확인
9 C++ 기타 특성
상수와 초기화
변하지 않는 값은 이해하고 추적하며 분석하기가 더 쉬우므로 변수 대신 가능한 한 상수를 사용해야 합니다. 값을 정의할 때 const를 기본 옵션으로 사용해야 합니다.
규칙 9.1.1 매크로를 상수를 나타내는 데 사용하는 것을 금지합니다.
설명: 매크로는 간단한 텍스트 대체이며, 전처리 단계에서 완료되어 실행 중 오류가 발생할 때 직접 해당 값이 표시됩니다. 디버깅 중에는 매크로 이름이 아닌 값만 표시됩니다. 매크로는 타입 검사가 없으며 안전하지 않습니다. 매크로는 스코프가 없습니다.
#define MAX_MSISDN_LEN 20 // 좋지 않음
// C++에서는 const 상수를 사용하십시오.
const int MAX_MSISDN_LEN = 20; // 좋음
// C++11 이상 버전의 경우 constexpr을 사용할 수 있습니다.
constexpr int MAX_MSISDN_LEN = 20;
권고 9.1.1 관련된 정수 상수 그룹은 열거형으로 정의합니다.
설명: 열거형은 #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 프로토콜에서 정의한 장치 ID 값으로 장치 유형을 식별합니다.
enum DeviceType {
DEV_UNKNOWN = -1,
DEV_DSMP = 0,
DEV_ISMG = 1,
DEV_WAPPORTAL = 2
};
프로그램 내부에서 사용되며 분류만을 위한 경우 명시적 할당을 피해야 합니다.
// 좋은 예: 세션 상태를 나타내는 열거형 정의
enum SessionState {
INIT,
CLOSED,
WAITING_FOR_RESPONSE
};
가능하면 열거형 값의 중복을 피해야 하며, 중복이 필요할 경우 이미 정의된 열거형을 사용하여 수정해야 합니다.
enum RTCPType {
RTCP_SR = 200,
RTCP_MIN_TYPE = RTCP_SR,
RTCP_RR = 201,
RTCP_SDES = 202,
RTCP_BYE = 203,
RTCP_APP = 204,
RTCP_RTPFB = 205,
RTCP_PSFB = 206,
RTCP_XR = 207,
RTCP_RSI = 208,
RTCP_PUBPORTS = 209,
RTCP_MAX_TYPE = RTCP_PUBPORTS
};
규칙 9.1.2 악마의 숫자를 사용하지 마십시오.
악마의 숫자란 이해할 수 없고, 이해하기 어려운 숫자를 말합니다.
악마의 숫자는 흑백 논리가 아니며, 이해하기 어려운 정도도 다양하며, 직접 판단해야 합니다.
예를 들어 숫자 12는 다른 맥락에서 상황이 다릅니다:
type = 12; 는 이해할 수 없지만, monthsCount = yearsCount * 12; 은 이해할 수 있습니다.
숫자 0도 때때로 악마의 숫자이며, 예를 들어 status = 0; 은 어떤 상태인지 나타내지 않습니다.
해결 방법:
- 지역적으로 사용되는 숫자의 경우 주석을 추가하여 설명합니다.
- 여러 곳에서 사용되는 숫자의 경우 const 상수를 정의하고, 심벌 네임으로 자동 주석을 제공해야 합니다.
다음과 같은 상황은 금지됩니다:
- 숫자의 의미를 설명하기 위해 심벌을 사용하지 않는 경우, 예:
const int ZERO = 0 - 심벌 네임이 그 값에 제한을 두는 경우, 예:
const int XX_TIMER_INTERVAL_300MS = 300, 대신XX_TIMER_INTERVAL_MS를 사용하여 해당 상수가 타이머의 시간 간격을 나타내는 것으로 표시합니다.
규칙 9.1.3 상수는 단일 책임을 보장해야 합니다.
설명: 하나의 상수는 특정 기능만을 나타내야 하며, 즉 하나의 상수는 여러 용도로 사용될 수 없습니다.
// 좋은 예: 프로토콜 A와 B에서 휴대폰 번호(MSISDN) 길이는 모두 20입니다.
const unsigned int A_MAX_MSISDN_LEN = 20;
const unsigned int B_MAX_MSISDN_LEN = 20;
// 또는 다른 이름공간을 사용:
namespace Namespace1 {
const unsigned int MAX_MSISDN_LEN = 20;
}
namespace Namespace2 {
const unsigned int MAX_MSISDN_LEN = 20;
}
규칙 9.1.4 memcpy_s, memset_s를 사용하여 POD 객체를 초기화하는 것을 금지합니다.
설명: POD는 Plain Old Data의 약자로, C++ 98 표준(ISO/IEC 14882, first edition, 1998-09-01)에서 도입된 개념으로, POD 타입은 주로 int, char, float, double, enumeration, void, 포인터 등의 원시 타입과 집합 타입을 포함하며, 캡슐화와 객체 지향 특성(예: 사용자 정의 생성/할당/소멸자, 기본 클래스, 가상 함수 등)을 사용할 수 없습니다.
비POD 타입의 경우, 예를 들어 비집합 타입의 class 객체는 가상 함수를 포함할 수 있으며, 메모리 레이아웃이 불확실하고 컴파일러에 따라 달라질 수 있습니다. 남용된 메모리 복사는 심각한 문제를 야기할 수 있습니다.
집합 타입의 class라도 직접 메모리 복사와 비교를 사용하는 것은 정보 은폐와 데이터 보호 기능을 파괴하므로 memcpy_s, memset_s 연산을 권장하지 않습니다.
POD 타입에 대한 자세한 설명은 부록을 참조하십시오.
권고 9.1.2 변수는 사용할 때 선언하고 초기화합니다.
설명: 변수가 사용 전에 초기값이 없이 사용되는 것은 일반적인 초보 프로그래밍 오류입니다. 사용 전에 변수를 선언하고 동시에 초기화하면 이러한 초보 오류를 쉽게 피할 수 있습니다.
함수 시작 위치에서 모든 변수를 선언하고 나중에 사용하는 것은 변수의 범위가 전체 함수 구현을 포함하게 되어 코드를 이해하고 유지하기 어렵게 만들 수 있습니다. 특히 함수 시작 시점에 충분한 정보가 없어 종종 기본값(예: 0)으로 초기화하게 되는데, 이는 일반적으로 낭비이며 변수가 유효한 값으로 할당되기 전에 사용되면 오류를 야기할 수 있습니다.
변수 범위 최소화 원칙과 근처 선언 원칙을 따르면 코드를 더 쉽게 읽을 수 있고 변수의 타입과 초기값을 이해하기 쉬워집니다. 특히 초기화를 사용하여 선언과 할당을 대체하는 것이 좋습니다.
// 좋지 않은 예: 선언과 초기화가 분리됨
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: 두 번째 매개변수를 전달할 때, 증가 연산이 발생했는지 여부가 불확실합니다.
올바른 작성 방법
i++; // Good: 별도의 줄
x = Func(i, i);
규칙 9.2.2 switch 문에는 default 분기를 포함해야 합니다.
대부분의 경우 switch 문에는 default 분기가 있어야 하며, 이는 누락된 case 라벨 처리 시 기본 처리 행위를 제공합니다.
예외: switch 조건 변수가 열거형 타입이고 case 분기가 모든 값을 포함하는 경우 default 분기를 추가하는 것은 다소 불필요합니다. 현대 컴파일러는 switch 문에서 열거형 값의 누락된 case 분기를 검사하는 기능을 가지고 있으며, 이에 대한 경고 메시지를 제공합니다.
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++ 언어는 기계 프로그래밍을 위한 언어이며 포인터 주소와 관련이 있고, 다양한 제3자 또는 저수준 API와 상호작용하게 됩니다. 이러한 API의 타입 설계가 합리적이지 않을 수 있으며, 이 적응 과정에서 타입 변환이 쉽게 발생할 수 있습니다.
예외: 특정 함수를 호출할 때 함수 결과를 처리하고 싶지 않은 경우, 이것이 최선의 선택인지 먼저 고려해야 합니다. 함수의 반환값을 처리하고 싶지 않다면 (void) 변환을 사용하여 해결할 수 있습니다.
규칙 9.3.1 타입 변환을 사용해야 한다면 C++가 제공하는 타입 변환을 사용하고 C 스타일 타입 변환을 사용하지 마십시오.
설명:
C++가 제공하는 타입 변환은 C 스타일 변환보다 더 구체적이고 읽기 쉬우며, 더 안전합니다. C++가 제공하는 변환은 다음과 같습니다:
- 타입 변환:
dynamic_cast: 주로 상속 체계 하위 변환에 사용되며,dynamic_cast는 타입 검사 기능을 가지고 있습니다. 기반 클래스와 파생 클래스 설계를 잘 해야 하며,dynamic_cast를 사용하여 변환하는 것은 피해야 합니다.static_cast: C 스타일 변환과 유사하며 값의 강제 변환이나 상위 변환(파생 클래스의 포인터 또는 참조를 기반 클래스의 포인터 또는 참조로 변환)에 사용할 수 있습니다. 이 변환은 다중 상속으로 인한 타입 모호성을 제거하는 데 자주 사용되며, 비교적 안전합니다. 순수한 산술 변환이라면 뒤에 나오는 중괄호 변환 방식을 사용하는 것이 좋습니다.reinterpret_cast: 관련이 없는 타입 간 변환에 사용됩니다.reinterpret_cast는 컴파일러에게 특정 타입 객체의 메모리를 다른 타입으로 다시 해석하도록 강제하며, 이는 안전하지 않은 변환으로尽可能少用reinterpret_cast.const_cast: 객체의const속성을 제거하여 객체를 수정 가능하게 만듭니다. 이는 데이터의 불변성을 파괴하므로尽可能少用.
- 산술 변환: (C++11부터 지원) 산술 변환 및 타입 정보가 손실되지 않는 변환의 경우, 예를 들어 float에서 double, int32에서 int64로의 변환의 경우, 대괄호 초기화 방식을 권장합니다.
double d{ someFloat };
int64_t i{ someInt32 };
권고 9.3.1 dynamic_cast 사용을 피하십시오.
dynamic_cast는 C++의 RTTI에 의존하여 런타임에 C++ 클래스 객체의 타입을 식별합니다.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의 형식이 맞지 않으면 결과는 알려지지 않습니다. 비클래스 타입의 경우, 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() 등이 포함될 수 있습니다.
// string이 수정되면 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*대신 사용하는 것은 많은 장점이 있습니다:
- 끝에 있는 ‘\0’을 고려할 필요가 없습니다.
- +, =, == 등의 연산자와 다른 문자열 조작 함수를 직접 사용할 수 있습니다.
- 명시적인 new/delete를 고려할 필요가 없으며, 이로 인한 오류를 피할 수 있습니다.
주의할 점은 일부 stl 구현에서 string이 쓰기 시 복사 전략을 기반으로 한다는 것입니다. 이는 두 가지 문제를 야기합니다. 하나는 일부 버전의 쓰기 시 복사 전략이 스레드 안전을 구현하지 않아 멀티 스레드 환경에서 프로그램 충돌을 야기할 수 있다는 것입니다. 두 번째는 동적 라이브러리와 상호 전달되는 쓰기 시 복사 전략 기반의 string이 언로드될 때 참조 횟수가 감소하지 않아 잘못된 포인터가 생길 수 있다는 것입니다. 따라서 프로그램 안정성을 보장하기 위해 신뢰할 수 있는 stl 구현을 선택하는 것은 매우 중요합니다.
예외:
시스템이나 다른 제3자 라이브러리 API를 호출할 때는 이미 정의된 인터페이스에 따라char*를 사용해야 합니다. 그러나 인터페이스를 호출하기 전까지는 string을 사용할 수 있으며, 인터페이스를 호출할 때는 string::c_str()을 사용하여 문자 포인터를 얻을 수 있습니다.
스택에서 버퍼로 사용할 문자 배열을 직접 할당하는 경우 string을 사용할 필요가 없습니다. 또한vector<char>등의 컨테이너를 사용할 필요도 없습니다.
규칙 9.5.2 auto_ptr 사용을 금지합니다.
설명: stl 라이브러리의 std::auto_ptr은 암시적 소유권 이전 동작을 가지고 있습니다. 다음 코드를 참조하십시오:
auto_ptr<T> p1(new T);
auto_ptr<T> p2 = p1;
2번째 문장이 실행된 후 p1은 1번째 문장에서 할당된 객체를 가리키지 않게 되고 nullptr이 됩니다. 이것이 바로 auto_ptr이 다양한 표준 컨테이너에 넣을 수 없는 이유입니다. 소유권 이전 동작은 일반적으로 원하는 결과가 아닙니다. 소유권을 반드시 이전해야 하는 시나리오의 경우에도 암시적 이전 방식을 사용하는 것은 바람직하지 않습니다. 이는 auto_ptr을 사용하는 코드에 대해 프로그래머가 추가적인 주의를 기울이도록 요구하며, 그렇지 않으면 잘못된 포인터에 대한 액세스가 발생할 수 있습니다. auto_ptr을 사용하는 일반적인 두 가지 시나리오는 다음과 같습니다. 첫째는 auto_ptr을 생성하는 함수 외부로 전달하는 것입니다. 둘째는 auto_ptr을 RAII 관리 클래스로 사용하여 auto_ptr의 수명이 초과되면 자동으로 자원을 해제하는 것입니다. 첫 번째 시나리오의 경우 std::shared_ptr로 대체할 수 있습니다. 두 번째 시나리오의 경우 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를 사용하는 것을 강력히 권고합니다.
때때로 C++11의 constexpr을 사용하여 진정한 상수를 정의하는 것이 더 나을 수 있습니다.
규칙 9.6.1 포인터와 참조 타입의 매개변수가 수정되지 않는 경우 const를 사용합니다.
변하지 않는 값은 이해/추적/분석하기가 더 쉽습니다. 가능한 경우 const를 기본 옵션으로 사용하면 컴파일 타임에 검사하여 코드를 더 견고하고 안전하게 만듭니다.
class Foo;
void PrintFoo(const Foo& foo);
규칙 9.6.2 멤버 변수를 수정하지 않는 멤버 함수에는 const로 수정합니다.
가능한 경우 멤버 함수를 const로 선언합니다. 액세스 함수는 항상 const로 선언해야 합니다. 멤버 변수를 수정하지 않는 멤버 함수는 모두 const로 선언합니다. 가상 함수의 경우 단일 클래스의 구현만 고려하는 것이 아니라 설계 의도에 따라 상속 체인의 모든 클래스가 해당 가상 함수에서 멤버 변수를 수정해야 하는지 고려해야 합니다.
class Foo {
public:
// ...
int PrintValue() const // const로 멤버 함수를 수정하면 멤버 변수를 수정하지 않습니다.
{
std::cout << value_ << std::endl;
}
int GetValue() const // const로 멤버 함수를 수정하면 멤버 변수를 수정하지 않습니다.
{
return value_;
}
private:
int value_;
};
권고 9.6.1 초기화 후 수정되지 않는 멤버 변수를 const로 정의합니다.
class Foo {
public:
Foo(int length) : dataLength_(length) {}
private:
const int dataLength_;
};
예외
권고 9.7.1 C++11에서 함수가 예외를 발생시키지 않는 경우 noexcept로 선언합니다.
이유
- 함수가 예외를 발생시키지 않는 경우 noexcept로 선언하면 컴파일러가 함수를 최대한 최적화할 수 있으며, 실행 경로를 줄이고 오류 종료 효율을 높일 수 있습니다.
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++ 제네릭 프로그래밍에는 다음과 같은 단점이 있습니다:
- 제네릭 프로그래밍에 능숙하지 않은 사람들은 종종 객체 지향 로직을 템플릿으로 작성하거나 템플릿 매개변수와 무관한 멤버를 템플릿에 작성하여 논리 혼란과 코드 팽창을 야기합니다.
- 템플릿 프로그래밍은 C++에 능숙하지 않은 사람들에게는 종종 모호하고 이해하기 어려운 기교를 사용합니다. 복잡한 곳에서 템플릿을 사용한 코드는 더 이해하기 어려우며, 디버그와 유지 보수가 매우 번거롭습니다.
- 템플릿을 사용하면 종종 컴파일 오류 메시지가 매우 친절하지 않습니다: 코드에 오류가 있을 때, 인터페이스가 매우 간단하더라도 템플릿 내부의 복잡한 구현 세부 사항이 오류 메시지에 표시됩니다. 이로 인해 컴파일 오류 메시지가 매우 이해하기 어려워집니다.
- 템플릿이 부적절하게 사용되면 런타임 코드 과다 팽창을 야기할 수 있습니다.
- 템플릿 코드는 수정과 리팩토링이 어렵습니다. 템플릿 코드는 많은 컨텍스트에서 확장되므로 리팩토링이 모든 확장된 코드에 유용한지 확인하기가 어렵습니다.
따라서 OpenHarmony 대부분의 부품은 템플릿 프로그래밍을 금지하며, __少数 부품__만이 제네릭 프로그래밍을 사용할 수 있으며, 개발된 템플릿에는 상세한 주석이 있어야 합니다. 예외:
- stl 적층층은 템플릿을 사용할 수 있습니다.
매크로
C++ 언어에서 가능한 한 적게 복잡한 매크로를 사용하는 것을 강력히 권고합니다.
- 상수 정의의 경우 앞서 언급한 장에 따라 const 또는 열거형을 사용해야 합니다.
- 매크로 함수의 경우 가능한 한 간단하게 작성하고 아래 규칙을 따르며, 내부 함수, 템플릿 함수 등을 대체하는 것을 우선시해야 합니다.
// 매크로 함수 사용을 권장하지 않음
#define SQUARE(a, b) ((a) * (b))
// 템플릿 함수, 내부 함수 등을 대체하기 위해 사용하십시오.
template<typename T> T Square(T a, T b) { return a * b; }
매크로를 사용해야 하는 경우 C 언어 규칙의 관련 장을 참조하십시오. 예외: new, delete의 래핑 처리와 같이 일반적이고 성숙한 애플리케이션의 경우 매크로 사용을 보존할 수 있습니다.
10 현대 C++ 특성
ISO가 2011년 C++11 언어 표준을 발표한 이후, 그리고 2017년 3월 C++17을 발표함에 따라 현대 C++(C++11/14/17 등)은 프로그래밍 효율과 코드 품질을 크게 향상시키는 새로운 언어 특성과 표준 라이브러리를 대량으로 추가했습니다. 이 장에서는 팀이 현대 C++을 보다 효율적으로 사용하고 언어 함정을 피할 수 있도록 돕는 지침을 설명합니다.
코드 간결성과 안전성 향상
권고 10.1.1 적절히auto를 사용합니다.
이유
auto는 길고 반복적인 타입명을 피하고 정의 변수 시 초기화를 보장할 수 있습니다.auto의 타입 추론 규칙이 복잡하여 신중히 이해해야 합니다.- 코드를 더 명확하게 만든다면 명시적인 타입을 계속 사용하고 지역 변수에서만
auto를 사용하십시오.
예시
// 길고 반복적인 타입명 피하기
std::map<string, int>::iterator iter = m.find(val);
auto iter = m.find(val);
// 반복적인 타입명 피하기
class Foo {...};
Foo* p = new Foo;
auto p = new Foo;
// 초기화 보장하기
int x; // 컴파일 성공, 초기화되지 않음
auto x; // 컴파일 실패, 초기화 필요
auto의 타입 추론은 혼동을 야기할 수 있습니다:
auto a = 3; // int
const auto ca = a; // const int
const auto& ra = a; // const int&
auto aa = ca; // int, const와 reference 무시
auto ila1 = { 10 }; // std::initializer_list<int>
auto ila2{ 10 }; // std::initializer_list<int>
auto&& ura1 = x; // int&
auto&& ura2 = ca; // const int&
auto&& ura3 = 10; // int&&
const int b[10];
auto arr1 = b; // const int*
auto& arr2 = b; // const int(&)[10]
auto 타입 추론 시 참조를 무시한다는 점에 주의하지 않으면 성능 문제를 야기할 수 있는 어려운 버그를 도입할 수 있습니다:
std::vector<std::string> v;
auto s1 = v[0]; // auto는 std::string으로 추론, v[0] 복사
auto를 인터페이스에 사용하는 경우, 예를 들어 헤더 파일의 상수 정의에서 개발자가 값을 수정함에 따라 타입이 변할 수 있습니다.
규칙 10.1.1 가상 함수를 재정의할 때는override또는final키워드를 사용합니다.
이유
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) 재정의, Derived의 파생 클래스는 이 함수를 다시 재정의할 수 없음
void Bar() override; // 컴파일 실패: Base::Bar는 가상 함수가 아님
};
요약
- 기본 클래스에서 처음 가상 함수를 정의할 때는
virtual키워드를 사용합니다. - 서브 클래스에서 기본 클래스 가상 함수(소멸자 포함)를 재정의할 때는
override또는final키워드를 사용합니다(둘을 함께 사용하지 마십시오). 그리고virtual키워드를 사용하지 마십시오. - 비가상 함수의 경우
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 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;
};
예외
- 객체를 반환할 때 포인터 소멸 함수가 필요한 경우 스마트 포인터를 사용할 수 있습니다.
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;
});
}
};
- 객체를 반환하고 객체가 다방면에서 참조되어야 하는 경우 shared_ptr을 사용할 수 있습니다.
규칙 10.2.2 std::make_unique를 사용하고new를 사용하지 마십시오.
이유
make_unique는 더 간결한 생성 방식을 제공합니다.- 복잡한 표현식에서 예외 안전을 보장합니다.
예시
// 나쁨: 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버전을 구현하는 것이 좋습니다.
사용자 정의deleter가 있는unique_ptr을 생성하기 위해new를 사용하는 것은 마지막 선택입니다.
규칙 10.2.4 std::make_shared를 사용하고new를 사용하지 마십시오.
이유
std::make_shared를 사용하여std::shared_ptr을 생성하는 것은 일관성 등의 이유 외에도 성능 측면에서 이점이 있습니다.
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() 호출 중 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 표현식은 두 가지 기본 캡처 모드를 제공합니다: 참조(&)와 값(=). 기본 참조 캡처는 모든 지역 변수의 참조를 암시적으로 캡처하여 액세스할 수 없는 참조를 야기할 수 있습니다. 반대로 명시적으로 캡처할 변수를 작성하면 객체 수명 주기를 더 쉽게 검사할 수 있어 실수 가능성을 줄일 수 있습니다. 기본 값 캡처는 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&를 매개변수로 사용합니다.
이유
- 명시적으로 소유권 메커니즘을 전달하거나 공유해야 할 때만 스마트 포인터를 사용합니다.
- 스마트 포인터를 통해 전달하면 함수 호출자가 스마트 포인터를 사용하도록 제한됩니다(예: 호출자가 this를 전달하려는 경우).
- 공유 소유권 스마트 포인터 전달에는 런타임 오버헤드가 있습니다.
예시
// 모든 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만 사용 -- 수명 주기 관리와 완전히 무관
// ...
};