C++ 虚函数表解析

2023-11-18

C++ 虚函数表解析

 

陈皓

http://blog.csdn.net/haoel

 

 

前言

 

C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。这种技术可以让父类的指针有“多种形态”,这是一种泛型技术。所谓泛型技术,说白了就是试图使用不变的代码来实现可变的算法。比如:模板技术,RTTI技术,虚函数技术,要么是试图做到在编译时决议,要么试图做到运行时决议。

 

 

关于虚函数的使用方法,我在这里不做过多的阐述。大家可以看看相关的C++的书籍。在这篇文章中,我只想从虚函数的实现机制上面为大家 一个清晰的剖析。

 

当然,相同的文章在网上也出现过一些了,但我总感觉这些文章不是很容易阅读,大段大段的代码,没有图片,没有详细的说明,没有比较,没有举一反三。不利于学习和阅读,所以这是我想写下这篇文章的原因。也希望大家多给我提意见。

 

言归正传,让我们一起进入虚函数的世界。

 

 

虚函数表

 

C++ 了解的人都应该知道虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。

 

这里我们着重看一下这张虚函数表。C++的编译器应该是保证虚函数表的指针存在于对象实例中最前面的位置(这是为了保证取到虚函数表的有最高的性能——如果有多层继承或是多重继承的情况下)。 这意味着我们通过对象实例的地址得到这张虚函数表,然后就可以遍历其中函数指针,并调用相应的函数。

 

听我扯了那么多,我可以感觉出来你现在可能比以前更加晕头转向了。 没关系,下面就是实际的例子,相信聪明的你一看就明白了。

 

假设我们有这样的一个类:

 

class Base {

     public:

            virtual void f() { cout << "Base::f" << endl; }

            virtual void g() { cout << "Base::g" << endl; }

            virtual void h() { cout << "Base::h" << endl; }

 

};

 

按照上面的说法,我们可以通过Base的实例来得到虚函数表。 下面是实际例程:

 

          typedef void(*Fun)(void);

 

            Base b;

 

            Fun pFun = NULL;

 

            cout << "虚函数表地址:" << (int*)(&b) << endl;

            cout << "虚函数表 — 第一个函数地址:" << (int*)*(int*)(&b) << endl;

 

            // Invoke the first virtual function 

            pFun = (Fun)*((int*)*(int*)(&b));

            pFun();

 

实际运行经果如下:(Windows XP+VS2003,  Linux 2.6.22 + GCC 4.1.3)

 

虚函数表地址:0012FED4

虚函数表 — 第一个函数地址:0044F148

Base::f

 

 

通过这个示例,我们可以看到,我们可以通过强行把&b转成int *,取得虚函数表的地址,然后,再次取址就可以得到第一个虚函数的地址了,也就是Base::f(),这在上面的程序中得到了验证(把int* 强制转成了函数指针)。通过这个示例,我们就可以知道如果要调用Base::g()Base::h(),其代码如下:

 

            (Fun)*((int*)*(int*)(&b)+0);  // Base::f()

            (Fun)*((int*)*(int*)(&b)+1);  // Base::g()

            (Fun)*((int*)*(int*)(&b)+2);  // Base::h()

 

这个时候你应该懂了吧。什么?还是有点晕。也是,这样的代码看着太乱了。没问题,让我画个图解释一下。如下所示:

注意:在上面这个图中,我在虚函数表的最后多加了一个结点,这是虚函数表的结束结点,就像字符串的结束符“/0”一样,其标志了虚函数表的结束。这个结束标志的值在不同的编译器下是不同的。在WinXP+VS2003下,这个值是NULL。而在Ubuntu 7.10 + Linux 2.6.22 + GCC 4.1.3下,这个值是如果1,表示还有下一个虚函数表,如果值是0,表示是最后一个虚函数表。

 

 

