构造函数优化:初始化列表 vs 成员赋值¶
在嵌入式 C++ 项目中,我们很容易把精力放在"看得见"的地方:中断、DMA、时序、缓存命中率、Flash/RAM 占用……而对于构造函数这种"看起来只执行一次"的代码,往往下意识地放松了警惕。
但实际上,在 对象创建频繁、内存紧张、构造路径复杂 的系统中,构造函数的写法,直接影响:
- 是否产生多余的构造 / 析构
- 是否引入隐藏的默认初始化成本
- 是否破坏对象的不变量
- 是否在编译期就已经"输掉了优化空间"
而这些问题,几乎都集中体现在一个地方:你是否使用了初始化列表。
一、一个常见、但并不"无害"的写法¶
很多人最早接触 C++ 时,构造函数往往是这样写的:
class Timer
{
public:
Timer(uint32_t period)
{
period_ = period;
enabled_ = false;
}
private:
uint32_t period_;
bool enabled_;
};
查看完整可编译示例
// 初始化列表示例:展示初始化列表 vs 成员赋值的差异
#include <iostream>
#include <cstdint>
#include <string>
// ==================== 不推荐的写法:成员赋值 ====================
class Timer_Bad
{
public:
Timer_Bad(uint32_t period)
{
period_ = period; // 赋值,不是初始化
enabled_ = false; // 先默认初始化,再赋值
}
private:
uint32_t period_;
bool enabled_;
};
// ==================== 推荐的写法:初始化列表 ====================
class Timer_Good
{
public:
Timer_Good(uint32_t period)
: period_(period) // 直接初始化
, enabled_(false) // 直接初始化
{}
private:
uint32_t period_;
bool enabled_;
};
// ==================== const 成员必须使用初始化列表 ====================
class Device
{
public:
Device(uint32_t id)
: id_(id) // const 成员只能初始化一次
{}
uint32_t get_id() const { return id_; }
private:
const uint32_t id_;
};
// ==================== 引用成员必须使用初始化列表 ====================
class GPIO
{
public:
explicit GPIO(uint32_t pin) : pin_(pin) {}
uint32_t read() const { return pin_; }
private:
uint32_t pin_;
};
class Driver
{
public:
Driver(GPIO& gpio)
: gpio_(gpio) // 引用必须初始化
{}
void use_gpio() {
std::cout << "GPIO pin: " << gpio_.read() << std::endl;
}
private:
GPIO& gpio_;
};
// ==================== 没有默认构造的成员必须使用初始化列表 ====================
class SpiBus
{
public:
explicit SpiBus(uint32_t base_addr) : base_addr_(base_addr) {}
uint32_t get_base() const { return base_addr_; }
private:
uint32_t base_addr_;
};
class Sensor
{
public:
Sensor()
: spi_(0x40013000) // SpiBus 没有默认构造,必须使用初始化列表
{}
uint32_t read_spi() const { return spi_.get_base(); }
private:
SpiBus spi_;
};
// ==================== 循环缓冲区:展示完整初始化 ====================
class RingBuffer
{
public:
RingBuffer(uint8_t* buf, size_t size)
: buffer_(buf)
, size_(size)
, head_(0)
, tail_(0)
{
// 空函数体,所有初始化已在列表中完成
}
bool write(uint8_t data) {
size_t next = (head_ + 1) % size_;
if (next == tail_) return false; // 缓冲区满
buffer_[head_] = data;
head_ = next;
return true;
}
bool read(uint8_t* data) {
if (head_ == tail_) return false; // 缓冲区空
*data = buffer_[tail_];
tail_ = (tail_ + 1) % size_;
return true;
}
private:
uint8_t* buffer_;
size_t size_;
size_t head_;
size_t tail_;
};
// ==================== 演示主函数 ====================
int main() {
std::cout << "=== 初始化列表示例 ===" << std::endl;
// 基本用法对比
std::cout << "\n--- 基本用法 ---" << std::endl;
Timer_Bad timer_bad(1000);
Timer_Good timer_good(1000);
// const 成员
std::cout << "\n--- const 成员 ---" << std::endl;
Device device(42);
std::cout << "Device ID: " << device.get_id() << std::endl;
// 引用成员
std::cout << "\n--- 引用成员 ---" << std::endl;
GPIO gpio(5);
Driver driver(gpio);
driver.use_gpio();
// 没有默认构造的成员
std::cout << "\n--- 没有默认构造的成员 ---" << std::endl;
Sensor sensor;
std::cout << "SPI Base: 0x" << std::hex << sensor.read_spi() << std::dec << std::endl;
// 循环缓冲区
std::cout << "\n--- 循环缓冲区 ---" << std::endl;
uint8_t buf[16];
RingBuffer ring_buf(buf, sizeof(buf));
for (uint8_t i = 0; i < 10; ++i) {
ring_buf.write(i);
}
uint8_t val;
while (ring_buf.read(&val)) {
std::cout << "Read: " << int(val) << std::endl;
}
std::cout << "\n关键点:" << std::endl;
std::cout << "1. 成员赋值:先默认初始化,再赋值(两次操作)" << std::endl;
std::cout << "2. 初始化列表:直接初始化(一次操作)" << std::endl;
std::cout << "3. const 成员、引用成员、无默认构造成员必须用初始化列表" << std::endl;
std::cout << "4. 初始化列表让对象在构造完成后处于可用状态" << std::endl;
return 0;
}
乍一看没有任何问题,逻辑清晰、可读性也不错。
但在编译器眼中,这段代码的真实含义是:
period_被 默认初始化enabled_被 默认初始化- 进入构造函数体
- 对两个成员执行 赋值操作
也就是说,成员至少被"处理"了两次。
在桌面平台上,这种开销通常可以忽略;但在嵌入式系统里,尤其是:
- 构造对象数量多
- 成员是结构体 / 数组 / STL 容器
- 构造发生在启动阶段(Boot / Driver Init)
这个"看不见的默认初始化"就开始变得真实存在了。
二、初始化列表并不是"语法糖"¶
对比一下使用初始化列表的写法:
class Timer
{
public:
Timer(uint32_t period)
: period_(period)
, enabled_(false)
{}
private:
uint32_t period_;
bool enabled_;
};
这里的关键变化并不是"少写了几行代码",而是 对象生命周期发生了变化。这里我们的成员初始化变得更加直接——直接在构造阶段完成初始化,换句话说,初始化列表不是赋值,它是构造的一部分。
三、某些成员,根本"不能被赋值"¶
在嵌入式系统中,这种情况并不少见。
1. const 成员¶
const 成员 只能在初始化阶段赋值一次,构造函数体内的赋值在语义上是非法的。这不是语法限制,而是语言层面对"对象不变量"的保护。
2. 引用成员¶
引用一旦绑定,就不能再指向其他对象。因此,初始化列表是唯一正确的写法。
3. 没有默认构造函数的成员¶
在你自己的框架代码中,这种类型其实非常常见:
如果一个类作为成员存在:
此时如果不用初始化列表,代码甚至无法通过编译。
四、初始化列表带来的"语义完整性"¶
在嵌入式工程里,我们经常强调 "对象在构造完成后,必须处于可用状态"。初始化列表天然符合这一原则。
class RingBuffer
{
public:
RingBuffer(uint8_t* buf, size_t size)
: buffer_(buf)
, size_(size)
, head_(0)
, tail_(0)
{}
private:
uint8_t* buffer_;
size_t size_;
size_t head_;
size_t tail_;
};
这种写法传达的信息非常明确:
对象一旦构造完成,内部状态就是完整、自洽的。
而如果把初始化拆散在构造函数体中,实际上就允许了"半初始化状态"的存在,这在底层系统中是非常危险的设计信号。
五、编译器优化视角:初始化列表 = 更大的优化空间¶
从编译器的角度看:
- 初始化列表提供了 确定的构造语义
- 成员的初始值在构造阶段已知
- 更容易进行:
- 常量传播
- 构造消除
- 栈上对象合并
- 甚至在某些场景下完全消除对象
尤其是在你大量使用 constexpr、inline、模板时,初始化列表是编译期优化的前提条件之一。
最后¶
初始化列表并不是什么"高级技巧",其实并不复杂,对于嵌入式系统中,每一次多余的初始化,都会真实地变成指令、变成 Flash、变成时间。而初始化列表,正是那种不写就亏、写了稳赚的现代 C++ 基本功。