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

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
全局流量管理 GTM,标准版 1个月
云解析 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点,首先是需要是基类对象是因为在赋值中派生类对象可以赋值给基类对象,而基类对象不能赋值给派生类对象;
    而对于需要指针或者引用而不是传值是因为可以通过指针或者引用直接找到该对象中对应的那个虚表指针,并通过该虚表指针找到对应的虚表从而完成函数的调用;
    还有一点是因为在这个地方若是传值而不是传引用或指针,将会去调用它的拷贝构造函数,但是这个拷贝构造并不能实质性的去完成真正的深拷贝问题(虚函数指针数组中的各个指针所指向的位置),就算是可以的话也将会有大量的开销或者使底层变得更加复杂;
相关文章
|
2月前
|
存储 编译器 C语言
【c++丨STL】string类的使用
本文介绍了C++中`string`类的基本概念及其主要接口。`string`类在C++标准库中扮演着重要角色,它提供了比C语言中字符串处理函数更丰富、安全和便捷的功能。文章详细讲解了`string`类的构造函数、赋值运算符、容量管理接口、元素访问及遍历方法、字符串修改操作、字符串运算接口、常量成员和非成员函数等内容。通过实例演示了如何使用这些接口进行字符串的创建、修改、查找和比较等操作,帮助读者更好地理解和掌握`string`类的应用。
63 2
|
2月前
|
存储 编译器 数据安全/隐私保护
【C++】多态
多态是面向对象编程中的重要特性,允许通过基类引用调用派生类的具体方法,实现代码的灵活性和扩展性。其核心机制包括虚函数、动态绑定及继承。通过声明虚函数并让派生类重写这些函数,可以在运行时决定具体调用哪个版本的方法。此外,多态还涉及虚函数表(vtable)的使用,其中存储了虚函数的指针,确保调用正确的实现。为了防止资源泄露,基类的析构函数应声明为虚函数。多态的底层实现涉及对象内部的虚函数表指针,指向特定于类的虚函数表,支持动态方法解析。
33 1
|
2月前
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
113 5
|
2月前
|
存储 编译器 C++
【c++】类和对象(中)(构造函数、析构函数、拷贝构造、赋值重载)
本文深入探讨了C++类的默认成员函数,包括构造函数、析构函数、拷贝构造函数和赋值重载。构造函数用于对象的初始化,析构函数用于对象销毁时的资源清理,拷贝构造函数用于对象的拷贝,赋值重载用于已存在对象的赋值。文章详细介绍了每个函数的特点、使用方法及注意事项,并提供了代码示例。这些默认成员函数确保了资源的正确管理和对象状态的维护。
116 4
|
2月前
|
存储 编译器 Linux
【c++】类和对象(上)(类的定义格式、访问限定符、类域、类的实例化、对象的内存大小、this指针)
本文介绍了C++中的类和对象,包括类的概念、定义格式、访问限定符、类域、对象的创建及内存大小、以及this指针。通过示例代码详细解释了类的定义、成员函数和成员变量的作用,以及如何使用访问限定符控制成员的访问权限。此外,还讨论了对象的内存分配规则和this指针的使用场景,帮助读者深入理解面向对象编程的核心概念。
153 4
|
3月前
|
存储 编译器 对象存储
【C++打怪之路Lv5】-- 类和对象(下)
【C++打怪之路Lv5】-- 类和对象(下)
35 4
|
3月前
|
编译器 C语言 C++
【C++打怪之路Lv4】-- 类和对象(中)
【C++打怪之路Lv4】-- 类和对象(中)
34 4
|
3月前
|
存储 安全 C++
【C++打怪之路Lv8】-- string类
【C++打怪之路Lv8】-- string类
33 1
|
3月前
|
存储 编译器 C++
【C++类和对象(下)】——我与C++的不解之缘(五)
【C++类和对象(下)】——我与C++的不解之缘(五)
|
3月前
|
编译器 C++
【C++类和对象(中)】—— 我与C++的不解之缘(四)
【C++类和对象(中)】—— 我与C++的不解之缘(四)