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

这里体现了封装的两个特点:

  1. 数据和操作数据的方法组织在同一个类中。
  2. 成员变量 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

要求:

  1. Animal 是公共基类。
  2. Dog 和 Cat 模拟继承 Animal。
  3. 每个类具有自己的 speak() 实现。
  4. 使用统一的 Animal * 指针调用 speak()。
  5. 实现类似虚函数表的机制。
  6. 通过公共 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。

因此,项目中常常需要通过类型标识、统一的对象创建接口、约定好的内存布局等方式保证安全性。

posted @ 2026-10-27 16:55  光風霽月  阅读(8)  评论(0)    收藏  举报