『 C++类与对象 』虚函数与多态

本文涉及的产品
全局流量管理 GTM,标准版 1个月
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
简介: 『 C++类与对象 』虚函数与多态


前言 🌐

多态对通俗的概念来说就是一个事件被多种类型的角色触发从而产生的不同结果称之为多态;

就以学校为例;

  • 老师进学校是为了教课;
  • 学生进学校是为了上课;

不同的角色对同一个事件的触发从而产生出不同的结果;

多态是在不同继承关系的类对象去调用同一成员函数所产生的不同行为;


多态的构成条件 🌐

多态是在继承之后所产生的一个新的语法,所以多态的最基础的条件必须是一个父子类;

不仅如此,要构成多态还必须满足一下两个条件;

  1. 多态所调用的函数必须是虚函数,且派生类必须完成对基类虚函数(所调用的虚函数)的重写;
  2. 在虚函数调用时必须以父类对象的指针或者引用;

以上图为例即为构成多态;


虚函数 🖥️

在多继承中有有提到虚继承,同时也介绍了关键字virtual;

该关键字是专门用来为虚继承和虚函数做准备的;

virtual不仅可以用来虚继承从而解决棱形继承中的数据冗余和二义性的问题,该关键字还可以用来修饰函数使其变为虚函数或者纯虚函数使得其能满足多态或者是其他条件;

class A{
    public:
    virtual void Func(){//该函数即为虚函数
    std::cout<<"virtual function"<<std::endl;        
    }
};

虚函数的重写 🖥️

当一个基类的派生类中存在一个和该基类中虚函数完全相同(三同:即同函数名,同返回值类型,同参数列表)的函数(不一定用virtual修饰)时,这两个函数构成覆盖(重写);

class A{//基类
  public:
    virtual void func(){
      cout<<"funcA()"<<endl;
    }
};
class B : public A{//派生类
  public:
    virtual void func(){  /*该函数的写法也可以使用void func(),即不使用virtual进行修饰,但是为了代码的可读性尽量不使用该方法写*/
      cout<<"funcB()"<<endl;
    }
};

对于普通函数的继承来说,其派生类继承了基类的函数,可以通过该派生类调用这个函数;

而对于虚函数的继承也被称为是一种接口继承,即派生类继承了其虚函数的接口从而到达重写的目的,而重写的目的是为了构成多态;

因为继承的是接口,所以若是不需要构成多态时不要把函数定义为虚函数(与不存在棱形继承中不要使用虚继承是一样的);


虚函数重写的两个例外 🖥️

派生类在对基类的虚函数进行重写时将存在两个例外;

🔵 协变

协变是例外中的其中一个,当派生类重写基类的虚函数时返回值类型不同即称为协变,但是构成协变必须还满足一个条件;

  • 虽然返回值类型不同但是构成协变时返回值类型有限制:
  1. 基类虚函数的返回值类型应该是基类对象的指针或者引用;
  2. 派生类虚函数的返回值类型应该是派生类对象的指针或者引用;
  • 满足以上条件才能构成虚函数重写中的其中一个特例协变;

若是不满足条件将会报错,即既不构成重写(覆盖)也不构成协变;


🔵 析构函数的虚函数重写

对于析构函数来说,在程序员写代码中可以发现每个析构函数的函数名是不同的,对应的每个类的析构函数都是~class_name();

但在实际中若是存在继承关系,其基类的析构函数若是虚函数时,其派生类的析构函数也必定是虚函数;

class A{//基类
  public:
    virtual ~A(){
     cout<<"~A()"<<endl;   
    }
};
class B : public A{//派生类
  public:
       ~B(){  //此处已经构成了重写
     cout<<"~B()"<<endl;   
    }
};

这是因为编译器当遇到析构函数时会自动将析构函数重命名为destructor(),这也是为什么当存在继承关系,基类中的析构函数若是虚函数时其派生类的析构函数将会完成对基类的析构函数虚函数的重写;


override 和 final 🖥️

由于在使用多态时可能会因为函数名的写错而无法构成重写,但是这种情况下是符合语法条件,虽然符合语法条件但是并不是使用者希望的;

因此在C++11中提供了两个关键字:

  • final
    该关键字用来修饰类或者是修饰虚函数,这里主要谈虚函数;
    该关键字修饰虚函数表示该虚函数不能再被重写;
class A{//基类
  public:
    virtual ~A() final {//使用final修饰虚函数表示该虚函数不能再被重写;
     cout<<"~A()"<<endl;   
    }
};
  • overide
    该关键字修饰虚函数,具体的用法为检查该派生类虚函数是否重写了其基类的某个虚函数,如果未重写则编译报错;
