返回博客
用 C 实现面向对象 —— 深入理解 C++ 第一篇

用 C 实现面向对象 —— 深入理解 C++ 第一篇

引言

很多人觉得 C 语言不支持面向对象编程。严格来说,C 确实没有 classvirtualprivate 这些关键字。但如果你看过 Linux 内核、FreeRTOS、GLib 或者各种嵌入式设备框架的源码,你会发现它们全都在用 C 做"面向对象"——而且做得非常优雅。

为什么要用 C 模拟面向对象?有两个原因:

  1. 理解 C++ 的底层原理。C++ 的 class、继承、虚函数在编译后到底变成了什么?答案是:和你用 C 手动实现的东西几乎一模一样。理解了 C 版本,你就真正理解了 C++
  2. 在纯 C 项目中获得 OOP 的好处。嵌入式领域大量项目只用 C(编译器限制、代码体积、实时性要求),但仍然需要封装、多态、可替换等设计能力

本文是"深入理解 C++"系列的第一篇。我们从最底层出发——用纯 C 实现封装、继承、多态,然后对照 C++ 编译器的实际输出,揭示 C++ 面向对象的底层真相。

封装:struct + 函数指针

C++ 的 class 本质上是什么

C++ 中一个简单的类:

class Uart {
public:
    void init(int baudrate);
    void send(const char *data, int len);
private:
    int fd;
    int baudrate;
};

编译后,这个 class 在内存中就是一个 struct。成员函数不存在于对象内部——它们和普通函数一样放在 .text 段,只是编译器自动在第一个参数位置插入了 this 指针。

也就是说,uart.init(115200) 在编译后变成了类似 Uart_init(&uart, 115200) 的调用。

用 C 实现封装

理解了这个本质,用 C 实现封装就很自然了:

// uart.h
typedef struct {
    int fd;
    int baudrate;
} Uart;

void uart_init(Uart *self, int baudrate);
void uart_send(Uart *self, const char *data, int len);
// uart.c
#include "uart.h"

void uart_init(Uart *self, int baudrate)
{
    self->baudrate = baudrate;
    self->fd = -1;
    // 配置硬件...
}

void uart_send(Uart *self, const char *data, int len)
{
    // 使用 self->fd 发送数据...
}

使用方式:

Uart uart1;
uart_init(&uart1, 115200);
uart_send(&uart1, "Hello", 5);

和 C++ 版本对比:

Uart uart1;
uart1.init(115200);
uart1.send("Hello", 5);

区别仅在语法糖——uart1.init(115200) 编译后本质就是 Uart::init(&uart1, 115200)

内存布局完全相同

sizeofoffsetof 验证:

// C 版本
printf("sizeof(Uart) = %zu\n", sizeof(Uart));            // 8
printf("offset of fd = %zu\n", offsetof(Uart, fd));       // 0
printf("offset of baudrate = %zu\n", offsetof(Uart, baudrate)); // 4
// C++ 版本(没有虚函数时)
printf("sizeof(Uart) = %zu\n", sizeof(Uart));             // 8
printf("offset of fd = %zu\n", offsetof(Uart, fd));        // 0
printf("offset of baudrate = %zu\n", offsetof(Uart, baudrate)); // 4

结果完全一致。没有虚函数的 C++ class 和 C struct 的内存布局是一样的。

访问控制:static 模拟 private

C++ 的访问控制

C++ 用 private/protected/public 控制成员的可访问性。但这只是编译期检查——编译器在编译时拒绝对 private 成员的直接访问,运行时没有任何保护。如果你用指针偏移强行访问 private 成员,程序照样能跑。

C 的替代方案

C 没有 private 关键字,但有 static——将函数或变量的可见性限制在当前文件内。效果甚至比 C++ 的 private 更强,因为 static链接层面就不可见了,而 C++ 的 private 在链接层面仍然是导出符号。

// uart.c
#include "uart.h"

