温习 C++ 的虚函数

闲静少言,不慕荣利。好读书,不求甚解;每有会意,便欣然忘食。性嗜酒,家贫不能常得。

非常喜欢上面的这段文字,莫名感同身受,这几天从自己的博客上迁移了些文章来简书,不为别的,只是为了更好的阅读和写作体验。可能是酒喝得太多,也有可能是年纪越来越大,也不排除是花哨的东西看得太多,最近越来越喜欢普适性的东西,工具如此,技术如此,真理亦是如此。

当前是离职的高峰季,也就是面试的密集期,希望这一篇对 C++ 虚函数的探讨,能帮你在面试中加得几分。虽说难以致用,但现在的面试就是这样,如果问这方面的问题你没能答上来,面试官会认为你没有刨根问底的态度,技术深度不够(即便是你长时间不看忘记了)。

废话有点多,那我们这篇就从最基础的指针开始吧。

指针被玩坏了

指针是什么?相信大家的脑海里立马会浮现一个箭头指在一个长方形上的图形,指针就是一串数字,这个数字对应着内存上的地址。指针很简单,除了内存地址,没有携带任何其它数据,在 32 位系统上它就是 32 位无符号整形,在 64 位系统上便是 64 位无符号整形,所以为什么我们说 32 位系统内存大了也没太多用,因为它指针的寻址范围只有 2^32 Byte = 4G。

那么既然指针只是内存地址,按照这样的理解,我们应该可以对不同类型的指针随意转换,毕竟所有的指针都是相同大小的整形。那么转来转去也无可厚非,反正它指向的地址已经确定了,但真的就是这样了么?指向的地址确定了?来看下这段代码:

struct structA {
    virtual void func_a() {}
};

struct structB {
    virtual void func_b() {}
};

struct structAB : public structA, public structB {
    void func_a() {
        printf("func_a\n");
    }
    
    void func_b() {
        printf("func_b\n");
    }
};


int main(int argc, const char * argv[]) {
    structAB *pAB = new structAB;
    structB *pB = pAB;
    void *pVoid = pB;
    pAB = (structAB *)pVoid;
    
    pAB->func_a();
    
    return 0;
}

执行结果:

func_b

在这样几次的类型转换后,我们发现指针所指向的地址变了,不可思议?如果这颠覆了你一直以来对指针的认知,那么接下来的内容,我觉得你很有必要仔细阅读下去。这是一个很好的引子,我将尽可能用最通俗易懂的方式,来阐述这里面所暗藏的一切玄机。

虚函数的本质

从面向对象的角度而言,虚函数存在的意义是为了实现多态,什么是多态?

多态性是允许你将父对象设置成为一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。

简单的说,就是允许用子类对象替换父类对象,而一个父类可以对应多个子类的实现,所以相同的父类函数调用,会产生不同的调用结果,这便是多态性

struct Base {
    virtual void func() {}
};

struct ImplA : public Base {
    void func() { printf("ImplA\n"); }
};

struct ImplB : public Base {
    void func() { printf("ImplB\n"); }
};

void make_call(Base *base) {
    base->func(); // 多态性
}


int main(int argc, const char * argv[]) {
    make_call(new ImplA);
    make_call(new ImplB);
    
    return 0;
}

不同面向对象的高级语言对多态性的实现并不一致,比如在我们的 Objective-C 中,是通过消息处理机制来实现多态的。而在 C++ 里,便是通过虚函数来实现,那么对于 C++ 而言,虚函数的本质是啥?相信有过相关了解的人都会有个大概印象,比如虚函数表虚表指针,说到底,虚函数实现的核心就是编译器在编译时修改了我们的代码,使得我们对虚函数的直接调用被替换成了间接调用。

那么接下来,我们针对单继承多继承两种情况,分别阐述下编译器大致给我们做了怎样的事情。

单继承

在单继承的情况下,虚函数的实现还是比较简单的,我们以下面这段代码来作为展开的示例:

struct Base {
    virtual void func() {} // 虚成员函数
    void func_n() {} // 普通成员函数
};

struct Impl : public Base {
    void func() {}
};

void make_call(Base *base) {
    base->func_n();
    base->func();
}

首先,我们要清楚:在 C++ 中,成员函数最终会被编译成类似于 C 中的普通方法,把 this 作为参数传递。虚的成员函数也不例外,所以上面的代码,我们可以转换成类似下面的这段 C 代码:

struct _Base;
struct _Impl;

typedef struct _Base Base;
typedef struct _Impl Impl;

void Base_func(Base *const this) {}
void Base_func_n(Base *const this) {}
void Impl_func(Impl *const this) {}

struct _Base {};
struct _Impl {};

void make_call(Base *base) {
    Base_func_n(base); // 直接调用
    // base->func(); ???
}

可以看到,我们对 base->func_n() 这样的普通成员函数可以替换成 C 模拟方法的直接调用,但对于虚成员函数 base->func() 而言,我们没办法直接替换,因为这里要支持多态性(Base_func? Impl_func?)。

