用汽车这个每个人都熟悉的例子,彻底搞懂 C 语言怎么写 OOP。
可以微 信来关注 青衫嵌入式,会持续发布嵌入式干货。
一、为什么 C 语言也需要面向对象?
做嵌入式或系统软件开发的同学都有过这种体验:项目一开始几十个全局变量、十几个函数,结构清晰一目了然。随着需求叠加,全局变量膨胀到几百个,函数散落在十几个文件里,改一个参数要追着七八个文件跑,整个项目像一张越织越密的蜘蛛网——碰一下,整张网都在抖。
这就是没有结构的 C 代码到了一定规模后必然出现的"复杂性爆炸"。
面向对象编程(OOP)本质上是一种组织代码的思维方式——把数据和对数据的操作绑定在一起,对外隐藏实现细节,对内保持职责单一。它不依赖任何语言特性:Java 有 class 关键字,C++ 有 class 关键字,但 C 语言没有——可这并不妨碍我们用 C 写出漂亮的面向对象代码。
拿汽车这个任何人都熟悉的例子——我们一步步拆解,看看 C 语言如何模拟面向对象的四大核心概念:封装、继承、多态、接口。
类继承关系:
燃油车子类(继承汽车基类+包含燃油引擎组件),实例化宝马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);
使用者只能通过 carInit、carDrive 这些"方法"来操作 Car 对象——至于 Car 内部的 speed、mileage 字段怎么存的?藏起来了,你碰不到。这跟 C++ 里 class 的 public 方法完全是一个思路。
注:本项目中因为子类继承需要(见下一节),
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;
}
电机 填充了耗电逻辑——结构完全镜像,只是操作"电"而非"油"。
混动系统 是全场最佳——它内部同时组合了 FuelEngine 和 ElectricMotor,在 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
自己动手跑一遍,理解会更透彻。