// "private" 函数——外部文件无法调用
static void configure_baudrate(Uart *self, int baudrate)
{
    // 写寄存器...
    self->baudrate = baudrate;
}

// "private" 变量——外部文件无法访问
static int error_count = 0;

// "public" 函数——头文件中声明,外部可用
void uart_init(Uart *self, int baudrate)
{
    configure_baudrate(self, baudrate);
    error_count = 0;
}

头文件只暴露"公有"接口,.c 文件中的 static 函数和变量就是"私有"的。

不透明指针(Opaque Pointer)

如果你想把 struct 的成员也藏起来(不在头文件中暴露),可以使用不透明指针模式:

// uart.h —— 只声明类型,不定义结构体内容
typedef struct Uart Uart;

Uart *uart_create(int baudrate);
void  uart_destroy(Uart *self);
void  uart_send(Uart *self, const char *data, int len);
// uart.c —— 结构体定义只在 .c 中
#include "uart.h"
#include <stdlib.h>

struct Uart {
    int fd;
    int baudrate;
    char rx_buffer[256];
};

Uart *uart_create(int baudrate)
{
    Uart *self = (Uart *)malloc(sizeof(Uart));
    self->baudrate = baudrate;
    self->fd = -1;
    return self;
}

void uart_destroy(Uart *self)
{
    free(self);
}

外部代码只持有 Uart * 指针,完全不知道里面有什么成员——比 C++ 的 private 隐藏得更彻底。修改 struct 内部成员不需要重新编译依赖它的代码(只需重新编译 uart.c),这就是 pimpl(pointer to implementation) 模式在 C 中的原生版本。

继承:结构体嵌套

C++ 继承的内存布局

C++ 继承在内存层面的实现非常直接——子类对象的开头就是父类对象:

class Device {
public:
    const char *name;
    int type;
};

class Uart : public Device {
public:
    int baudrate;
    int data_bits;
};

Uart 对象在内存中的布局:

偏移  成员
0     name      ← Device 部分
8     type      ← Device 部分
12    baudrate  ← Uart 新增
16    data_bits ← Uart 新增

子类对象的前半部分就是一个完整的父类对象。这意味着 Uart* 可以安全地转换为 Device*——指针地址不需要调整。

用 C 实现继承

理解了内存布局,C 的实现方式就是把"父类"struct 作为"子类"struct 的第一个成员

// 基类
typedef struct {
    const char *name;
    int type;
} Device;

// 子类——Device 作为第一个成员
typedef struct {
    Device base;        // "继承" Device
    int baudrate;
    int data_bits;
} UartDevice;

// 另一个子类
typedef struct {
    Device base;        // "继承" Device
    int mode;
    int frequency;
} SpiDevice;

关键在于"第一个成员"这个约束。C 标准保证结构体第一个成员的地址和结构体本身的地址相同,因此:

UartDevice uart_dev;
Device *dev = (Device *)&uart_dev;  // 安全!指向同一地址

这个指针转换和 C++ 的向上转型(upcast)在机器码层面完全等价——都是零开销的地址传递。

使用"继承"

void uart_device_init(UartDevice *self, const char *name, int baudrate)
{
    // 初始化"父类"部分
    self->base.name = name;
    self->base.type = 1;
    // 初始化"子类"部分
    self->baudrate = baudrate;
    self->data_bits = 8;
}

// 接受"父类"指针的通用函数
void device_print_info(Device *dev)
{
    printf("Device: %s, type: %d\n", dev->name, dev->type);
}

// 使用
UartDevice uart;
uart_device_init(&uart, "UART0", 115200);

// 向上转型——UartDevice* → Device*
device_print_info((Device *)&uart);

Linux 内核中大量使用这种模式。比如 struct cdev 嵌入了 struct kobjectstruct net_device 嵌入了 struct device。内核还提供了 container_of 宏来做反向转换(从父类指针找回子类指针):

#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member)))

// 从 Device* 反推 UartDevice*
Device *dev = get_some_device();
UartDevice *uart = container_of(dev, UartDevice, base);