下面,我将分别说明“无覆盖”和“有覆盖”时的虚函数表的样子。没有覆盖父类的虚函数是毫无意义的。我之所以要讲述没有覆盖的情况,主要目的是为了给一个对比。在比较之下,我们可以更加清楚地知道其内部的具体实现。

 

一般继承(无虚函数覆盖)

 

下面,再让我们来看看继承时的虚函数表是什么样的。假设有如下所示的一个继承关系:

 

 

请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:

 

对于实例:Derive d; 的虚函数表如下:

 

我们可以看到下面几点:

1)虚函数按照其声明顺序放于表中。

2)父类的虚函数在子类的虚函数前面。

 

我相信聪明的你一定可以参考前面的那个程序,来编写一段程序来验证。

 

 

 

一般继承(有虚函数覆盖)

 

覆盖父类的虚函数是很显然的事情,不然,虚函数就变得毫无意义。下面,我们来看一下,如果子类中有虚函数重载了父类的虚函数,会是一个什么样子?假设,我们有下面这样的一个继承关系。

 

 

 

为了让大家看到被继承过后的效果,在这个类的设计中,我只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子:

 

 

我们从表中可以看到下面几点,

1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。

2)没有被覆盖的函数依旧。

 

这样,我们就可以看到对于下面这样的程序,

 

            Base *b = new Derive();

 

            b->f();

 

b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时,是Derive::f()被调用了。这就实现了多态。

 

 

 

多重继承(无虚函数覆盖)

 

下面,再让我们来看看多重继承中的情况,假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数。

 

 

 

对于子类实例中的虚函数表,是下面这个样子:

 

我们可以看到:

1)  每个父类都有自己的虚表。

2)  子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)

 

这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。

 

 

 

 

多重继承(有虚函数覆盖)

 

下面我们再来看看,如果发生虚函数覆盖的情况。

 

下图中,我们在子类中覆盖了父类的f()函数。

 

 

 

下面是对于子类实例中的虚函数表的图:

 

 

我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:

 

            Derive d;

            Base1 *b1 = &d;

            Base2 *b2 = &d;

            Base3 *b3 = &d;

            b1->f(); //Derive::f()

            b2->f(); //Derive::f()

            b3->f(); //Derive::f()

 

            b1->g(); //Base1::g()

            b2->g(); //Base2::g()

            b3->g(); //Base3::g()

 

 

安全性

 

每次写C++的文章,总免不了要批判一下C++。这篇文章也不例外。通过上面的讲述,相信我们对虚函数表有一个比较细致的了解了。水可载舟,亦可覆舟。下面,让我们来看看我们可以用虚函数表来干点什么坏事吧。

 

一、通过父类型的指针访问子类自己的虚函数

我们知道,子类没有重载父类的虚函数是一件毫无意义的事情。因为多态也是要基于函数重载的。虽然在上面的图中我们可以看到Base1的虚表中有Derive的虚函数,但我们根本不可能使用下面的语句来调用子类的自有虚函数:

 

          Base1 *b1 = new Derive();

            b1->f1();  //编译出错

 

任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法,所以,这样的程序根本无法编译通过。但在运行时,我们可以通过指针的方式访问虚函数表来达到违反C++语义的行为。(关于这方面的尝试,通过阅读后面附录的代码,相信你可以做到这一点)

 

 

二、访问non-public的虚函数

另外,如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数,这是很容易做到的。

 

如:

 

class Base {

    private:

            virtual void f() { cout << "Base::f" << endl; }

 

};

 

class Derive : public Base{

 

};

 

typedef void(*Fun)(void);

 

void main() {

    Derive d;

    Fun  pFun = (Fun)*((int*)*(int*)(&d)+0);

    pFun();

}

 

 

结束语

C++这门语言是一门Magic的语言,对于程序员来说,我们似乎永远摸不清楚这门语言背着我们在干了什么。需要熟悉这门语言,我们就必需要了解C++里面的那些东西,需要去了解C++中那些危险的东西。不然,这是一种搬起石头砸自己脚的编程语言。

 

