1. C++ 设计准则
1. Philosophy
1. 保持代码直观清晰
1 | class Date |
在上述示例中,前者是更好的写法,它的返回值是类型明确的,并且本身具有 const 限定。
1 | int index = -1; |
使用 STL 能使代码更加清晰简洁,不易出错。有一句现代 C++ 谚语:如果你还在显式地使用循环,那么你就没有真正理解 STL 算法。
2. 遵循 ISO Standard
按照由国际标准化组织(ISO)制定并发布的《ISO/IEC 14882》C++ 标准来编写代码。
这意味着尽量避免依赖各编译器厂商(如 GCC、MSVC、Clang)提供的额外语法或库扩展,以保证代码在不同环境下都能编译和运行。
另外,需要注意 Undefined Behavior 和 Implementation‑Defined Behavior
-
Undefined Behavior
指 C++ 标准未明确规定行为规范的代码操作,允许编译器自由处理。程序可能崩溃、产生错误结果、或看似正常运行实则存在隐患。程序应避免任何 UB。
-
Implementation‑Defined Behavior
指 C++ 标准允许编译器或运行时环境自由选择具体实现方式,但要求该行为必须被明确文档化。虽然具有平台依赖性,但结果是可预测的,由特定编译器的实现文档保证。
3. 注释应写明意图
1 | for(const auto& v : vec) {...} // (1) |
上述示例中,(1) 与 (2) 的区别在于前者不会修改容器中的元素。
(3) 是使用并行的方式对容器中的每个元素执行一个操作。但是需要注意的小规模数据可能因线程调度开销导致性能下降,这种写法更适用于大规模数据。
总之,注释应阐明意图,指出应该做什么。
4. 保证程序的静态类型安全
C++ 是静态类型语言。即所有的变量或表达式的类型必须在编译时确定。静态类型安全要求编译器不仅知道其类型,还要能检测出类型相关的错误。C++ 并非完全类型安全,因此需要在编写代码时人工规避问题。
以下是常见的类型安全的问题及规避方案:
-
联合体(Union)
联合体允许不同类型共享同一内存空间,同一时间只能存储一个成员的值,可以达到节省内存的效果,
union的大小等于其最大成员的大小;union不记录当前活跃的成员,访问错误的成员会导致未定义行为。1
2
3
4
5
6
7
8
9union Data
{
int i;
float f;
};
Data data;
data.i = 10;
std::cout << data.f << std::endl; // 错误:此时通过 f 访问是未定义行为在 C++11 之前,
union仅支持平凡类型。C++11 之后可以包含非平凡类型,但有严格限制。必须提供自定义构造函数,因为union的默认构造函数是delete状态。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
union MyUnion
{
int i;
std::string s; // 非平凡类型
// 编译器不会生成默认构造函数
// MyUnion() = delete;
};
int main()
{
// MyUnion u; // 错误,默认构造函数被删除
return 0;
}必须显式管理生命周期和追踪活跃成员,比如析构时需要识别出活跃成员,否则析构出错会有未定义行为。
C++17 引入了
std::variant,它是一个类型安全的联合体。可以方便地存储非平凡类型,并且自动管理存储资源的生命周期,是union的上位替代。1
2
3
4
5
6
7
8
9
10std::variant<int, std::string> v = "hello";
// 需要知道类型进行访问
std::cout << std::get<std::string>(v) << std::endl;
// 类型安全的访问方式
if (auto* str = std::get_if<std::string>(&v))
{
std::cout << *str << std::endl;
} -
类型转换
显式类型转换(如
static_cast、dynamic_cast、reinterpret_cast、const_cast)可能引发错误或未定义行为dynamic_cast相对安全,有运行时类型检查。对于指针类型,转换失败会返回空指针;对于引用类型,转换失败会抛出std::bad_cast异常。而基于模板的泛型代码减少了对类型转换的需求,从某种程度上可以减少此类错误。
-
数组退化
C 风格数组在传递给函数时,会退化为指向其首元素的指针,丢失数组大小信息。
1
2
3
4
5
6void processArray(int arr[])
{
// 这里 arr 实际上是指针,不是数组
// 输出指针大小(8/4字节),无法获取原始数组大小
std::cout << "Size in function: " << sizeof(arr) << std::endl;
}C++20 提供的
std::span是一个轻量级的视图,包含指针和大小信息,提供安全的数组访问,可以解决这个问题。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 使用 std::span 接收数组,自动保持大小信息
void processWithSpan(std::span<int> arr)
{
std::cout << "Span size: " << arr.size() << std::endl;
std::cout << "Span data: ";
for (int value : arr)
{
std::cout << value << " ";
}
std::cout << std::endl;
// 安全的索引访问
for (size_t i = 0; i < arr.size(); i++)
{
std::cout << arr[i] << " ";
}
std::cout << std::endl;
// 子视图,同样安全
if (arr.size() >= 3)
{
auto subspan = arr.subspan(1, 2); // 从索引1开始,取2个元素
std::cout << "Subspan: ";
for (int val : subspan)
{
std::cout << val << " ";
}
std::cout << std::endl;
}
}
int main()
{
// 1. C 风格数组,自动推导大小
int cArray[] = { 1, 2, 3, 4, 5 };
processWithSpan(cArray);
// 2. std::array
std::array<int, 4> stdArray = { 6, 7, 8, 9 };
processWithSpan(stdArray);
// 3. std::vector
std::vector<int> vec = { 10, 11, 12, 13, 14 };
processWithSpan(vec);
// 4. 动态数组,需要显式提供大小
int* dynamicArray = new int[3] {15, 16, 17};
processWithSpan(std::span<int>(dynamicArray, 3));
delete[] dynamicArray;
return 0;
}需要注意的是,动态数组的大小在运行时确定,编译期无法推导,所以需要显式提供大小。而
std::vector因为它本身是包含 size 信息的完整类型,所以即使其 size 是在运行期确定的,也无需向std::span显式提供大小。 -
窄化转换
隐式转换可能导致数据丢失(如
double→int截断小数)使用
{}初始化(列表初始化)时,编译器会强制检查并拒绝窄化转换,帮助在编译期捕获此类错误。
5. 优先编译期检查而非运行期检查
所有能够在编译期进行的检查,都应当置于编译期完成。C++11 引入编译期断言 static_assert ,它接受一个常量表达式,在编译期验证条件,对条件不满足或者运行期才能确定的表达式产生编译错误。
此外,类型特征库(type-traits library)允许开发者构建强大的条件检查:例如static_assert(std::is_integral<T>::value)。
6. 无法在编译期检查的内容,应当能够在运行期进行检查
借助 dynamic_cast,我们能够安全地在继承层次结构中向上、向下及横向转换类的指针和引用。如果转换失败,对于指针会返回 nullptr,对于引用则会抛出 std::bad_cast 异常
dynamic_cast 依赖于运行时类型信息(Run-Time Type Information, RTTI),而 RTTI 只对多态类型(即包含虚函数的类)可用。
编译器在
vtable中嵌入type_info对象,非多态类类型没有vtable,因此没有存储类型信息的地方,dynamic_cast通过查询对象的type_info来验证转换的合法性
7. 尽早捕获运行期错误
可采用多种对策消除运行时错误。比如检查指针、数组范围、类型转换等,避免错误的传播和扩散。
8. 不要泄露任何资源
资源不仅指内存,还包括系统资源,如文件句柄、网络套接字、数据库连接、图形界面句柄、互斥锁等
任何资源如果只申请不释放,都会随着程序的运行而不断累积。对于需要长时间运行的服务端程序或后台进程,即使是微小的泄露,最终也可能耗尽系统资源,导致程序崩溃或系统变得不稳定。
处理资源的惯用方法是 RAII (Resource Acquisition Is Initialization):
- 将资源的生命周期与一个对象的生命周期绑定
- 在对象的构造函数中获取资源
- 在对象的析构函数中释放资源
- C++保证,当对象离开其作用域时(无论是正常离开,还是因为异常而离开),其析构函数一定会被自动调用,无需手动干预
成员对象的析构函数在持有它的类析构时会自动调用,但它持有的资源是否需要手动释放,取决于该成员类型本身的实现
RAII 应用示例:
- 锁:如
std::lock_guard或std::unique_lock。在构造时锁定互斥量,在析构时自动解锁。这防止了因忘记解锁而导致的死锁 - 智能指针:如
std::unique_ptr和std::shared_ptr。它们包装了原始指针,在析构时自动释放所指向的内存。这是现代C++取代new/delete的首选方式 - STL容器:如
std::vector,std::string等。它们在析构时会自动清理其内部动态分配的所有元素和内存缓冲区,无需手动管理
9. 不要浪费时间和空间
需要有意识地避免不必要的时间和空间开销。比如以下例子:
1 | void lower(std::string s) |
该函数想将字符串 s 转换为小写字符,但有以下问题:
- 时间开销:
std::strlen(s.data())在每次循环迭代时都会被调用。strlen是一个 O(n) 复杂度的函数,它需要遍历整个字符串直到找到空终止符\0。这导致一个原本是 O(n) 的循环变成了 O(n²),对于长字符串会造成巨大的性能浪费 - 传参错误:应传引用,传值并没有改动
s,并造成了额外的拷贝
使用 std::transform 可以解决上述问题:
1 | std::transform(s.begin(), s.end(), s.begin(), |
下一个例子是抑制移动语义:
1 | struct S |
C++11 引入了移动语义,通过移动构造函数和移动赋值运算符实现。它将资源从一个临时对象(通常是右值)移动过来,而不是复制,成本低廉。
但是在这个例子中,用户手动定义了拷贝构造函数和拷贝赋值运算符。根据C++标准规则,一旦用户显式定义了这些拷贝操作,编译器就不会自动生成默认的移动操作(移动构造函数和移动赋值运算符)。
但可以通过
= default显式要求编译器生成移动操作
因此,在代码 S s2 = std::move(s1); 中,std::move(s1) 本意是将 s1 转换为一个右值,期望触发移动操作。但由于 S 没有移动构造函数,编译器只能退而求其次,调用拷贝构造函数。这里的 std::move 实际上没有起到任何加速作用,反而产生了昂贵的拷贝开销。
现代C++的最佳实践是“零规则”(Rule of Zero):尽量避免手动定义拷贝构造函数、移动构造函数、拷贝赋值运算符、移动赋值运算符和析构函数。让编译器为你生成所有这些默认操作。在无需直接管理资源的大多数情况下,编译器生成的都是正确且最优的。
如果类需要直接管理资源,确实需要自定义这些操作,那么应遵循 “五规则”(Rule of Five):如果需要自定义其中任何一个,那么很可能需要同时自定义所有五个(拷贝构造、移动构造、拷贝赋值、移动赋值、析构),并正确地实现它们。
10. 优先使用不可变数据而非可变数据
不可变数据(常量)有以下优势:
- 固化状态流转路径,降低代码复杂度
- 利于编译期优化:包括常量传播、公共子表达式消除、死代码删除等编译优化技术
- 并发安全
11. 封装杂乱的构造,而非将其散布在代码中
如果有可能,尽量不要自己实现复杂易错的底层代码,优先考虑类似 STL 这样的成熟代码库。
如果找不到现成的高级库来替代必要的复杂逻辑,尽量将混乱、易变的细节隐藏在简洁的接口之中。
12. 根据需要使用辅助工具
使用如静态分析工具、并发分析工具、测试工具等辅助工具,可有效提高代码的正确性,可移植性和健壮性。
不同的编译器(如 GCC, Clang, MSVC)对 C++ 标准的实现和理解略有不同,使用多种 C++ 编译器来编译代码,也是很好的验证代码的方式。
13. 根据需要使用支持库
根据需要使用高质量外部库可避免重复造轮子,提升开发效率和代码质量。
2. Interfaces
1. 避免使用非 const 的全局变量
全局变量向函数内部注入了一个隐藏的依赖项,并且这个依赖项并非接口的一部分,容易出错,并且存在并发安全问题。
2. 谨慎使用单例
从本质上讲,单例也是一种全局变量。虽然它在很多场景下是有必要的,但我们在使用时也需要考虑以下问题:
- 谁负责销毁单例?
- 应该允许单例派生吗?
- 如何以线程安全的方式初始化单例?
- 当单例相互依赖且位于不同的编译单元时,它们的初始化顺序是怎样的?(静态初始化顺序问题)
3. 书写好的接口
好的接口应遵循以下规则:
- 明确
- 强类型
- 尽可能少的参数
- 避免相邻的、不相关的同类型参数
结构体传参是一种好的写法,将复杂的参数列表封装为一个 struct,既很好地满足了上述规则,还使得接口的调整变得容易。如果接口需要新的参数,只需要在 struct 新增一个给出默认值的参数即可,所有的老代码依然正常调用,而无需到处修改。如:
1 | struct Light |
而所有的老代码依然可以正常调用,未指定的 brightNess 会具有默认值 .0
1 | void buildLightPass({.name = "Light1", .color = {1, 1, 1}, .type = 1, .loc = {.0, .0, .0}}); |
同理,如果有必要的话,返回值也可以定义为结构体。
对于返回值可能为空的的函数,或许空值语义是一种更好的写法。C++17 引入了 std::optional,形如 std::optional<T> 的类型有两种可能的状态:为空nullopt 和有值。这是一种语义更加明确的设计。
4. 不要以指针的方式传递数组
当我们将数组传递给一个入参是指针的函数时,数组会自动退化为其首元素的指针,所以往往还需要传递数组的大小。
这是一种容易出错的写法,如果有可能的话使用 std::vector 作为函数的入参更好,但如果场景中为了满足入参不得不拷贝数据,从数组创建 std::vector,std::span 将是更好的选择。
5. 为了稳定的 ABI,可考虑 Pimpl
Pimpl(Pointer to Implementation)是 C++ 中的一种编译时封装技术,通过将类的实现细节隐藏在一个指向实现类的指针后面来减少编译依赖和提高封装性。
ABI 是二进制程序组件之间的接口,它定义了:
内存布局:类/结构体的大小、成员偏移量
函数调用约定:参数传递方式、栈清理责任
名称修饰:C++ 函数名在二进制中的表示方式
异常处理机制:异常如何抛出和捕获
虚函数表布局:多态类的运行时结构
基本结构
1 | class MyClass |
Pimpl 主要优势有两个:
- 分离定义,加速编译(修改了具体实现
Impl,只需要重新编译MyClass.cpp) - 可以保持 ABI 的稳定性,方便发布库更新和插件热装载
其代价是每次访问实现都有一次指针间接寻址,有极轻微的性能损失,代码结构更加复杂。适用于大型类(有很多的私有成员和方法),库开发(需要保持二进制的兼容性),编译时间敏感等场景。
3. Functions
2. C++ 基础
1. 平凡类型(Trivial Type)
C++11 引入平凡类型的概念,用于描述“没有副作用的构造/析构/赋值等操作”的类型,一个类型是平凡类型当且仅当它满足以下所有条件:
- 无用户定义的构造函数
- 无用户定义的析构函数
- 无用户定义的拷贝/移动构造函数
- 无用户定义的拷贝/移动赋值操作符
- 无虚函数,无虚基类
- 所有非静态成员和基类都是平凡的
可使用 std::is_trivial 进行判别。
POD 类型(Plain Old Data)相比于平凡类型是一个更严格、更古老的限制。在平凡类型的基础上还有标准布局(Standard Layout)的要求。
标准布局类型满足以下条件:
- 所有非静态成员具有相同的访问控制
- 无虚函数,无虚基类
- 没有引用类型的非静态数据成员
- 所有非静态数据成员都是标准布局类型
- 所有基类都是标准布局类型
- 满足以下继承条件之一:
- 没有基类
- 只有一个基类且没有非静态数据成员
- 基类和派生类中不能同时有非静态数据成员
可使用 std::is_standard_layout_v 判别是否是标准布局类型。
POD 类型也有 std::is_pod 这样的判断方法,但是该方法已经在 C++20 中被移除,不推荐使用。可通过 std::is_standard_layout_v 和 std::is_trivial 的组合来达到相同的目的。即:
POD = Trivial && Standard Layout
1 |
|
应用场景
-
平凡类型
其核心特性在于可以使用
memcpy等低级内存操作进行复制,而不需要逐个调用拷贝构造函数,在需要高效移动和复制时很有用。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35// 高效内存复制
template<typename T>
void trivial_copy(const T* src, T* dest, size_t count)
{
static_assert(std::is_trivial_v<T>,
"Type must be trivial for memcpy operations");
std::memcpy(dest, src, count * sizeof(T));
}
// 序列化/反序列化
struct TrivialData
{
int id;
double value;
char name[32];
};
void serialize_trivial(const TrivialData& data, char* buffer)
{
// 安全地直接复制内存
std::memcpy(buffer, &data, sizeof(TrivialData));
}
// 对象池和内存管理
template<typename T>
class ObjectPool
{
static_assert(std::is_trivial_v<T>, "ObjectPool requires trivial types for efficient reuse");
std::vector<T> pool;
public:
T* allocate()
{
// 可以安全地重用内存而不调用析构函数
return &pool.emplace_back();
}
}; -
标准布局类型
其核心特性是确定性的内存布局,并与 C 语言兼容
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43// 与C语言互操作
extern "C"
{
struct CStruct
{
int x;
double y;
};
void process_c_struct(CStruct* data);
}
struct CompatibleCppStruct
{
int x;
double y;
}; // 标准布局,可以与C结构体安全互操作
static_assert(std::is_standard_layout_v<CompatibleCppStruct>);
// 硬件寄存器映射
struct DeviceRegisterMap
{
volatile uint32_t control;
volatile uint32_t status;
volatile uint32_t data;
}; // 标准布局确保成员顺序与硬件寄存器一致
// 网络协议数据包
struct NetworkPacket
{
uint16_t header;
uint32_t sequence;
uint8_t payload[1024];
uint16_t checksum;
}; // 标准布局 + 打包确保精确的内存布局
// 通过偏移量访问成员
template<typename T, typename M>
size_t member_offset(M T::*member)
{
static_assert(std::is_standard_layout_v<T>);
return reinterpret_cast<size_t>(&(reinterpret_cast<T*>(0)->*member));
} // 只有标准布局类型才有确定的成员偏移量 -
POD 类型
同时拥有以上两种类型的特性,是二者的交集。但现代 C++ 编码中应尽量避免使用这个概念。
Reference