多态:函数指针表(vtable)

多态是面向对象中最强大的特性——通过基类指针调用函数,实际执行的是子类的实现。C++ 用虚函数实现,底层靠的是 vtable(虚函数表)

C++ 虚函数的底层实现

class Animal {
public:
    virtual void speak() = 0;
    virtual void eat(const char *food) = 0;
    const char *name;
};

编译器为 Animal 类生成了一个隐藏的 vtable:

Animal 的 vtable:
[0] → Animal::speak  (纯虚,无实现)
[1] → Animal::eat    (纯虚,无实现)

每个 Animal 对象的开头都有一个隐藏的指针 vptr,指向对应类的 vtable:

Animal 对象内存布局:
偏移 0: vptr → 指向 vtable
偏移 8: name

当子类 override 虚函数时,子类有自己的 vtable:

class Dog : public Animal {
public:
    void speak() override { printf("Woof!\n"); }
    void eat(const char *food) override { printf("Dog eats %s\n", food); }
};
Dog 的 vtable:
[0] → Dog::speak
[1] → Dog::eat

调用 animal->speak() 时,编译器生成的代码实际上是:

// animal->speak() 的真实执行过程
animal->vptr[0](animal);  // 从 vtable 取出函数指针,传入 this

用 C 实现多态

理解了 vtable 的原理,用 C 手动实现就是水到渠成的事:

// "虚函数表"——函数指针结构体
typedef struct {
    void (*speak)(void *self);
    void (*eat)(void *self, const char *food);
} AnimalOps;

// "基类"
typedef struct {
    const AnimalOps *ops;   // vptr —— 指向虚函数表
    const char *name;
} Animal;

// 通过基类指针调用"虚函数"
void animal_speak(Animal *animal)
{
    animal->ops->speak(animal);
}

void animal_eat(Animal *animal, const char *food)
{
    animal->ops->eat(animal, food);
}

然后是"子类"的实现:

// Dog "子类"
typedef struct {
    Animal base;        // "继承" Animal
    int energy;
} Dog;

static void dog_speak(void *self)
{
    Dog *dog = (Dog *)self;
    printf("%s: Woof!\n", dog->base.name);
}

static void dog_eat(void *self, const char *food)
{
    Dog *dog = (Dog *)self;
    dog->energy += 10;
    printf("%s eats %s, energy=%d\n", dog->base.name, food, dog->energy);
}

// Dog 的 vtable(全局唯一,只读)
static const AnimalOps dog_ops = {
    .speak = dog_speak,
    .eat   = dog_eat,
};

void dog_init(Dog *dog, const char *name)
{
    dog->base.ops = &dog_ops;   // 设置 vptr
    dog->base.name = name;
    dog->energy = 100;
}
// Cat "子类"
typedef struct {
    Animal base;
    int mood;
} Cat;

static void cat_speak(void *self)
{
    Cat *cat = (Cat *)self;
    printf("%s: Meow!\n", cat->base.name);
}

static void cat_eat(void *self, const char *food)
{
    Cat *cat = (Cat *)self;
    cat->mood += 5;
    printf("%s eats %s, mood=%d\n", cat->base.name, food, cat->mood);
}

static const AnimalOps cat_ops = {
    .speak = cat_speak,
    .eat   = cat_eat,
};

void cat_init(Cat *cat, const char *name)
{
    cat->base.ops = &cat_ops;
    cat->base.name = name;
    cat->mood = 50;
}

多态调用

int main(void)
{
    Dog rex;
    Cat whiskers;

    dog_init(&rex, "Rex");
    cat_init(&whiskers, "Whiskers");

    // 向上转型,存入基类指针数组
    Animal *animals[] = {
        (Animal *)&rex,
        (Animal *)&whiskers,
    };

    // 多态调用——同一个接口,不同的行为
    for (int i = 0; i < 2; i++) {
        animal_speak(animals[i]);
        animal_eat(animals[i], "kibble");
    }

    return 0;
}

