C语言模拟面向对象三大特性与C++实现对比
前阵子有个做嵌入式开发的哥们问我:项目里只能用C,但代码越写越难受,事件处理全靠switch-case疯狂堆分支,驱动层新加一个设备就得改一遍分发逻辑,问我有没有什么"降级版面向对象"能用。这个问题我太熟了,做了这么多年底层开发,C语言模拟面向对象的路子早就被各路前辈趟平了,只是一直没人系统地整理成一篇能直接照着写的教程。
这篇文章我打算用"三大特性逐个拆解+双语言对照"的方式,把封装、继承、多态在C语言里的模拟手法全部展开,再用C++的同功能代码做逐行对比。这样一来,学C的能理解面向对象的本质,学C++的能看清class关键字背后到底做了什么,做嵌入式的拿到手就能用。文章里所有代码我都跑过,可以直接抄作业。
1. 先搞清楚:为什么有人非要在C里搞面向对象
1.1 需求从哪来:状态机、驱动框架、协议栈都需要"对象思维"
先说个残酷的现实:很多领域你根本选不了C++。嵌入式MCU上,编译器支持力度、代码体积、运行开销、团队成员的技术栈,任何一个因素都可能逼你继续用C。但业务复杂度不会因为语言受限而降低——一个典型的物联网节点,要管理传感器驱动、通信协议、消息队列、任务调度,如果用传统的"全局结构体+散落各处的函数"来组织,代码到后期基本就是屎山,加一个功能要牵一发动全身。
举个例子,做协议栈的时候,TCP、UDP、ICMP每种协议都有"创建、发送、接收、销毁"这一套操作,但实现完全不同。没有面向对象,你只能写tcp_send()、udp_send()、icmp_send()三个函数,然后在调用点用if-else或switch去分发。新加一个协议,所有分发点全部要改一遍,这就是典型的"对修改开放"——改到你怀疑人生。
面向对象要解决的核心问题从来不是"语法好看",而是:把数据和操作数据的方法绑定在一起,让调用方依赖稳定的接口,而不是依赖具体的类型。这个需求跟语言无关,所以C语言也照样能做,只不过要自己动手捏一个"对象"出来。
1.2 三大特性拆开看,本质到底是什么
很多人背八股文背得熟:封装、继承、多态。但真到用的时候又觉得抽象。我用大白话翻译一下:
- 封装:数据别让人随便改,想改必须走我规定的路。就像银行不会把保险库钥匙给你,但允许你通过柜台窗口办业务。
- 继承:新类型在老类型的基础上"加东西"或"改东西",不用重新造轮子。就像轿车继承汽车的所有结构,再加个后备箱。
- 多态:同一个动作,不同的对象做出来的效果不一样。就像按喇叭这个动作,货车按出低沉的声音,救护车按出警报声——调用方只需要知道"按喇叭"这一个操作,具体怎么响由对象自己决定。
C++用class、public、private、virtual这些语法层面的东西把这三件事固化了下来。而C语言里没有这些关键字,只能靠结构体布局的约定、函数指针、手动类型转换这些底层的机制去"手工实现"。说白了,C++编译器替你在背后做的事,C语言你得自己写出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先拆封装:从"全局函数裸奔"到"结构体里装方法"
2.1 最朴素的C写法:一切公开,边界模糊
大多数C语言初学者写代码是这样的:
c复制// device.h
typedef struct {
int fd;
int baudrate;
char buffer[128];
} UartDevice;
// 全局函数,谁都能调
void uart_init(UartDevice* dev, int baudrate);
void uart_send(UartDevice* dev, const char* data);
void uart_recv(UartDevice* dev, char* buf, int len);
这段代码的问题在哪?baudrate这个字段是"公开"的,任何地方的代码都能写dev->baudrate = 115200,但实际上一旦设备初始化完成,波特率是不能随便改的,改完硬件不会自动重新配置,通信直接乱掉。更麻烦的是,如果哪天你想把波特率改成"必须经过校验才能设置",你得把所有直接赋值的代码全部找出来改一遍——这还只是个字段,大型项目里这样的裸奔字段有几十个,维护成本直接爆炸。
2.2 用"不透明指针+操作函数"实现真正的信息隐藏
C语言实现封装有一个经典的组合拳:在头文件里只暴露前向声明和不透明指针,在源文件里定义真正的结构体。这个模式在Linux内核、很多嵌入式驱动库里都能看到。
来看完整实现:
c复制// uart.h —— 使用者只看到这个头文件
#ifndef UART_H
#define UART_H
typedef struct UartDevice UartDevice; // 前向声明,外部看不到结构体内部
UartDevice* uart_create(int baudrate);
void uart_destroy(UartDevice* dev);
void uart_send(UartDevice* dev, const char* data, int len);
int uart_recv(UartDevice* dev, char* buf, int len);
#endif
c复制// uart.c —— 结构体的真正定义只出现在这里
#include "uart.h"
#include <stdlib.h>
#include <string.h>
struct UartDevice {
int fd;
int baudrate;
char buffer[128];
};
UartDevice* uart_create(int baudrate) {
UartDevice* dev = (UartDevice*)malloc(sizeof(UartDevice));
if (!dev) return NULL;
dev->fd = open("/dev/ttyS0", O_RDWR);
dev->baudrate = baudrate;
memset(dev->buffer, 0, sizeof(dev->buffer));
return dev;
}
void uart_destroy(UartDevice* dev) {
if (dev) {
close(dev->fd);
free(dev);
}
}
void uart_send(UartDevice* dev, const char* data, int len) {
// 内部实现,外部无法绕过
write(dev->fd, data, len);
}
这段代码能达到的效果是:调用方虽然拿着UartDevice*指针,但根本不知道结构体里有什么,想直接改baudrate都无从下手,所有对设备的操作必须通过uart_send、uart_recv这些函数。这其实就是C++里private成员的天然模拟——信息隐藏靠的不是编译器的强制性检查,而是"你压根看不到定义"这个物理事实。
2.3 更进一步:把函数指针塞进结构体,模拟"成员函数"
上面的写法解决了"数据不被乱改"的问题,但还没到"对象调用方法"的体验。再进一步,就是把操作函数也塞进结构体里,让它看起来像个成员函数:
c复制typedef struct Circle Circle;
struct Circle {
double radius;
void (*draw)(const Circle* self); // 成员函数
double (*area)(const Circle* self); // 成员函数
};
Circle* circle_create(double radius);
void _circle_draw(const Circle* self) {
printf("Draw a circle with radius %.2f\