那么 C++ 编译器大体是这样操作的:编译器在面对到有虚成员函数的类型时,会在类型开始位置插入一个指针,这个指针指向一个表,表里面记录了自身类型对应的虚函数地址。这个指针一般叫做虚表指针,表一般称作虚函数表,有了这两个东西,我们就可以通过虚表指针间接的来调用虚成员函数,而在类型向上转型时便体现了多态性。描述可能会看懵,看代码应该会更加清楚:

struct _Base;
struct _Impl;

typedef struct _Base Base;
typedef struct _Impl Impl;

void Base_func(Base *const this) {}
void Base_func_n(Base *const this) {}
void Impl_func(Impl *const this) {}

void *BaseVTable[] = {&Base_func};
void *ImplVTable[] = {&Impl_func};

struct _Base {
    void *vtbl_ptr;
};

struct _Impl {
    void *vtbl_ptr;
};

void make_call(Base *base) {
    Base_func_n(base);
    
    // 从虚函数表中取到要调用的函数地址
    void *method_addr = ((void **)base->vtbl_ptr)[0];
    // 将函数地址转成函数指针调用
    ((void (*)(Base *))method_addr)(base);
}

int main(int argc, char *argv[]) {
    Base *pBase = (Base *)malloc(sizeof(Base));
    pBase->vtbl_ptr = BaseVTable;
    
    Impl *pImpl = (Impl *)malloc(sizeof(pImpl));
    pImpl->vtbl_ptr = ImplVTable;
    
    make_call(pBase);
    make_call((Base *)pImpl);
    
    return 0;
}

上面代码虽然有点多,但并不复杂,核心点在于类型初始化后,这个类型所拥有的虚表指针(vtabl_ptr)指向的地址是确定的(编译器做的事情),所以向上转型时(Impl -> Base),虚表指针指向的虚函数是不会变的(vtabl_ptr 的值不会因类型转换而改变),另外,编译器把我们对虚函数的调用,替换成了通过虚表指针的间接调用,最终调用到了子类型的实现里。也就是上述 C++ 代码中:base->func() 的调用,最终落入了 Impl::func 里面。

我觉得对于虚函数表和虚表指针应该已经阐述的非常清楚了,比较低级的面试里面会经常让你去计算一个类型的大小,如果它包含虚函数或者它的继承链里面包含虚函数,记得一定要加上一个虚表指针的大小。另外,如果子类型里没有覆写父类型的虚函数,那么子类型的虚函数表里,存放的便是父类型中定义的虚函数地址。

struct Base {
    virtual void func() {}
    virtual void func_b() {}
};

struct Impl : public Base {
    void func() {}
};

// Impl 虚函数表类似这样
void *ImplVTable[] = {&Impl::func, &Base::func_b};

单继承情况下,虚函数的实现原理还是比较简单的,放到多继承的环境中,其实也复杂不了多少,接下来我们就来说说多继承。

多继承

我相信在你使用 C++ 的时候,肯定会使用多重继承,因为 C++ 中没有接口的概念,也就是没有类似 Objective-C 中的 Protocol,所以用纯虚类来模拟,以此来实现观察者之类的常用模式。在讲多继承中的虚函数之前,我们要先了解下多继承类型的内存布局,看看下面这段代码:

struct structA {
    int a1;
    int a2;
};

struct structB {
    int b1;
    int b2;
};

struct structAB : public structA, public structB {
    int ab1;
};

那么 structAB 实例的内存布局图在 C++ 中应该如下:

structAB 内存布局

其实这张图很好理解,就相当于我们把这三个结构体,直接从上到下堆叠在了一起,从主观的感受上是这样,实际的布局也的确就是这样。所以,继承的先后顺序,决定了成员最终在内存中的位置。那么我们分析下下面这段非常简单的代码(这里涉及到的知识点,和前面那个被玩坏的指针有关):

structAB *pAB = new structAB;
structB *pB = pAB;
pB->b1 = 2;

我们对最开始定义的 structAB 指针做了次类型转换,转换成了父类型 structB 的类型指针,然后对其成员 b1 赋值。要知道,在 C++ 中对成员的访问都是以偏移地址的方式,所以 pB->b1 可以转述为:获取相对于指针 pB 偏移为 0 的那个 int,因为 b1structB 中属于第一个成员,所以偏移为 0。但这里有个问题:在 structABb1 的偏移并不是为 0,如果 pABpB 相等的话,后续的所有 pB->b1 都不能按照常规的偏移值来取,而仅仅通过 pB 又是无法知道它的实际类型的。为了解决这样的问题,编译器又为我们做了类似下面这样的事情:

structAB *pAB = new structAB;
structB *pB = pAB; // pB = pAB + sizeof(structA)
pB->b1 = 2;

编译器在面对这样多重继承的转型场景时,主动的将 pB 向后偏移了 structA 大小的地址,我们可以将这两个指针的具体地址打印出来,应该刚好相差 8 个字节(structA 的大小),如果向下转型,编译器则会向相反的方向增加偏移。所以开始我们那个被玩坏的指针,结合前面所说的虚表指针,最终的现象大家应该可以理解了吧?我们通过 void * 打破了编译器这种自动偏移机制,所以导致了奇怪的后果。那么有了这样的自动偏移调整机制,以 pB 为基准的偏移取值,都没有问题了,也就是前面我们所说的 pB->b1

