前言

封装隐藏了类内部细节,通过继承加虚函数的方式,我们还可以做到隐藏类之间的差异,这就是多态(运行时多态)。多态意味一个接口有多种行为,今天就来说说C++的多态是怎么实现的。

编译时多态感觉没什么好说的,编译时直接绑定了函数地址。

多态

有下面这么一段代码:A有两个虚函数(virtual关键字修饰的函数),B继承了A,还有一个参数为A*的函数foo()

#include <iostream>
class A
{
public:
    A();
    virtual void foo();
    virtual void bar();
private:
    int a;
};
A::A()
    : a( 1 )
{
}
void A::foo()
{
    std::cout << "A::foo()\n";
    return;
}
void A::bar()
{
    std::cout << "A::bar()\n";
    return;
}

class B : public A
{
public:
    B();
    virtual void foo();
    virtual void bar();
private:
    int b ;
};
B::B()
    : b( 2 )
{
}
void B::foo()
{
    std::cout << "B::foo()\n";
    return;
}
void B::bar()
{
    std::cout << "B::bar()\n";
    return;
}
void foo( A* x )
{
    x->foo();
    x->bar();
    return;
}

我们要先知道,对于虚函数的重写,规则要求编译器必须根据实际类型调用对应的函数,而不是像重写普通成员函数那样,直接调用当前类型的函数。

假设bar()是一个非虚函数,B重写了bar(),那么即使x指向B的对象,在foo()调用x->bar()时也还是输出"A::bar()"

这段代码编译成动态库的话,编译器就无法确定foo()的入参x指向的对象是什么类型了(父类指针可以指向自身类型的对象或任意子类的对象),因此编译器就无法直接得出foo()bar()实际的函数地址,无法完成函数调用。这中间肯定发生了什么!

题外话:一旦函数重写,A::foo()B::foo()就是两个函数,两个地址。如果只是单纯继承的话,之前介绍继承的时候说过,子类是不存在B:;foo()这个函数,而只是让编译器允许通过B类型的对象调用A::foo()

如何确定实际函数地址

一旦无法自然地想通一个流程,觉得中间缺了什么东西时,那肯定是编译器干了什么。因此还是要祭出gdb这件大杀器。

// 省略前面那段代码
int main()
{
    B* x = new B;
    foo( x );
    return 0;
}

当我们打印x的内容时,会发现其多了一个位于对象的首地址的_vptr.A,它其实指向了虚函数表

(gdb) p *x
$2 = {<A> = {_vptr.A = 0x400a70 <vtable for B+16>, a = 1}, b = 2}

foo()中的x->foo()x->bar()对应着如下汇编

    # x->foo()
   0x0000000000400815 <+8>: mov    %rdi,-0x8(%rbp) # 将rdi中的对象地址保存到-0x8(%rbp) 中
=> 0x0000000000400819 <+12>:    mov    -0x8(%rbp),%rax
   0x000000000040081d <+16>:    mov    (%rax),%rax  # 取对象首地址的8个字节也就是_vptr.A 0x400a70保存到rax中
   0x0000000000400820 <+19>:    mov    (%rax),%rax # 再取出0x400a70这个地址存放的4个字节数据保存到rax中,其实就是B::foo()函数地址
   0x0000000000400823 <+22>:    mov    -0x8(%rbp),%rdx # 将对象地址保存到rdx中
   0x0000000000400827 <+26>:    mov    %rdx,%rdi # 将对象地址保存到rdi中,作为虚函数foo()的参数
   0x000000000040082a <+29>:    callq  *%rax  # 调用B::foo()
    # x->bar()
   0x000000000040082c <+31>:    mov    -0x8(%rbp),%rax
   0x0000000000400830 <+35>:    mov    (%rax),%rax # 取对象首地址的8个字节也就是_vptr.A 0x400a70保存到rax中
   0x0000000000400833 <+38>:    add    $0x8,%rax # 跳过8字节,即0x400a70+8
   0x0000000000400837 <+42>:    mov    (%rax),%rax # 取出B::bar()的地址
   0x000000000040083a <+45>:    mov    -0x8(%rbp),%rdx
   0x000000000040083e <+49>:    mov    %rdx,%rdi
   0x0000000000400841 <+52>:    callq  *%rax # 调用B::bar()

看一下0x400a70这个地址的内容,更容易理解上面的汇编。

(gdb) x /4x 0x400a70
0x400a70 <_ZTV1B+16>:   0x0040095e  0x00000000  0x0040097c  0x00000000
(gdb) x 0x0040095e
0x40095e <B::foo()>:    0xe5894855          # 0x0040095e就是B::foo()的首地址
(gdb) x 0x0040097c
0x40097c <B::bar()>:    0xe5894855          # 0x0040097c就是B::bar()的首地址

