C 语言中的函数表:用结构体和函数指针实现封装、抽象与多态

C 语言本身没有 classvirtualoverride 这些语法,也没有内建的面向对象机制。但这并不意味着 C 不能表达“封装”“抽象”和“多态”这些设计思想。

这份示例通过一个简单的 Animal / Cat / Dog 模型展示一种常见写法:

  • 用结构体保存对象的公共数据。
  • 用函数指针表描述一组抽象行为。
  • 让具体类型在自己的实现文件里提供函数表。
  • 调用端只通过抽象接口调用行为,由运行时对象内部的函数表决定真正执行哪个函数。

最终效果是:同样调用 animalDrink()animalSpeak(),传入猫对象时执行猫的行为,传入狗对象时执行狗的行为。

整体结构

这个示例由以下文件组成:

.
├── animal.h   # 抽象层:Animal 基类与函数表定义
├── animal.c   # 抽象接口的分发实现
├── cat.h      # Cat 的公开接口
├── cat.c      # Cat 的私有结构、函数表和具体行为
├── dog.h      # Dog 的公开接口
├── dog.c      # Dog 的私有结构、函数表和具体行为
├── main.c     # 调用端示例
└── Makefile   # 构建脚本

其中最重要的抽象关系是:

Cat / Dog 对象
    ↓ 内嵌
Animal base
    ↓ 持有
AnimalVtbl* vtblptr
    ↓ 指向
具体类型自己的函数表 catVtbl / dogVtbl
    ↓ 分发
catSpeak / dogSpeak
catDrink / dogDrink

抽象层:Animal 与函数表

animal.h 是整个设计的核心。它定义了两个关键结构:

  • AnimalVtbl:函数表,描述“动物应该支持哪些行为”。
  • Animal:公共基类对象,保存名称和指向函数表的指针。

这里的 AnimalVtbl 很像 C++ 对象背后的虚函数表。区别在于,C 不会自动帮我们生成和维护它,一切都要手动写出来。

animal.h

/**
 * @file animal.h
 * @author quirkybrain
 * @brief 用于多态动物对象的抽象基类接口。
 * @version 0.1
 * @date 2026-05-20
 * 
 */
#ifndef _ANIMAL_H
#define _ANIMAL_H


/** @brief 动物名称可用的最大字节数,包含结尾的 '\0'。 */
#define MAX_NAME_LEN 24


/** @brief 抽象动物基类的前向声明。 */
typedef struct Animal Animal;

/** @brief 用于分发动物行为的虚函数表。 */
typedef struct AnimalVtbl AnimalVtbl;

/**
 * @brief 具体动物类型需要覆盖的操作函数表。
 */
struct AnimalVtbl {
        /** @brief 让动物发出叫声。 */
        void (*speak)(Animal* self);

        /** @brief 让动物喝水。 */
        void (*drink)(Animal* self);
};

/**
 * @brief 嵌入到每个具体动物类型中的公共基类对象。
 */
struct Animal {
        /** @brief 动物的显示名称。 */
        char name[MAX_NAME_LEN];

        /** @brief 指向具体类型函数表的指针。 */
        const AnimalVtbl* vtblptr;
};

/**
 * @brief 通过对象的函数表分发 speak 操作。
 *
 * @param self Animal 基类指针,通常由具体对象转换得到。
 */
void animalSpeak(Animal* self);

/**
 * @brief 通过对象的函数表分发 drink 操作。
 *
 * @param self Animal 基类指针,通常由具体对象转换得到。
 */
void animalDrink(Animal* self);

#endif

这里的抽象点在于:animalSpeak()animalDrink() 并不关心传进来的到底是 Cat 还是 Dog。它们只要求传入一个 Animal*,然后通过 self->vtblptr 找到真正的行为实现。

animal.c

#include <stdio.h>
#include "animal.h"

/**
 * @brief 使用具体对象的函数表分发 drink 行为。
 *
 * @param self Animal 基类指针。
 */
void animalDrink(Animal* self) {
        self->vtblptr->drink(self);
}

/**
 * @brief 使用具体对象的函数表分发 speak 行为。
 *
 * @param self Animal 基类指针。
 */
void animalSpeak(Animal* self) {
        self->vtblptr->speak(self);
}

这两个函数就是多态分发的入口:

self->vtblptr->drink(self);
self->vtblptr->speak(self);

它们不直接调用 catDrink()dogDrink()catSpeak()dogSpeak(),而是通过对象内部保存的函数表间接调用。这样,同一个接口就能根据对象的实际类型表现出不同的行为。

具体类型:Cat

Cat 的公开头文件只暴露“不透明类型”和少量操作函数。调用端知道有一个 Cat 类型,但不知道 struct Cat 内部是什么样子。

这就是 C 里常见的封装方式:在 .h 里只声明类型,在 .c 里定义结构体细节。

