虚函数、虚表与多继承

经常在编译错误中看到的vTable究竟是什么?

为什么要有虚函数

C++的设计理念是:用不到的功能就不要在运行时花费时间。正因如此,C++中会有静态绑定、动态绑定、虚函数这些概念。

对比其他一些面向对象的语言,可以认为它们所有成员函数都是虚函数,都是动态绑定,而C++则为了性能考虑,只有实际用到时,即成员函数有virtual修饰,才开启动态绑定。

静态绑定与动态绑定

所谓静态绑定,是指成员函数的地址在编译期就可以确定,运行时则直接跳转到对应地址执行;而动态绑定则是指编译期不确定,只有到运行时才能找到函数地址,这需要两次额外的寻址指令:第1次找到虚表,第2次从虚表中找到函数地址。

哪些情况会出现动态绑定?答案是只有使用指针或引用调用虚成员函数时才会出现。

例如:

class VirtualClass {
public:
  virtual void f() {}
};

class SubClass : public VirtualClass {
  virtual void f() override {}
};

int main() {
  auto* p = new SubClass();
  p->f(); // 动态绑定
}

之所以不能用静态绑定,是因为p不仅可以指向VirtualClass对象,也可以指向它的子类SubClass ,而我们在编译期并不能确定它具体指向哪个类。

内存布局与虚表指针

在分析虚表之前,先讨论一下类的内存布局。

一个类实例需要占据一定的内存空间,而空间的大小以及其中内容的排布,对同一个类的所有实例都是相同的。例如下面这个类:

class Layout {
public:
    short s;
    int i;
    long long int l;
    void f();
};

在Visual Studio的工具中可以看到它的内存排布:


而当我们把成员函数f改成虚函数时:

class Layout {
public:
    short s;
    int i;
    long long int l;
    virtual void f();
};

我们发现,前8个字节增加了一个指针 vfptr 即虚表指针,它指向一个虚表。从另一个角度,我们也可以理解为,Layout类的前8个字节用来标识它的实际类型(Layout或其某个子类)。

虚表

前面说到了虚表,那么虚表到底是什么呢?

虚表就是一个数组,它存储了一系列函数指针。只有包含虚函数的类才会有虚表,一个类的所有实例公用一个虚表。虚表中的每个指针则是指向这个类的所有虚函数。

下面的代码和对应的示意图可以看得很清楚:

class Instrument {
public:
    virtual void play() {};
    virtual void adjust() {};
};

class Wind : public Instrument {
public:
    virtual void play() override { printf("Wind play"); }
    virtual void adjust() override { printf("Wind adjust"); }
    int score = 1;
};

class Brass : public Wind {
public:
    virtual void play() override { printf("Brass play"); }
    virtual void what() { printf("Brass what"); }
    int score = 2;
};

int main() {
    Instrument* list[4];
    list[0] = new Instrument();
    list[1] = new Wind();
    list[2] = new Brass();
    list[3] = new Brass();
    return 0;
}

从这个例子中可以看到,当子类重写(override)父类的虚函数时,虚表中的对应指针也会修改,但顺序不变。当子类新增虚函数时,则会在虚表末尾新增。

按照这样的规则,当我们把子类的指针或引用向上类型转换时,它的虚表完全可以当做时父类的虚表来使用,无需关心实际类型。

多继承

上面说到,子类的内存布局和虚表都兼容父类,但这时又出现一个新的问题,如果有多继承怎么办?如何同时兼容两个父类呢?

其实,多继承的情况,子类会的内存布局会将两个父类依次排布,也就是会有两个虚表指针。

例如:

class Flyable {
public:
    virtual void fly() {}
    int hight = 0;
};

class Runnable {
public:
    virtual void run() {}
    int speed = 0;
};

class Bird : public Flyable, public Runnable {
public:
    virtual void fly() override {}
    virtual void run() override {}
    virtual void eat() {}
    int weight = 0;
};

Bird的内存布局如下:

从图上可以清楚看到,0x00 ~ 0x0f 这部分内存布局兼容Flyable类,0x10 ~ 0x1f 兼容,0x20之后的地址是Bird类自己的成员变量。

而Bird类自己的虚成员函数 eat() 会加在哪个虚表里呢?答案是加到第一个虚表中,和单继承的情况类似。

指针偏移

这时你可能又要问了,当Bird类型指针向上转换成Runnable指针之后,再调用虚函数时,又怎么知道此时应该去0x10的位置,而非0x00找虚表指针呢?

答案是,不需要。因为在做类型转换的时候,会直接将指针偏移到0x10的位置,我们来验证一下:

int main() {
    Bird* b = new Bird();
    Runnable* r = b;
    printf("b: %x\nr: %x\nb == r: %d", b, r, b == r);
}

output:
b: ee617fb0
r: ee617fc0
b == r: 1

可以看到,b和r的地址确实不同,但当我们做比较运算时,结果却是相等。所以大多数时候,我们不需要关注这里的指针偏移。

但这样一来,也存在一个坑,就是我们不能将Bird*类型先转成void*之后,再强转成Runnable*类型,因为这样的转换不会做指针偏移。

对于包含虚表的类,做类型转换时一般用dynamic_cast,但不支持void*。

还是以上面的继承关系为例:

int main() {
    Bird* b = new Bird();
    Flyable* f = b;
    void* v = b;
    printf("sc: %x\ndc: %x", static_cast<Runnable*>(v), dynamic_cast<Runnable*>(f));
}

output:
sc: 96079740
dc: 96079750

可以看到, vf实际指向的时同一个 Bird对象,但两种类型转换后指针却不同,就是因为 static_castvoid*转换到 Runnable 时没有做指针偏移。而 dynamic_cast会动态检查对象的实际类型,所以总能做出正确的指针偏移。

更多思考

就到此为止了吗?其实还有其他更复杂的情况,例如多继承时,两个父类包含相同签名的虚函数;例如有菱形继承、虚继承的情况。这些复杂情况在实际应用中较少碰到,就不做详细讨论了。

另外再提一下,C++中没有“虚成员变量”,当我们做向上类型转换后,就无法直接获取到子类的成员变量了,只能通过虚函数来获取。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,125评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,293评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,054评论 0 351
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,077评论 1 291
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,096评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,062评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,988评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,817评论 0 273
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,266评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,486评论 2 331
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,646评论 1 347
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,375评论 5 342
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,974评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,621评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,796评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,642评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,538评论 2 352