Skip to content

Commit e602a93

Browse files
feat: start chapter3
1 parent 8d1e17a commit e602a93

4 files changed

Lines changed: 697 additions & 12 deletions

File tree

drafts/Content-Table-Draft.md

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,19 @@
3030
#### 第2章 零开销抽象原则
3131

3232
- [x] 3.1 什么是零开销抽象(Zero-overhead Abstraction)
33-
- [ ] 3.2 内联函数与编译器优化
34-
- [ ] 3.3 constexpr:编译期计算
35-
- [ ] 3.4 编译期多态 vs 运行时多态
36-
- [ ] 3.5 模板元编程基础
37-
- [ ] 3.6 性能测量:汇编代码分析
33+
- [x] 3.2 内联函数与编译器优化
34+
- [x] 3.3 constexpr:编译期计算
35+
- [x] 3.4 编译期多态 vs 运行时多态
3836

3937
#### 第3章 高效的类设计
4038

41-
- 4.1 构造函数优化:初始化列表与成员初始化
42-
- 4.2 移动语义(Move Semantics)在嵌入式中的应用
43-
- 4.3 RVO与NRVO(返回值优化)
44-
- 4.4 空基类优化(EBO)
45-
- 4.5 对象大小与内存对齐
46-
- 4.6 trivial类型与标准布局类型
47-
- 4.7 聚合初始化与designated initializers(C++20)
39+
- [x] 4.1 构造函数优化:初始化列表与成员初始化
40+
- [x] 4.2 移动语义(Move Semantics)在嵌入式中的应用
41+
- [ ] 4.3 RVO与NRVO(返回值优化)
42+
- [ ] 4.4 空基类优化(EBO)
43+
- [ ] 4.5 对象大小与内存对齐
44+
- [ ] 4.6 trivial类型与标准布局类型
45+
- [ ] 4.7 聚合初始化与designated initializers(C++20)
4846

