你是一名1980年代初的C语言程序员,正在开发图形界面库,今天你需要处理圆形、矩形、三角形,每种图形都要实现两个操作:绘制和计算面积。
你喝了口咖啡,开始敲代码。
你的第一版代码是这样的:
void draw_circle(Circle* c) {
...
}
void draw_rectangle(Rectangle* r) {
...
}
// 使用时需要判断类型
void draw_shape(void* shape, int type) {
if (type == CIRCLE) {
draw_circle((Circle*)shape);
} elseif (type == RECTANGLE) {
draw_rectangle((Rectangle*)shape);
} elseif (type == TRIANGLE) {
draw_triangle((Triangle*)shape);
}
}
硬编码类型判断的噩梦
第二天,产品经理说要加梯形,你打开每一处调用draw_shape的代码,加上新的else if分支。
第三天,要加椭圆。你又重复一遍。
很快,你就意识到了一个让人头疼的问题:如何用同一套代码处理不同类型的图形?
你盯着屏幕上的if-else判断,突然意识到:如果再增加新图形,这段代码会膨胀成什么样子?你盯着200行的if-else判断,明白:这条路走不通了。
你开始思考:能不能用统一的方式处理所有类型?
把函数指针定义在结构体中
你想起C语言里有个叫"函数指针"的东西,能不能把函数指针塞进结构体?你写出了这段代码:
typedef struct {
void (*draw)(void*);
double (*area)(void*);
} Shape;
typedefstruct {
Shape base;
double radius;
} Circle;
void draw_circle(void* obj) {
Circle* c = (Circle*)obj;
printf("Drawing circle with radius %.2f\n", c->radius);
}
double calc_circle_area(void* obj) {
Circle* c = (Circle*)obj;
return3.14 * c->radius * c->radius;
}
Circle c;
c.base.draw = draw_circle;
c.base.area = calc_circle_area;
c.radius = 5.0;
// 调用时不需要判断类型!
c.base.draw(&c);
成功了!所有图形都能用统一的方式调用:
Shape* shapes[3] = {&circle.base, &rect.base, &triangle.base};
for (int i = 0; i < 3; i++) {
shapes[i]->draw(shapes[i]); // 统一接口!
}
致命缺陷暴露
某天,你的同事打来电话:"程序崩溃了,快来看看",你查日志、加断点,最后发现问题出在新来的实习生写的代码:
Circle c1, c2, c3;
c1.base.draw = draw_circle;
c1.base.area = calc_circle_area;
// 实习生忘记初始化c2的函数指针了!
c2.radius = 10.0;
c2.base.draw(&c2); // 崩溃!调用空指针
更糟的是,有时候函数指针被设置错了:
Circle c;
c.base.draw = draw_rectangle; // 编译器不会报错
c.base.area = calc_circle_area;
c.radius = 5.0;
c.base.draw(&c); // 运行时崩溃,类型不匹配
你盯着代码明白了:函数指针不应该由用户手动设置,应该由"类型"自动决定。
每个Circle对象的draw函数指针都应该是draw_circle,这是Circle这个类型的固有属性,为什么要让用户每次创建对象时都手动设置一遍?
初始化函数自动设置
你想到一个办法:提供专门的初始化函数,在创建对象时自动设置函数指针。
void init_circle(Circle* c, double radius) {
c->base.draw = draw_circle; // 自动设置
c->base.area = calc_circle_area; // 自动设置
c->radius = radius;
}
void init_rectangle(Rectangle* r, double w, double h) {
r->base.draw = draw_rectangle;
r->base.area = calc_rectangle_area;
r->width = w;
r->height = h;
}
// 使用
Circle c;
init_circle(&c, 5.0); // 不会忘记设置函数指针了
c.base.draw(&c);
你把新方案部署到项目中,之前困扰的"忘记设置函数指针"问题消失了。
新问题:内存不够用了
一个月后,测试工程师指着内存占用曲线问你:"为什么10000个Circle对象占用了这么多内存?"
你排查了下代码发现了新问题:每个Circle有2个函数指针,但问题是每个Circle对象的函数指针都指向相同的函数,为什么要存储10000份?
你开始思考:既然所有Circle对象的函数指针都一样,能不能让它们共享同一份?
函数表的诞生
凌晨两点,你盯着代码突然有了一个想法:
既然所有Circle对象的函数指针都相同,为什么不把函数指针单独存成一张表,让所有对象都指向这张表?
你开始重构代码:
// 虚函数表(所有Circle对象共享)
typedefstruct {
void (*draw)(void*);
double (*area)(void*);
} Function_Table;
Function_Table circle_ftable = {
.draw = draw_circle,
.area = calc_circle_area
};
Function_Table rectangle_ftable = {
.draw = draw_rectangle,
.area = calc_rectangle_area
};
// 每个对象只存表指针
typedefstruct {
Function_Table* ftable; // 指向共享的函数表
double radius;
} Circle;
typedefstruct {
Function_Table* ftable;
double width;
double height;
} Rectangle;
void init_circle(Circle* c, double radius) {
c->ftable = &circle_ftable; // 指向共享表
c->radius = radius;
}
void init_rectangle(Rectangle* r, double w, double h) {
r->ftable = &rectangle_ftable;
r->width = w;
r->height = h;
}
// 调用时通过表指针间接跳转
Circle c;
init_circle(&c, 5.0);
c.ftable->draw(&c);
你写了个测试程序:
程序运行,输出:
现在10000个Circle对象只需要额外持有一个ftable指针,相比之前的方案,节省了80KB!
而且只需要这样一段代码就可以处理各种不同类型的图形:
void draw_all(Shape** shapes, int count) {
for (int i = 0; i < count; i++) {
shapes[i]->ftable->draw(shapes[i]);
}
}
支持继承和方法覆盖
新方案发布后开发人员对此赞不绝口,几天后讨厌的产品经理又来了:"我们需要一种'可填充圆形',绘制时先画圆再填充颜色。"
接到需求后你发现其实可以复用Circle的大部分逻辑,而只修改draw函数,于是你尝试创建新FilledCircle:
// 覆盖draw,复用area
void draw_filled_circle(void* obj) {
Circle* c = (Circle*)obj;
draw_circle(obj); // 先调用父类方法
printf("Filling with color\n"); // 再增强
}
Function_Table filled_circle_ftable = {
.draw = draw_filled_circle, // 覆盖
.area = calc_circle_area // 复用
};
typedefstruct {
Circle base; // 继承Circle
} FilledCircle;
void init_filled_circle(FilledCircle* fc, double r) {
fc->base.ftable = &filled_circle_ftable; // 指向新表
fc->base.radius = r;
}
这个新创建的FilledCircle实际上只是包装一下Circle,你把这种包装称之为继承,而"通过修改ftable的实现"可以达到覆盖父类方法的目的。
C++的语法糖
1983年,你把这套机制展示给了一个叫做Bjarne Stroustrup的同事,他看完代码说:"这个想法很棒!但就是用起来太繁琐了。每次都要手动定义ftable、在init函数里设置ftable指针、调用时还要自己写obj->ftable->func(obj)"。
能不能让编译器自动完成这些?
于是他开始设计C++的语法,几个月后,他拿着新版本给你看:
class Shape {
public:
virtual void draw() = 0; // 编译器会生成vtable槽位
virtual double area() = 0;
};
class Circle :public Shape {
private:
double radius;
public:
Circle(double r) : radius(r) {} // 编译器会自动设置vtable指针
void draw() override { // 编译器会更新vtable条目
printf("Drawing circle with radius %.2f\n", radius);
}
double area() override {
return3.14 * radius * radius;
}
};
// 使用
Circle c(5.0);
c.draw(); // 编译器翻译成 c.vtable->draw(&c)
你盯着这段代码,突然明白:C++的virtual关键字,只是把你手写的函数表机制自动化了。
Bjarne Stroustrup把这套机制在C++中称之为虚函数,ftable称之为vtable也就是虚函数表,你不禁暗自感叹,这套命名高大上了很多,不愧是大师。

评论区
登录后即可参与讨论
立即登录