class A{//基类
  public:
    virtual ~A(){
     cout<<"~A()"<<endl;   
    }
};
class B : public A{//派生类
  public:
       ~B() override{ 
     cout<<"~B()"<<endl;   
    }
};

  • 关于重载、覆盖(重写)、隐藏(重定义)的区别 🖥️


抽象类 🌐

在虚函数中有一种特殊的虚函数为纯虚函数,纯虚函数即为只有一个虚函数的声明但是虚函数未定义且函数名后跟=0即为纯虚函数;

  • 语法 – virtual void Func() = 0;

包含纯虚函数的类被称为抽象类,也叫做接口类;

抽象类不能被实例化出对象,即使继承了派生类之后其派生类也会因为继承存在该纯虚函数从而不能实例化出对象;

只有当其派生类重写了该纯虚函数后其派生类才能实例化出对象;

纯虚函数规范了派生类必须重写;

class A{//基类
  public:
    virtual void func()=0;//纯虚函数,代表该类为抽象类
};
class B : public A{//派生类
  public:
    virtual void func(){//重写了抽象类父类的纯虚函数
      cout<<"funcB()"<<endl;
    }
};
int main()
{
    B b1;
    b1.func();
    return 0;
}

多态的原理🌐

虚函数表 🖥️

以下操作均为在CentOS7_x64机器上的操作
//存在以下代码
class A{
  public:
    virtual void func(){
      cout<<"funcA()"<<endl;
    }
  int _a = 1;
};
int main()
{ 
  A a;
  cout<<sizeof(a)<<endl;
  return 0;
}

该题的结果是多少?

  • 以一般的想法来看,在这段代码中对象a中的大小应该为4个字节(对象a中只包含变量_a的大小);
    但实际上该题的结果为16(x64位机器);

使用GDB调试该段代码,并打印出对象a;

(gdb) print a
$1 = {_vptr.A = 0x400b00 <vtable for A+16>, _a = 1}

打印出的结果除了变量_a以外还有一个为_vptr.A = 0x400b00的指针;

使用x/x对该地址进行解析 (x/为查看内存命令,后面的x为可选项,即以十六进制格式显示变量)

(gdb) display a
3: a = {_vptr.A = 0x400b00 <vtable for A+16>, _a = 1}
(gdb) x/x 0x400b00
0x400b00 <_ZTV1A+16>: 0x004009d6
(gdb) x/x 0x004009d6
0x4009d6 <A::func()>: 0xe5894855

0x400b00地址解析出来之后为0x004009d6;

再次使用x/x对其解析即能看到最后一次的解析为

  • 0x4009d6 <A::func()>: 0xe5894855

其中变量a中的首地址,_vptr.A = 0x400b00 <vtable for A+16>即为虚表(虚函数表)指针,虚表指针存放着虚表(虚函数表)的地址,而对应的它所指向的那块空间即为虚表0x400b00 <_ZTV1A+16>: 0x004009d6;

其中_vptr.A = 0x400b00 <vtable for A+16>中的<vtable for A+16>表示从虚表开始至向后偏移16个字节赋值给该_vptr.A指针当中;

此时对该虚函数进行重写同时增加两个普通函数再进行操作;

class A{//基类
  public:
    virtual void func(){
      cout<<"funcA()"<<endl;
    }
  int _a = 1;
};
class B : public A{//派生类
  public:
    virtual void func(){//虚函数的重写
      cout<<"funcB()"<<endl;
    }
    void func1(){//普通函数
      cout<<"func1()"<<endl;
    }
    void func2(){//普通函数
      cout<<"func2()"<<endl;
    }
};
int main()
{ 
  A a;
  B b;
  return 0;
}

使用GDB调试同时打印出变量a与变量b的值;

(gdb) p a
$2 = {_vptr.A = 0x400aa0 <vtable for A+16>, _a = 1}
(gdb) p b
$3 = {<A> = {_vptr.A = 0x400a88 <vtable for B+16>, _a = 1}, <No data fields>}

从上图可以发现打印出两个变量的值时,变量a第一次所打印的样式不变;

而变量b作为派生类对象,包含了其基类对应的部分,但是其虚表指针却与基类部分中的虚表指针地址不同;

以最初的方式使用x/x对两个变量中的_vptr.A对该地址进行解析;

  • _vptr.A = 0x400aa0 <vtable for A+16>
