懂了汽车,就懂了 C 语言面向对象编程

0 阅读8分钟

用汽车这个每个人都熟悉的例子,彻底搞懂 C 语言怎么写 OOP。


可以微 信来关注 青衫嵌入式,会持续发布嵌入式干货。

一、为什么 C 语言也需要面向对象?

做嵌入式或系统软件开发的同学都有过这种体验:项目一开始几十个全局变量、十几个函数,结构清晰一目了然。随着需求叠加,全局变量膨胀到几百个,函数散落在十几个文件里,改一个参数要追着七八个文件跑,整个项目像一张越织越密的蜘蛛网——碰一下,整张网都在抖。

这就是没有结构的 C 代码到了一定规模后必然出现的"复杂性爆炸"。

面向对象编程(OOP)本质上是一种组织代码的思维方式——把数据和对数据的操作绑定在一起,对外隐藏实现细节,对内保持职责单一。它不依赖任何语言特性:Java 有 class 关键字,C++ 有 class 关键字,但 C 语言没有——可这并不妨碍我们用 C 写出漂亮的面向对象代码。 在这里插入图片描述 拿汽车这个任何人都熟悉的例子——我们一步步拆解,看看 C 语言如何模拟面向对象的四大核心概念:封装、继承、多态、接口。

源码地址:gitcode.com/qingshan120…

类继承关系:

燃油车子类(继承汽车基类+包含燃油引擎组件),实例化宝马530Li;

纯电车子类(继承汽车基类+包含电动马达组件),实例化特斯拉Model3;

混动车子类(继承汽车基类+包含混动系统组件),实例化比亚迪秦L,其中混动系统组件是由燃油引擎+电动马达两个组件整合而成

在这里插入图片描述

二、封装:你的数据,只有你的方法能碰

痛点

传统 C 开发中,结构体往头文件一扔,谁都能直接 car.speed = -100 来一手——改错了?排查半天。

解决方案

封装的核心思想是:"外部使用者不关心你的数据长什么样,只关心你能做什么"。在 C 语言里,这通过不透明结构体 + 操作函数实现:

/* car.h —— 对外只暴露函数接口 */
typedef struct Car Car;     /* 前置声明:外部不知道 Car 里面有什么 */

void  carInit(Car* c, const char* brand, const char* model);
void  carDrive(Car* c, float distance);
float carGetSpeed(const Car* c);
float carGetMileage(const Car* c);

使用者只能通过 carInitcarDrive 这些"方法"来操作 Car 对象——至于 Car 内部的 speed、mileage 字段怎么存的?藏起来了,你碰不到。这跟 C++ 里 classpublic 方法完全是一个思路。

注:本项目中因为子类继承需要(见下一节),struct Car 的定义实际放在头文件中——但通过后文要讲的向上转型宏,使用层面仍然保持了封装性。


三、继承:把基类嵌在子类的"头"上

概念

燃油车是一辆车,纯电车是一辆车,混动车也是一辆车。三者共享"品牌、型号、速度、里程"这些通用属性,但各自又有独特的动力系统。

OOP 用继承来表达这种"is-a"关系——子类自动获得父类的所有属性和方法,再叠加自己的特性。

C 语言的实现手法

在 C 语言中模拟继承,只有一个标准手段:把基类作为子类结构体的第一个字段

/* fuelCar.h —— 燃油车:继承 Car + 组合 FuelEngine */
typedef struct FuelCar {
    Car        base;    /* ★ 第一个字段 = 继承 Car(is-a) */
    FuelEngine engine;  /* 普通成员 = 拥有发动机(has-a) */
} FuelCar;

C 标准(ISO C11 第 6.7.2.1 节第 15 条)明确保证:结构体的第一个成员地址等于结构体自身的地址。因此 (Car*)&fuelCar 永远安全,且零运行时开销——编译器在编译期就能搞定。 在这里插入图片描述

子类初始化的"三步曲"

以燃油车为例,它的初始化遵循一个固定流程:

void fuelCarInit(FuelCar* fc, const char* brand, const char* model,
                 float tankCap, float fuelCons)
{
    /* 步骤 1: 调用基类的"构造函数" */
    carInit(&fc->base, brand, model);

    /* 步骤 2: 初始化自己的组件 */
    fuelEngineInit(&fc->engine, tankCap, fuelCons);

    /* 步骤 3: 将组件的接口注入基类(关键!) */
    PowerSource* ps = carGetPowerSource(&fc->base);
    *ps = fuelEngineGetInterface(&fc->engine);
}

第一步搞定通用属性(品牌、型号),第二步初始化特有的油箱和油耗数据,第三步把发动机的"能力"注册到基类——后续基类的 carDrive 方法就能通过这个接口调用到燃油发动机的具体消耗逻辑。

锦上添花:向上转型宏

如果你觉得每次调用 carDrive((Car*)&bmw5, 300) 还要手动强转太啰嗦——我们写了一个宏自动搞定:

#define carDrive(p, dist)    _carDrive((Car*)(p), (dist))
#define carGetSpeed(p)       _carGetSpeed((const Car*)(p))
#define carGetMileage(p)     _carGetMileage((const Car*)(p))