了解了转型时的自动偏移,那么我们多重继承中的虚函数就很好理解了,老规矩,先来一段代码:

struct structA {
    virtual void func_a() {}
};

struct structB {
    virtual void func_b() {}
};

struct structAB : public structA, public structB {
    void func_a() {}
    void func_b() { printf("a value is: %d\n", a); }
    
    int a;
};



int main(int argc, const char * argv[]) {
    structAB *pAB = new structAB;
    pAB->a = 5656;
    
    structB *pB = pAB;
    pB->func_b();
    
    return 0;
}

上面代码中 structAB 中和 structA 相关的虚函数,和前面所说的单继承完全一致,因为 structA 是第一位被继承的,没有自动偏移。考虑下目前 structAB 的内存布局,其实很简单,两个虚表指针加一个 int a,全部叠加起来即可:

structAB 内存布局

其中 vtabl_ptr_a 指向的虚函数表便是 {&structAB::func_a},但 vtabl_ptr_b 并不能简单的指向 {&structAB::func_b},因为涉及到了 this 指针的偏移,所以编译器构建了 thunk 方法,将 this 偏移纠正了回去,具体看看我们用 C 转换后的代码,应该就能非常清楚了:

struct _structA;
struct _structB;
struct _structAB;

typedef struct _structA structA;
typedef struct _structB structB;
typedef struct _structAB structAB;

void structA_func_a(structA *const this) {}
void structB_func_b(structB *const this) {}
void structAB_func_a(structAB *const this) {}
void structAB_func_b(structAB *const this);
void structAB_thunk_func_b(structB *const this);

void *structA_VTable[] = {&structA_func_a};
void *structB_VTable[] = {&structB_func_b};
void *structAB_A_VTable[] = {&structAB_func_a};
void *structAB_B_VTable[] = {&structAB_thunk_func_b};

struct _structA {
    void *vtbl_ptr;
};

struct _structB {
    void *vtbl_ptr;
};

struct _structAB {
    void *vtbl_ptr_a;
    void *vtbl_ptr_b;
    int a;
};

// this 的地址对了,this->a 才能取出正确的值
void structAB_func_b(structAB *const this) {
    printf("a value is: %d\n", this->a);
}

// 模拟 Thunk 程序,自动将偏移纠正回来
void structAB_thunk_func_b(structB *const this) {
    structAB_func_b((structAB *)((void *)this - sizeof(structA)));
}

int main(int argc, char *argv[]) {
    structAB *pAB = (structAB *)malloc(sizeof(structAB));
    pAB->vtbl_ptr_a = structAB_A_VTable;
    pAB->vtbl_ptr_b = structAB_B_VTable;
    pAB->a = 5656;
    
    // 模拟编译器自动偏移
    structB *pB = (structB *)((void *)pAB + sizeof(structA));
    
    // 偏移之后 pB->vtbl_ptr == pAB->vtbl_ptr_b
    void *method_addr = ((void **)pB->vtbl_ptr)[0];
    ((void (*) (structB *const))(method_addr))(pB);
    
    return 0;
}

对照前面的 C++ 代码,我相信聪明的你一定能够非常快速的接受并深刻理解,多继承中,其他的套路都与单继承类似,唯一不同的是非第一个继承而来的虚表指针, 所指向的虚函数表里面已覆写的虚函数,都是 Thunk 程序,用于纠正 this 指针偏移。

后话

本文还有一个点没有讲到,也就是多级继承下的虚函数表,但我觉得按照前面所讲的那些知识点,应该不难推演出来,所以,大家还是自己动手实践下吧。

温故而知新,希望这么简单而基础的一篇文章,能给大家带来些许收货,我要去喝壶酒提个神了。

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

推荐阅读更多精彩内容

  • 原创:神秘编程神秘编程今天 问题抛出:为什么要使用多态?如果子类定义了与父类中原型相同的函数会发生什么? 多态:同...
    编程小兔崽阅读 540评论 0 0
  • 一个博客,这个博客记录了他读这本书的笔记,总结得不错。《深度探索C++对象模型》笔记汇总 1. C++对象模型与内...
    Mr希灵阅读 5,561评论 0 13
  • 原文地址:C语言函数调用栈(一)C语言函数调用栈(二) 0 引言 程序的执行过程可看作连续的函数调用。当一个函数执...
    小猪啊呜阅读 4,582评论 1 19
  • 几种语言的特性 汇编程序:将汇编语言源程序翻译成目标程序编译程序:将高级语言源程序翻译成目标程序解释程序:将高级语...
    囊萤映雪的萤阅读 2,853评论 1 5
  • 每天一个关键词# 擦身而过 今早九点海南广电观众节开放日启动,我在化妆间的走廊和他擦身而过。这是我认识他的第八个年...
    然然然然后呢阅读 326评论 0 0