cat.h

/**
 * @file cat.h
 * @author quirkybrain
 * @brief 具体 Cat 类型的公开接口。
 * @version 0.1
 * @date 2026-05-20
 * 
 */
#ifndef _CAT_H
#define _CAT_H


#include "animal.h"


/** @brief 不透明的具体猫类型。 */
typedef struct Cat Cat;

/**
 * @brief 分配并初始化一个 Cat 对象。
 *
 * @param name 要复制到内嵌 Animal 基类对象中的名称。
 * @return 指向新 Cat 对象的指针;如果分配失败则返回 NULL。
 */
Cat* newCat(const char* name);

/**
 * @brief 释放由 newCat() 创建的 Cat 对象。
 *
 * @param cat 要释放的 Cat 对象。
 */
void deleteCat(Cat* cat);

/**
 * @brief 将 Cat 对象视为其内嵌的 Animal 基类对象。
 *
 * @param cat 要转换的 Cat 对象。
 * @return 指向内嵌 Animal 基类对象的指针。
 */
Animal* catAsAnimal(Cat* cat);

#endif

注意这一行:

typedef struct Cat Cat;

这里没有给出 struct Cat 的字段。因此,对外部调用者来说,Cat 是一个不透明类型。调用者不能直接访问猫对象内部的数据,只能通过 newCat()deleteCat()catAsAnimal() 这些公开函数操作它。

cat.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cat.h"


/**
 * @brief 具体的 Cat 对象。
 *
 * Animal 基类作为第一个成员嵌入,因此该对象可以通过 Animal 指针
 * 进行多态分发。
 */
struct Cat {
        Animal base;
};


/**
 * @brief 初始化存储在内嵌基类对象中的 Cat 专有状态。
 *
 * @param self 要初始化的 Cat 对象。
 * @param name 要复制到 Animal 基类中的名称。
 */
static void catInit(Cat* self, const char* name) {
        strncpy(self->base.name, name, MAX_NAME_LEN - 1);
        self->base.name[MAX_NAME_LEN-1] = 0;
        printf("I am %s. (Init a cat)\n", self->base.name);
}

/**
 * @brief AnimalVtbl::speak 的 Cat 实现。
 *
 * @param self 属于 Cat 对象的 Animal 基类指针。
 */
static void catSpeak(Animal* self) {
        printf("miaow~ I am %s, a cat.\n", self->name);
}

/**
 * @brief AnimalVtbl::drink 的 Cat 实现。
 *
 * @param self 属于 Cat 对象的 Animal 基类指针。
 */
static void catDrink(Animal* self) {
        printf("miaow~ %s drink water.\n", self->name);
}

/** @brief 将 Animal 操作绑定到 Cat 行为的函数表。 */
static const AnimalVtbl catVtbl = {
        .speak = catSpeak,
        .drink = catDrink
};

/**
 * @brief 分配并初始化一个 Cat 对象。
 *
 * @param name 要复制到内嵌 Animal 基类对象中的名称。
 * @return 指向新 Cat 对象的指针;如果分配失败则返回 NULL。
 */
Cat* newCat(const char* name) {
        Cat* cat = (Cat*) malloc (sizeof(Cat));
        if (cat == NULL) return NULL;

        cat->base.vtblptr = &catVtbl;
        catInit(cat, name);
        
        return cat;
}

/**
 * @brief 释放由 newCat() 创建的 Cat 对象。
 *
 * @param cat 要释放的 Cat 对象。
 */
void deleteCat(Cat* cat) {
        free(cat);
}

/**
 * @brief 将 Cat 视为其内嵌的 Animal 基类对象。
 *
 * @param cat 要转换的 Cat 对象。
 * @return 指向内嵌 Animal 基类对象的指针。
 */
Animal* catAsAnimal(Cat* cat) {
        return &(cat->base);
}

Cat 的关键设计有三处。

第一,Cat 内部嵌入了一个 Animal base

struct Cat {
        Animal base;
};

这相当于说:猫对象“拥有一个 Animal 基类部分”。公共字段和函数表指针都放在这个 base 里。

第二,catSpeak()catDrink()static 函数:

static void catSpeak(Animal* self)
static void catDrink(Animal* self)

static 让它们只在 cat.c 内部可见。外部代码不能直接调用它们,只能通过 AnimalVtbl 间接调用。这也是一种封装。

第三,catVtbl 把抽象行为绑定到具体实现:

static const AnimalVtbl catVtbl = {
        .speak = catSpeak,
        .drink = catDrink
};

newCat() 创建对象时,会把猫自己的函数表写进 base.vtblptr

cat->base.vtblptr = &catVtbl;

从这一刻开始,这个对象被当作 Animal* 使用时,调用 animalSpeak() 就会走到 catSpeak(),调用 animalDrink() 就会走到 catDrink()

