现代嵌入式C++教程:C++98------从C向C++的演化(2)
完整的仓库地址在Tutorial_AwesomeModernCPP中,您也可以光顾一下,喜欢的话给一个Star激励一下作者
4. 默认参数(Default Arguments)
4.1 就说说默认参数
在真实工程中,函数参数并不是"越多越好"。很多时候,一个函数的参数里总会混着几类角色:核心必选参数 、高频但几乎不变的配置 ,以及只有极少数场景才会调整的高级选项 。如果每次调用都被迫把这些参数一个不落地写出来,不仅代码冗长,而且会迅速掩盖真正重要的信息。默认参数正是为了解决这个问题而存在的------那些你已经决定好"默认行为"的参数,就干脆别让调用者操心。
一个非常典型的例子是硬件外设配置。以 UART 为例,真正每次都会变的,往往只有波特率;至于数据位、停止位、校验位,大多数项目里几乎一成不变。用默认参数,我们就可以把"常识"编码进接口里,让调用点尽可能简洁:
cpp
void configure_uart(int baudrate,
int databits = 8,
int stopbits = 1,
char parity = 'N') {
// 配置UART
}
这样一来,最常见的调用形式只剩下真正关心的那一个参数:
cpp
configure_uart(115200);
而当你真的需要偏离默认行为时,也仍然可以逐步"向右展开"参数:
cpp
configure_uart(115200, 8);
configure_uart(115200, 8, 2);
configure_uart(115200, 8, 2, 'E');
从接口设计的角度看,这是一种非常温和的向前兼容手段:你可以不断在函数右侧追加新的可选能力,而不会破坏已有代码。
4.2 默认参数的一些规则
当然,默认参数并不是随心所欲的语法糖,它的规则其实非常严格。首先,默认参数必须从右向左连续出现 ,因为编译器在函数调用时只能通过"省略尾部参数"的方式来判断哪些值使用默认值。如果你试图在非默认参数后面再放一个没有默认值的参数,编译器会直接拒绝这种设计。其次,每一个默认参数只能被指定一次 ,而且通常应该放在函数声明处,而不是定义处。这一点在头文件与源文件分离的工程中尤为重要:默认值是接口的一部分,而不是实现细节,如果你在 .cpp 里再写一遍默认参数,编译器会认为你在试图重新定义规则,从而报错。
在嵌入式开发中,默认参数尤其适合用在"配置型接口"和"初始化函数"上。比如 SPI、I²C、定时器这类外设,往往都有一套"推荐配置",只有在极少数情况下才需要完全自定义。通过默认参数,你可以让最常见的用法几乎零负担:
cpp
spi.init(); // 使用推荐配置
spi.init(2000000, 3); // 只改频率和模式
这种接口的可读性非常强:调用点本身就已经在"讲故事",而不是一串神秘的魔法数字。
5. 类与对象(Classes and Objects)
类和对象是 C++ 面向对象编程的核心概念,但在嵌入式语境下,它们常常被误解成"重""慢""花里胡哨"。实际上,类并不等于复杂,OOP 也不等于必须上继承、多态那一套。在资源紧张、业务逻辑清晰的嵌入式系统中,类最核心的价值只有一个:把"状态"和"操作状态的代码"绑在一起。
换句话说,类的第一价值不是抽象,而是约束。
以一个最简单的 LED 控制为例,如果你用 C 风格代码,很容易把 GPIO 引脚号、当前状态、操作函数散落在各个文件里。而用一个类,你可以把这些东西自然地收拢到一起:
cpp
class LED {
private:
int pin;
bool state;
public:
LED(int pin_number) : pin(pin_number), state(false) {
gpio_init(pin, OUTPUT);
}
void on() {
state = true;
gpio_write(pin, HIGH);
}
void off() {
state = false;
gpio_write(pin, LOW);
}
void toggle() {
state = !state;
gpio_write(pin, state ? HIGH : LOW);
}
bool is_on() const {
return state;
}
};
这里的 private 并不是为了"防黑客",而是为了在语法层面告诉使用者:哪些东西你不该碰。你当然可以通过各种手段绕过它,但那已经属于未定义行为的范畴,后果自负。对大多数工程代码来说,这种约束本身就是一种极强的自文档。
构造函数和析构函数则进一步强化了这种"绑定关系"。构造函数负责把对象带入一个合法、可用的状态,析构函数负责在对象生命周期结束时做清理工作。在嵌入式系统中,这种模式尤其适合用来管理硬件资源:
cpp
UARTPort uart(1); // 构造时初始化
// 使用 uart
// 离开作用域时自动关闭
这并不是为了追求"RAII 教科书式优雅",而是为了减少人为遗漏清理步骤的可能性。
在构造函数中,成员初始化列表是一个经常被忽视、但非常重要的细节。它并不是"写起来好看",而是真正决定了对象是初始化 还是先默认构造再赋值 。对于 const 成员、引用成员,以及复杂对象成员来说,初始化列表甚至是唯一合法的选择。从效率和语义正确性上看,它都应该成为你的第一选择。
至于 this 指针,它的存在本身并不神秘:每一个非静态成员函数,都会隐式地携带一个指向当前对象的指针。理解这一点之后,链式调用这种写法就显得非常自然了------你只是不断返回"自己",而已。
静态成员则提供了另一种维度的组织方式。它们属于类本身,而不是某个具体对象,这在嵌入式中非常适合用来表达"全局唯一状态",比如硬件是否已经初始化、当前存在多少实例等。通过静态成员函数访问这些信息,可以避免滥用全局变量,同时保持接口的清晰边界。
const 成员函数是 C++ 提供的一种非常强的语义承诺:这个函数不会修改对象状态 。这不仅是给读代码的人看的,更是给编译器看的。它允许编译器在更多场景下进行检查和优化,也让 const 对象真正具备"只读"属性。
最后是友元。它的存在本身并不邪恶,但几乎总是一个危险信号。友元意味着你主动打破了封装边界,把类的内部实现暴露给外部代码。除非你非常清楚自己在做什么,否则一旦开始依赖友元,往往说明你的类设计已经出现了结构性问题。如果一个类需要大量友元才能工作,那它大概率不该被设计成一个类。
6. 运算符重载(Operator Overloading)
运算符重载是 C++ 最具"争议但也最有魅力"的特性之一。它允许自定义类型像内置类型一样参与表达式计算,从而显著提升代码的可读性与表达力。举个例子,你是喜欢看两个向量塞到一个叫做特别别扭的VectorAdd方法(这里内涵下Java(逃)),还是直接使用a + b的方式更可读呢?相信各位自有答案。
不过,别滥用这个玩意,笔者就建议一个准则:**当你"自然地"会用某个运算符来读这段代码时,才值得重载它。**比如说自然的处理非内置的向量数学运算,物理量运算,时间日期,容器处理等等
6.1 基本运算符重载
最经典、也是最合理的运算符重载场景,来自数学与物理模型。比如三维向量,本质就是一组数值参与加减乘运算,如果不用运算符重载,代码通常会退化成这样:
cpp
v3 = v1.add(v2);
v4 = v1.scale(2.0f);
而通过运算符重载,我们可以让代码直接贴近数学表达式本身:
cpp
v3 = v1 + v2;
v4 = v1 * 2.0f;
所以在之前,笔者涉及到需要自己手搓3D向量的时候,可能会这样书写代码(即兴写的比较烂
cpp
class Vector3D {
private:
int x, y, z;
public:
Vector3D(int x = 0, int y = 0, int z = 0)
: x(x), y(y), z(z) {}
// 二元加法:返回新对象,不修改原对象
Vector3D operator+(const Vector3D& other) const {
return Vector3D(x + other.x, y + other.y, z + other.z);
}
// 二元减法
Vector3D operator-(const Vector3D& other) const {
return Vector3D(x - other.x, y - other.y, z - other.z);
}
// 标量乘法(向量 * 标量)
Vector3D operator*(int scalar) const {
return Vector3D(x * scalar, y * scalar, z * scalar);
}
// 复合赋值:就地修改,避免不必要的临时对象
Vector3D& operator+=(const Vector3D& other) {
x += other.x;
y += other.y;
z += other.z;
return *this;
}
// 一元负号:向量取反
Vector3D operator-() const {
return Vector3D(-x, -y, -z);
}
// 相等比较(通常只在语义明确时才重载)
bool operator==(const Vector3D& other) const {
return x == other.x && y == other.y && z == other.z;
}
bool operator!=(const Vector3D& other) const {
return !(*this == other);
}
};
所以我们现在的代码看起来超级自然,脑子不用大了
cpp
Vector3D v1(1, 2, 3);
Vector3D v2(4, 5, 6);
Vector3D v3 = v1 + v2; // (5, 7, 9)
Vector3D v4 = v1 * 2; // (2, 4, 6)
v1 += v2; // v1 变为 (5, 7, 9)
6.2 下标运算符重载
operator[] 是容器类的"门面接口",重载它几乎是自定义容器的标配操作。它的核心价值在于:
让自定义类型看起来像数组一样可访问
cpp
buffer[3] = 0xFF;
auto x = buffer[10];
一个关键点是:必须同时提供 const 和 非 const 两个版本。
cpp
class ByteBuffer {
private:
uint8_t data[256];
size_t size;
public:
ByteBuffer() : size(0) {}
// 非 const 版本:可写
uint8_t& operator[](size_t index) {
if (index >= size) {
// 真实项目中应抛异常 / assert / 返回安全值
}
return data[index];
}
// const 版本:只读
const uint8_t& operator[](size_t index) const {
if (index >= size) {
// 错误处理
}
return data[index];
}
size_t get_size() const { return size; }
};
使用效果:
cpp
ByteBuffer buffer;
buffer[0] = 0xFF; // 调用非 const 版本
uint8_t value = buffer[0];
const ByteBuffer& const_buffer = buffer;
uint8_t val = const_buffer[0]; // 调用 const 版本
// const_buffer[0] = 0xAA; // 编译期直接禁止
这一节非常适合你强调 const-correctness:
- const 对象只能调用 const 成员函数
- const 下标运算符返回
const T& - 这是 C++ 类型系统帮你兜底的重要方式
6.3 函数调用运算符 operator()
嘿,我的东西要用起来像一个函数,那这个时候,这玩意就派上用场了,函数对象和Lambda的实现,都是基于函数调用运算符 operator()的重载实现的。
cpp
class Accumulator {
private:
int sum;
public:
Accumulator() : sum(0) {}
void operator()(int value) {
sum += value;
}
int get_sum() const { return sum; }
void reset() { sum = 0; }
};
使用时几乎没有任何学习成本:
cpp
Accumulator acc;
acc(10);
acc(20);
acc(30);
int total = acc.get_sum(); // 60
6.4 类型转换运算符
类型转换运算符允许对象被显式或隐式地转换为其他类型 ,但这是最容易踩坑的一类重载。
cpp
class Temperature {
private:
float celsius;
public:
Temperature(float c) : celsius(c) {}
// 转换为 float:摄氏度
operator float() const {
return celsius;
}
// 转换为 int:取整
operator int() const {
return static_cast<int>(celsius);
}
float to_fahrenheit() const {
return celsius * 9.0f / 5.0f + 32.0f;
}
};
使用效果:
cpp
Temperature temp(25.5f);
float c = temp; // 隐式转换:25.5
int c_int = temp; // 隐式转换:25
float f = temp.to_fahrenheit(); // 显式接口:77.9
- 除非语义极其明确,否则避免多个隐式转换
- 优先使用
explicit operator T()(C++11+) - 对"单位转换 / 精度损失"的场景,更推荐显式成员函数
7. 继承(Inheritance)
7.1 继承(Inheritance)自己
继承是 C++ 面向对象里最容易被滥用、也最容易被误解的一项机制。
很多初学者一提到继承,脑子里立刻浮现的是"代码复用""少写代码",但在工程实践中,继承真正解决的问题并不是少写几行,而是表达"是什么"这种关系 。也就是说,继承更多是一个语义工具,而不是一个省事工具。
我要强调一些事情:特别是在比较关键的设计场景下------使用正确的语义总是比为了图省事强!使用正确的语义总是比为了图省事强!使用正确的语义总是比为了图省事强!(你也不想给让未来的你和你的同事给你加班擦屁股吧)
在最理想、也最安全的使用方式下,继承用来表达一种非常明确的关系:派生类 is-a 基类。例如,一个温度传感器"是一种传感器",UART"是一种设备"。在这种语义成立的前提下,继承才是自然的。
以一个传感器为例,基类负责定义"所有传感器都具备的能力和状态",比如是否已经初始化、初始化的基本流程等;而派生类只需要关心自己特有的行为。基类中的 protected 成员正是为这种场景准备的:它们不对外暴露,但允许派生类在合理范围内使用这些内部状态。这样一来,派生类可以在不破坏封装的前提下,复用和扩展基类逻辑。
7.2 继承存在分类
继承方式本身也有访问控制之分,但在嵌入式工程中,绝大多数情况下你只应该使用公有继承 。原因很简单:公有继承才能维持"is-a"语义,也才能保证通过基类接口使用派生类对象是安全且直观的。protected 继承和 private 继承更多是语言层面的技巧,适用场景非常有限,一旦使用,往往意味着设计已经开始变得晦涩。
需要特别强调的是,继承并不是"免费午餐"。它会引入更复杂的对象关系、更难追踪的调用路径,也会让代码的理解成本明显上升。因此在嵌入式开发中,一个非常实用的经验是:如果只是为了复用实现,而不是为了表达语义关系,那继承多半是错的选择。这种情况下,组合往往更清晰、更安全。
7.3 多重继承
cpp
cpp
class Readable {
public:
virtual int read() = 0; // 纯虚函数
};
class Writable {
public:
virtual void write(int value) = 0;
};
// 同时继承两个接口
class SerialPort : public Readable, public Writable {
private:
int buffer;
public:
int read() override {
// 读取数据
return buffer;
}
void write(int value) override {
// 写入数据
buffer = value;
}
};
// 使用
SerialPort port;
port.write(42);
int value = port.read();
多重继承则是一个更需要克制的特性。虽然 C++ 支持一个类同时继承多个基类,但这条路几乎注定通向复杂性,尤其是经典的"菱形继承"问题。一旦两个基类又继承自同一个共同基类,你就需要面对对象中到底存在几份基类子对象、成员访问是否歧义等一系列问题。虚继承确实可以从语言层面解决这些歧义,但代价是对象布局、构造顺序和理解成本都会显著上升。在嵌入式环境下,这种复杂性通常是不值得的。一个相对安全的共识是:多重继承只用于"接口继承",而不要用于"实现继承"。
cpp
class Base {
public:
int value;
};
class Derived1 : public Base { };
class Derived2 : public Base { };
// 菱形继承:Multiple会有两份Base
class Multiple : public Derived1, public Derived2 {
void foo() {
// value是歧义的:是Derived1::value还是Derived2::value?
// Derived1::value = 10; // 需要明确指定
}
};
// 使用虚继承解决
class Derived1 : virtual public Base { };
class Derived2 : virtual public Base { };
class Multiple : public Derived1, public Derived2 {
void foo() {
value = 10; // 现在只有一份Base
}
上面这个代码就是菱形继承,嗯,您看到多复杂了对吧,别用这个,除非你真的需要。
8. 多态(Polymorphism)
8.1 什么是多态
如果说继承回答的是"你是什么",那么多态回答的就是"你现在表现得像什么"。多态允许你通过基类指针或引用,去操作一个派生类对象,并在运行时调用到派生类的实现。这种能力并不神秘,本质上只是一次间接函数调用,但它对系统架构的影响却非常大。
多态的核心在于虚函数。当一个成员函数被声明为 virtual,就意味着:具体调用哪一个实现,要等到运行时才能确定,而不是在编译期静态绑定。这正是多态能够成立的根本原因。在图形系统、驱动抽象层、协议栈等场景中,这种能力非常有价值,因为它允许上层代码完全不关心底层对象的具体类型,只关心"它能做什么"。
抽象类和纯虚函数则把这种思想推向了极致。一个只包含接口、不包含具体实现的类,本身并不是为了被实例化,而是为了定义一种能力契约。派生类必须完整实现这些接口,才能成为"合法的具体类型"。这种设计在驱动层尤为常见:UART、SPI、I²C 看起来完全不同,但在"发送数据""接收数据"这个层面,它们可以共享一套抽象接口。上层协议处理逻辑只依赖接口,而不依赖任何具体硬件,这使得代码的可移植性和可测试性大幅提升。
不过,多态并非没有代价。每一个虚函数调用,背后都意味着一次间接跳转;每一个含虚函数的类,通常都会多一个虚表指针。这些开销在 PC 上微不足道,但在资源紧张、对实时性敏感的嵌入式系统中,就必须被认真对待。因此,一个非常重要的工程判断是:只有当"解耦带来的收益"明确大于"运行时开销和复杂度"时,多态才值得使用。
8.2 虚析构函数
虚析构函数是多态中一个极其容易被忽视、却又极其致命的细节。只要你打算通过基类指针来管理派生类对象的生命周期,那么基类析构函数就必须是虚的 。否则,在删除对象时,只会调用基类析构函数,派生类中持有的资源将完全得不到释放。这类问题在嵌入式中往往表现为"莫名其妙的内存泄漏"或"外设状态异常",而定位起来异常困难。一个简单但几乎可以写成铁律的经验是:只要类中存在任何虚函数,就几乎一定要把析构函数也声明为 virtual。
在嵌入式实际工程中,多态最有价值的应用场景,往往出现在"驱动抽象"和"协议解耦"上。通过一个统一的通信接口,上层逻辑可以完全不关心底层是 UART 还是 SPI,只需要调用同一套 send、receive 接口即可。这种设计并不是为了炫技,而是为了在硬件变化、平台迁移时,把修改范围控制在最小。
9. 动态内存管理
C++提供了new和delete运算符来替代C的malloc和free。可以最最简化和不严谨的说------new是malloc和对应初始化的简单封装------让您可以在sizeof(TargetType)大小的内存上就地初始化对象,delete就是在这块内存上调用相关的析构函数(处理准备回收内存的尾巴)然后再回收内存
9.1 new和delete
这里,笔者不说话,看代码就行
cpp
// 分配单个对象
int* p = new int; // 分配一个int
*p = 42;
delete p; // 释放
// 分配并初始化
int* p2 = new int(100); // 分配并初始化为100
delete p2;
// 分配数组
int* arr = new int[10]; // 分配10个int的数组
delete[] arr; // 使用delete[]释放数组
// 分配对象
class MyClass {
public:
MyClass() { printf("Constructor\n"); }
~MyClass() { printf("Destructor\n"); }
};
MyClass* obj = new MyClass(); // 调用构造函数
delete obj; // 调用析构函数
// 分配对象数组
MyClass* objs = new MyClass[5]; // 调用5次构造函数
delete[] objs; // 调用5次析构函数
9.2 placement new
placement new允许在指定的内存位置构造对象,在上位机开发上,这个活实际上用的不是非常的多。
cpp
#include <new> // 需要包含这个头文件
// 预分配的内存缓冲区
alignas(MyClass) uint8_t buffer[sizeof(MyClass)];
// 在缓冲区中构造对象
MyClass* obj = new (buffer) MyClass();
// 使用对象
obj->some_method();
// 必须显式调用析构函数
obj->~MyClass();
// 不要使用delete,因为内存不是用new分配的
在嵌入式系统中,placement new在固定内存池中构造对象时很有用:
cpp
class FixedMemoryPool {
private:
static constexpr size_t POOL_SIZE = 1024;
alignas(max_align_t) uint8_t memory_pool[POOL_SIZE];
size_t used;
public:
FixedMemoryPool() : used(0) {}
void* allocate(size_t size, size_t alignment = alignof(max_align_t)) {
// 对齐地址
size_t padding = (alignment - (used % alignment)) % alignment;
size_t new_used = used + padding + size;
if (new_used > POOL_SIZE) {
return nullptr; // 内存池已满
}
void* ptr = &memory_pool[used + padding];
used = new_used;
return ptr;
}
void reset() {
used = 0;
}
};
// 使用
FixedMemoryPool pool;
// 在池中构造对象
void* mem = pool.allocate(sizeof(MyClass), alignof(MyClass));
if (mem) {
MyClass* obj = new (mem) MyClass();
// 使用obj
obj->~MyClass(); // 显式调用析构函数
}