在文章束之前还是介绍一下自己吧。我从事软件研发有十个年头了,目前是软件开发技术主管,技术方面,主攻Unix/C/C++,比较喜欢网络上的技术,比如分布式计算,网格计算,P2PAjax等一切和互联网相关的东西。管理方面比较擅长于团队建设,技术趋势分析,项目管理。欢迎大家和我交流,我的MSNEmail是:haoel@hotmail.com 

 

附录一:VC中查看虚函数表

 

我们可以在VCIDE环境中的Debug状态下展开类的实例就可以看到虚函数表了(并不是很完整的)

附录 二:例程

下面是一个关于多重继承的虚函数表访问的例程:

 

#include <iostream>

using namespace std;

 

class Base1 {

public:

            virtual void f() { cout << "Base1::f" << endl; }

            virtual void g() { cout << "Base1::g" << endl; }

            virtual void h() { cout << "Base1::h" << endl; }

 

};

 

class Base2 {

public:

            virtual void f() { cout << "Base2::f" << endl; }

            virtual void g() { cout << "Base2::g" << endl; }

            virtual void h() { cout << "Base2::h" << endl; }

};

 

class Base3 {

public:

            virtual void f() { cout << "Base3::f" << endl; }

            virtual void g() { cout << "Base3::g" << endl; }

            virtual void h() { cout << "Base3::h" << endl; }

};

 

 

class Derive : public Base1, public Base2, public Base3 {

public:

            virtual void f() { cout << "Derive::f" << endl; }

            virtual void g1() { cout << "Derive::g1" << endl; }

};

 

 

typedef void(*Fun)(void);

 

int main()

{

            Fun pFun = NULL;

 

            Derive d;

            int** pVtab = (int**)&d;

 

            //Base1's vtable

            //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+0);

            pFun = (Fun)pVtab[0][0];

            pFun();

 

            //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+1);

            pFun = (Fun)pVtab[0][1];

            pFun();

 

            //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+2);

            pFun = (Fun)pVtab[0][2];

            pFun();

 

            //Derive's vtable

            //pFun = (Fun)*((int*)*(int*)((int*)&d+0)+3);

            pFun = (Fun)pVtab[0][3];

            pFun();

 

            //The tail of the vtable

            pFun = (Fun)pVtab[0][4];

            cout<<pFun<<endl;

 

 

            //Base2's vtable

            //pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);

            pFun = (Fun)pVtab[1][0];

            pFun();

 

            //pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);

            pFun = (Fun)pVtab[1][1];

            pFun();

 

            pFun = (Fun)pVtab[1][2];

            pFun();

 

            //The tail of the vtable

            pFun = (Fun)pVtab[1][3];

            cout<<pFun<<endl;

 

 

 

            //Base3's vtable

            //pFun = (Fun)*((int*)*(int*)((int*)&d+1)+0);

            pFun = (Fun)pVtab[2][0];

            pFun();

 

            //pFun = (Fun)*((int*)*(int*)((int*)&d+1)+1);

            pFun = (Fun)pVtab[2][1];

            pFun();

 

            pFun = (Fun)pVtab[2][2];

            pFun();

 

            //The tail of the vtable

            pFun = (Fun)pVtab[2][3];

            cout<<pFun<<endl;

 

            return 0;

}





FROM: http://blog.csdn.net/haoel/article/details/1948051

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