具体类型:Dog

Dog 的设计和 Cat 几乎一样。区别在于它绑定的是狗自己的行为函数。

这种重复结构正好体现了抽象层的价值:只要具体类型遵守 AnimalVtbl 这个“接口约定”,就可以被统一地当作 Animal 使用。

dog.h

/**
 * @file dog.h
 * @author quirkybrain
 * @brief 具体 Dog 类型的公开接口。
 * @version 0.1
 * @date 2026-05-20
 * 
 */
#ifndef _DOG_H
#define _DOG_H


#include "animal.h"


/** @brief 不透明的具体狗类型。 */
typedef struct Dog Dog;

/**
 * @brief 分配并初始化一个 Dog 对象。
 *
 * @param name 要复制到内嵌 Animal 基类对象中的名称。
 * @return 指向新 Dog 对象的指针;如果分配失败则返回 NULL。
 */
Dog* newDog(const char* name);

/**
 * @brief 释放由 newDog() 创建的 Dog 对象。
 *
 * @param dog 要释放的 Dog 对象。
 */
void deleteDog(Dog* dog);

/**
 * @brief 将 Dog 对象视为其内嵌的 Animal 基类对象。
 *
 * @param dog 要转换的 Dog 对象。
 * @return 指向内嵌 Animal 基类对象的指针。
 */
Animal* dogAsAnimal(Dog* dog);

#endif

dog.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "dog.h"


/**
 * @brief 具体的 Dog 对象。
 *
 * Animal 基类作为第一个成员嵌入,因此该对象可以通过 Animal 指针
 * 进行多态分发。
 */
struct Dog {
        Animal base;
};


/**
 * @brief 初始化存储在内嵌基类对象中的 Dog 专有状态。
 *
 * @param self 要初始化的 Dog 对象。
 * @param name 要复制到 Animal 基类中的名称。
 */
static void dogInit(Dog* self, const char* name) {
        strncpy(self->base.name, name, MAX_NAME_LEN - 1);
        self->base.name[MAX_NAME_LEN-1] = 0;
        printf("I am %s. (Init a dog)\n", self->base.name);
}

/**
 * @brief AnimalVtbl::speak 的 Dog 实现。
 *
 * @param self 属于 Dog 对象的 Animal 基类指针。
 */
static void dogSpeak(Animal* self) {
        printf("woof~ I am %s, a dog.\n", self->name);
}

/**
 * @brief AnimalVtbl::drink 的 Dog 实现。
 *
 * @param self 属于 Dog 对象的 Animal 基类指针。
 */
static void dogDrink(Animal* self) {
        printf("woof~ %s drink water.\n", self->name);
}

/** @brief 将 Animal 操作绑定到 Dog 行为的函数表。 */
static const AnimalVtbl dogVtbl = {
        .speak = dogSpeak,
        .drink = dogDrink
};

/**
 * @brief 分配并初始化一个 Dog 对象。
 *
 * @param name 要复制到内嵌 Animal 基类对象中的名称。
 * @return 指向新 Dog 对象的指针;如果分配失败则返回 NULL。
 */
Dog* newDog(const char* name) {
        Dog* dog = (Dog*) malloc (sizeof(Dog));
        if (dog == NULL) return NULL;

        dog->base.vtblptr = &dogVtbl;
        dogInit(dog, name);
        
        return dog;
}

/**
 * @brief 释放由 newDog() 创建的 Dog 对象。
 *
 * @param dog 要释放的 Dog 对象。
 */
void deleteDog(Dog* dog) {
        free(dog);
}

/**
 * @brief 将 Dog 视为其内嵌的 Animal 基类对象。
 *
 * @param dog 要转换的 Dog 对象。
 * @return 指向内嵌 Animal 基类对象的指针。
 */
Animal* dogAsAnimal(Dog* dog) {
        return &(dog->base);
}

Dog 的构造函数里同样有一行非常关键:

dog->base.vtblptr = &dogVtbl;

这说明每个具体对象在初始化时,都要把自己的函数表挂到内嵌的 Animal base 上。否则,抽象层就不知道应该分发到哪个具体函数。

调用端:只依赖抽象接口

main.c 里,创建对象时仍然使用具体类型的构造函数:

Cat* cat = newCat("Tom");
Dog* dog = newDog("Max");

但真正调用行为时,走的是抽象接口:

animalDrink(catAsAnimal(cat));
animalDrink(dogAsAnimal(dog));

animalSpeak(catAsAnimal(cat));
animalSpeak(dogAsAnimal(dog));

调用端调用的是同一组函数:animalDrink()animalSpeak()。不同的输出不是由 main.c 里的 if / switch 决定的,而是由对象内部的 vtblptr 决定的。

main.c

#include "cat.h"
#include "dog.h"

