C++虚函数是如何一步步发明出来的?

来源:码农的荒岛求生 嵌入式 14 次阅读
摘要:你是一名1980年代初的C语言程序员,正在开发图形界面库,今天你需要处理圆形、矩形、三角形,每种图形都要实现两个操作:绘制和计算面积。 你喝了口咖啡,开始敲代码。 你的第一版代码是这样的: void draw_circle(Circle* c) {      ... } void draw_rectangle(Rectangle* r) {      ... } // 使用时需要判断类型 vo

你是一名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也就是虚函数表,你不禁暗自感叹,这套命名高大上了很多,不愧是大师。

图片

评论区

登录后即可参与讨论

立即登录