跳转至

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) 两个函数,不用太麻烦,您直接写这两行就行:

void process(int x) { }
void process(double x) { }

那么调用 process(5) 时,编译器几乎不需要思考:字面量 5 本身就是 int,这属于精确匹配,而 process(double) 需要一次从 intdouble 的转换。在重载解析的规则下,精确匹配对任何形式的转换都有压倒性优势,因此最终调用的一定是 process(int)。同样地,调用 process(5.0) 时,5.0double,这一次精确匹配发生在 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
}

这样一来,最常见的调用形式只剩下真正关心的那一个参数:

configure_uart(115200);

而当你真的需要偏离默认行为时,也仍然可以逐步“向右展开”参数:

configure_uart(115200, 8);
configure_uart(115200, 8, 2);
configure_uart(115200, 8, 2, 'E');

从接口设计的角度看,这是一种非常温和的向前兼容手段:你可以不断在函数右侧追加新的可选能力,而不会破坏已有代码。

4.2 默认参数的一些规则

当然,默认参数并不是随心所欲的语法糖,它的规则其实非常严格。首先,默认参数必须从右向左连续出现,因为编译器在函数调用时只能通过“省略尾部参数”的方式来判断哪些值使用默认值。如果你试图在非默认参数后面再放一个没有默认值的参数,编译器会直接拒绝这种设计。其次,每一个默认参数只能被指定一次,而且通常应该放在函数声明处,而不是定义处。这一点在头文件与源文件分离的工程中尤为重要:默认值是接口的一部分,而不是实现细节,如果你在 .cpp 里再写一遍默认参数,编译器会认为你在试图重新定义规则,从而报错。

在嵌入式开发中,默认参数尤其适合用在“配置型接口”和“初始化函数”上。比如 SPI、I²C、定时器这类外设,往往都有一套“推荐配置”,只有在极少数情况下才需要完全自定义。通过默认参数,你可以让最常见的用法几乎零负担:

spi.init();              // 使用推荐配置
spi.init(2000000, 3);    // 只改频率和模式

这种接口的可读性非常强:调用点本身就已经在“讲故事”,而不是一串神秘的魔法数字。


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 并不是为了“防黑客”,而是为了在语法层面告诉使用者:哪些东西你不该碰。你当然可以通过各种手段绕过它,但那已经属于未定义行为的范畴,后果自负。对大多数工程代码来说,这种约束本身就是一种极强的自文档。

构造函数和析构函数则进一步强化了这种“绑定关系”。构造函数负责把对象带入一个合法、可用的状态,析构函数负责在对象生命周期结束时做清理工作。在嵌入式系统中,这种模式尤其适合用来管理硬件资源:

UARTPort uart(1);  // 构造时初始化
// 使用 uart
// 离开作用域时自动关闭

这并不是为了追求“RAII 教科书式优雅”,而是为了减少人为遗漏清理步骤的可能性。

在构造函数中,成员初始化列表是一个经常被忽视、但非常重要的细节。它并不是“写起来好看”,而是真正决定了对象是初始化还是先默认构造再赋值。对于 const 成员、引用成员,以及复杂对象成员来说,初始化列表甚至是唯一合法的选择。从效率和语义正确性上看,它都应该成为你的第一选择。

至于 this 指针,它的存在本身并不神秘:每一个非静态成员函数,都会隐式地携带一个指向当前对象的指针。理解这一点之后,链式调用这种写法就显得非常自然了——你只是不断返回“自己”,而已。

静态成员则提供了另一种维度的组织方式。它们属于类本身,而不是某个具体对象,这在嵌入式中非常适合用来表达“全局唯一状态”,比如硬件是否已经初始化、当前存在多少实例等。通过静态成员函数访问这些信息,可以避免滥用全局变量,同时保持接口的清晰边界。

const 成员函数是 C++ 提供的一种非常强的语义承诺:这个函数不会修改对象状态。这不仅是给读代码的人看的,更是给编译器看的。它允许编译器在更多场景下进行检查和优化,也让 const 对象真正具备“只读”属性。

最后是友元。它的存在本身并不邪恶,但几乎总是一个危险信号。友元意味着你主动打破了封装边界,把类的内部实现暴露给外部代码。除非你非常清楚自己在做什么,否则一旦开始依赖友元,往往说明你的类设计已经出现了结构性问题。如果一个类需要大量友元才能工作,那它大概率不该被设计成一个类。

6. 运算符重载(Operator Overloading)

运算符重载是 C++ 最具“争议但也最有魅力”的特性之一。它允许自定义类型像内置类型一样参与表达式计算,从而显著提升代码的可读性与表达力。举个例子,你是喜欢看两个向量塞到一个叫做特别别扭的VectorAdd方法(这里内涵下Java(逃)),还是直接使用a + b的方式更可读呢?相信各位自有答案。

不过,别滥用这个玩意,笔者就建议一个准则:当你“自然地”会用某个运算符来读这段代码时,才值得重载它。比如说自然的处理非内置的向量数学运算,物理量运算,时间日期,容器处理等等


6.1 基本运算符重载

最经典、也是最合理的运算符重载场景,来自数学与物理模型。比如三维向量,本质就是一组数值参与加减乘运算,如果不用运算符重载,代码通常会退化成这样:

v3 = v1.add(v2);
v4 = v1.scale(2.0f);

而通过运算符重载,我们可以让代码直接贴近数学表达式本身

v3 = v1 + v2;
v4 = v1 * 2.0f;

所以在之前,笔者涉及到需要自己手搓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[]容器类的“门面接口”,重载它几乎是自定义容器的标配操作。它的核心价值在于:

让自定义类型看起来像数组一样可访问

buffer[3] = 0xFF;
auto x = buffer[10];

一个关键点是:必须同时提供 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; }
};

使用时几乎没有任何学习成本:

Accumulator acc;
acc(10);
acc(20);
acc(30);

int total = acc.get_sum();  // 60

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,只需要调用同一套 sendreceive 接口即可。这种设计并不是为了炫技,而是为了在硬件变化、平台迁移时,把修改范围控制在最小。

9. 动态内存管理

C++提供了newdelete运算符来替代C的mallocfree。可以最最简化和不严谨的说——newmalloc和对应初始化的简单封装——让您可以在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 作用域解析运算符

作用域解析运算符::用于访问全局作用域或命名空间:

int value = 100;  // 全局变量

void function() {
    int value = 50;  // 局部变量

    printf("Local: %d\n", value);      // 50
    printf("Global: %d\n", ::value);   // 100:访问全局变量
}

// 访问命名空间成员
namespace math {
    const double PI = 3.14159;
}

double circumference = 2.0 * math::PI * radius;