int main(void) {
        Cat* cat = newCat("Tom");
        Dog* dog = newDog("Max");

        animalDrink(catAsAnimal(cat));
        animalDrink(dogAsAnimal(dog));

        animalSpeak(catAsAnimal(cat));
        animalSpeak(dogAsAnimal(dog));

        deleteCat(cat);
        deleteDog(dog);
}

这里的 catAsAnimal()dogAsAnimal() 可以理解为手动的“向上转型”:

Animal* catAsAnimal(Cat* cat) {
        return &(cat->base);
}

它把具体对象中的 Animal base 取出来,让调用端可以用统一的 Animal* 处理不同类型的对象。

编译运行

为了方便构建,使用了一个简单的 Makefile

Makefile

CC := gcc
CFLAGS := -Wall -Wextra -g

TARGET := main
SRCS := main.c animal.c cat.c dog.c
OBJS := $(SRCS:.c=.o)
HEADERS := animal.h cat.h dog.h

.PHONY: all run clean

all: $(TARGET)

$(TARGET): $(OBJS)
	$(CC) $(CFLAGS) -o $@ $^

%.o: %.c $(HEADERS)
	$(CC) $(CFLAGS) -c $< -o $@

run: $(TARGET)
	./$(TARGET)

clean:
	rm -f $(TARGET) $(OBJS)

运行:

make run

输出示例:

I am Tom. (Init a cat)
I am Max. (Init a dog)
miaow~ Tom drink water.
woof~ Max drink water.
miaow~ I am Tom, a cat.
woof~ I am Max, a dog.

从输出可以看到,同样的抽象调用:

animalDrink(...)
animalSpeak(...)

Cat 对象上表现为 miaow~,在 Dog 对象上表现为 woof~。这就是通过函数指针表实现的多态效果。

这份代码里的几个设计点

1. 用不透明类型实现封装

cat.hdog.h 中,只写:

typedef struct Cat Cat;
typedef struct Dog Dog;

真正的结构体定义放在 cat.cdog.c 中:

struct Cat {
        Animal base;
};

这样调用端无法直接访问 Cat / Dog 的内部字段。它只能通过公开函数创建、销毁或转换对象。

这就是 C 中常见的封装方式:头文件暴露接口,源文件隐藏实现。

2. 用函数表表达抽象接口

AnimalVtbl 定义了一组“动物应该具备的行为”:

struct AnimalVtbl {
        void (*speak)(Animal* self);
        void (*drink)(Animal* self);
};

它本身不关心具体怎么叫、怎么喝水。它只规定:具体类型必须提供 speakdrink 这两个函数。

因此,AnimalVtbl 就是这份代码里的抽象接口。

3. 用内嵌 base 模拟继承

每个具体类型都把 Animal 嵌入到自己的结构体中:

struct Cat {
        Animal base;
};

struct Dog {
        Animal base;
};

这让 CatDog 都拥有一份公共的 Animal 数据,包括 namevtblptr

这种方式不是 C 语言层面的继承,而是一种工程上的组合约定:具体对象内部包含一个公共基类对象,外部通过这个公共基类对象进行统一操作。

4. 用 vtblptr 实现运行时分发

对象初始化时会保存自己的函数表:

cat->base.vtblptr = &catVtbl;
dog->base.vtblptr = &dogVtbl;

抽象接口调用时再通过这个函数表分发:

self->vtblptr->drink(self);
self->vtblptr->speak(self);

所以,animalDrink() 不需要知道 self 来自猫还是狗。它只要相信 self->vtblptr 已经指向了正确的函数表即可。

5. 新类型可以按同样模式扩展

如果以后要增加一个 Bird,大致只需要:

  1. 定义 Bird 的公开接口,例如 bird.h
  2. bird.c 中定义 struct Bird { Animal base; };
  3. 实现 birdSpeak()birdDrink()
  4. 定义 birdVtbl
  5. newBird() 中设置 bird->base.vtblptr = &birdVtbl;
  6. 提供 birdAsAnimal() 返回 &(bird->base)

这样,调用端依旧可以通过 animalSpeak()animalDrink() 使用它。

小结

这份代码展示的是一种朴素但非常重要的 C 语言抽象技巧:

  • struct 负责组织对象数据。
  • .h.c 的边界负责隐藏实现细节。
  • 函数指针负责描述可替换的行为。
  • 函数表负责把一组行为打包成接口。
  • 对象中的函数表指针负责在运行时选择具体实现。

用一句话概括就是:

在 C 语言里,可以通过“内嵌公共基类结构体 + 函数指针表 + 公开分发函数”的组合,手动实现类似面向对象语言中的封装、抽象和多态。

这种写法不会把 C 变成 C++,也不会自动提供类型检查、继承层次管理或析构链。它更像是一种清晰的工程约定:只要对象按约定初始化好自己的函数表,调用端就能用统一接口处理不同类型的对象。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