很多人一听到 OOP,脑子里蹦出来的就是 class、private、virtual 这些关键字,然后说一句“C 语言做不到”。但实际上,如果你天天写 C,尤其天天在写数据结构,你会发现自己早就把面向对象那套思路用得滚瓜烂熟——结构体存数据,函数指针存行为,一个"对象"就是这两样东西再加上一套生命周期管理。这篇定在“数据结构篇”,我不打算系统讲 OO 理论,而是拿栈、线性表、容器这些常见数据结构当例子,把 C 语言实现 OOP 的具体做法拆开:怎么把数据和行为封装进一个对象,怎么把字段藏起来,怎么让不同结构体表现出同一套接口,甚至怎么用宏量产出不同元素类型的容器。适合正在写 C 数据结构库、或者想把 C 代码写得更有结构感的读者。
1. 纯C写数据结构时的真实痛点:数据与行为互相割裂
用 C 语言写数据结构,最朴素的方式就是定义一个 struct,然后写一组操作它的函数。这个模式太常见了,常见到很多人根本不会觉得哪里有问题。比如你要写一个整数栈,很自然就是:
typedef struct { int *data; int top; int capacity; } MyStack; void stack_push(MyStack *s, int value); int stack_pop(MyStack *s); int stack_is_empty(MyStack *s);这段代码在项目很小的时候没有任何问题。但只要你稍微放大一点规模,或者试图把同样的思路迁移到另一个项目里,麻烦就来了。
第一个痛点是“行为”和“数据”在代码组织上是割裂的。每个操作函数都得把结构体指针当作第一个参数传进去,函数本身没有归属关系。今天写 stack_push,明天写 queue_push,后天写 list_push,函数名越来越长,逻辑却越来越散。你在调用端看到的是一堆散装函数,得靠命名规范才能猜测谁跟谁是配套的。这不是语法问题,是代码组织问题。
第二个痛点是类型一换,整份代码基本就要复制粘贴。int 栈能用,double 栈能直接用吗?字符串栈呢?自定义结构体栈呢?大部分初学者第一反应是把 stack_push 改成 void* 版本,但 void* 会带来类型安全问题,而且还得额外管理元素的大小和生命周期。更常见的做法是复制一份,把所有 int 全替换成 double,再手动改函数名。文件一多,改漏一个,编译期往往还不会报错,运行期才爆雷。
第三个痛点是结构体字段全暴露。data、top、capacity 全放在头文件里,谁 include 谁就能访问。本来栈应该只允许从顶部操作,结果外部代码随手写一句s->top = 999,整个栈直接坏掉。这种“公开所有字段”的设计,本质上是把数据结构的大门敞开着,依赖调用方自觉。
第四个痛点是换实现方式代价太高。今天用数组实现,明天想改成链表,后者想换成一个环形缓冲。如果调用方直接访问了字段,那底层一换,上层全部得跟着改。这就完全失去了“接口稳定、实现可变”的能力。
这四个痛点凑在一起,正是面向对象思想在 C 语言里还有价值的原因。OOP 不是 C++ 或者 Java 的专利,它是一套工程上的约定,而 C 语言给我们的工具完全够用——struct 负责布局,函数指针负责行为绑定,不透明类型负责信息隐藏。
2. struct+函数指针:让一个对象自带行为
2.1 对象长什么样:数据成员+方法指针
既然要模拟类和对象,第一步就是把数据和操作放进同一个结构体里。数据成员还是原来的字段,方法则用函数指针字段表示。
typedef struct MyStack MyStack; struct MyStack { int *data; int top; int capacity; void (*push)(MyStack *self, int value); int (*pop)(MyStack *self); int (*isEmpty)(const MyStack *self); void (*destroy)(MyStack *self); };这行代码看起来简单,但它本质上就是在做一个“类”:data、top、capacity 是成员变量,函数指针是成员方法。对象在 C 语言里的内存布局,就是普通字段和函数指针字段按 struct 规则排布,并不神秘。
2.2 手动传入self:C语言的this
有了对象,接下来要写实现。注意,函数指针的类型必须和实现函数签名完全一致,否则编译器会警告或者直接报错。
static void stack_push_impl(MyStack *self, int value) { if (self->top == self->capacity) { int newcap = (self->capacity == 0) ? 4 : self->capacity * 2; int *tmp = realloc(self->data, sizeof(int) * newcap); if (tmp == NULL) { return; } self->data = tmp; self->capacity = newcap; } self->data[self->top++] = value; } static int stack_pop_impl(MyStack *self) { if (self->top == 0) { return -1; } return self->data[--self->top]; } static int stack_is_empty_impl(const MyStack *self) { return self->top == 0; } static void stack_destroy_impl(MyStack *self) { free(self->data); self->data = NULL; self->top = 0; self->capacity = 0; }然后在初始化函数里把这些实现函数挂到对象上:
void stack_init(MyStack *self) { self->data = NULL; self->top = 0; self->capacity = 0; self->push = stack_push_impl; self->pop = stack_pop_impl; self->isEmpty = stack_is_empty_impl; self->destroy = stack_destroy_impl; }调用方式也随之改变:
MyStack s; stack_init(&s); s.push(&s, 10); s.push(&s, 20); while (!s.isEmpty(&s)) { printf("%d\n", s.pop(&s)); } s.destroy(&s);这里有一个细节值得展开:每个函数指针的第一个参数都是 MyStack *self。这就是 C 语言手写的 this。在 C++ 里,编译器会帮你把 this 隐藏掉,但底层其实也是把对象地址塞进寄存器或者栈上。在 C 语言里,我们必须老老实实地把对象指针传给每个方法。虽然写起来看着繁琐,但它把“对象是什么”这件事讲得非常直白:你操作的始终是这一块内存。
2.3 函数指针到底带来了什么
有人会说,这不就是给函数换了个马甲吗?直接调用stack_push(&s, 10)和s.push(&s, 10),本质不都一样?
不完全一样。函数指针最大的价值在于“可替换”。当你把函数指针作为对象的一个字段时,它的指向可以被重新绑定。比如你可以临时写一个带日志的 push 实现,只改这一个对象的方法指针,其他任何代码都不用动,这个对象的行为就变了。这在调试和测试场景下非常有用,尤其是在 C 语言这种没有反射、没有动态代理的语言里,函数指针几乎是唯一能实现“行为注入”的手段。
另外一个价值,是一个对象自身携带了一份“方法表”。后面讲继承和多态时你会看到,当多个结构体共用同一个布局约定时,这份方法表就能让调用方用统一的方式处理不同类型的数据结构。
当然,直接在每个对象里存函数指针也有缺点。每个对象都要多占用几字节存放指针,而且一个类的所有对象往往存储着完全相同的函数指针,造成浪费。所以接下来我们会看到更工程化的方式,比如不透明指针和 vtable。
3. 不透明指针:把struct定义藏进.c文件才是真封装
上一节的 MyStack 虽然已经有了一点“对象感”,但说实话封装还是不到位的。data、top、capacity 还躺在头文件里,任何代码都能越权访问。这相当于把一个 class 的所有成员都搞成了 public,那 OOP 里最核心的封装就名存实亡了。
C 语言里真正做到信息隐藏的标准手法,是不透明指针。
3.1 公开接口里只剩下一个句柄
头文件可以这样写:
typedef struct MyStack MyStack; MyStack *stack_new(void); void stack_push(MyStack *self, int value); int stack_pop(MyStack *self); int stack_is_empty(const MyStack *self); void stack_free(MyStack *self);注意,这里只有typedef struct MyStack MyStack;这个前置声明,没有 struct MyStack 的完整定义。C 语言允许我们定义指向不完整类型的指针,但不可以对它解引用、访问成员,或者用 sizeof。这意味着,任何只包含这个头文件的代码,都无法写出s->data这样的表达式,连编译都不会通过。
真正的 struct 定义放在 .c 文件里:
#include <stdlib.h> #include "stack.h" struct MyStack { int *data; int top; int capacity; }; MyStack *stack_new(void) { MyStack *s = malloc(sizeof(*s)); if (s == NULL) { return NULL; } s->data = NULL; s->top = 0; s->capacity = 0; return s; } void stack_push(MyStack *self, int value) { // 扩容、赋值、更新 top…… } int stack_pop(MyStack *self) { // 判断空、返回栈顶…… } int stack_is_empty(const MyStack *self) { return self->top == 0; } void stack_free(MyStack *self) { if (self == NULL) { return; } free(self->data); free(self); }调用方只能拿到一个指针,然后通过公开函数操作它。至于指针指向的内存长什么样,调用方永远不知道。这才是真正的 private:不是靠命名约定下划线开头,而是靠编译期类型系统挡住。
3.2 私有实现与内部头文件
不透明指针方案有一个代价:对象必须在堆上分配。因为调用方不知道 struct 的大小,没办法在栈上直接声明一个MyStack s。每个栈对象都得stack_new()拿内存,用完再stack_free(),这在某些性能敏感场景下不太友好。
工程上还有一种折中方案,叫“内部头文件”。你可以在公开头文件里只留下不透明类型声明,在模块内部提供一个stack_internal.h,里面放完整的 struct 定义,只有模块内部实现以及那些需要栈上分配对象的核心代码才 include 它。外部调用方看到的依然是黑盒,内部模块之间又可以享受栈分配的便利。这个手法在不少 C 代码库里都能看到,我自己也经常用。
再聊一个比较容易忽略的点:不透明指针让“数据布局”和“用户代码”彻底解耦。今天 MyStack 里面用数组,明天换成链表,头文件完全不用改,用户代码一行都不用动,只要重新编译链接就行。能做到这一层,封装才真正有了意义,也才是 OOP 所说的“封装变化”。
4. 首字段嵌套:C语言里的继承就靠这个布局约定
4.1 父结构体为什么必须是第一个字段
C 语言没有 extends、没有: public,但继承的本质其实非常朴素:子类对象里包含父类的所有数据和行为。这个在 C 语言里用结构体嵌套就能做到,关键在于父结构体必须放在子结构体的第一个字段位置。
为什么必须放在第一位?因为 C 标准明确保证:结构体变量的地址,和第一个成员的地址是相同的,首成员偏移量为 0。这带来一个巨大的好处——子类对象的地址,恰好等于它内部那个父结构体对象的地址。于是我可以把子类指针当作父类指针来用,用完之后再安全地转回子类指针访问扩展字段。
如果你能理解这一点,你就能明白 C 语言里所谓继承,就是“内存布局上的父子关系”。它不是语法层面的保证,而是开发者之间的一种约定。
4.2 一个容器接口和两个子类
我们来设计一个容器接口。它不去管底层是数组还是链表,只关心两个行为:元素个数、是否为空。
typedef struct { int (*size)(const void *self); int (*empty)(const void *self); } Container;然后定义两个实现。第一个是动态数组:
typedef struct { Container base; /* 必须放在第一位 */ int *data; size_t length; size_t capacity; } ArrayList; typedef struct Node { int value; struct Node *next; } Node; typedef struct { Container base; /* 同样放在第一位 */ Node *head; size_t length; } LinkedList;注意看,两个结构体的第一个字段都是 Container base,这就是“继承”。ArrayList 和 LinkedList 都是 Container 的子类,它们除了拥有 Container 的行为外,还各自有扩展字段。
接下来实现方法,注意函数的 self 类型用 const void*,然后在函数内部把它转回真实的子类指针:
static int array_size_impl(const void *self) { const ArrayList *arr = self; return (int)arr->length; } static int array_empty_impl(const void *self) { const ArrayList *arr = self; return arr->length == 0; } static int list_size_impl(const void *self) { const LinkedList *list = self; return (int)list->length; } static int list_empty_impl(const void *self) { const LinkedList *list = self; return list->head == NULL; } void array_init(ArrayList *arr) { arr->base.size = array_size_impl; arr->base.empty = array_empty_impl; arr->data = NULL; arr->length = 0; arr->capacity = 0; } void list_init(LinkedList *list) { list->base.size = list_size_impl; list->base.empty = list_empty_impl; list->head = NULL; list->length = 0; }现在写一个完全不关心具体类型的公共函数:
int total_size(const Container *c) { return c->size(c); }调用端可以这样用:
ArrayList arr; LinkedList list; array_init(&arr); list_init(&list); printf("%d %d\n", total_size((const Container *)&arr), total_size((const Container *)&list));这段代码的执行过程值得捋一遍。total_size 拿到一个 Container 指针,它并不知道这个指针背后到底是个数组还是个链表,但它调用c->size(c)。假如传入的是 ArrayList 的地址,size 字段指向 array_size_impl,函数内部就把同一个地址解释成 ArrayList*,访问 length;假如传入的是 LinkedList 的地址,size 字段指向 list_size_impl,访问的是链表的 length。调用方不需要 if,不需要 switch,行为完全由对象自己携带的方法指针决定。
这就是多态。
4.3 继承只是“布局约定”,不是语法保证
你可能会问:既然这么好用,那能不能把 Container 放到结构体中间而不是第一位?技术上可以,但不推荐。放在中间,或者放在末尾,子类地址和基类地址就不相等了。你需要在转换的时候做偏移计算,比如从子类地址减去一个 offsetof,或者从基类地址加上一个 offsetof,换算风险很高,代码可读性也变差。所以 C 语言 OOP 社区基本形成了一条铁律:父结构体放第一位。
另一个要强调的是:strong cast 本身是未定义行为的温床。C 语言允许你把任意结构体指针强转成 Container*,但它并不保证安全。如果传入一个跟 Container 毫无关系的对象,函数指针字段会被随机解释,轻则崩溃,重则内存破坏。所以这种继承需要开发者自己维护对象身份。工程上的缓解办法是:设计宏来做转换,比如#define TO_CONTAINER(p) ((Container*)(p)),然后所有转换都走这个宏,日后想加检查也方便。
5. 函数指针表:运行时多态和容器接口的实现路径
5.1 同一份接口,不同的实现行为
上面那个例子已经展示了最基本的运行时多态:调用同一个total_size函数,传入不同对象,得到不同实现函数来处理。这个能力在数据结构场景里非常实用。比如你已经用 ArrayList 实现了整个算法流程,现在想换成 LinkedList,理论上只需要替换初始化函数和对象类型声明,业务逻辑里凡是只依赖 Container 接口的代码都不用动。
再往前推一步,如果容器接口里还有 push、pop、peek 这类方法,那Container*就是一套真正的抽象容器接口。你可以把栈、队列、双向队列各自实现一份,然后让算法代码只面向这个接口编程。这就是面向对象设计里最经典的“面向接口编程”,它在 C 语言里完全可行。
5.2 从“指针字段”到真正的vtable
上一节里我们在对象内部直接放了两个函数指针,每个 ArrayList 对象都要存两份重复的指针,每个 LinkedList 对象也要存两份。这种写法简单直观,但内存不够省。如果容器有 10 个方法,那每个对象都要多占用 80 字节左右,这在一万个对象里就是 800KB,不可忽视。
于是 C 语言 OOP 里最常见的 vtable 方案出现了:把函数指针集中到一张静态方法表里,对象里只放一个指向这张表的指针。
typedef struct VectorVtbl VectorVtbl; struct VectorVtbl { int (*size)(const void *self); int (*empty)(const void *self); void (*push)(void *self, int item); int (*pop)(void *self); }; typedef struct { const VectorVtbl *vptr; int *data; size_t length; size_t capacity; } ArrayList;然后定义一个静态的类级方法表:
static const VectorVtbl array_vtbl = { array_size_impl, array_empty_impl, array_push_impl, array_pop_impl }; void array_init(ArrayList *arr) { arr->vptr = &array_vtbl; arr->data = NULL; arr->length = 0; arr->capacity = 0; }调用就很直观:
arr.vptr->push(&arr, 10); int x = arr.vptr->pop(&arr);这已经非常接近 C++ 虚函数的底层原理了。同类对象共享一张 vtable,vptr 通常不会在运行时发生变化,编译器可以用它来分派方法。对象里不再保存 N 个函数指针,只保存一个指针,省下的内存相当可观。
5.3 直接函数指针和vtable怎么取舍
如果是小对象、小规模,直接在对象里放函数指针没有太大问题,代码更好读,也允许你针对某一个对象实例单独替换某个方法。如果需要管理大量对象,或者接口方法特别多,vtable 方案内存更省、结构也更接近“类”的语义。我自己的习惯是:两三个方法的轻量接口直接用成员函数指针,方法超过五六个就上 vtable。把 vtable 指针设计成const,配合使用,至少能保证普通代码不会意外篡改方法表。
再提一句性能:函数指针调用是间接调用,编译器很难内联。在数据结构这种高频操作场景里,每层间接调用都可能带来可测量的损耗。如果你的 push/pop 每秒执行几十万次,先用普通全局函数实现,再用函数指针封装,在测试机上对比一下性能差异再决定要不要为多态付出运行时成本。这也是 C 语言与 C++ 的一个显著区别:C++ 编译器有很多机会在编译期解析虚调用,而 C 语言手搓的 vtable 几乎全部要等运行时。
6. 用宏量产“类”:给C语言补上泛型容器的短板
6.1 宏展开:一次定义,处处生成
前面写的栈是 int 栈,想写 double 栈就得再复制一份;自定义结构体栈还得再复制一份。C 语言没有模板,没有泛型,但预处理器的宏可以扮演“代码生成器”的角色。我们可以写一个宏,让它根据传入的类型和名字,生成一套完整的容器“类”。
下面是一个简化版的宏容器定义:
#define DEFINE_VECTOR(Type, Name) \ typedef struct Name##_vector { \ Type *data; \ size_t length; \ size_t capacity; \ void (*push)(struct Name##_vector *self, Type item); \ Type (*get)(const struct Name##_vector *self, size_t index); \ void (*free)(struct Name##_vector *self); \ } Name##_vector; \ \ static void Name##_vector_push_impl(Name##_vector *self, Type item) \ { \ if (self->length == self->capacity) { \ size_t newcap = self->capacity == 0 ? 4 : self->capacity * 2; \ Type *tmp = realloc(self->data, sizeof(Type) * newcap); \ if (tmp == NULL) return; \ self->data = tmp; \ self->capacity = newcap; \ } \ self->data[self->length++] = item; \ } \ \ static Type Name##_vector_get_impl(const Name##_vector *self, size_t index) \ { \ return self->data[index]; \ } \ \ static void Name##_vector_free_impl(Name##_vector *self) \ { \ free(self->data); \ self->data = NULL; \ self->length = self->capacity = 0; \ } \ \ static void Name##_vector_init(Name##_vector *self) \ { \ self->data = NULL; \ self->length = self->capacity = 0; \ self->push = Name##_vector_push_impl; \ self->get = Name##_vector_get_impl; \ self->free = Name##_vector_free_impl; \ }然后在需要使用的地方“实例化”:
DEFINE_VECTOR(int, int) DEFINE_VECTOR(double, double) int main(void) { int_vector v; int_vector_init(&v); v.push(&v, 42); v.push(&v, 17); printf("%d\n", v.get(&v, 0)); v.free(&v); return 0; }宏里的##是预处理器连接符号,会把Name和_vector拼在一起。DEFINE_VECTOR(int, int)生成int_vector类型,DEFINE_VECTOR(double, double)生成double_vector类型。你甚至可以用自定义结构体作为元素类型,只要先给它取个简洁的名字就行了。
6.2 宏容器使用的几个硬性纪律
使用宏容器有几个容易踩的坑,早点知道能省很多时间。
第一,宏参数不要带逗号。如果你直接写DEFINE_VECTOR(struct { int x; int y; }, point),预处理器会认为宏参数多于两个,直接报错。正确做法是先给这个结构体取一个 typedef 名:
typedef struct { int x; int y; } Point; DEFINE_VECTOR(Point, point)第二,函数名设计成 static 是有原因的。宏通常放在头文件里,而头文件会被多个 .c 文件 include。如果函数不是 static,链接阶段就会出现重复定义。static 让每个编译单元都保留一份自己的函数实现,互不干扰。代价是每个用到的编译单元都会生成一份代码副本,程序体积会大一点,但这是 C 语言宏泛型常见的取舍。
第三,宏展开后很难读。排查问题之前,一定要先把预处理结果展开出来看。比如我用 GCC:
gcc -E -P main.c | less看到宏真正的展开代码,再判断是拼接问题、签名问题还是内存问题。宏报错往往指向展开后的某一行,不展开看基本靠猜。
第四,宏生成的类型是一个独立的类型系统,与另一个相同元素的宏实例完全无关。int_vector和double_vector是两种不相关的类型,不能通过同一个接口指针统一管理。如果你希望让它们都实现同一个接口,那还得在宏里面嵌套前面那种 Container 首字段套路。所以宏适合批量生成“平行类型”,而 vtable 适合对既有类型做统一抽象。
6.3 宏代码调试三板斧
我自己调宏容器时有三板斧。第一板斧是上面说的gcc -E -P看展开,这个最重要。第二板斧是给宏生成的每个函数都写一个最小测试用例,比如只定义一种类型,只调用一个方法,确认它能编译运行,再逐步加方法。第三板斧是注意不要在一个宏定义里塞太多逻辑,能拆成小函数就尽量拆成小函数。宏不是万能膏药,它只负责“生成代码”,真正复杂的逻辑应该放到普通函数里,宏只做粘贴和转发。
7. 生命周期管理:构造、析构和内存所有权约定
7.1 init/destroy一对一把门
面向对象语言有构造函数和析构函数,C 语言没有语法机制,但我们可以用命名约定来模拟。我的习惯是任何自定义数据结构都必须提供xxx_init和xxx_destroy两个函数,没有任何例外。哪怕这个结构体现在只有一个 int 字段,也要写。因为你不会知道三个月后它会不会多一个指针成员,到那时候再补析构,已经漏了一地。
前面写过 Stack 用不透明指针的方案,这里我把析构设计得更有防御性一点:
void stack_free_auto(MyStack **self) { MyStack *s = *self; if (s == NULL) { return; } free(s->data); free(s); *self = NULL; }调用方式变成stack_free_auto(&s);。这个设计有个明显好处:析构之后调用方手里的指针自动变成 NULL,再想误 free 或者误解码访问,会在第一时间暴露问题。
7.2 realloc失败与对象一致性
动态数组的扩容是高频操作,也是最容易写错的地方。新手最容易写错的是直接覆盖原指针:
self->data = realloc(self->data, sizeof(int) * newcap); // 风险代码一旦 realloc 失败,返回 NULL,旧指针就丢了,原来数据全部泄漏,对象状态直接损坏。正确做法永远是用临时变量接收,成功后再覆盖原指针:
int *tmp = realloc(self->data, sizeof(int) * newcap); if (tmp == NULL) { return; // 原 data 仍然有效,对象状态没有变 } self->data = tmp; self->capacity = newcap;这样即使扩容失败,旧数据还在,对象还处于一致状态。如果你的 push 函数需要通知调用方失败,最好返回 int 错误码,而不是 void。返回 void 的接口等于把错误处理的责任推给调用方猜,C 语言里这是很不友好的。
7.3 元素级析构:别让深一层内存泄漏
大部分初学者以为vector_free里free(vector->data)就够了,直到他们开始往容器里放指针,比如字符串指针。
DEFINE_VECTOR(char *, str_vector)假设你往str_vector里 push 了 100 个 malloc 出来的字符串,然后调用str_vector_free。free(v.data)释放的是这 100 个字符串指针所在的数组,而不是字符串本身。这 100 个字符串全泄漏了,尤其当字符串存在于程序热路径上时,泄漏量会非常惊人。
解决办法是在宏里增加一个可选的元素析构函数字段,或者干脆在设计时就约定:凡是元素为指针的容器,销毁时必须额外遍历一遍元素并逐一释放。我自己更推荐在宏里留一个字段:
void (*elemFree)(void *elem);初始化时置为 NULL,调用方需要时再设置。比如:
void free_cstring(void *p) { free(*(char**)p); }在销毁函数里,如果 elemFree 不为 NULL,就遍历所有元素调用它。这个设计把所有权问题摊开在调用方面前,而不是等程序跑几个小时后才在监控里发现内存曲线一直在涨。
最后说点个人体会。C 语言里 OOP 不是银弹,我见过为了强行上继承和多态把代码写得特别绕的项目。但反过来,只要把几条约定执行干净——构造析构配对、数据字段不外泄、接口只依赖基类,你的 C 数据结构库绝对会稳重得多。先别急着上宏和 vtable,把一个普通的栈用不透明指针包好,再谈继承和多态那些技巧。地基稳了,上面这些东西才真正值钱。