从上面可以看出,虚函数表类似于一个数组,其中每个元素是该类实现的虚函数地址,利用虚函数表,就执行正确的函数了。

何时设置虚函数表

既然虚函数表是类数据结构里的一部分,那它的初始化肯定就是在类的构造函数里了,让我们去找找。
下面是B::B()的一部分汇编,A::A()也类似只不过是将A的虚函数表地址赋值给_vptr.A

   0x0000000000400941 <+19>:    callq  0x4008d2 <A::A()>        # 先构造父类
   0x0000000000400946 <+24>:    mov    -0x8(%rbp),%rax
   0x000000000040094a <+28>:    movq   $0x400a70,(%rax)       # 将B的虚函数表地址0x400a70保存到对象的首地址中,即给_vptr.A赋值
   0x0000000000400951 <+35>:    mov    -0x8(%rbp),%rax
   0x0000000000400955 <+39>:    movl   $0x2,0xc(%rax)           # 初始化列表

题外话:在更新虚函数表和初始化列表之后,才执行我们显式写在B::B()中的代码。

每个类都有一个自己的虚函数表,这在编译时就确定了。如果子类没有实现虚函数,虚函数表里对应位置的函数地址就还是父类的函数地址。

隐晦的错误

从上面我们知道

  • 虚函数表中的元素顺序就是函数声明的顺序,这在编译时就固定了。
  • 执行虚函数时,只是取了虚函数表中对应偏移的元素(即函数地址)去执行,并没有做符号绑定。这个偏移是由虚函数声明顺序决定的。
    基于这两点,如果我们在真正构造B的地方修改了虚函数的声明顺序,就会导致函数调用出错。
    简单验证一下,将最开始的那段代码编译为动态库(liba.so),并在main.cpp中调换其函数声明顺序
class A
{
public:
    A();
    virtual void bar();
    virtual void foo();
private:
    int a;
};

class B : public A
{
public:
    B();
    virtual void bar();
    virtual void foo();
    int b;
};
void bar( A* x )
{
    x->foo();
    x->bar();
    return;
}
int main()
{
    B* b = new B;
    bar( b );
    return 0;
}

上面代码的输出是

B::bar()
B::foo()

与预期结果刚好相反

B::foo()
B::bar()

出现这样错误的原因就是在编译main.cpp时,编译器认为B::foo()是虚函数表的第二个元素,但实际在liba.so中B::foo()是虚函数表中的第一个元素。

强烈建议虚函数的声明顺序必须保持一致,而且增加虚函数时,只在尾部增加

结语

了解C++的多态实现后,对于理解其他语言的多态实现也是有益处的,本质都应当是在通过一个中间结构确定实际函数的地址。

除了以上内容外,还有

  • 不论是否能通过上下文判断出实际类型,只要是以指针方式调用虚函数,都会以虚函数表跳转的方式来调用函数。
  • 在构造函数中调用虚函数,并不会使用多态,而是直接调用函数地址。
    这两点通过上面的调试方法很容易就能确认。

gcc version 4.8.5

