C++ 代码编写建议
这是一份 C++ 的代码编写建议书,本建议书类似于 Google C++ Style Guide,但更倾向于推广现代 C++。不同于 C++ Core Guidelines,本建议更为细致。本建议书具有强烈的个人倾向,也会参考其他建议。如果建议在 C 中也可行,则同时对 C 也适用。
目前处于初期阶段。
-
避免使用具有静态储存周期的可变量,例如命名空间内变量,非常量静态成员,非常量函数内静态变量
理由:
- 静态储存周期变量的生命周期不可控
- 静态声明周期变量打破了函数的局部性,使得函数的运行结果依赖于函数参数之外的对象,更容易产生 bug 以及更难以调试
-
避免虚假的数组声明,使用指针,例如
void func(int *c)
而不是void func(int c[])
理由:
- 使用这种虚假的数组声明往往会带来一种误解,使得数组长度被忽视,并且曲解了 c 的真正类型
-
避免在正式交付的软件中做不必要的边界检查用于 debug,例如
std::vector<T, U>::at
,应使用提前检查,例如判断size
,或在正式发布时禁用检查,例如使用assert
理由:
- 完全可以使用提前检查
size
来实现相同效果 - 抛出该类型异常会增加软件复杂度
- at 不是原子的,不具有线程安全性
- 完全可以使用提前检查
-
避免使用
define
宏定义,使用constexpr
表示常量理由:
- 宏没有作用域约束
- 宏展开的结果无法预测
- 宏没有类型
-
类成员函数仅在类内声明而不实现
理由:
- 存在循环依赖的情况下不得不声明和实现分类
- 类声明的行数过长不利于阅读
- 友元函数通常需要在类外声明(除了隐藏友元)
-
使用
const
变量传递参数理由:
- 对象状态清晰,方便维护
- 避免误操作导致产生野指针或者悬垂引用
-
使用
inline
修饰静态成员变量(C++17)理由:
- 直观,减少冗余
- 避免重复定义的报错
-
使用
using
和函数类型构造函数指针理由:
- 清晰
例如:
using f_type = void(int, int); using f_pointer = f_type*; void f_impl(int a, int b); f_pointer a = f_impl;
-
避免内存别名使用
理由:
- 别名使用通常存在 bug,错误的使用会导致错误的结果
- 别名使用影响编译器优化
例如:
template<typename T> void accumulate(T* source, size_t len, T* target) { for (size_t i = 0; i < len; ++i) { *target += source[i]; } } template<typename T> void accumulate1(T* source, size_t len, T* target) { T acc = *target; for (size_t i = 0; i < len; ++i) { acc += source[i]; } *target = acc; }
注意这两个
accumulate
函数的区别:accumulate
每次做+=
操作都会通过target
指针进行间接访问,造成效率降低,由于source
和target
的类型都是T*
,所以编译器不能贸然将accumulate
优化为accumulate1
,因为两个函数语义上不等价:当target
是source
的一个元素,两个函数的结果不一样这种现象叫做内存别名使用:编译器无法判断指针是否重叠。一个好的方案是引入一个中间变量,这种方式首先能避免别名使用,还能提高可读性:字符数少的代码有可能性能差又难读
-
使用引用限定修饰赋值意义的运算符重载函数,限制
*this
的值类别(参考 C++ 非静态成员函数的引用限定修饰)理由:
- 对一个右值进行赋值通常是无意义的
- 允许对一个右值进行赋值可能会使形如
if(foo() = 8)
的语句合法
-
避免抛出含有状态的异常,使用无状态异常(参考 C++ 异常问题的简单分析)
理由:
- 带状态异常会存在堆内存分配,存在逻辑矛盾
- 带状态异常性能差
-
使用
std::string_view
代替字符串常量(C++17)理由:
std::string_view
编译器计算长度,避免不必要的strlen
std::string_view
自带长度,方便使用和传递
-
使用
std::string_view
和std::span
作为数组视图(C++17)理由:
- 避免不必要的堆内存分配
- 自带长度方便使用和传递
-
使用到
std::string_view
的推导指引代替char*
(C++17)理由:
- 编译期即可计算长度,避免不必要的
strlen
- 方便使用和传递
- 编译期即可计算长度,避免不必要的
-
同类型比较使用成员函数,非同类比较使用友元函数
理由:
- 同类比较实现为成员可以防止函数扩散到其他作用域
- 非同类比较实现为友元便于维持对称性
- 非同类比较实现为友元可以避免循环引用
-
实现三路比较代替实现谓词比较,以及优先启用默认比较(C++20)
理由:
- 三路比较能提高有序容器的性能
- 三路比较能简化比较函数实现
- 三路比较具有对称性
- 默认比较避免了冗余代码
-
使用无符号数(例如
size_t
)代替有符号数理由:
- 有符号数溢出通常未定义
- 无符号数能表示更大范围
-
使用
std::unique_ptr
代替多次new
理由:
- 保证动态内存分配的异常安全(参考 C++ 异常安全 - 智能指针)
-
使用
const_cast
,static_cast
和reinterpret_cast
代替 C 风格的转换理由:
static_cast
可以检查溢出和非常规转换- 由于错误转换而存在 bug 时利于发现
- 防止
reinterpret_cast
造成非对齐访问内存,参考第 34 条
-
使用枚举类代替枚举
理由:
- 枚举类的成员不会泄漏
-
使用匿名命名空间代替
static
理由:
- 更易于维护
-
使用
inline
函数和inline
变量代替extern
(C++17)理由:
- 防止定义冲突
- 便于维护
- 不违反 odr
-
使用 copy-and-swap idiom 实现移动构造和移动赋值
理由:
- 异常安全
- 方便编写
-
默认构造,移动构造,移动赋值,析构和无异常函数使用
noexcept
修饰理由:
- 默认构造应该只进行 0 初始化,不进行资源分配(参考 C++ 异常 - 资源管理),不会产生异常
- 移动构造和移动赋值应该使用 copy-and-swap idiom,天生异常安全
- 析构函数的异常无法正确处理(参考 C++ 异常 - 类和异常)
-
避免使用
using
指令理由:
using
指令只影响查找规则,不引入名字到当前作用域,所以可能会发生遮蔽,造成错误的函数调用(参考 C++ 命名空间和 using)
-
避免使用无限定调用,例如避免依赖 ADL 或者 ADL 两步法,改为使用限定调用
理由:
- 无限定调用会增加查找范围,增加编译时间
- 无限定调用会启动 ADL,依赖 ADL 不利于维护,如果 ADL 查找到的函数在将来被错误修改,会使用错误的函数
- ADL 两步法会造成代码无法维护,在使用 ADL 两步法后,不能贸然删除
using std::swap;
以及将无限定调用改为限定调用 - 无限定调用有可能会因为函数遮蔽问题找不到正确实现,或者使用错误实现
-
优先支持 STL 工具,而不是对 STL 进行重载或特化,例如增加成员函数 swap 以及实现移动以支持
std::swap
理由:
- 给 STL 工具添加特化不利于维护
- 对 STL 函数增加重载通常是错误的
-
使用只属于 global namespace 内变量及函数时加上 global 限定
理由:
- 消除
std
和 global 同名名字的歧义 - 限定查找缩小了查找范围,加快编译速度
- 消除
-
使用大括号进行默认初始化而不是使用 0 进行值初始化
理由:
- 只有基本类型使用可以使用 0 进行初始化
- 使用大括号初始化可以将 C 风格的结构体(聚合类)方便的初始化为空
由于只有基本类型才能够使用 0 进行值初始化,非基本类型中的构造函数有可能有接受 0 的重载,但这通常导致无意义甚至错误的结果,例如
std::basic_string
的构造函数接受一个指针,但该指针不能为 0此外由于 C 没有简便方法对结构体中的所有成员初始化为空,并且结构体中有些成员可能当前无意义或未文档化,导致“按需”进行了不完全的初始化
-
使用直接初始化而不使用复制初始化
理由:
- 复制初始化不考虑
explicit
构造函数 - 复制初始化期待目标类型,而直接初始化期待到目标类型的隐式转换,这使得复制初始化不考虑用户定义的转换函数
- 复制初始化不考虑
-
如果函数确定不会抛异常,则添加
noexcept
修饰理由:
noexcept
修饰使得编译器不用为此函数插入处理异常的代码,因此能减小二进制体积- 由于标准目前未强制使得用户定义的移动构造不抛出异常,这使得在某些情况下必须有
noexcept
修饰才能使用移动构造(参考 std::move_if_noexcept)
-
优先考虑通过显式
this
调用函数而不是隐含this
理由:
- 增强没有代码提示下的代码可读性
-
考虑将
private
成员添加下划线后缀理由:
- 增强没有代码提示下的代码可读性
- 区分成员和非成员
-
慎用
reinterpret_cast
和从字节流中隐式创造对象理由:
- 从字节流隐式创造对象可能创造未对齐的访问
- 使用
reinterpret_cast
可能创造指向未对齐对象的指针 reinterpret_cast
通常来说是不必要的,应该由static_cast
代替
例如:
假设存在函数
void foo(int* i) { i = 0; }
,若传入参数为0xFFF0001
这个地址,则在某些 CPU 上会产生硬件中断。原因为硬件指令可能要求读取和写入内存只能在 4 的整数倍地址上进行。即使某些 CPU 允许非对齐访问,但会造成效率降低通常来说编译器会进行基础的对齐保证,例如将所有类型为 x,且
sizeof(!x<4)
的变量的地址对齐到 4 字节和在结构体中填充额外位,例如struct S {int i; char c;};
在 int 为 4 字节的情况下,sizeof
的结果是 8,这是由于在数组中,结构体对象是紧密排列的,若不在尾部进行填充,则会导致从第二个结构体开始出现非对齐的数据因此唯一能创造非对齐访问的情况下就是字节流中隐式的对象,以及经过
reinterpret_cast
,或者经过void*
转换的指针,此时应该添加额外代码确保对象是对齐的,或者使用memcpy
将对象重新装入满足对齐要求的缓冲区 -
避免混用有符号/无符号类型进行算术运算(但允许在得有保证的情况下进行赋值)
理由:
- C++ 中同样宽度的有符号整数和无符号整数进行算术运算时会提升到同样宽度的无符号数,将导致出现难以理解并且伴随 UB
例如:
int x = -2; unsigned y = 1u; int c = x / y; long long d = x / y;
假设 int 的宽度为 32 位,long long 的宽度为 64 位,则 c 通常被初始化为 -2,d 则是 4294967294
这是由于 x 被提升为 unsigned 以和 y 进行计算,导致计算的结果是无符号的 4294967294,同时将无符号的 4294967294 赋值给 32 位有符号整数将得到 -2(存在 UB:溢出),但赋值给 64 位的有符号整数则得到原值 4294967294
-
优先使用现有函数对象而不是随意定义 lambda
理由:
- 使用具名对象可防止由于 lambda 符号的随机性使得模板产生额外的无法消除的函数实体
例如:
#include <functional> #include <array> #include <algorithm> int main() { std::array arr1 = { 1,4,7,2,5,8 }; auto arr2 = arr1; std::sort(arr1.begin(), arr1.end(), std::greater()); std::sort(arr2.begin(), arr2.end(), [](int l, int r) noexcept {return l > r; }); }
两次
std::sort
调用虽然功能一致,但是由于std::greater<void>::operator()(int&, int&)
和main::lambda(int, int)::opeartor()(int, int)
是两个符号,使得std::sort<std::array<int>::iterator, iterator, T>
中的T
被处理为两个符号,导致std::sort
被生成了两份相同但无法消除的代码,增大了二进制体积若标准库无法满足需求可定义其他具名 lambda,只有在确保 lambda 不会重复使用时才考虑使用匿名 lambda
-
使用枚举创建新的整数类型而不是使用类型别名(C++17)
理由:
- 枚举真正发明新的类型而不是类型别名
- 枚举避免了窄化转换更安全
参考 C++ Reference 的枚举声明一节