输出:

Rex: Woof!
Rex eats kibble, energy=110
Whiskers: Meow!
Whiskers eats kibble, mood=55

animal_speak 内部执行 animal->ops->speak(animal)——如果 animal 指向 Dog,就调用 dog_speak;指向 Cat,就调用 cat_speak。这和 C++ 虚函数的运行时分发机制完全相同

对比 C++ 等价代码

class Animal {
public:
    virtual void speak() = 0;
    virtual void eat(const char *food) = 0;
    const char *name;
};

class Dog : public Animal {
public:
    Dog(const char *n) : energy(100) { name = n; }
    void speak() override { printf("%s: Woof!\n", name); }
    void eat(const char *food) override {
        energy += 10;
        printf("%s eats %s, energy=%d\n", name, food, energy);
    }
private:
    int energy;
};

class Cat : public Animal {
public:
    Cat(const char *n) : mood(50) { name = n; }
    void speak() override { printf("%s: Meow!\n", name); }
    void eat(const char *food) override {
        mood += 5;
        printf("%s eats %s, mood=%d\n", name, food, mood);
    }
private:
    int mood;
};

int main() {
    Dog rex("Rex");
    Cat whiskers("Whiskers");

    Animal *animals[] = { &rex, &whiskers };
    for (auto a : animals) {
        a->speak();
        a->eat("kibble");
    }
}

C++ 版本更简洁,但编译器在背后做的事情和我们 C 版本手动做的一模一样——生成 vtable、设置 vptr、通过函数指针分发调用。

构造与析构:init/deinit 函数

C++ 构造函数做了什么