C++ 虚函数表解析 的相关文章

  • C指针之初始化(三)

    一 引言 C C 语言中引入了指针 使得程序能够直接访问内存地址 使得很多复杂的操作变得简单 同时也提高了程序的运行效率 指针即是地址 但是地址却是通过指针变量来存储的 因此我们通常所说的指针在很多时候说的都是指针变量 指针变量在使用之前必
  • C++ STL : std::list

    练习下C STL中std list类的常用方法 方便以后查阅 如有不正确的地方 请读者及时指正 欢迎转载 谢谢 include
  • 虚函数在对象中的内存布局

    典型地 C 通过虚函数实现多态性 多态性的定义 无论发送消息的对象属于什么类 他们均发送具有相同形式的消息 对消息的处理方式可能随接受消息的对象而变 具体地说 在某个基类上建立起来的类的层次结构中 可以对任何一个派生类的对象中的同名成员函数
  • C++中虚析构函数的作用

    我们知道 用C 开发的时候 用来做基类的类的析构函数一般都是虚函数 可是 为什么要这样做呢 下面用一个小例子来说明 有下面的两个类 class ClxBase public ClxBase virtual ClxBase virtual v
  • Qt/MFC获取主窗口的指针

    在不同的窗口类中 不同的类之间需要互相访问 有时需要知道另一个窗口类的指针来调用他的函数 本文介绍两种方法 如下 Qt 方法1 WId ir WId FindWindow NULL L Target className pM classNa
  • Modern C++的应用,实现golang中的defer

    modern C 实现 golang 的defer 关于RAII的一些思考 defer 的简介 注 没有 golang 语法基础的读者可以看看 反之 可以跳过 golang语法中的defer是什么 defer用来声明一个延迟函数 把这个函数
  • windows系统c++多线程开发

    线程的一些基本概念 一 线程的基本概念 基本概念 线程 即轻量级进程 LWP LightWeight Process 是程序执行流的最小单元 一个标准的线程由线程ID 当前指令指针 PC 寄存器集合和堆栈组成 线程是进程中的一个实体 是被系
  • 单链表实现多项式相加

    这个小项目用C语言实现 代码中有我的注释 思路 用链表的每个节点存储表达式的每一项 因此每个链表就是一个表达式 链表节点类型的定义 struct Node DataType elem 项的系数 Variate ch 常量和变量的标志 规定如
  • C++异常处理机制详解

    异常处理是一种允许两个独立开发的程序组件在程序执行期间遇到程序不正常的情况 异常exception 时相互通信的机制 本文总结了19个C 异常处理中的常见问题 基本涵盖了一般C 程序开发所需的关于异常处理部分的细节 1 throw可以抛出哪
  • Qt C++中的关键字explicit

    最近在复习QT 准备做项目了 QT Creator 默认生成的代码 explicit Dialog QWidget parent 0 中 有这么一个关键字explicit 用来修饰构造函数 以前在Windows下写程序的时候 基本上没有碰到
  • 菜鸟操作:QString和QMap转化(QMap嵌套QMap)

    学习QT的时候遇到一个问题 我想要将QMap转成QString 用于socket通信 查了网上找不到我想到的效果 然后就用一个比较粗糙的做法来实现 以下代码是对于二级QMap操作的 主要思路 将QMap中的数据全都放到QString中 包括
  • C++ 创建共享内存

    共享内存用于实现进程间大量的数据传输 共享内存是在内存中单独开辟一段内存空间 这段内存空间有自己特有的数据结构 包括访问权限 大小和最近访问时间等 1 shmget函数 include
  • C++ 构造函数和析构函数是否可以继承?

    先看一个例子 cpp view plain copy include
  • 马虎的算式

    标题 马虎的算式 小明是个急性子 上小学的时候经常把老师写在黑板上的题目抄错了 有一次 老师出的题目是 36 x 495 他却给抄成了 396 x 45 但结果却很戏剧性 他的答案竟然是对的 因为 36 495 396 45 17820 类
  • 移动构造-C++11

    移动构造 移动构造是C 11标准中提供的一种新的构造方法 在现实中有很多这样的例子 我们将钱从一个账号转移到另一个账号 将手机SIM卡转移到另一台手机 将文件从一个位置剪切到另一个位置 移动构造可以减少不必要的复制 带来性能上的提升 有些复
  • c++继承中的内存布局(转)

    今天在网上看到了一篇写得非常好的文章 是有关c 类继承内存布局的 看了之后获益良多 现在转在我自己的博客里面 作为以后复习之用 谈VC 对象模型 美 简 格雷 程化 译 译者前言 一个C 程序员 想要进一步提升技术水平的话 应该多了解一些语
  • c语言中的字符数组和字符串之间的关系

    一 字符串的结束标志 1 很多时候我们都是可以看到相关的内容就是 使用数组来存储字符串 也就是我们经常会使用到sizeof 和这个函数 而 这个函数只是求出当前该数组的最大容量 而不是数组中实际存放的内容 我们一般都是需要使用 0 来表示字
  • 有些运行符不能重载为友元函数,它们是:=,(),[]和->。

    原因 有人说是因为 C 规定赋值运算符 只能重载为类的非静态成员函数 而不可以重载为类的友元函数 不能重载为类的静态成员应该比较容易理解 因为静态成员函数是属于整个类的 不是属于某个对象的 它只能去操作类静态数据成员 而赋值运算符 是基于对
  • cout 格式化输出

    将 cout 的 flag 保存到变量 以便修改后的恢复 ostream fmtflags old cout flag 无参将返回当前 flag 值 cout flag old 恢复到原先保存的值 将 bool 值以 literals 输出
  • C++系列目录

    基础语言篇 C 数据类型 C位操作 C预编译处理 C指针 C结构体与枚举类型 C 函数 C 虚函数 C 容器与算法 C 类 C I O处理 C 重载操作符与转换 模板与泛型 C C 编译和调试 C C 动态链接 C C 通用MakeFile