4947
#### 第4章 模板与泛型编程
5048

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
# 编译期多态 vs 运行时多态
2+
3+
在工程实践里说“多态”,大家第一反应往往是 `virtual` 与接口——也就是运行时多态。
4+
5+
但现代 C++ 给了我们另一套同样强大的工具:模板、CRTP、`std::variant`、类型擦除(type erasure)等,这些构成了**编译期多态**的世界。两者看似只是在“什么时候决定行为”的差异,实际上牵涉到性能、闪存与 RAM 占用、可测试性、ABI 稳定性、编译时间、调试体验等多维权衡。对嵌入式系统来说,这些权衡往往不是学术性的,而是现实的工程约束。
6+
7+
## 先统一一下概念
8+
9+
我们C++最开始最原生支持的多态,是**运行时多态(dynamic polymorphism)**,这种最常见的多态通常指通过基类指针/引用调用虚函数:基类含有 `virtual` 函数,派生类重写,运行时通过对象的实际类型去索引 vtable 执行对应实现。关键点在于:调用点在编译时只知道基类,真正的绑定在运行时完成。其实现依赖 vtable(每个有虚表的类)+ 对象中的 vptr(指向 vtable 的指针)。
10+
11+
所以您就能看到,运行时的多态,有函数转发操作。
12+
13+
**编译期多态(static polymorphism)**则是通过模板、重载、`constexpr`、CRTP(Curiously Recurring Template Pattern)以及代数数据类型(`std::variant`/`std::visit`)等,在编译阶段就把不同实现分派、内联、优化掉。函数调用在编译期能被决定并展开为直接调用或内联,从而消除了运行时间接调用的代价。
14+
15+
从实现角度看,运行时多态会产生一张或多张 vtable、每个对象携带 vptr(占用 RAM),每次虚函数调用是一次间接跳转(可能影响分支预测),而编译期多态通常会生成多个具体函数实例(模板实例化),这些可以被内联与优化,调用开销可接近普通函数调用,甚至为零开销抽象。
16+
17+
------
18+
19+
## 典型代码对比:设备驱动接口
20+
21+
想象一个简单场景:抽象一个 `Sensor`,有读取值的操作。先看运行时多态版本:
22+
23+
```cpp
24+
struct ISensor {
25+
virtual ~ISensor() = default;
26+
virtual int read() = 0;
27+
};
28+
29+
struct ADCSensor : ISensor {
30+
int read() override {
31+
// 直接访问 ADC 寄存器
32+
return read_adc_hw();
33+
}
34+
};
35+
36+
void poll(ISensor* s) {
37+
int v = s->read(); // 虚函数调用
38+
// ...处理 v
39+
}
40+
```
41+
42+
再看编译期多态(模板)版本:
43+
44+
```cpp
45+
template<typename Sensor>
46+
void poll(Sensor& s) {
47+
int v = s.read(); // 非虚,编译期解析
48+
// ...处理 v
49+
}
50+
51+
struct ADCSensor {
52+
int read() { return read_adc_hw(); }
53+
};
54+
```
55+
56+
差异立竿见影:模板版本在 `poll<ADCSensor>` 处可以把 `read()` 内联,消除间接调用;运行时多态版本在二进制里则保留了虚表/间接跳转与对象的 vptr。
57+
58+
------
59+
60+
## 性能与空间(嵌入式常关心的两大资源)
61+
62+
### 执行速度
63+
64+
编译期多态胜在“零运行时开销抽象”——电子系统中的热点(例如 ISR 中的驱动调用、实时路径)极其适合模板化,以便内联与优化。运行时多态每次调用都会多一次内存读(读取 vptr 指向 vtable)并做一次间接跳转,且这样跳转的目标对分支预测不友好,带来的延迟在实时场景下不容忽视。
65+
66+
### RAM 与 Flash
67+
68+
运行时多态:每个对象通常携带一个指向 vtable 的指针(vptr),这会占用对象的 RAM(通常一个指针大小)。vtable 本身放在只读区(Flash),但对象的 vptr 会占用可观的 RAM,尤其是在有大量对象时。另一方面,运行时多态可以通过一个 vtable 共用多个对象的函数实现,从而 Flash 占用较小(函数体只生成一份实现)。
69+
70+
编译期多态:模板实例化会为每个不同模板参数生成代码(函数/类实例),这可能导致二进制增长(code bloat),即 Flash 占用上升。但对象本身不必保留 vptr(节省 RAM)。在 Flash 空间充足但 RAM 紧张的嵌入式设备上,这通常是一个值得做的交换:把运行时开销和 RAM 占用换成 Flash 的增长。
71+
72+
### 启动时间与可预测性
73+
74+
模板实例化产生的静态初始化可以很明确,且没有动态构造的隐患(除非使用复杂全局对象)。虚表机制可能间接依赖静态构造/动态初始化顺序(尤其当与非 `constexpr` 的静态对象结合时),会复杂化启动流程。在需要极其可预测的启动行为的系统里,编译期多态更容易推理与验证。
75+
76+
## CRTP(静态多态的一种)
77+
78+
CRTP 把具体实现的接口强制在编译期检查,并允许在基类中实现复用代码而调用派生类的实现:
79+
80+
```cpp
81+
template<typename Derived>
82+
struct SensorBase {
83+
int read_and_scale() {
84+
int v = static_cast<Derived*>(this)->read();
85+
return scale(v);
86+
}
87+
// ...
88+
};
89+
struct ADCSensor : SensorBase<ADCSensor> {
90+
int read() { return read_adc_hw(); }
91+
};
92+
```
93+
94+
CRTP 的优点是既有静态分派又能复用代码,常用于驱动框架、状态机实现等。
95+
96+
## `std::variant` / `std::visit`
97+
98+
当你需要封闭型多态(不是任意扩展,而是有限、多种已知变体)时,`std::variant` + `std::visit` 是很好的选择:它在编译期把所有变体列举清楚,`visit` 会在编译期产生分支表或内联化逻辑,既可以避免 vtable 的开销,又比模板参数传递更灵活(可在容器中保存不同类型的对象)。
99+
100+
`std::variant` 在嵌入式里需要注意其内存占用(会分配为最宽变体的大小)——但它把类型信息放在对象内部,不需要外部 vptr。
101+
102+
## 类型擦除(type erasure)
103+
104+
通过 `std::function`、自写的 type-erased wrapper(通常带有 small-buffer-optimization),我们可以在不暴露模板参数的情况下获得“近编译期效率”的接口,同时保持运行时可替换性。代价是实现复杂度和可能的内存开销(small buffer + virtual-like calls)。这种方式常被用于库层或 API 层,隐藏实现细节。
105+
106+
------
107+
108+
## 小结:没有绝对的“更好”,只有“更合适”
109+
110+
编译期多态与运行时多态并非对立的神学命题,而是工具箱里的两把刀。嵌入式工程师的任务是根据目标平台的约束与工程流程,选择并混合使用它们。我的建议是:
111+
112+
- 先用最清晰易懂的实现(通常是运行时多态或简单函数),把功能、接口、测试先做透;
113+
- 在性能或资源成为瓶颈时,识别热点并用编译期多态(模板/CRTP/`constexpr`)进行局部优化;
114+
- 启用 LTO 与链接级去重来缓解模板带来的二进制膨胀;
115+
- 对跨模块、插件式架构保留运行时多态接口以保证 ABI 与替换能力;
116+
- 在设计层面,把“可变点”与“稳定点”明确区分:把不变逻辑放到编译期,把需要灵活替换的逻辑留给运行时。
117+
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# 构造函数优化:初始化列表 vs 成员赋值
2+
3+
在嵌入式 C++ 项目中,我们很容易把精力放在“看得见”的地方:中断、DMA、时序、缓存命中率、Flash/RAM 占用……而对于构造函数这种“看起来只执行一次”的代码,往往下意识地放松了警惕。
4+
5+
但实际上,在 **对象创建频繁、内存紧张、构造路径复杂** 的系统中,构造函数的写法,直接影响:
6+
7+
- 是否产生多余的构造 / 析构
8+
- 是否引入隐藏的默认初始化成本
9+
- 是否破坏对象的不变量
10+
- 是否在编译期就已经“输掉了优化空间”
11+
12+
而这些问题,**几乎都集中体现在一个地方:你是否使用了初始化列表。**
13+
14+
------
15+
16+
## 一、一个常见、但并不“无害”的写法
17+
18+
很多人最早接触 C++ 时,构造函数往往是这样写的:
19+
20+
```cpp
21+
class Timer
22+
{
23+
public:
24+
Timer(uint32_t period)
25+
{
26+
period_ = period;
27+
enabled_ = false;
28+
}
29+
30+
private:
31+
uint32_t period_;
32+
bool enabled_;
33+
};
34+
```
35+
36+
乍一看没有任何问题,逻辑清晰、可读性也不错。
37+
38+
但在编译器眼中,这段代码的真实含义是:
39+
40+
1. `period_`**默认初始化**
41+
2. `enabled_`**默认初始化**
42+
3. 进入构造函数体
43+
4. 对两个成员执行 **赋值操作**
44+
45+
也就是说,**成员至少被“处理”了两次**
46+
47+
在桌面平台上,这种开销通常可以忽略;但在嵌入式系统里,尤其是:
48+
49+
- 构造对象数量多
50+
- 成员是结构体 / 数组 / STL 容器
51+
- 构造发生在启动阶段(Boot / Driver Init)
52+
53+
这个“看不见的默认初始化”就开始变得真实存在了。
54+
55+
------
56+
57+
## 二、初始化列表并不是“语法糖”
58+
59+
对比一下使用初始化列表的写法:
60+
61+
```cpp
62+
class Timer
63+
{
64+
public:
65+
Timer(uint32_t period)
66+
: period_(period)
67+
, enabled_(false)
68+
{}
69+
70+
private:
71+
uint32_t period_;
72+
bool enabled_;
73+
};
74+
```
75+
76+
这里的关键变化并不是“少写了几行代码”,而是 **对象生命周期发生了变化**。这里我们的成员初始化变得更加直接——**直接在构造阶段完成初始化**,换句话说,**初始化列表不是赋值,它是构造的一部分**
77+
78+
## 三、某些成员,根本“不能被赋值”
79+
80+
在嵌入式系统中,这种情况并不少见。
81+
82+
#### 1. `const` 成员
83+
84+
```cpp
85+
class Device
86+
{
87+
public:
88+
Device(uint32_t id)
89+
: id_(id)
90+
{}
91+
92+
private:
93+
const uint32_t id_;
94+
};
95+
```
96+
97+
`const` 成员 **只能在初始化阶段赋值一次**,构造函数体内的赋值在语义上是非法的。这不是语法限制,而是语言层面对“对象不变量”的保护。
98+
99+
------
100+
101+
#### 2. 引用成员
102+
103+
```cpp
104+
class Driver
105+
{
106+
public:
107+
Driver(GPIO& gpio)
108+
: gpio_(gpio)
109+
{}
110+
111+
private:
112+
GPIO& gpio_;
113+
};
114+
```
115+
116+
引用一旦绑定,就不能再指向其他对象。因此,**初始化列表是唯一正确的写法**
117+
118+
------
119+
120+
#### 3. 没有默认构造函数的成员
121+
122+
在你自己的框架代码中,这种类型其实非常常见:
123+
124+
```cpp
125+
class SpiBus
126+
{
127+
public:
128+
explicit SpiBus(uint32_t base_addr);
129+
};
130+
```
131+
132+
如果一个类作为成员存在:
133+
134+
```cpp
135+
class Sensor
136+
{
137+
public:
138+
Sensor()
139+
: spi_(SPI1_BASE)
140+
{}
141+
142+
private:
143+
SpiBus spi_;
144+
};
145+
```
146+
147+
此时如果不用初始化列表,代码甚至无法通过编译。
148+
149+
------
150+
151+
## 四、初始化列表带来的“语义完整性”
152+
153+
在嵌入式工程里,我们经常强调 **“对象在构造完成后,必须处于可用状态”**。初始化列表天然符合这一原则。
154+
155+
```cpp
156+
class RingBuffer
157+
{
158+
public:
159+
RingBuffer(uint8_t* buf, size_t size)
160+
: buffer_(buf)
161+
, size_(size)
162+
, head_(0)
163+
, tail_(0)
164+
{}
165+
166+
private:
167+
uint8_t* buffer_;
168+
size_t size_;
169+
size_t head_;
170+
size_t tail_;
171+
};
172+
```
173+
174+
这种写法传达的信息非常明确:
175+
176+
> **对象一旦构造完成,内部状态就是完整、自洽的。**
177+
178+
而如果把初始化拆散在构造函数体中,实际上就允许了“半初始化状态”的存在,这在底层系统中是非常危险的设计信号。
179+
180+
------
181+
182+
## 五、编译器优化视角:初始化列表 = 更大的优化空间
183+
184+
从编译器的角度看:
185+
186+
- 初始化列表提供了 **确定的构造语义**
187+
- 成员的初始值在构造阶段已知
188+
- 更容易进行:
189+
- 常量传播
190+
- 构造消除
191+
- 栈上对象合并
192+
- 甚至在某些场景下完全消除对象
193+
194+
尤其是在你大量使用 `constexpr``inline`、模板时,**初始化列表是编译期优化的前提条件之一**
195+
196+
------
197+
198+
## 最后
199+
200+
初始化列表并不是什么“高级技巧”,其实并不复杂,对于嵌入式系统中,**每一次多余的初始化,都会真实地变成指令、变成 Flash、变成时间**。而初始化列表,正是那种**不写就亏、写了稳赚**的现代 C++ 基本功。
201+

0 commit comments

Comments
 (0)