(gdb) p a
$4 = {_vptr.A = 0x400aa0 <vtable for A+16>, _a = 1}
(gdb) x/x 0x400aa0
0x400aa0 <_ZTV1A+16>: 0x00400976
(gdb) x/x 0x00400976
0x400976 <A::func()>: 0xe5894855
  • <A> = {_vptr.A = 0x400a88 <vtable for B+16>
(gdb) p b
$5 = {<A> = {_vptr.A = 0x400a88 <vtable for B+16>, _a = 1}, <No data fields>}
(gdb) x/x 0x400a88
0x400a88 <_ZTV1B+16>: 0x004009a2
(gdb) x/x 0x004009a2
0x4009a2 <B::func()>: 0xe5894855

其中可以发现两个变量的虚表指针以及虚表都不同;


多态的原理 🖥️

所以为什么编译器能够通过虚函数的重写从而完成多态?

实际上从上面的现象就能观察到一定的细节;

首先回到开始的满足多态的两个条件:

  1. 多态所调用的函数必须是虚函数,且派生类必须完成对基类虚函数(所调用的虚函数)的重写;
    是因为在定义虚函数之后实例化阶段时该类模型中将会存在一个虚表指针,虚表指针指向一个名为虚函数表==(本质上是一种指针数组,即虚函数指针数组),而虚函数重写后派生类的对象模型与基类的对象模型中将各有一个虚表(虚函数表)==;

  1. 在虚函数调用时必须以基类对象的指针或者引用;
    从第1点的解释可以推断出为什么要有第2点,首先是需要是基类对象是因为在赋值中派生类对象可以赋值给基类对象,而基类对象不能赋值给派生类对象;
    而对于需要指针或者引用而不是传值是因为可以通过指针或者引用直接找到该对象中对应的那个虚表指针,并通过该虚表指针找到对应的虚表从而完成函数的调用;
    还有一点是因为在这个地方若是传值而不是传引用或指针,将会去调用它的拷贝构造函数,但是这个拷贝构造并不能实质性的去完成真正的深拷贝问题(虚函数指针数组中的各个指针所指向的位置),就算是可以的话也将会有大量的开销或者使底层变得更加复杂;
相关文章
|
6天前
|
并行计算 Unix Linux
超级好用的C++实用库之线程基类
超级好用的C++实用库之线程基类
12 4
|
6天前
|
C++ Windows
HTML+JavaScript构建C++类代码一键转换MASM32代码平台
HTML+JavaScript构建C++类代码一键转换MASM32代码平台
|
6天前
|
C++
2合1,整合C++类(Class)代码转换为MASM32代码的平台
2合1,整合C++类(Class)代码转换为MASM32代码的平台
|
6天前
|
存储 运维 监控
超级好用的C++实用库之日志类
超级好用的C++实用库之日志类
12 0
|
17天前
|
编译器 C++
C++ 类构造函数初始化列表
构造函数初始化列表以一个冒号开始,接着是以逗号分隔的数据成员列表,每个数据成员后面跟一个放在括号中的初始化式。
60 30
|
1月前
|
存储 编译器 C++
C ++初阶:类和对象(中)
C ++初阶:类和对象(中)
|
2月前
|
存储 安全 编译器
【C++】类和对象(下)
【C++】类和对象(下)
【C++】类和对象(下)
|
1月前
|
C++
C++(十六)类之间转化
在C++中,类之间的转换可以通过转换构造函数和操作符函数实现。转换构造函数是一种单参数构造函数,用于将其他类型转换为本类类型。为了防止不必要的隐式转换,可以使用`explicit`关键字来禁止这种自动转换。此外,还可以通过定义`operator`函数来进行类型转换,该函数无参数且无返回值。下面展示了如何使用这两种方式实现自定义类型的相互转换,并通过示例代码说明了`explicit`关键字的作用。
|
1月前
|
存储 设计模式 编译器
C++(十三) 类的扩展
本文详细介绍了C++中类的各种扩展特性,包括类成员存储、`sizeof`操作符的应用、类成员函数的存储方式及其背后的`this`指针机制。此外,还探讨了`const`修饰符在成员变量和函数中的作用,以及如何通过`static`关键字实现类中的资源共享。文章还介绍了单例模式的设计思路,并讨论了指向类成员(数据成员和函数成员)的指针的使用方法。最后,还讲解了指向静态成员的指针的相关概念和应用示例。通过这些内容,帮助读者更好地理解和掌握C++面向对象编程的核心概念和技术细节。
|
2月前
|
存储 算法 编译器
c++--类(上)
c++--类(上)
下一篇
无影云桌面