随机推荐

  • 深聊性能测试,从入门到放弃之:我只做了这几点,公司的架构师也对我刮目相看

    1 引言 2 执行步骤 2 1 测试确认 2 2 通过标准 2 3 测试设计 2 4 数据准备 2 5 处理问题 3 总结 1 引言 接着上一篇 深聊性能测试 从入门到放弃之 性能测试如何做 这篇我们看看 到底做到那几点 架构师也对我刮目相
  • python如何输入一个整数逆序输出_「每日一练」巧用Python识别输入的是几位数

    Python对于数字的处理能力是很强大的 那么你能让Python瞬间知道输入的是个几位数 并且逆序打印出所有的数字吗 往下看 就是这么简单 案例 识别输入的是几位数 并且逆序打印出所有的数字 先上代码 运行效果 题目详述 程序分析 要实现一
  • 数据结构与算法:KMP模式匹配算

    KMP模式匹配算法原理 如果主串S abcdefgab 其实还可以更长一些 我们就省略掉只保留前9位 我们要匹配的T abcdex 那么如果用BF算法的话 前5个字母 两个串完全相等 直到第6个字母 f 与 x 不等 如图5 7 1的 所示
  • VTK可视化工具库:编译与添加模块

    VTK 可视化工具库 一 编译 VTK使用CMake作为项目管理工具 在源代码根目录下有CMakeLists txt文件 1 编译过程 运行CMakeGUI 选择源代码目录和编译目录 不要使用相同目录 依次点击configure gener
  • 【第十届泰迪杯B题电力负荷预测代码】

    第十届泰迪杯B题电力负荷预测源代码及可视化数据图 包括全部问题的代码 现在的数据分析是根据官网暂时发布的数据进行的分析 后续会继续更新代码 import matplotlib pyplot as plt import seaborn as
  • 音乐人解密:究竟是如何一步一步成为音乐人的?

    音乐人解密 究竟是如何一步一步成为音乐人的 音乐是人类伟大的产物 近些年来越来越多的人都开始尝试学习音乐 成为一名音乐人 而艺术高考等途径也为许多想要学习音乐 成为职业歌手或者编曲师的人群提供了途径 然而想要成为一名合格的音乐人并不是那么容
  • 烧录的HEX文件大于flash存储空间问题

    一 背景 在用一款芯片NRF52832做项目 发现使用Keil编译后的文件大小达到了1M 但是片内flash资源只有512K 结果程序可以正常通过J link烧写 且运行正常 芯片资源如下 nRF52832 是 32 位 ARM Corte
  • A Survey on Metaverse: the State-of-the-art,Technologies, Applications, and Challenges

    本文是对 A Survey on Metaverse the State of the art Technologies Applications and Challenges 的翻译 元宇宙综述 现状 技术 应用和挑战 摘要 1 引言 2
  • vue生命周期mounted和activated使用、踩坑

    activated 说到activated不得不提到keep alive 你切换出去又切出来会调用到它 你可以理解为生命周期钩子函数 用法也一样 mounted 指的是实例被挂载后调用 如果没有keep alive每次切回来该组件都会触发一
  • 理解Spring的AOP和Ioc/DI就这么简单

    一 什么叫Ioc DI Ioc Inversion of Control 控制反转 DI Dependency Injection 依赖注入 其实这两个概念本质上是没有区别的 那我们先来看看什么叫做Ioc 假设这么一个场景 在A类中调用B类
  • 华硕重装系统键盘灯失效 =>重装ATK驱动

    1 点击网站华硕服务与支持 https www asus com cn support 2 输出笔记本型号 选择产品 3 下载驱动 3 1选择驱动程序和工具软件 3 2选择操作系统 3 3找到ATK驱动并且下载 4 安装驱动 4 1安装AT
  • 如何使用随机数实现自动发扑克牌?

    学习不止 问答不止 一 粉丝问题 二 相关函数说明 1 函数说明 产生随机数的方法很多 常用的是rand srand 来看一下这2个函数的定义 SYNOPSIS include
  • 如何导入符号 emdk?

    我在最新的 Android Studio 中创建了一个新的 android projekt 我想导入和使用 Symbol EMDK 包 虽然我像这样放入 gradle implementation com symbol emdk 9 1 1
  • 一文带您了解软件多租户技术架构

    1 多租户技术概述 随着近几年云计算技术的不断发展和成熟 云计算多租户技术在 SaaS 服务领域获得得快速的发展和广泛的应用 基于多租户技术的业务平台首先要保证不同租户业务的隔离 业务隔离主要包括下面 2 个方面 物理隔离 租户开展业务所依
  • 字符串的字体和显示 (3)

    安卓有三种字符串 String String Array Quantity String Plurals String和String Array容易理解 一个是字符串 一个是字符串数组 通过 String planets res getSt
  • Qt做发布版,解决声音和图片、中文字体乱码问题

    前些天做Qt发布版 发现居然不显示图片 后来才发现原来还有图片的库没加 找找吧 去qt的安装包 我装在了F盘 在F盘F QT qt plugins 找到了plugins 这里面有个 imageformats是图片的库 里面有jpg gif等
  • 谷歌开源代码评审规范:好坏代码应该这样来判断

    谷歌开源了一套代码评审 Code Review 规范 它是谷歌一套通用的工程实战指南 几乎涵盖了所有编程语言与各种类型的项目 这个规范代表了谷歌长期发展以来最佳实战经验的集合 谷歌表示希望开源项目或其他组织能够从这套规范中受益 代码评审 也
  • Docker学习:Docker核心命令

    前言 本讲是从Docker系列讲解课程 单独抽离出来的一个小节 重点介绍八大核心命令和一些常用的辅助命令 比如inspect logs push commit等 如果你想 通过部署Tomcat容器 从查找镜像 到拉取 到运行 最后到移除 来
  • sql server - 将sqlserver安装到虚拟机内

    目录 1 安装 打开虚拟机 1 1 打开vmware 1 2 安装虚拟系统到vmware Windows Server 2016 2 安装SQL server 2014 2 1 把SQL server下载并上传到虚拟机 2 2 安装与配置
  • C++ 虚函数表解析

    C 虚函数表解析 陈皓 http blog csdn net haoel 前言 C 中的虚函数的作用主要是实现了多态的机制 关于多态 简而言之就是用父类型别的指针指向其子类的实例 然后通过父类的指针调用实际子类的成员函数 这种技术可以让父类