C++98基础特性:从C到C++的演进¶
完整的仓库地址在Tutorial_AwesomeModernCPP中,您也可以光顾一下,喜欢的话给一个Star激励一下作者
在上一章中,我们系统回顾了C语言的核心语法,这些知识是理解C++的基础。C++最初被设计为"C with Classes",即在保持C语言高效性的同时,引入面向对象编程的特性。本章将专注于C++98标准中引入的基础特性,这些特性使得C++成为一门更加强大和表达力更强的语言,同时在嵌入式系统中仍然保持高效。
不过,其实C++98开始就有模板了,但是笔者没有介绍,是因为模板本身就很复杂,我们必须单独开几章讲(以C++ Template为代表的著作甚至专门介绍了模板编程,笔者看过,小六七百页呢)
1. 命名空间 (Namespace)¶
命名空间是C++引入的一个重要特性,用于组织代码并避免命名冲突。在大型嵌入式项目中,特别是使用多个第三方库时,命名空间可以有效地防止标识符冲突。这个东西不会再任何层次上干扰性能。因为他最终只是退化成带有命名空间修饰的符号名称。所以这个好用的特性,能用就用。
1.1 命名空间的定义与使用¶
// 定义命名空间
namespace sensor {
const int MAX_READINGS = 100;
struct Reading {
float temperature;
float humidity;
};
void init();
Reading get_reading();
}
// 实现命名空间中的函数
namespace sensor {
void init() {
// 初始化传感器
}
Reading get_reading() {
Reading r;
// 读取数据
return r;
}
}
// 使用命名空间
int main() {
// 完全限定名
sensor::init();
sensor::Reading data = sensor::get_reading();
// 使用using声明
using sensor::Reading;
Reading data2 = sensor::get_reading();
// 使用using指令(不推荐在头文件中使用, 有个知乎回答专门聊using namespace的,感兴趣搜下)
using namespace sensor;
init();
Reading data3 = get_reading();
return 0;
}
1.2 嵌套命名空间¶
命名空间可以嵌套,这在组织复杂的代码库时非常有用,举个例子,以基础库下,我们有高性能高精度乘法函数,在C里,我们写:BasicComponent_HighResolution_multiply,现在不用这么又臭又长的,直接写basic::high_resolution::multiply,特别结合上面提到的using namespace,咱们如果这个文件下所有的运算都是这个命名空间下的运算函数,那就直接using namespace即可。
或者一个嵌入式的朋友看得懂的代码:
namespace hardware {
namespace gpio {
enum PinMode {
INPUT,
OUTPUT,
ALTERNATE
};
void set_mode(int pin, PinMode mode);
}
namespace uart {
void init(int baudrate);
void send(const char* data);
}
}
// 使用
hardware::gpio::set_mode(5, hardware::gpio::OUTPUT);
hardware::uart::init(115200);
// 或者使用别名简化
namespace hw = hardware;
hw::gpio::set_mode(5, hw::gpio::OUTPUT);
1.3 匿名命名空间¶
匿名命名空间提供文件级别的作用域,替代C语言中的static关键字,虽然等价,但是现在你终于不用给每一个想藏起来变量和函数都搞一个static了。
// 在C++中推荐使用匿名命名空间而非static
namespace {
// 这些变量和函数只在本文件可见
const int BUFFER_SIZE = 256;
void internal_helper() {
// 内部辅助函数
}
}
void public_function() {
internal_helper(); // 可以直接调用
}
2. 引用 (Reference)¶
引用是C++引入的一个重要特性,它为变量提供了一个别名。引用在很多方面比指针更安全、更方便,特别是在函数参数传递中。这个东西,仁者见仁智者见智,在C++里,您可以放心的代替很多希望修改本变量的指针为引用。下面是使用的例子,不过,很少人这样写引用,这里只是说明:
int value = 42;
int& ref = value; // ref是value的引用(别名)
ref = 100; // 修改ref就是修改value
// 此时value也变成了100
// 引用必须在声明时初始化
// int& bad_ref; // 错误:引用必须初始化
// 引用一旦绑定就不能重新绑定到其他变量
int other = 200;
ref = other; // 这不是重新绑定,而是将other的值赋给value
2.2 引用作为函数参数¶
更多的情况下,我们使用引用参数是为了避免了拷贝开销(也就是老生常谈的形参实参问题),在嵌入式系统中特别有用:
// 传值:拷贝整个结构体(低效)
void process_by_value(SensorData data) {
// data是副本
}
// 传指针:需要检查空指针,语法稍显笨拙
void process_by_pointer(SensorData* data) {
if (data != nullptr) {
data->temperature += 10; // 需要使用->
}
}
// 传引用:高效且语法简洁
void process_by_reference(SensorData& data) {
data.temperature += 10; // 直接使用.操作符
// 不需要空指针检查,一般下,引用总是有效的,除非你的对象失效了!
}
// const引用:既高效又防止修改
void read_only_access(const SensorData& data) { // 在C++98中很常见的用法
float temp = data.temperature; // 可以读取
// data.temperature = 0; // 错误:不能修改const引用
}
2.3 引用作为返回值¶
函数可以返回引用,但需要特别小心不要返回局部变量的引用:
class Buffer {
private:
uint8_t data[256];
size_t size;
public:
// 返回引用允许连续调用
Buffer& append(uint8_t byte) {
if (size < 256) {
data[size++] = byte;
}
return *this; // 返回当前对象的引用
}
// 允许通过引用访问元素
uint8_t& operator[](size_t index) {
return data[index];
}
// const版本
const uint8_t& operator[](size_t index) const {
return data[index];
}
};
// 使用
Buffer buf;
buf.append(0x01).append(0x02).append(0x03); // 链式调用
buf[0] = 0xFF; // 通过引用修改元素
警告:不要返回局部变量的引用!本质上,他只是告诉编译器,不要单独的拷贝对象出来操作临时的拷贝对象,现在如果你返回了栈上对象的引用,他们在return的时候就被销毁了,所以返回栈上对象的引用是很危险的,更是一种低级错误,永远不要做这种事情!
// 危险!不要这样做!
int& dangerous_function() {
int local = 42;
return local; // 返回局部变量的引用(未定义行为)
}
// 正确的做法
int& safe_function(int& input) {
return input; // 返回参数的引用是安全的
}
3. 函数重载 (Function Overloading)¶
函数重载允许多个函数使用相同的名称,只要它们的参数列表不同。这使得API设计更加直观和灵活。但这个东西从不同的层次上会带来麻烦——比如说,导出重载函数的符号。
3.1 基本函数重载¶
基本的函数重载就这样用:
// 不同参数类型的重载
void print(int value) {
printf("Integer: %d\n", value);
}
void print(float value) {
printf("Float: %f\n", value);
}
void print(const char* str) {
printf("String: %s\n", str);
}
// 不同参数数量的重载
void init_uart(int baudrate) {
// 使用默认配置
}
void init_uart(int baudrate, int databits, int stopbits) {
// 使用自定义配置
}
// 使用
print(42); // 调用print(int)
print(3.14f); // 调用print(float)
print("Hello"); // 调用print(const char*)
OK,这次我完全按“博客正文文段”来重写,不再拆成教学式小块,也不做列表堆砌,而是一口气讲清楚编译器在想什么,你可以直接整体贴进文章里用。
3.2 重载解析规则¶
在 C++ 中,看似简单的一次函数调用,背后其实隐藏着一套非常严格、近乎“冷酷”的决策流程。每当你调用一个存在多个重载版本的函数时,编译器都会先收集所有名字匹配、参数数量一致的候选函数,然后对它们逐一评估,试图回答一个问题:哪一个是“最合适”的?这个过程被称为重载解析(Overload Resolution)。需要强调的是,编译器并不会理解你的业务语义,它只会机械地按照语言规则打分,最终选出匹配度最高的那个版本。
在不涉及模板、可变参数等复杂因素的前提下,编译器的判断标准可以理解为一条由强到弱的“匹配优先级链”。首先是精确匹配,也就是实参与形参类型完全一致;如果不存在精确匹配,才会考虑类型提升,比如 char 提升为 int;再往后才是标准类型转换,例如 int 转换为 double;最后才轮到用户自定义的类型转换。这个顺序非常重要,因为它意味着:只要某一层级已经能找到可行的匹配,后面的规则就完全不会被考虑,哪怕它们在“人类直觉”中看起来更合理。
举一个最常见的例子,如果我们同时定义了 process(int) 和 process(double) 两个函数,不用太麻烦,您直接写这两行就行:
那么调用 process(5) 时,编译器几乎不需要思考:字面量 5 本身就是 int,这属于精确匹配,而 process(double) 需要一次从 int 到 double 的转换。在重载解析的规则下,精确匹配对任何形式的转换都有压倒性优势,因此最终调用的一定是 process(int)。同样地,调用 process(5.0) 时,5.0 是 double,这一次精确匹配发生在 process(double) 上,另一个版本反而需要进行带有精度风险的转换,自然会被淘汰。
稍微容易让人困惑的是 process(5.0f) 这种情况。5.0f 的类型是 float,而我们并没有 process(float) 的重载。此时编译器会比较两条可能的路径:float 转换为 double,以及 float 转换为 int。前者是浮点类型之间的标准提升,被认为更加自然、安全;后者则涉及截断语义,因此优先级更低。结果是,哪怕你没有显式写出 double,最终仍然会调用 process(double)。这也体现了一个事实:重载解析并不是“最少字符匹配”,而是“最合理的类型路径匹配”。
真正让人头疼的情况,往往出现在规则无法分出高下的时候。比如同时存在 func(int, double) 和 func(double, int) 两个重载,当你调用 func(5, 5) 时,从人的角度看似乎“随便选一个也行”,但在编译器眼里,这两个候选函数的匹配成本是完全一样的:对于第一个版本,一个参数是精确匹配、另一个需要标准转换;对于第二个版本,情况正好对称。两边的“代价”一模一样,没有任何一个能在规则层面胜出。此时,编译器不会尝试揣测你的意图,而是直接判定调用存在歧义,并以错误终止编译。
这背后反映的是 C++ 一个非常重要、也非常“工程化”的设计理念:只要存在同样可行、但无法比较优劣的选择,编译器宁可拒绝编译,也不会替程序员做决定。这正是 C++ 强类型系统的底色——明确性永远高于便利性(这里,即便是这样方便的语法也不能触犯的底线)。从实践角度来说,这也意味着我们在设计接口时,应当尽量避免仅靠参数顺序或微妙的类型差异来区分重载,尤其是在涉及内置类型或隐式转换时。一旦出现歧义,最可靠的做法永远是把类型写清楚,而不是寄希望于编译器“刚好懂你”。
如果要用一句话来总结这一节,那就是:重载解析不是智能推断,而是一套冷静、刻板的规则系统;当你觉得“它应该能工作”的时候,往往正是它最容易报错的时候。
4. 默认参数(Default Arguments)¶
4.1 就说说默认参数¶
在真实工程中,函数参数并不是“越多越好”。很多时候,一个函数的参数里总会混着几类角色:核心必选参数、高频但几乎不变的配置,以及只有极少数场景才会调整的高级选项。如果每次调用都被迫把这些参数一个不落地写出来,不仅代码冗长,而且会迅速掩盖真正重要的信息。默认参数正是为了解决这个问题而存在的——那些你已经决定好“默认行为”的参数,就干脆别让调用者操心。
一个非常典型的例子是硬件外设配置。以 UART 为例,真正每次都会变的,往往只有波特率;至于数据位、停止位、校验位,大多数项目里几乎一成不变。用默认参数,我们就可以把“常识”编码进接口里,让调用点尽可能简洁:
void configure_uart(int baudrate,
int databits = 8,
int stopbits = 1,
char parity = 'N') {
// 配置UART
}
这样一来,最常见的调用形式只剩下真正关心的那一个参数:
而当你真的需要偏离默认行为时,也仍然可以逐步“向右展开”参数:
从接口设计的角度看,这是一种非常温和的向前兼容手段:你可以不断在函数右侧追加新的可选能力,而不会破坏已有代码。
4.2 默认参数的一些规则¶
当然,默认参数并不是随心所欲的语法糖,它的规则其实非常严格。首先,默认参数必须从右向左连续出现,因为编译器在函数调用时只能通过“省略尾部参数”的方式来判断哪些值使用默认值。如果你试图在非默认参数后面再放一个没有默认值的参数,编译器会直接拒绝这种设计。其次,每一个默认参数只能被指定一次,而且通常应该放在函数声明处,而不是定义处。这一点在头文件与源文件分离的工程中尤为重要:默认值是接口的一部分,而不是实现细节,如果你在 .cpp 里再写一遍默认参数,编译器会认为你在试图重新定义规则,从而报错。
在嵌入式开发中,默认参数尤其适合用在“配置型接口”和“初始化函数”上。比如 SPI、I²C、定时器这类外设,往往都有一套“推荐配置”,只有在极少数情况下才需要完全自定义。通过默认参数,你可以让最常见的用法几乎零负担:
这种接口的可读性非常强:调用点本身就已经在“讲故事”,而不是一串神秘的魔法数字。
5. 类与对象(Classes and Objects)¶
类和对象是 C++ 面向对象编程的核心概念,但在嵌入式语境下,它们常常被误解成“重”“慢”“花里胡哨”。实际上,类并不等于复杂,OOP 也不等于必须上继承、多态那一套。在资源紧张、业务逻辑清晰的嵌入式系统中,类最核心的价值只有一个:把“状态”和“操作状态的代码”绑在一起。
换句话说,类的第一价值不是抽象,而是约束。
以一个最简单的 LED 控制为例,如果你用 C 风格代码,很容易把 GPIO 引脚号、当前状态、操作函数散落在各个文件里。而用一个类,你可以把这些东西自然地收拢到一起:
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 并不是为了“防黑客”,而是为了在语法层面告诉使用者:哪些东西你不该碰。你当然可以通过各种手段绕过它,但那已经属于未定义行为的范畴,后果自负。对大多数工程代码来说,这种约束本身就是一种极强的自文档。
构造函数和析构函数则进一步强化了这种“绑定关系”。构造函数负责把对象带入一个合法、可用的状态,析构函数负责在对象生命周期结束时做清理工作。在嵌入式系统中,这种模式尤其适合用来管理硬件资源:
这并不是为了追求“RAII 教科书式优雅”,而是为了减少人为遗漏清理步骤的可能性。
在构造函数中,成员初始化列表是一个经常被忽视、但非常重要的细节。它并不是“写起来好看”,而是真正决定了对象是初始化还是先默认构造再赋值。对于 const 成员、引用成员,以及复杂对象成员来说,初始化列表甚至是唯一合法的选择。从效率和语义正确性上看,它都应该成为你的第一选择。
至于 this 指针,它的存在本身并不神秘:每一个非静态成员函数,都会隐式地携带一个指向当前对象的指针。理解这一点之后,链式调用这种写法就显得非常自然了——你只是不断返回“自己”,而已。
静态成员则提供了另一种维度的组织方式。它们属于类本身,而不是某个具体对象,这在嵌入式中非常适合用来表达“全局唯一状态”,比如硬件是否已经初始化、当前存在多少实例等。通过静态成员函数访问这些信息,可以避免滥用全局变量,同时保持接口的清晰边界。
const 成员函数是 C++ 提供的一种非常强的语义承诺:这个函数不会修改对象状态。这不仅是给读代码的人看的,更是给编译器看的。它允许编译器在更多场景下进行检查和优化,也让 const 对象真正具备“只读”属性。
最后是友元。它的存在本身并不邪恶,但几乎总是一个危险信号。友元意味着你主动打破了封装边界,把类的内部实现暴露给外部代码。除非你非常清楚自己在做什么,否则一旦开始依赖友元,往往说明你的类设计已经出现了结构性问题。如果一个类需要大量友元才能工作,那它大概率不该被设计成一个类。
6. 运算符重载(Operator Overloading)¶
运算符重载是 C++ 最具“争议但也最有魅力”的特性之一。它允许自定义类型像内置类型一样参与表达式计算,从而显著提升代码的可读性与表达力。举个例子,你是喜欢看两个向量塞到一个叫做特别别扭的VectorAdd方法(这里内涵下Java(逃)),还是直接使用a + b的方式更可读呢?相信各位自有答案。
不过,别滥用这个玩意,笔者就建议一个准则:当你“自然地”会用某个运算符来读这段代码时,才值得重载它。比如说自然的处理非内置的向量数学运算,物理量运算,时间日期,容器处理等等
6.1 基本运算符重载¶
最经典、也是最合理的运算符重载场景,来自数学与物理模型。比如三维向量,本质就是一组数值参与加减乘运算,如果不用运算符重载,代码通常会退化成这样:
而通过运算符重载,我们可以让代码直接贴近数学表达式本身:
所以在之前,笔者涉及到需要自己手搓3D向量的时候,可能会这样书写代码(即兴写的比较烂
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);
}
};
所以我们现在的代码看起来超级自然,脑子不用大了
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[] 是容器类的“门面接口”,重载它几乎是自定义容器的标配操作。它的核心价值在于:
让自定义类型看起来像数组一样可访问
一个关键点是:必须同时提供 const 和 非 const 两个版本。
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; }
};
使用效果:
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()的重载实现的。
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; }
};
使用时几乎没有任何学习成本:
6.4 类型转换运算符¶
类型转换运算符允许对象被显式或隐式地转换为其他类型,但这是最容易踩坑的一类重载。
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;
}
};
使用效果:
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
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++ 支持一个类同时继承多个基类,但这条路几乎注定通向复杂性,尤其是经典的“菱形继承”问题。一旦两个基类又继承自同一个共同基类,你就需要面对对象中到底存在几份基类子对象、成员访问是否歧义等一系列问题。虚继承确实可以从语言层面解决这些歧义,但代价是对象布局、构造顺序和理解成本都会显著上升。在嵌入式环境下,这种复杂性通常是不值得的。一个相对安全的共识是:多重继承只用于“接口继承”,而不要用于“实现继承”。
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¶
这里,笔者不说话,看代码就行
// 分配单个对象
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允许在指定的内存位置构造对象,在上位机开发上,这个活实际上用的不是非常的多。
#include <new> // 需要包含这个头文件
// 预分配的内存缓冲区
alignas(MyClass) uint8_t buffer[sizeof(MyClass)];
// 在缓冲区中构造对象
MyClass* obj = new (buffer) MyClass();
// 使用对象
obj->some_method();
// 必须显式调用析构函数
obj->~MyClass();
// 不要使用delete,因为内存不是用new分配的
在嵌入式系统中,placement new在固定内存池中构造对象时很有用:
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(); // 显式调用析构函数
}
10. 类型转换运算符¶
C++提供了四种专用的类型转换运算符,比C风格的强制转换更安全、更明确。
10.1 static_cast¶
static_cast用于编译时已知的类型转换:
// 基本类型转换
int i = 10;
float f = static_cast<float>(i); // int到float
// 指针类型转换(需要有继承关系或void*)
void* void_ptr = &i;
int* int_ptr = static_cast<int*>(void_ptr);
// 向上转换(派生类到基类,总是安全的)
class Base {};
class Derived : public Base {};
Derived d;
Base* base_ptr = static_cast<Base*>(&d); // 向上转换
// 向下转换(基类到派生类,程序员需确保安全)
Base b;
// Derived* derived_ptr = static_cast<Derived*>(&b); // 危险!可能不安全
在嵌入式开发中的典型用法:
// 寄存器地址转换
uint32_t address = 0x40020000;
volatile uint32_t* reg = static_cast<volatile uint32_t*>(
static_cast<void*>(address)
);
// 或使用reinterpret_cast(见下文)
10.2 dynamic_cast¶
dynamic_cast用于运行时类型检查,主要用于多态类型(包含虚函数的类):
class Base {
public:
virtual ~Base() {} // 必须有虚函数
};
class Derived : public Base {
public:
void derived_specific_method() {}
};
// 向下转换,带运行时检查
Base* base_ptr = new Derived();
Derived* derived_ptr = dynamic_cast<Derived*>(base_ptr);
if (derived_ptr != nullptr) {
// 转换成功
derived_ptr->derived_specific_method();
} else {
// 转换失败,base_ptr不指向Derived对象
}
// 引用的dynamic_cast失败时抛出异常
Base& base_ref = *base_ptr;
try {
Derived& derived_ref = dynamic_cast<Derived&>(base_ref);
derived_ref.derived_specific_method();
} catch (std::bad_cast& e) {
// 转换失败
}
注意:在嵌入式系统中,dynamic_cast需要RTTI(运行时类型信息)支持,会增加代码大小和运行时开销。许多嵌入式编译器默认禁用RTTI以节省资源。
10.3 reinterpret_cast¶
reinterpret_cast执行低级别的重新解释转换,常用于指针类型之间的转换:
// 整数到指针的转换(嵌入式中常见)
uint32_t address = 0x40020000;
volatile uint32_t* reg = reinterpret_cast<volatile uint32_t*>(address);
// 不同指针类型之间的转换
struct GPIO_TypeDef {
volatile uint32_t MODER;
volatile uint32_t OTYPER;
// ...
};
uint32_t gpio_base = 0x40020000;
GPIO_TypeDef* gpio = reinterpret_cast<GPIO_TypeDef*>(gpio_base);
// 函数指针转换(例如中断向量表)
typedef void (*ISR_Handler)(void);
void timer_isr() {
// 中断处理代码
}
uint32_t isr_address = reinterpret_cast<uint32_t>(timer_isr);
在嵌入式系统中,reinterpret_cast是访问硬件寄存器的标准方法:
// 定义外设基地址
#define PERIPH_BASE 0x40000000UL
#define AHB1PERIPH_BASE (PERIPH_BASE + 0x00020000UL)
#define GPIOA_BASE (AHB1PERIPH_BASE + 0x0000UL)
// 定义寄存器结构
typedef struct {
volatile uint32_t MODER; // 模式寄存器
volatile uint32_t OTYPER; // 输出类型寄存器
volatile uint32_t OSPEEDR; // 输出速度寄存器
volatile uint32_t PUPDR; // 上拉/下拉寄存器
volatile uint32_t IDR; // 输入数据寄存器
volatile uint32_t ODR; // 输出数据寄存器
volatile uint32_t BSRR; // 位设置/复位寄存器
} GPIO_TypeDef;
// 创建指向硬件的指针
#define GPIOA (reinterpret_cast<GPIO_TypeDef*>(GPIOA_BASE))
// 使用
GPIOA->MODER |= 0x01; // 配置引脚模式
10.4 const_cast¶
const_cast用于添加或移除const属性:
// 移除const属性(应谨慎使用)
const int const_value = 100;
int* modifiable = const_cast<int*>(&const_value);
*modifiable = 200; // 危险!可能导致未定义行为
// 合法的用法:调用不接受const参数的遗留C API
void legacy_c_function(char* str); // 老的C函数,不接受const
const char* message = "Hello";
// legacy_c_function(message); // 错误:不能将const char*转换为char*
// 如果确定函数不会修改字符串,可以使用const_cast
legacy_c_function(const_cast<char*>(message));
在嵌入式开发中,const_cast偶尔用于与硬件或遗留代码接口:
// 某些硬件驱动库可能没有正确使用const
void hal_uart_send(uint8_t* data, size_t length); // 应该用const,但没有
class UARTWrapper {
public:
void send(const uint8_t* data, size_t length) {
// 我们知道hal_uart_send实际上不会修改数据
// 但它的签名错误
hal_uart_send(const_cast<uint8_t*>(data), length);
}
};
警告:移除真正const对象的const属性并修改它会导致未定义行为。const_cast应该只用于移除意外添加的const属性。
11. 异常处理 (Exception Handling)¶
异常处理提供了一种结构化的错误处理机制,可以将错误处理代码与正常逻辑分离。至少看起来,代码能干净一些,后面会讲为什么很多情况下,我们会禁止使用异常处理。
11.1 基本异常处理¶
基本的异常处理范式是try catch throw办法——尝试的执行代码,遇到错误抛出异常,然后捕获住这个异常。
#include <exception>
#include <stdexcept>
// 抛出异常
void risky_function(int value) {
if (value < 0) {
throw std::invalid_argument("Value must be non-negative");
}
if (value > 100) {
throw std::out_of_range("Value exceeds maximum");
}
// 正常处理
}
// 捕获异常
void caller() {
try {
risky_function(-5);
} catch (const std::invalid_argument& e) {
// 处理invalid_argument异常
printf("Invalid argument: %s\n", e.what());
} catch (const std::out_of_range& e) {
// 处理out_of_range异常
printf("Out of range: %s\n", e.what());
} catch (const std::exception& e) {
// 捕获所有标准异常
printf("Exception: %s\n", e.what());
} catch (...) {
// 捕获所有其他异常
printf("Unknown exception\n");
}
}
11.2 自定义异常类¶
// 自定义异常类
class HardwareException : public std::exception {
private:
const char* message;
int error_code;
public:
HardwareException(const char* msg, int code)
: message(msg), error_code(code) {}
const char* what() const throw() override {
return message;
}
int get_error_code() const {
return error_code;
}
};
class SensorException : public HardwareException {
public:
SensorException(const char* msg, int code)
: HardwareException(msg, code) {}
};
// 使用自定义异常
void read_sensor() {
if (!sensor_initialized) {
throw SensorException("Sensor not initialized", 0x01);
}
if (sensor_timeout) {
throw SensorException("Sensor read timeout", 0x02);
}
// 读取传感器
}
// 捕获
try {
read_sensor();
} catch (const SensorException& e) {
printf("Sensor error: %s (code: 0x%02X)\n",
e.what(), e.get_error_code());
}
11.3 异常安全性¶
编写异常安全的代码需要考虑资源管理:
// 不安全的代码
void unsafe_function() {
int* data = new int[100];
risky_operation(); // 如果抛出异常,data不会被释放
delete[] data;
}
// 安全的代码(使用try-catch)
void safe_function_v1() {
int* data = new int[100];
try {
risky_operation();
delete[] data;
} catch (...) {
delete[] data;
throw; // 重新抛出异常
}
}
// 更好的做法:使用RAII(Resource Acquisition Is Initialization)
class AutoArray {
private:
int* data;
public:
AutoArray(size_t size) : data(new int[size]) {}
~AutoArray() { delete[] data; }
int& operator[](size_t index) { return data[index]; }
};
void safe_function_v2() {
AutoArray data(100);
risky_operation(); // 即使抛出异常,data也会自动释放
}
11.4 异常规格说明¶
C++98允许指定函数可能抛出的异常类型(C++11中已废弃):
// 声明函数不会抛出异常
void no_throw_function() throw() {
// 不应该抛出异常
}
// 声明函数可能抛出特定异常
void specific_throw(int value) throw(std::invalid_argument, std::out_of_range) {
if (value < 0) throw std::invalid_argument("negative");
if (value > 100) throw std::out_of_range("too large");
}
11.5 嵌入式系统中的异常处理¶
在嵌入式系统中使用异常需要谨慎考虑,第一点,在本来资源环境就很紧张的情况下,异常处理会增加显著的代码大小(异常表、展开代码等),致命的是——发生错误了,咱们处理异常的时间开销完全无法预测,在实时性看得极重的嵌入式实时系统里,这么玩就是玩火自焚。所以,许多嵌入式项目选择禁用异常(使用-fno-exceptions编译选项),而使用返回值或错误码进行错误处理。
现代C++中的optional和expected就是成熟得多的方案,笔者就在用这个。
// 推荐的嵌入式错误处理方式
enum ErrorCode {
ERROR_OK = 0,
ERROR_INVALID_PARAM,
ERROR_TIMEOUT,
ERROR_HARDWARE_FAULT
};
ErrorCode initialize_hardware() {
if (!check_hardware()) {
return ERROR_HARDWARE_FAULT;
}
if (!configure_registers()) {
return ERROR_TIMEOUT;
}
return ERROR_OK;
}
// 使用
ErrorCode result = initialize_hardware();
if (result != ERROR_OK) {
// 处理错误
}
12. C++98的其他重要特性¶
12.1 explicit关键字¶
explicit关键字防止隐式类型转换:
class Distance {
private:
int meters;
public:
// 没有explicit:允许隐式转换
Distance(int m) : meters(m) {}
int get_meters() const { return meters; }
};
void process_distance(Distance d) {
// 处理距离
}
// 隐式转换
process_distance(100); // OK:100被隐式转换为Distance(100)
// 使用explicit防止隐式转换
class SafeDistance {
private:
int meters;
public:
explicit SafeDistance(int m) : meters(m) {}
int get_meters() const { return meters; }
};
void process_safe_distance(SafeDistance d) {
// 处理距离
}
// process_safe_distance(100); // 错误:不能隐式转换
process_safe_distance(SafeDistance(100)); // OK:显式构造
在嵌入式开发中,使用explicit可以避免意外的类型转换错误:
class PWMChannel {
private:
int channel;
public:
explicit PWMChannel(int ch) : channel(ch) {
if (ch < 0 || ch >= MAX_CHANNELS) {
// 错误处理
}
}
void set_duty_cycle(int duty) {
// 设置占空比
}
};
// 使用
// pwm.set_duty_cycle(PWMChannel(50)); // 错误!防止意外传递通道号而非占空比
12.2 mutable关键字¶
mutable允许在const成员函数中修改成员变量:
class Cache {
private:
mutable int access_count; // 可以在const函数中修改
int data;
public:
Cache() : access_count(0), data(0) {}
int get_data() const {
access_count++; // OK:access_count是mutable
return data;
}
int get_access_count() const {
return access_count;
}
};
const Cache cache;
int value = cache.get_data(); // 增加access_count
在嵌入式系统中,mutable可用于实现缓存、统计信息等:
class Sensor {
private:
mutable bool cached_valid;
mutable float cached_value;
int pin;
public:
explicit Sensor(int p) : cached_valid(false), pin(p) {}
float read() const {
if (!cached_valid) {
cached_value = read_from_hardware();
cached_valid = true;
}
return cached_value;
}
private:
float read_from_hardware() const {
// 实际读取硬件
return 25.0f;
}
};
12.3 内联函数¶
内联函数在C++中得到了增强:
// 在头文件中定义内联函数
inline int max(int a, int b) {
return (a > b) ? a : b;
}
class Math {
public:
// 类内定义的成员函数隐式为inline
int add(int a, int b) {
return a + b;
}
// 显式inline
inline int multiply(int a, int b);
};
// 在类外定义时也需要inline
inline int Math::multiply(int a, int b) {
return a * b;
}
在嵌入式开发中,内联函数可以提高性能而不牺牲类型安全:
// 相比宏,内联函数更安全
inline void set_bit(volatile uint32_t& reg, int bit) {
reg |= (1UL << bit);
}
inline void clear_bit(volatile uint32_t& reg, int bit) {
reg &= ~(1UL << bit);
}
inline bool read_bit(volatile uint32_t& reg, int bit) {
return (reg >> bit) & 1UL;
}
12.4 类型别名¶
除了C的typedef,C++还可以使用更灵活的语法:
// 传统typedef
typedef unsigned int uint32;
typedef void (*ISR_Handler)(void);
// C++的typedef用于类型
typedef std::vector<int> IntVector;
// 为类模板创建别名,不过涉及到模板了,这里只是提一嘴
typedef std::map<std::string, int> StringIntMap;
12.5 作用域解析运算符¶
作用域解析运算符::用于访问全局作用域或命名空间: