前言

封装隐藏了类内部细节,通过继承加虚函数的方式,我们还可以做到隐藏类之间的差异,这就是多态(运行时多态)。多态意味一个接口有多种行为,今天就来说说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. asp.net core系列 45 Web应用 模型绑定和验证

    一. 模型绑定 ASP.NET Core MVC 中的模型绑定,是将 HTTP 请求中的数据映射到action方法参数. 这些参数可能是简单类型的参数,如字符串.整数或浮点数,也可能是复杂类型的参数. ...

  2. 【3y】从零单排学Redis【青铜】

    前言 只有光头才能变强 最近在学Redis,我相信只要是接触过Java开发的都会听过Redis这么一个技术.面试也是非常高频的一个知识点,之前一直都是处于了解阶段.秋招过后这段时间是没有什么压力的,所 ...

  3. Data Lake Analytics的Geospatial分析函数

    0. 简介 为满足部分客户在云上做Geometry数据的分析需求,阿里云Data Lake Analytics(以下简称:DLA)支持多种格式的地理空间数据处理函数,符合Open Geospatial ...

  4. 设计模式 | 观察者模式/发布-订阅模式(observer/publish-subscribe)

    定义: 定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象.这个主题对象在状态发生变化时,会通知所有观察者对象,使他们能够自动更新自己. 结构:(书中图,侵删) 一个抽象的观察者接口, ...

  5. 解决Android编译时出现aapt.exe finished with non-zero exit value 1(第二篇)

    之前出现该错误,我用的是这个方法: https://www.cnblogs.com/tangZH/p/10691383.html 然而遗憾的是,这次不管用了,无奈,只好另寻他法,其实会出现这个错误就是 ...

  6. Linux系统的启动过程

    Linux 系统启动过程 Linux系统的启动过程可以分为5个阶段: BIOS自检 内核的引导. 运行init. 系统初始化. 用户登录系统. BIOS自检: BIOS是英文"Basic I ...

  7. Windows系统桌面右击反应变慢、卡顿问题解决方法

    博主的电脑是Win10系统,在修改完系统的用户文件夹名后,桌面右击出现了反应卡顿的现象,并且点击输入法,也变得卡顿.问题解决后,于是想简单记录一下. 还是注册表的问题,使用Win+R快捷键,打开运行, ...

  8. 关于OSError: [WinError 10038] 在一个非套接字上尝试了一个操作。

    在使用socket的时候,写了一个while循环,就报错了.结果如下: OSError: [WinError 10038] 在一个非套接字上尝试了一个操作. 代码 import socket impo ...

  9. 一篇读懂HTTPS:加密原理、安全逻辑、数字证书等

    1.引言 HTTPS(全称: Hypertext Transfer Protocol Secure,超文本传输安全协议),是以安全为目标的HTTP通道,简单讲是HTTP的安全版.本文,就来深入介绍下其 ...

  10. invokedynamic字节码指令

    1. 方法引用和invokedynamic invokedynamic是jvm指令集里面最复杂的一条.本文将从高观点的角度下分析invokedynamic指令是如何实现方法引用(Method refe ...