C++ 构造函数看起来很神奇——对象一创建就自动初始化。但编译器做的事情很朴素:

  1. 分配内存(栈上自动分配,堆上通过 operator new
  2. 设置 vptr(如果有虚函数)
  3. 调用父类构造函数
  4. 初始化成员变量
  5. 执行构造函数体

析构函数反过来:

  1. 执行析构函数体
  2. 析构成员变量(逆序)
  3. 调用父类析构函数
  4. 释放内存(堆对象通过 operator delete

C 中的等价实现

// 栈上对象——手动 init/deinit
Dog rex;
dog_init(&rex, "Rex");       // "构造"
// ... 使用 ...
dog_deinit(&rex);            // "析构"

// 堆上对象——手动 create/destroy
Dog *rex = dog_create("Rex");  // malloc + init
// ... 使用 ...
dog_destroy(rex);              // deinit + free

完整实现:

// "构造函数"
void dog_init(Dog *self, const char *name)
{
    // 2. 设置 vptr
    self->base.ops = &dog_ops;
    // 3+4. 初始化成员
    self->base.name = name;
    self->energy = 100;
}

// "堆构造"
Dog *dog_create(const char *name)
{
    Dog *self = (Dog *)malloc(sizeof(Dog));
    if (self) {
        dog_init(self, name);
    }
    return self;
}

// "析构函数"
void dog_deinit(Dog *self)
{
    // 释放动态分配的资源
    // (此例中没有,但真实项目可能有 buffer、句柄等)
}

// "堆析构"
void dog_destroy(Dog *self)
{
    if (self) {
        dog_deinit(self);
        free(self);
    }
}

RAII 的替代方案

C++ 的 RAII(Resource Acquisition Is Initialization)利用构造/析构自动管理资源。C 中没有自动析构,常见的替代方案:

方案 1:goto 清理模式

int process_data(void)
{
    int ret = -1;
    char *buf = malloc(1024);
    if (!buf) goto cleanup;

    FILE *fp = fopen("data.bin", "rb");
    if (!fp) goto cleanup_buf;

    // 正常处理...
    ret = 0;

cleanup_file:
    fclose(fp);
cleanup_buf:
    free(buf);
cleanup:
    return ret;
}

方案 2:GCC __attribute__((cleanup))

GCC 提供了一个扩展,可以在变量离开作用域时自动调用清理函数——非常接近 RAII:

static void auto_free(void *p) {
    free(*(void **)p);
}

void example(void)
{
    __attribute__((cleanup(auto_free))) char *buf = malloc(1024);
    // buf 在离开作用域时自动 free
}

Linux 内核、systemd 等项目广泛使用这个特性。

完整实例:用 C 实现一个"动物"类层次

将前面的知识整合成一个完整的、可编译运行的例子。

头文件定义

// animal.h
#ifndef ANIMAL_H
#define ANIMAL_H

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

/* ========== "基类" Animal ========== */

typedef struct Animal Animal;

// vtable
typedef struct {
    void (*speak)(Animal *self);
    void (*eat)(Animal *self, const char *food);
    void (*print_info)(Animal *self);
    void (*deinit)(Animal *self);
} AnimalOps;

struct Animal {
    const AnimalOps *ops;       // vptr
    const char *name;
    int age;
};

// "基类"公有方法
void animal_speak(Animal *self);
void animal_eat(Animal *self, const char *food);
void animal_print_info(Animal *self);
void animal_deinit(Animal *self);

/* ========== "子类" Dog ========== */

typedef struct {
    Animal base;                // 继承 Animal
    int energy;
    const char *breed;
} Dog;

void dog_init(Dog *self, const char *name, int age, const char *breed);
Dog *dog_create(const char *name, int age, const char *breed);
void dog_destroy(Dog *self);

/* ========== "子类" Cat ========== */

typedef struct {
    Animal base;                // 继承 Animal
    int mood;
    int indoor;
} Cat;

void cat_init(Cat *self, const char *name, int age, int indoor);
Cat *cat_create(const char *name, int age, int indoor);
void cat_destroy(Cat *self);

#endif // ANIMAL_H

实现文件

// animal.c
#include "animal.h"

/* ========== Animal "基类"方法 ========== */

void animal_speak(Animal *self)
{
    if (self && self->ops && self->ops->speak)
        self->ops->speak(self);
}

void animal_eat(Animal *self, const char *food)
{
    if (self && self->ops && self->ops->eat)
        self->ops->eat(self, food);
}

void animal_print_info(Animal *self)
{
    if (self && self->ops && self->ops->print_info)
        self->ops->print_info(self);
}

void animal_deinit(Animal *self)
{
    if (self && self->ops && self->ops->deinit)
        self->ops->deinit(self);
}

/* ========== Dog 实现 ========== */

static void dog_speak_impl(Animal *self)
{
    Dog *dog = (Dog *)self;
    printf("[%s] Woof! Woof!\n", dog->base.name);
    dog->energy -= 5;
}

static void dog_eat_impl(Animal *self, const char *food)
{
    Dog *dog = (Dog *)self;
    dog->energy += 20;
    printf("[%s] *happily eats %s* (energy: %d)\n",
           dog->base.name, food, dog->energy);
}

static void dog_print_info_impl(Animal *self)
{
    Dog *dog = (Dog *)self;
    printf("Dog{name=%s, age=%d, breed=%s, energy=%d}\n",
           dog->base.name, dog->base.age, dog->breed, dog->energy);
}

static void dog_deinit_impl(Animal *self)
{
    // 清理 Dog 特有资源(此例中无)
}

// Dog 的 vtable
static const AnimalOps dog_ops = {
    .speak      = dog_speak_impl,
    .eat        = dog_eat_impl,
    .print_info = dog_print_info_impl,
    .deinit     = dog_deinit_impl,
};

void dog_init(Dog *self, const char *name, int age, const char *breed)
{
    self->base.ops  = &dog_ops;     // 设置 vptr
    self->base.name = name;
    self->base.age  = age;
    self->breed     = breed;
    self->energy    = 100;
}

Dog *dog_create(const char *name, int age, const char *breed)
{
    Dog *self = (Dog *)malloc(sizeof(Dog));
    if (self) dog_init(self, name, age, breed);
    return self;
}

void dog_destroy(Dog *self)
{
    if (self) {
        animal_deinit((Animal *)self);
        free(self);
    }
}

/* ========== Cat 实现 ========== */

static void cat_speak_impl(Animal *self)
{
    Cat *cat = (Cat *)self;
    if (cat->mood > 30)
        printf("[%s] Purrrr~\n", cat->base.name);
    else
        printf("[%s] Hiss!\n", cat->base.name);
}

static void cat_eat_impl(Animal *self, const char *food)
{
    Cat *cat = (Cat *)self;
    if (strcmp(food, "fish") == 0) {
        cat->mood += 20;
        printf("[%s] *loves the fish!* (mood: %d)\n",
               cat->base.name, cat->mood);
    } else {
        cat->mood += 5;
        printf("[%s] *reluctantly eats %s* (mood: %d)\n",
               cat->base.name, food, cat->mood);
    }
}

static void cat_print_info_impl(Animal *self)
{
    Cat *cat = (Cat *)self;
    printf("Cat{name=%s, age=%d, mood=%d, indoor=%s}\n",
           cat->base.name, cat->base.age, cat->mood,
           cat->indoor ? "yes" : "no");
}

static void cat_deinit_impl(Animal *self)
{
    // 清理 Cat 特有资源
}

static const AnimalOps cat_ops = {
    .speak      = cat_speak_impl,
    .eat        = cat_eat_impl,
    .print_info = cat_print_info_impl,
    .deinit     = cat_deinit_impl,
};

void cat_init(Cat *self, const char *name, int age, int indoor)
{
    self->base.ops  = &cat_ops;
    self->base.name = name;
    self->base.age  = age;
    self->mood      = 50;
    self->indoor    = indoor;
}

Cat *cat_create(const char *name, int age, int indoor)
{
    Cat *self = (Cat *)malloc(sizeof(Cat));
    if (self) cat_init(self, name, age, indoor);
    return self;
}

void cat_destroy(Cat *self)
{
    if (self) {
        animal_deinit((Animal *)self);
        free(self);
    }
}

主程序:多态演示

// main.c
#include "animal.h"

void feed_all(Animal *animals[], int count, const char *food)
{
    printf("--- Feeding time: %s ---\n", food);
    for (int i = 0; i < count; i++) {
        animal_eat(animals[i], food);
    }
    printf("\n");
}

int main(void)
{
    // 创建不同类型的动物
    Dog rex;
    dog_init(&rex, "Rex", 3, "Labrador");

    Cat *whiskers = cat_create("Whiskers", 5, 1);
    Cat *shadow   = cat_create("Shadow", 2, 0);

    // 存入基类指针数组——向上转型
    Animal *animals[] = {
        (Animal *)&rex,
        (Animal *)whiskers,
        (Animal *)shadow,
    };
    int count = sizeof(animals) / sizeof(animals[0]);

    // 多态:打印所有动物信息
    printf("=== Animal Info ===\n");
    for (int i = 0; i < count; i++) {
        animal_print_info(animals[i]);
    }
    printf("\n");

    // 多态:所有动物说话
    printf("=== Roll Call ===\n");
    for (int i = 0; i < count; i++) {
        animal_speak(animals[i]);
    }
    printf("\n");

    // 多态:喂食
    feed_all(animals, count, "kibble");
    feed_all(animals, count, "fish");

    // 析构
    cat_destroy(whiskers);
    cat_destroy(shadow);
    // rex 是栈上对象,不需要 free

    return 0;
}

输出:

=== Animal Info ===
Dog{name=Rex, age=3, breed=Labrador, energy=100}
Cat{name=Whiskers, age=5, mood=50, indoor=yes}
Cat{name=Shadow, age=2, mood=50, indoor=no}

=== Roll Call ===
[Rex] Woof! Woof!
[Whiskers] Purrrr~
[Shadow] Purrrr~

--- Feeding time: kibble ---
[Rex] *happily eats kibble* (energy: 115)
[Whiskers] *reluctantly eats kibble* (mood: 55)
[Shadow] *reluctantly eats kibble* (mood: 55)

--- Feeding time: fish ---
[Rex] *happily eats fish* (energy: 130)
[Whiskers] *loves the fish!* (mood: 75)
[Shadow] *loves the fish!* (mood: 75)

feed_all 函数只知道 Animal *,完全不知道传进来的是 Dog 还是 Cat。但通过 animal->ops->eat(animal, food) 调用了正确的实现——这就是多态。

C++ 编译器到底做了什么

前面我们用 C 手动实现了 vtable。那 C++ 编译器自动生成的 vtable 到底长什么样?让我们一探究竟。

查看 vtable 布局

GCC 提供了 -fdump-lang-class 选项(旧版本用 -fdump-class-hierarchy)来输出类的内存布局和 vtable:

g++ -fdump-lang-class -c animal.cpp

输出(简化):

Vtable for Animal
Animal::_ZTV6Animal: 4 entries
0     (int (*)(...))0                    # offset to top
8     (int (*)(...))(& _ZTI6Animal)      # typeinfo
16    (int (*)(...))__cxa_pure_virtual    # Animal::speak [pure]
24    (int (*)(...))__cxa_pure_virtual    # Animal::eat [pure]

Vtable for Dog
Dog::_ZTV3Dog: 4 entries
0     (int (*)(...))0
8     (int (*)(...))(& _ZTI3Dog)
16    (int (*)(...))Dog::speak           # override
24    (int (*)(...))Dog::eat             # override

Class Dog
   size=24 align=8
   base size=24 base align=8
   Dog (0x...) 0
     vptr=((& Dog::_ZTV3Dog) + 16)       # vptr 指向 vtable 第 3 项
     Animal (0x...) 0
       primary-for Dog (0x...)

几个关键信息:

  • vtable 前面有两个隐藏条目offset to top(多重继承时使用)和 typeinfo(RTTI 运行时类型信息)。vptr 实际指向 vtable 的第三项(跳过这两个)
  • Dog 的 vtablespeakeat 的位置放了 Dog 自己的实现
  • Animal 的 vtable 在对应位置放了 __cxa_pure_virtual(纯虚函数占位符,调用会 abort)

用 sizeof 验证

#include <cstdio>
#include <cstddef>

class Base {
public:
    int x;
};

class BaseVirtual {
public:
    virtual void func() {}
    int x;
};

int main() {
    printf("sizeof(Base)        = %zu\n", sizeof(Base));         // 4
    printf("sizeof(BaseVirtual) = %zu\n", sizeof(BaseVirtual));  // 16
}

Base 只有一个 int,大小 4 字节。BaseVirtual 多了一个隐藏的 vptr(8 字节指针)+ 对齐填充,变成了 16 字节。

这就是 C++ 虚函数的唯一运行时开销——每个有虚函数的对象多一个指针大小的空间。调用虚函数比普通函数多一次内存间接寻址(取 vptr → 取函数地址 → 调用)。

对比手写 C 和 C++ 编译输出

// C 版本的 Animal
typedef struct {
    const void **ops;   // 我们手写的 vptr
    const char *name;
    int age;
} Animal_C;
// sizeof = 24(64 位系统:8+8+4+padding4)
// C++ 版本的 Animal
class Animal_CPP {
public:
    virtual void speak() = 0;
    virtual void eat(const char *) = 0;
    const char *name;
    int age;
};
// sizeof = 24(64 位系统:vptr8+name8+age4+padding4)

大小完全一致。我们手动写的 C 版本和 C++ 编译器自动生成的版本,在内存层面是等价的。

实际项目中的应用

"C 语言面向对象"不是学术游戏——它在大量生产级项目中被广泛使用。

Linux 内核:file_operations

Linux 的"一切皆文件"哲学,底层就是一个 vtable:

struct file_operations {
    struct module *owner;
    loff_t (*llseek)(struct file *, loff_t, int);
    ssize_t (*read)(struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write)(struct file *, const char __user *, size_t, loff_t *);
    int (*open)(struct inode *, struct file *);
    int (*release)(struct inode *, struct file *);
    // ... 更多操作
};

不同的设备驱动(字符设备、块设备、网络设备)各自实现自己的 file_operations,注册到 VFS 中。用户态调用 read(),VFS 通过 f->f_op->read() 分发到具体驱动——这就是多态。

q_device 框架

在之前的文章中我们分析过的 q_device 框架,本质也是同样的模式:

struct q_device_ops {
    int (*init)(q_device_t *dev);
    int (*read)(q_device_t *dev, int pos, const void *buffer, int size);
    int (*write)(q_device_t *dev, int pos, const void *buffer, int size);
    int (*control)(q_device_t *dev, int cmd, void *args);
    // ...
};

struct q_device {
    const char *name;
    const struct q_device_ops *dops;   // vptr
    void *argv;
    struct q_device *next;
};

q_device_ops 就是 vtable,dops 就是 vptr。BSP 层的 I2C、SPI、UART 各自实现自己的 ops,上层通过 q_device_write 统一调用——不关心底层是什么总线。

FreeRTOS 的 task 和 queue

FreeRTOS 的内部数据结构也大量使用类似模式。StaticTask_t 嵌入了各种链表节点、状态标志,不同类型的任务通过函数指针(task function)实现不同的行为。Queue 的底层实现也用了类似的策略——同一套 API 根据 queue type 分发到不同的操作实现。

GLib 的 GObject

GNOME 桌面环境的基础库 GLib 实现了一套完整的 C 语言对象系统——GObject。它支持:

  • 单继承和接口(interface)
  • 属性系统(property)
  • 信号系统(signal,类似 Qt 的 signal/slot)
  • 引用计数内存管理

GObject 证明了即使是复杂的面向对象系统,纯 C 也完全可以实现。GTK 图形界面库就建立在 GObject 之上。

总结与系列预告

C 实现 OOP 的本质

OOP 概念C++ 语法C 实现方式底层本质
封装class { }struct + 函数第一参数 selfstruct 聚合数据,函数接收指针
访问控制private / publicstatic + 不透明指针链接可见性 / 类型隐藏
继承class Dog : public Animal子类 struct 首成员为父类 struct内存布局嵌套
多态virtual 函数函数指针表(ops struct)vtable + vptr
构造构造函数xxx_init() / xxx_create()内存分配 + 成员初始化 + vptr 设置
析构析构函数xxx_deinit() / xxx_destroy()资源释放 + 内存回收

优缺点

优点

  • 一切都是显式的,没有编译器"魔法",调试时所见即所得
  • 编译后的代码体积和运行开销可以精确控制
  • 不需要 C++ 运行时(异常处理、RTTI 等),适合资源受限环境
  • 与 C 生态完全兼容,无 ABI 问题

缺点

  • 手动维护 vtable、init/deinit 调用,代码量更大
  • 没有编译器帮你检查类型安全(指针强转没有编译期保护)
  • 没有自动析构(RAII),资源泄漏风险更高
  • 没有模板/泛型,代码复用靠 void * 和宏

系列预告

本文揭示了 C++ 面向对象在底层的真实面貌。后续文章将继续深入 C++ 的其他特性:

  • 第二篇:模板与泛型 —— C++ 模板在编译期做了什么?和 C 的 void * / 宏泛型有何区别?
  • 第三篇:异常与 RTTI —— try/catch 的零开销异常模型、dynamic_casttypeid 的底层实现
  • 第四篇:STL 容器 —— vectormapstring 的内存布局和性能特征

理解了底层原理,你使用 C++ 时就不是在"记语法",而是在"做设计选择"——知道每个特性的代价,才能做出最优的权衡。