C 实现 C++ 面向对象特性
在 C++ 中,面向对象编程有三个非常重要的特性:封装(Encapsulation)、继承(Inheritance)、多态(Polymorphism)。
虽然 C 语言没有 class、public、private、virtual 等关键字,但我们完全可以通过 结构体(struct)、函数指针(function pointer)、指针类型转换以及模块化设计,模拟 C++ 的面向对象机制。
实际上,理解 C 如何实现面向对象,对于理解 C++ 对象内存模型、虚函数表(vtable)、虚函数指针(vptr)和动态绑定 非常有帮助。
下面我们从底层原理出发,分别介绍这三个特性,并最终使用纯 C 语言实现一个完整的面向对象程序。
一、C 与 C++ 面向对象机制的对应关系
先看一下 C++ 的面向对象特性在 C 语言中如何实现。
| C++ 特性 | C 语言实现方式 | 核心原理 |
|---|---|---|
| 类(class) | struct + 函数 |
数据与操作相结合 |
| 对象(object) | 结构体变量 | 保存对象状态 |
| 封装 | 不透明指针 + API | 隐藏数据结构的实现 |
| 构造函数 | 初始化函数 | 初始化对象数据 |
| 析构函数 | 销毁函数 | 释放对象资源 |
| 继承 | 结构体嵌套 | 组合基类的数据结构 |
| 多态 | 函数指针 + 虚函数表 | 运行时动态选择函数 |
| 虚函数(virtual) | 函数指针 | 通过指针间接调用 |
| 虚函数表(vtable) | 函数指针结构体 | 保存一组函数入口 |
| 虚函数指针(vptr) | 指向函数表的指针 | 关联对象与虚函数表 |
this 指针 |
显式传递对象指针 | 指明操作的对象 |
其中最重要的对应关系是:
C++ 的面向对象机制可以通过 C 的结构体、指针和函数指针来模拟。
但要注意,这只是模拟其核心机制,而不是说 C 语言实现了 C++ 全部的对象语义。
二、封装:使用结构体和函数实现
2.1 C++ 如何实现封装?
首先看一个简单的 C++ 类。
#include <iostream>
using namespace std;
class Student {
private:
int age;
public:
Student(int a) { age = a; }
void setAge(int a) { age = a; }
int getAge() { return age; }
};
int main() {
Student stu(20);
stu.setAge(25);
cout << stu.getAge() << endl;
return 0;
}
输出:
25
这里体现了封装的两个特点:
- 数据和操作数据的方法组织在同一个类中。
- 成员变量
age被private修饰,外部不能直接访问。
那么 C 语言如何实现?
2.2 C 语言使用 struct + 函数实现
最直接的方法是使用结构体保存数据,使用普通函数操作数据。
#include <stdio.h>
typedef struct {
int age;
} Student;
// 初始化
void Student_init(Student *self, int age) {
self->age = age;
}
// 修改年龄
void Student_setAge(Student *self, int age) {
self->age = age;
}
// 获取年龄
int Student_getAge(const Student *self) {
return self->age;
}
int main(void) {
Student stu;
Student_init(&stu, 20);
Student_setAge(&stu, 25);
printf("%d\n", Student_getAge(&stu));
return 0;
}
输出:
25
这段代码和前面的 C++ 代码具有相似的功能。
对比一下。
C++:
stu.setAge(25);
C:
Student_setAge(&stu, 25);
实际上,它们有一个非常重要的联系:
C++ 非静态成员函数内部存在一个隐式的 this 指针,而 C 语言需要显式地传递对象指针。
可以将:
stu.setAge(25);
概念性地理解为:
Student_setAge(&stu, 25);
这也是理解 C++ 成员函数调用机制的一种有效方式。
不过,目前的 C 实现存在一个问题。
Student stu;
stu.age = -100;
外部仍然可以直接修改 age。
因此,这段代码只实现了数据与操作的组织,并没有真正隐藏内部数据。
2.3 使用不透明指针实现真正的数据隐藏
在实际的 C 项目中,通常使用:
头文件中的不完整结构体声明 + 源文件中的完整结构体定义。
例如:
student.h
#ifndef STUDENT_H
#define STUDENT_H
typedef struct Student Student;
Student *Student_create(int age);
void Student_setAge(Student *self, int age);
int Student_getAge(const Student *self);
void Student_destroy(Student *self);
#endif
注意:
typedef struct Student Student;
这里只声明了 Student 类型,并没有暴露其内部成员。
外部代码不知道 Student 的具体结构。
student.c
#include "student.h"
#include <stdlib.h>
struct Student {
int age;
};
Student *Student_create(int age) {
Student *self = malloc(sizeof(*self));
if (self == NULL) {
return NULL;
}
self->age = age;
return self;
}
void Student_setAge(Student *self, int age) {
if (self != NULL) {
self->age = age;
}
}
int Student_getAge(const Student *self) {
if (self == NULL) {
return -1;
}
return self->age;
}
void Student_destroy(Student *self) {
free(self);
}
main.c
#include <stdio.h>
#include "student.h"
int main(void) {
Student *stu = Student_create(20);
if (stu == NULL) {
return 1;
}
Student_setAge(stu, 25);
printf("%d\n", Student_getAge(stu));
Student_destroy(stu);
return 0;
}
此时,如果在 main.c 中写:
stu->age = 100;
编译器就会报错。
Member access into incomplete type 'Student'
main.h(4, 16): Forward declaration of 'Student'
原因是:
main.c 只知道 Student 是一个结构体类型,却不知道这个结构体包含哪些成员。
因此,它无法直接访问 age。
这就实现了 C 语言中的数据隐藏。
封装的本质
可以总结为:
- 使用
struct保存对象的数据。 - 使用函数操作对象的数据。
- 使用不透明指针隐藏结构体的内部实现。
- 通过头文件对外提供统一接口。
注意,C 语言本身并不存在 private 访问控制机制。这里是通过模块边界来隐藏实现细节。
三、继承:使用结构体嵌套实现
继承的核心思想是:
派生类复用基类的数据和行为,并在此基础上扩展新的功能。
3.1 首先看 C++ 的继承
假设存在一个动物基类:
#include <iostream>
using namespace std;
class Animal {
public:
int age;
void eat() { cout << "Animal is eating" << endl; }
};
class Dog : public Animal {
public:
void bark() { cout << "Dog is barking" << endl; }
};
int main() {
Dog dog;
dog.age = 3;
dog.eat();
dog.bark();
return 0;
}
输出:
Animal is eating
Dog is barking
这里:
class Dog : public Animal
表示 Dog 继承 Animal。
因此,Dog 对象可以使用 Animal 的成员变量和成员函数。
那么 C 语言如何实现?
3.2 使用结构体嵌套模拟继承
直接看代码。
#include <stdio.h>
// 基类
typedef struct {
int age;
} Animal;
// 派生类
typedef struct {
Animal base;
int weight;
} Dog;
// 基类方法
void Animal_eat(Animal *self) {
printf("Animal(age=%d) is eating\n", self->age);
}
// 派生类方法
void Dog_bark(Dog *self) {
printf("Dog(weight=%d) is barking\n", self->weight);
}
int main(void) {
Dog dog;
dog.base.age = 3;
dog.weight = 20;
Animal_eat(&dog.base);
Dog_bark(&dog);
return 0;
}
输出:
Animal(age=3) is eating
Dog(weight=20) is barking
核心代码是:
typedef struct {
Animal base;
int weight;
} Dog;
这里让 Dog 结构体包含一个 Animal 结构体。
从内存布局的角度看:
Dog 对象
┌─────────────────────────┐
│ Animal base │
│ ┌─────────────────────┐ │
│ │ int age │ │
│ └─────────────────────┘ │
├─────────────────────────┤
│ int weight │
└─────────────────────────┘
由于 Dog 包含 Animal 结构体,所以它自然包含了 Animal 的数据。
我们还可以将 Animal 作为 Dog 的第一个成员。
这样做有什么好处?
3.3 基类指针与派生类指针转换
考虑:
Dog dog;
它的内存布局可以表示为:
低地址
│
▼
┌─────────────────┐
│ Animal base │ ◄── Animal *
│ age │
├─────────────────┤
│ weight │
└─────────────────┘
▲
│
Dog *
因为 Animal 是 Dog 的第一个成员,所以它们具有相同的起始地址。
在 C 语言中,结构体对象的指针与其首成员的指针可以进行适当转换。
因此,可以这样操作:
Dog dog = {0};
Animal *animal = &dog.base;
也可以在该布局下写成:
Animal *animal = (Animal *)&dog;
更推荐第一种写法,因为语义更加明确。
反过来,如果我们确定 animal 实际上指向 Dog 对象的首成员,也可以转换回去:
Dog *dog_ptr = (Dog *)animal;
但这里需要注意:
只有确认实际对象确实是 Dog 时,才能安全地按照 Dog 访问。
不能将一个普通的 Animal 对象随意转换为 Dog。
这与 C++ 中的向下转型存在相似的安全性问题。
继承的本质
C 语言模拟继承,本质上是:
通过结构体嵌套,使派生对象包含基类子对象,并通过基类指针访问公共部分。
不过,它与真正的 C++ 继承并不完全相同。
例如,C 语言不会自动提供成员继承、访问权限控制或编译器支持的继承类型检查。
四、多态:使用函数指针和虚函数表实现
这是三个特性中最重要的部分,也是理解 C++ 虚函数机制的关键。
我们先思考一个问题。
假设有:
Animal *animal;
这个指针既可以指向 Dog,也可以指向 Cat。
当执行:
animal->speak();
时:
- 如果指向
Dog,执行Dog::speak()。 - 如果指向
Cat,执行Cat::speak()。
这就是运行时多态。
那么,C 语言没有 virtual,如何实现这种功能?
答案是:
函数指针 + 虚函数表。
4.1 先看 C++ 的多态
#include <iostream>
using namespace std;
class Animal {
public:
virtual void speak() {
cout << "Animal speaking" << endl;
}
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
void speak() override {
cout << "Dog barking" << endl;
}
};
class Cat : public Animal {
public:
void speak() override {
cout << "Cat meowing" << endl;
}
};
int main() {
Dog dog;
Cat cat;
Animal *animal1 = &dog;
Animal *animal2 = &cat;
animal1->speak();
animal2->speak();
return 0;
}
输出:
Dog barking
Cat meowing
关键在于:
virtual void speak();
由于 speak() 被声明为虚函数,程序会在运行时根据对象的实际类型决定调用哪个版本。
4.2 C 语言的函数指针
在实现多态之前,需要先理解函数指针。
例如:
#include <stdio.h>
void dog_speak(void) {
printf("Dog barking\n");
}
void cat_speak(void) {
printf("Cat meowing\n");
}
int main(void) {
void (*speak)(void);
speak = dog_speak;
speak();
speak = cat_speak;
speak();
return 0;
}
输出:
Dog barking
Cat meowing
这里:
void (*speak)(void);
声明了一个函数指针。
它可以指向任何满足以下函数签名的函数:
void 函数名(void);
因此,我们可以在运行时修改函数指针,让同一条调用语句执行不同的函数。
这实际上已经具备了动态分派的基本能力。
4.3 使用结构体保存函数指针
进一步,我们可以将函数指针放入结构体。
typedef struct {
void (*speak)(void);
} Animal;
然后:
Animal dog;
Animal cat;
dog.speak = dog_speak;
cat.speak = cat_speak;
调用:
dog.speak();
cat.speak();
即可执行不同的方法。
不过,这个实现还比较简单。
它只是让不同的结构体变量保存不同的函数指针,并没有完整模拟 C++ 的继承和虚函数表机制。
下面进一步实现。
五、完整案例:使用纯 C 实现封装、继承和多态
下面实现一个完整的动物系统。
设计目标如下:
Animal
(动物基类)
│
┌─────────┴─────────┐
│ │
Dog Cat
(狗) (猫)
│ │
speak() speak()
│ │
Dog barking Cat meowing
要求:
Animal是公共基类。Dog和Cat模拟继承Animal。- 每个类具有自己的
speak()实现。 - 使用统一的
Animal *指针调用speak()。 - 实现类似虚函数表的机制。
- 通过公共 API 隐藏具体对象的实现。
5.1 完整 C 代码
下面代码可以直接复制到 main.c 中编译运行。
#include <stdio.h>
#include <stdlib.h>
/* =========================
* 1. 对外接口
* ========================= */
typedef struct Animal Animal;
/* 创建不同的动物 */
Animal *Dog_create(void);
Animal *Cat_create(void);
/* 公共操作 */
void Animal_speak(const Animal *self);
void Animal_destroy(Animal *self);
/* =========================
* 2. 虚函数表
* ========================= */
typedef struct {
void (*speak)(const Animal *self);
void (*destroy)(Animal *self);
} AnimalVTable;
/* =========================
* 3. 基类 Animal
* ========================= */
struct Animal {
const AnimalVTable *vptr;
};
/* =========================
* 4. 派生类 Dog
* ========================= */
typedef struct {
Animal base;
int age;
} Dog;
/* =========================
* 5. 派生类 Cat
* ========================= */
typedef struct {
Animal base;
int lives;
} Cat;
/* =========================
* 6. Dog 的成员函数
* ========================= */
static void Dog_speak(const Animal *self) {
const Dog *dog = (const Dog *)self;
printf("Dog barking, age = %d\n", dog->age);
}
static void Dog_destroy(Animal *self) {
free(self);
}
/* =========================
* 7. Cat 的成员函数
* ========================= */
static void Cat_speak(const Animal *self) {
const Cat *cat = (const Cat *)self;
printf("Cat meowing, lives = %d\n", cat->lives);
}
static void Cat_destroy(Animal *self) {
free(self);
}
/* =========================
* 8. 创建虚函数表
* ========================= */
static const AnimalVTable Dog_vtable = {.speak = Dog_speak, .destroy = Dog_destroy};
static const AnimalVTable Cat_vtable = {.speak = Cat_speak, .destroy = Cat_destroy};
/* =========================
* 9. 创建对象(模拟构造函数)
* ========================= */
Animal *Dog_create(void) {
Dog *dog = malloc(sizeof(*dog));
if (dog == NULL) {
return NULL;
}
dog->base.vptr = &Dog_vtable;
dog->age = 3;
return &dog->base;
}
Animal *Cat_create(void) {
Cat *cat = malloc(sizeof(*cat));
if (cat == NULL) {
return NULL;
}
cat->base.vptr = &Cat_vtable;
cat->lives = 9;
return &cat->base;
}
/* =========================
* 10. 公共接口(多态调用)
* ========================= */
void Animal_speak(const Animal *self) {
if (self != NULL) {
self->vptr->speak(self);
}
}
void Animal_destroy(Animal *self) {
if (self != NULL) {
self->vptr->destroy(self);
}
}
/* =========================
* 11. 主函数
* ========================= */
int main(void) {
Animal *animals[2];
animals[0] = Dog_create();
animals[1] = Cat_create();
if (animals[0] == NULL || animals[1] == NULL) {
Animal_destroy(animals[0]);
Animal_destroy(animals[1]);
return 1;
}
for (int i = 0; i < 2; i++) {
Animal_speak(animals[i]);
}
for (int i = 0; i < 2; i++) {
Animal_destroy(animals[i]);
}
return 0;
}
可以使用 GCC 编译:
gcc -std=c11 -Wall -Wextra main.c -o main
执行结果:
Dog barking, age = 3
Cat meowing, lives = 9
这段代码同时模拟了封装、继承和多态。
需要说明的是:虽然为了方便演示,我们将代码放在同一个文件中,但如果要实现严格的数据隐藏,应当将 Animal 的不完整类型声明及公共函数放在 .h 文件中,将具体结构体定义和虚函数表放在 .c 文件中。
六、深入分析:C 语言究竟如何实现多态?
这是整个案例最值得理解的部分。
我们从对象的内存结构开始分析。
6.1 第一层:虚函数表
首先看这段代码:
typedef struct {
void (*speak)(const Animal *self);
void (*destroy)(Animal *self);
} AnimalVTable;
这里定义了一个结构体:
AnimalVTable
其中包含两个函数指针:
speak
destroy
可以将它理解为一个函数地址表。
例如:
static const AnimalVTable Dog_vtable = {
.speak = Dog_speak,
.destroy = Dog_destroy
};
对应的逻辑结构如下:
Dog_vtable
┌──────────────────────────┐
│ speak ───────────────┐ │
├────────────────────────┼─┤
│ destroy ────────────┐ │ │
└─────────────────────┼──┼─┘
│ │
▼ ▼
Dog_destroy()
Dog_speak()
注意,虚函数表本身不是函数,而是保存函数指针的数据结构。
对于 Cat:
static const AnimalVTable Cat_vtable = {
.speak = Cat_speak,
.destroy = Cat_destroy
};
它指向另一组函数。
因此:
Dog_vtable
│
├── Dog_speak
└── Dog_destroy
Cat_vtable
│
├── Cat_speak
└── Cat_destroy
到这里,我们已经建立了不同类型对应的函数表。
6.2 第二层:虚函数指针 vptr
再看基类:
struct Animal {
const AnimalVTable *vptr;
};
其中:
const AnimalVTable *vptr;
就是我们手动实现的虚函数表指针。
它指向当前对象对应的函数表。
对于 Dog:
dog->base.vptr = &Dog_vtable;
对于 Cat:
cat->base.vptr = &Cat_vtable;
因此,不同类型的对象会保存不同的虚函数表地址。
它们的内存关系可以理解为:
Dog 对象
┌────────────────┐
│ Animal base │
│ ┌────────────┐ │
│ │ vptr ──────┼─┼──────────┐
│ └────────────┘ │ │
├────────────────┤ │
│ int age = 3 │ │
└────────────────┘ │
▼
Dog_vtable
┌─────────────────┐
│ Dog_speak │
├─────────────────┤
│ Dog_destroy │
└─────────────────┘
Cat 对象
┌────────────────┐
│ Animal base │
│ ┌────────────┐ │
│ │ vptr ──────┼─┼──────────┐
│ └────────────┘ │ │
├────────────────┤ │
│ int lives = 9 │ │
└────────────────┘ │
▼
Cat_vtable
┌─────────────────┐
│ Cat_speak │
├─────────────────┤
│ Cat_destroy │
└─────────────────┘
这是理解多态最重要的一张图。
不同对象虽然具有相同的基类结构,但其 vptr 可以指向不同的虚函数表。
因此,它们能够表现出不同的行为。
6.3 第三层:动态绑定
现在重点分析:
void Animal_speak(const Animal *self) {
if (self != NULL) {
self->vptr->speak(self);
}
}
其中最关键的是:
self->vptr->speak(self);
假设:
Animal *animal = Dog_create();
此时:
animal
│
▼
Dog 对象
│
└── vptr
│
▼
Dog_vtable
│
└── speak
│
▼
Dog_speak()
因此:
Animal_speak(animal);
最终调用:
Dog_speak(animal);
如果改成:
Animal *animal = Cat_create();
那么:
animal
│
▼
Cat 对象
│
└── vptr
│
▼
Cat_vtable
│
└── speak
│
▼
Cat_speak()
同样的:
Animal_speak(animal);
最终调用的却是:
Cat_speak(animal);
这就是:
相同的接口,根据对象的实际类型,在运行时执行不同的函数。
也就是运行时多态。
七、进一步理解:C++ 虚函数的底层原理
通过前面的案例,我们可以进一步分析 C++ 虚函数。
考虑下面的 C++ 代码:
class Animal {
public:
virtual void speak() {
cout << "Animal" << endl;
}
};
class Dog : public Animal {
public:
void speak() override {
cout << "Dog" << endl;
}
};
在常见的 C++ 编译器实现中,编译器会为具有虚函数的类生成类似虚函数表的结构。
因此,可以从概念上将它理解为:
typedef struct Animal Animal;
typedef struct {
void (*speak)(Animal *);
} AnimalVTable;
struct Animal {
const AnimalVTable *vptr;
};
当执行 C++ 代码:
Animal *animal = new Dog();
animal->speak();
我们可以将虚函数调用概念性地理解为:
animal->vptr->speak(animal);
但这里需要特别强调:
C++ 标准并没有规定编译器必须使用 vtable 和 vptr 实现虚函数。
虚函数表是 GCC、Clang、MSVC 等主流编译器普遍采用的一种实现方案。
而且,不同 ABI、不同继承关系下,实际对象布局、虚表内容及调用方式也可能不同。
因此,我们编写的 C 代码是对常见虚函数机制的模拟,而不是所有 C++ 编译器内部实现的精确复刻。
八、为什么需要将 Animal 放在 Dog 结构体的第一个成员?
再分析一个容易忽略的细节。
之前的定义是:
typedef struct {
Animal base;
int age;
} Dog;
为什么不写成:
typedef struct {
int age;
Animal base;
} Dog;
原因是我们希望基类子对象与派生类对象具有相同的起始地址。
例如:
Dog *dog = malloc(sizeof(Dog));
Animal *animal = &dog->base;
当 Animal 位于第一个成员时:
Dog 对象起始地址: 0x1000
Animal base 起始地址: 0x1000
age 地址: 0x1008(仅为示意)
注意,具体偏移受平台、指针大小和内存对齐影响。
这样:
Dog *dog = (Dog *)animal;
才能在确认实际对象类型为 Dog 的前提下,正确恢复对象指针。
如果把 Animal 放在后面:
typedef struct {
int age;
Animal base;
} Dog;
内存可能变成:
Dog 对象
0x1000 ┌──────────────────┐
│ int age │
0x1008 ├──────────────────┤
│ Animal base │
│ vptr │
└──────────────────┘
此时:
Animal *animal = &dog->base;
指向的就不再是整个 Dog 对象的起始地址。
直接将这个指针转换为 Dog *,并按照 Dog 对象访问,就可能产生未定义行为。
所以,在这种模拟继承的设计中:
通常约定将基类结构体放在派生类结构体的第一个成员。
这种方式在一些系统级 C 项目和嵌入式框架中经常出现。
不过,如果希望支持基类位于其他位置的情况,也可以通过明确的偏移计算设计相应的容器恢复机制,而不能简单地强制转换指针。
九、C 语言实现的面向对象与 C++ 有什么区别?
虽然我们已经实现了三个核心特性,但两者仍然存在明显差别。
| 比较项目 | C++ | C 语言模拟 |
|---|---|---|
| 类定义 | class |
struct |
| 数据封装 | private、protected |
不透明类型、模块化 |
| 成员函数 | 编译器直接支持 | 普通函数 + 对象指针 |
| 构造函数 | 自动调用 | 手动调用创建或初始化函数 |
| 析构函数 | 语言机制支持 | 手动调用销毁函数 |
| 继承 | 编译器直接支持 | 结构体嵌套 |
| 虚函数 | virtual |
函数指针 |
| 虚函数表 | 通常由编译器生成 | 程序员手动定义 |
| 动态多态 | 自动进行虚函数分派 | 手动通过函数指针分派 |
| 基类类型转换 | 支持继承关系检查 | 程序员保证转换安全 |
| 运行时类型识别 | RTTI、dynamic_cast |
自行实现类型标识与检查 |
| 资源管理 | RAII、智能指针等 | 手动管理或自行封装 |
特别需要注意两点。
第一,C 语言模拟面向对象会增加手动管理成本。
例如:
dog->base.vptr = &Dog_vtable;
需要程序员手动初始化虚函数指针。
如果忘记初始化,调用虚函数时就可能发生未定义行为。
而 C++ 通常由编译器负责对象构造期间的相关工作。
第二,C 语言的继承和多态缺少编译器提供的完整类型安全机制。
例如:
Dog *dog = (Dog *)animal;
C 编译器一般无法判断 animal 实际指向的对象是不是 Dog。
因此,项目中常常需要通过类型标识、统一的对象创建接口、约定好的内存布局等方式保证安全性。

浙公网安备 33010602011771号