C++系列总结——多态的更多相关文章

  1. 【JAVA零基础入门系列】Day13 Java类的继承与多态

    继承是类的一个很重要的特性,什么?你连继承都不知道?你是想气死爸爸好继承爸爸的遗产吗?(滑稽) 开个玩笑,这里的继承跟我们现实生活的中继承还是有很大区别的,一个类可以继承另一个类,继承的内容包括属性跟 ...

  2. 夯实Java基础系列23:一文读懂继承、封装、多态的底层实现原理

    本系列文章将整理到我在GitHub上的<Java面试指南>仓库,更多精彩内容请到我的仓库里查看 https://github.com/h2pl/Java-Tutorial 喜欢的话麻烦点下 ...

  3. iOS开发笔记系列-基础3(多态、动态类型和动态绑定)

    多态:相同的名称,不同的类 使不同的类共享相同方法名称的能力成为多态.它让你可以开发一组类,这组类中的每一个类都能响应相同的方法名.每个类的定义都封装了响应特定方法所需要的代码,这使得它独立于其他的类 ...

  4. [Effective C++系列]-为多态基类声明Virtual析构函数

    Declare destructors virtual in polymorphic base classes.   [原理] C++指出,当derived class对象经由一个由base clas ...

  5. 红豆带你从零学C#系列之:初识继承与多态

    继承 现实生活当中,人类又可以根据职业分为:教师,学生,理发师,售货员 又比如飞机又有种类之分:直升飞机.客机.货机.战斗机等 在程序里面我们可能会通过创建类来描述这样的事物,比如学生类.教师类.理发 ...

  6. Java入门系列(三)面向对象三大特性之封装、继承、多态

    面向对象综述 封装 封装的意义,在于明确标识出允许外部使用的所有成员函数和数据项,或者叫接口. 有了封装,就可以明确区分内外,使得类实现者可以修改封装内的东西而不影响外部调用者:而外部调用者也可以知道 ...

  7. 程序基石系列之C++多态的前提条件

    准备知识 C++中多态(polymorphism)有下面三个前提条件: 必须存在一个继承体系结构. 继承体系结构中的一些类必须具有同名的virtual成员函数(virtualkeyword) 至少有一 ...

  8. Java面向对象系列(10)- 什么是多态

    多态 即同一方法可以根据发送对象的不同而采取不同的行为方式 一个对象的实际类型是确定的,但可以指向对象的引用类型有很多 多态存在的条件 有继承关系 子类重写父类方法 父类引用指向子类对象 注意:多态是 ...

  9. 【Java学习系列】第3课--Java 高级教程

    本文地址 可以拜读: 从零开始学 Java 分享提纲: 1. Java数据结构 2. Java 集合框架 3. Java泛型 4. Java序列化 5. Java网络编程 6. Java发送Email ...

随机推荐

  1. 深入理解Java 栈数据结构

    栈(stack)又名堆栈,它是一种运算受限的线性表.其限制是仅允许在表的一端进行插入和删除运算.这一端被称为栈顶,相对地,把另一端称为栈底.向一个栈插入新元素又称作进栈.入栈或压栈,它是把新元素放到栈 ...

  2. 如何在招聘中考核.NET架构师

    .NET架构师招聘不如JAVA那么顺利,可以搜索到的.NET架构师可以说是凤毛菱角.当然好的架构师都是需要长期观察和挖角才能得手,如何去招聘到合适的.NET架构师可能是摆在所有求贤者面前的难题.这里的 ...

  3. MySQL 数据库字符集 utf8 和 utf8mb4 的区别

    参考于今日头条上Java芋道源码的-----记住:永远不要在 MySQL 中使用 UTF-8 字符集选择 MySQL 的 utf8 实际上不是真正的 UTF-8.utf8 只支持每个字符最多三个字节, ...

  4. android使用.9图作为背景,内容不能居中的问题解决方案

    在xml中使用.9图作为背景,内容不能居中,试了好多方法最后,加一个属性就ok了. android:padding:0dip; 解析:.9图作为背景时,不可拉伸的部分就相当于该空间的padding距离 ...

  5. Vue.js-10:第十章 - 组件间的数据通信

    一.前言 在上一章的学习中,我们继续学习了 Vue 中组件的相关知识,了解了在 Vue 中如何使用组件的 data.prop 选项.在之前的学习中有提到过,组件是 Vue 中的一个非常重要的概念,我们 ...

  6. 【纯技术贴】.NETStandard FreeSql v0.0.9 功能预览

    年关将至,首页技术含量文章真是越来越少,理解大家盼着放假过年,哥们我何尝不是,先给大家拜个早年. 兄弟我从11月底发了神经,开启了 ORM 功能库的开发之旅,历时两个月编码和文档整理,目前预览版本更新 ...

  7. EFCore动态切换Schema

    最近做个分库分表项目,用到schema的切换感觉还是有些坑的,在此分享下. 先简要说下我们的分库分表 分库分表规则 我定的规则是,订单号(数字)除以16,得出的结果为这个订单所在的数据库,然后他的余数 ...

  8. C#开发APP,ToolBar控件在Smobiler中的使用方式【附案例源码】——Smobiler移动开发平台

    控件说明 底部工具栏控件. 效果演示 其他效果 该界面为仿淘宝UI制作的一个简单的UI模板,源码获取方式请拉至文章末尾. 特色属性 属性 属性说明 Direction(相对布局) 容器主轴方向. Fl ...

  9. Android resource compilation failed

    报错:Android resource compilation failed D:\android\EasySports\app\build\intermediates\incremental\mer ...

  10. DataPipeline成功入选微软加速器 企业创新力获业界专家认可

    近日,微软加速器•北京公布最终入选的第13期创新企业名单.作为国内领先的“iPaaS+AI”一站式数据融合服务提供商DataPipeline,历经多轮角逐,在上千家企业激烈竞争中,成功入选. 高手过招 ...