现在直接写 carDrive(&bmw5, 300) 就行,宏在背后帮你完成向上转型。


四、多态与接口:同一个操作,不同的行为 —— 这才是灵魂

如果说封装和继承是"规矩",那多态就是"灵魂"——它让同一个函数调用在不同对象上表现出不同行为。

回到汽车:同样是"行驶 300 公里",燃油车要烧油,纯电车要耗电,混动车要先用电、电不够再烧油。调用方不想知道这些细节——它只想说"开",然后各自各的方式去开。

核心手段:虚函数表(vtable)

在 C++ 里,虚函数表是编译器帮你生成的。在 C 语言里,我们手动构造它——用函数指针结构体:

/* PowerSource —— 动力源接口:一组纯虚函数的集合 */
typedef struct PowerSourceOps {
    void  (*refill)(void* self, float amount);       /* 补充能量(加油/充电) */
    float (*consume)(void* self, float distance);    /* 消耗能量行驶 */
    float (*getRemaining)(void* self);               /* 查询剩余能量 */
    float (*getRange)(void* self);                   /* 查询剩余续航 */
    const char* (*getType)(void* self);              /* 能量类型名 */
} PowerSourceOps;

typedef struct PowerSource {
    void*          self;  /* 指向实际引擎数据(多态的关键:void* 的 this 指针) */
    PowerSourceOps ops;   /* 虚函数表 */
} PowerSource;

这个 PowerSource 就是一个"接口"——它只定义契约,不包含实现。任何实现了这五个函数的结构体,都可以"伪装成"一个 PowerSource。

三种实现者

在这里插入图片描述

燃油发动机 填充了烧油逻辑:

PowerSource fuelEngineGetInterface(FuelEngine* e)
{
    PowerSource ps;
    ps.self = e;
    ps.ops.consume      = fuelEngineConsume;   /* 按百公里油耗计算 */
    ps.ops.getRemaining = fuelEngineGetRemaining;
    ps.ops.getType      = fuelEngineGetType;    /* 返回 "汽油" */
    /* ... */
    return ps;
}

电机 填充了耗电逻辑——结构完全镜像,只是操作"电"而非"油"。

混动系统 是全场最佳——它内部同时组合了 FuelEngineElectricMotor,在 consume 函数里实现了策略调度:

static float hybridConsume(void* self, float distance)
{
    HybridSystem* h = (HybridSystem*)self;

    /* 策略:优先用电 */
    float elecDist = h->electric.chargeLevel / (elecPerKm);
    if (elecDist >= distance) {
        /* 纯电就够 */
        h->electric.chargeLevel -= consumed;
    } else {
        /* 用完电,剩下的烧油 */
        h->electric.chargeLevel = 0;
        h->fuel.fuelLevel -= fuelNeeded;
    }
}

注意:HybridSystem 没有继承 FuelEngine 也不继承 ElectricMotor——它组合两者、按策略委托。这就是"组合优于继承"原则的完美示范,优雅避开了 C++ 里让人头疼的菱形继承问题。

多态的统一入口

不管什么车,最终都通过同一个 carDrive 函数来行驶:

void carDrive(Car* c, float distance)
{
    PowerSource* ps = &c->powerSource;
    float consumed = ps->ops.consume(ps->self, distance);  /* ★ 多态分发在此 */
    c->totalMileage += distance;
}

carDrive 不关心 ps->self 指向的是 FuelEngine 还是 ElectricMotor——它只认 PowerSource 接口。具体消耗汽油还是电力,完全由子类在初始化时注入的 vtable 决定。 在这里插入图片描述

跑起来看看

三辆车(宝马 530Li 燃油、特斯拉 Model 3 电动、比亚迪秦L 混动)各行驶 300km,输出如下:

--- 宝马 530Li (燃油车) ---
  行驶 300km, 消耗 21.0 汽油, 剩余续航 671km

--- 特斯拉 Model 3 (纯电车) ---
  行驶 300km, 消耗 45.0 电力, 剩余续航 200km

--- 比亚迪 秦L (混动车: 优先用电) ---
  用电 15.8kWh + 烧油 13.6L
  行驶 300km, 消耗 29.4 油电混合, 剩余续航 734km

同一个 carDrive,三种截然不同的消耗行为——这就是多态。


五、总结

用一张表回顾 C 语言映射 OOP 的四大手法:

OOP 概念C 语言实现手法本项目体现
封装不透明结构体 + 访问器函数Car 的字段通过 carInit/carGetSpeed 等函数操作
继承基类作为子类第一个字段 + 指针强转FuelCar { Car base; }(Car*)&fuelCar
多态虚函数表(函数指针结构体)ps->ops.consume → 三种引擎各实现各的
接口纯函数指针表,不含数据PowerSource 定义 5 个行为契约

掌握了这四种手法,你会发现 C 语言也能写出结构清晰、易于扩展的代码。工厂模式、策略模式、观察者模式——这些经典设计模式背后,无非是继承、组合、多态的排列组合。

想看完整代码和实际效果?

git clone https://gitcode.com/qingshan1206/EmbedStory.git
cd cobject
cmake -B build && cmake --build build
./build/app/carDemo

自己动手跑一遍,理解会更透彻。