【多态】初次遇见我就爱上了多态

简介: 【多态】初次遇见我就爱上了多态

什么叫多态

多态和继承一样,是面向对象编程的一个重要概念,简单来说,多态是指不同的对象通过同样的接口,产生不同的结果

举个例子:同样是去动物园买票,成人全价而儿童半价。我们可以将这个例子抽象成一个对象模型,即儿童继承于基类成人。动物园的票价则是基类的一个成员,根据多态的思想,在调用这个成员函数时,就应该根据对象的不同产生不一样的票价。

在继承关系中,子类可以重写父类的方法,从而实现运行时多态,即同一个方法在不同的子类中可以有不同的行为表现。

这样做有什么好处呢?

场景再现

假设我们的程序已经有了自己的对象框架,即所有的方法和属性都已经封装成了一个个类,并且这些类的实例化对象都已经融入到了我们的程序之中。随着业务需求的不断变化,我们的程序也势必需要做出更新。

再假设我们需要将程序中原来的A类中的某一个方法进行更新,比如fun()。那么我们就可以定义一个子类去继承这个A类,对A类的有关函数进行重写(不改函数名,只改实现)

来实现新的功能和特性。

又由于子类是继承父类的,我们只需要将新的子类实例化对象替换掉原来的父类的实例化对象,而无需更改程序中的其它代码。对于不用做修改的部分,新的子类已经从父类那里继承过来了,需要修改的部分,子类又进行了的重写。根据多态的思想,”谁调用的,就用谁的“。(参考知乎的风影忍者)根据以上例子总结实现多态的好处:

  1. 提高了程序的扩展性
  2. 使编程更加灵活
  3. 使代码更加容易维护

我们该如何实现多态呢?

在继承关系中,实现多态要满足以下两个条件:

  • 必须通过基类的指针或者引用调用基函数
  • 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写
  • 先来观察一个多态的例子:

class Person {
public:
  virtual void BuyTicket() {
    cout << "买票全价" << endl;
  }
};

class Student : public Person {
public:
  virtual void BuyTicket() {
    cout << "买票半价" << endl;
  }
};

void fun(Person& people) {
  people.BuyTicket();
}
void test1() {
  Student s;
  fun(s);

  Person p;
  fun(p);
}

在这个代码样例中, BuyTicket是一个基类的虚函数,在子类中对这个虚函数进行了重写。然后通过基类的引用调用了基类的虚函数,发现传参是student对象时,显示的是半票,person对象就显示的是全票。这就是多态的一个实例。

什么是虚函数

表面上来看,虚函数就是被关键字virtual修饰的函数。而一旦被声明成了虚函数,将来通过父类指针或者引用就能区分出应该调用父类的函数还子类的函数。虚函数不能被static修饰,也通常不能是内联函数

为什么虚函数通常不能是内联函数?

从设计角度上来说,通常情况下,内联函数需要在编译期间确定。而虚函数的行为是在程序运行的时候确定的,这一点后面也会提到。虽然同时被virtual和inline修饰,编译器也不会报错,只不过编译器会忽略inline的属性,毕竟inline也并非强制内联展开。

为什么虚函数不能是静态的?

  1. 绑定时间冲突:虚函数往往需要在运行的时候才被绑定具体的方法,但是静态函数在编译阶段就被绑定了,且不依赖任何实例对象。
  2. 设计角度的冲突:虚函数的设计是为了能在不同的子类中有不同的特性,而静态函数与任何实例化对象无关。
    下面讲讲不符合三同,但是依旧认为是重写的两个特例。
  3. 静态成员函数没有this指针,无法找到虚函数表。

例如上面例子中的BuTicket()函数。虚函数的主要目的就是实现多态,并为接下来的函数重写做准备。

虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(参数列表,返回值类型,函数名),我们称派生类的这个虚函数重写了基类的虚函数。值得注意的是,重写是作用于派生类的,并不影响基类。

如同上面的代码样例:

一般来说,重写的函数除了三同(参数列表,返回值类型,函数名)之外,在基类和派生类中都应该用virtual修饰。虽然在派生类中不用virtual修饰也可以构成重写(继承后基类的虚拟函数也会被继承下来),但是这样的代码可读性较低,显得不规范

综上,子类对父类函数重写需要有以下条件:

  1. 三同,即重写函数于被重写函数的参数列表、返回值类型、函数名相同。这是设计冲突
  2. 父类中需要被重写的函数被virtual修饰

函数重写的两个特例

  1. 协变(基类与派生类虚函数返回值类型不同)

当派生类重写基类的虚函数时,可以允许基类虚函数与派生类虚函数的返回值类型不同,但前提是,基类虚函数的返回值类型是某一个基类类型(可以是其它基类)的指针或者引用,并且派生类虚函数的返回值类型是某一个派生类的指针或者引用。我们称这种特殊情况为协变。

来观察以下代码样例:

class A{};
class B : public A {};
class Person {
public:
 virtual A* f() {return new A;
};
class Student : public Person {
public:
 virtual B* f() {return new B;}
};
  1. 析构函数的重写(基类与派生类虚函数函数名不同)
    如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写。 其原因是,编译器会将析构函数同一处理成destructor,这样被处理之后析构函数就能被重写了。

为什么要重写基类的析构函数?

只有派生类的析构函数重写了基类的的析构函数,delete对象调用析构函数,此时形成多态,才能保证能区分基类的指针或者引用的对象。

比如下面代码:

override和final

C++11提供了override和final关键字用来帮助用户检测是否重写。

  1. final:可以用来修饰类与虚函数。修饰虚函数,表示该虚函数不能被重写。修饰类,则表示该类不能被继承。

  1. override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

重写、重载(覆盖)、隐藏(重定义)的区别

  1. 要想区分两个函数是重写,重载还是重定义,首先观察其函数名是否一样。如果不一样,那就直接拜拜。
  2. 再观察两个函数是否在不同的域中。如果在同一个域中且返回值类型不同,那就构成重载。
  3. 如果一个在子类,一个在父类,那么再观察这俩函数是否是虚函数。如果是,且三同(除了协变,析构外),那么就是构成重写,子类重写父类的方法。否则就是构成重定义,隐藏父类的同名函数。

抽象类

在虚函数后面标明=0,则这个函数为纯虚函数,纯虚函数没有实现只有声明。包含纯虚函数的类叫抽象类,抽象类不能实例化出对象。派生类继承抽象类后必须对基类的纯虚函数进行重写才能实例化。我们将这种抽象类的继承称为接口继承

抽象类的目的就是让派生类强制去实现父类的一些方法集(纯虚函数)。也可以认为抽象类其实是基类要求派生类执行协议的一种方法,即”你要想继承我,那就必须先把活干完“。此外,这种必须要求重写的方式也注定了会实现多态

多态的原理是什么呢?

我们说多态就是通过基类的指针或者引用,让不同的子类调用同一个方法产生不同的结果。那么编译器是如何识别这些不同的子类的呢?中间发生了什么?接下来我们来谈谈多态的原理。

认识虚函数表

在c++中,每一个使用虚函数的类都会有一张虚函数表(vtable)。这个表在编译期间生成,本质是一个函数指针数组,这个数组最后面放了一个nullptr。生成这个表之后,由一个指针指向该表,我们讲这个指针称为虚表指针(vptr)。当通过基类的指针或者引用调用一个虚函数的时候,编译器会根据vptr找到虚表,并在虚表找到目标虚函数来执行。

我们该怎么证明这个虚表指针的存在呢?

观察以下代码:

class Base
{
public:
  virtual void Func1()
  {
    cout << "Func1()" << endl;
  }
private:
  int _b = 1;
};

int main() {
  Base b;
  cout << sizeof b << endl;
  return 0;
}

为什么会得到8呢?除去一个int变量_b,还有4字节的变量在哪?其实,这多出来的四个字节就是虚表指针的空间。

打开调试窗口就可以看到这个指针了。

在vs里,__vfptr(vf表示virtual funcation,ptr表示指针)其实就是一个虚表指针。并且我们还能看到虚表的元素,即虚函数Func1()的指针。

一般来说,每个类只有一个虚表指针,但是在多继承的情况下,对象可能会有多个虚表指针,因为每个父类都需要一个虚表指针指向。

针对以上代码做出一些改动,继续观察相象:

class Base
{
public:
  virtual void Func1()
  {
    cout << "Base::Func1()" << endl;
  }
  virtual void Func2()
  {
    cout << "Base::Func2()" << endl;
  }
  void Func3()
  {
    cout << "Base::Func3()" << endl;
  }
private:
  int _b = 1;
};
class Derive : public Base
{
public:
  virtual void Func1()
  {
    cout << "Derive::Func1()" << endl;
  }
private:
  int _d = 2;
};
int main()
{
  Base b;
  Derive d;
  return 0;
}

增加一个派生类Derive去继承Base,Derive中重写Func1,Base再增加一个虚函数Func2和一个普通函数Func3。

下面分别观察基类Base对象和派生类Derive对象的虚表的情况。

仔细观察以上监视窗口的内容。得到以下结论:

  • 虚表是会被继承的。
  • 重写虚函数实际上就是覆盖了父类的虚表内容

这两个结论非常重要!重写虚函数的本质,就是覆盖从父类继承而来的虚表的对应内容。

其次由于func3不是虚函数,所以并没有被放进虚函数表。

总结一下派生类虚表的生成:

  1. 先将虚基中的虚表拷贝一份到派生类需表中
  2. 如果派生类重写了虚表中的某个虚函数,用派生类重写的虚函数覆盖虚表中的虚函数。
  1. 派生类自己的虚函数按照声明顺序增加到派生类的虚表中。(如果是多继承,通常加到第一个继承的虚表中

值得注意的是

虚表指针相当于是类中的一个变量,存在于对象的实例中。如果对象是new出来的,那就在内存的堆区,如果是局部对象,那就是在栈区,如果被static修饰,那就在静态区。总之它随着对象的存在而存在。

虚表通常在程序的只读数据段中,即常量区,编译阶段生成,在运行时不能被修改。

虚函数存在于代码的只读数据段中,跟普通的成员函数一样。

多态的原理

搞懂了虚表指针和虚表这个机制之后,我们就能从虚表的角度分析多态的原理了。再来回顾一下动物园买票的例子。再次掏出这张多态结构图:

分析上图代码:

  • 当我们用基类对象Mike作为Func的参数传参时,在Func内部,基类对象People是Mike的引用。然后我们调用虚函数BuyTicket,在自己的虚表中去找BuyTicket这个函数,由于people引用Mike,此时peole的虚表就是Mike的虚表。而Mike又是一个Person类,调用的就是Person类的BuyTicket函数。打印的就是”买票全价“。
  • 当我们用派生类对象Johnson作为Func的参数传参时,由于参数类型是基类Person,需要进行类型转换。派生类类型对象转换为基类类型对象实行的机制是切片。

     具体切片过程:


然后people调用函数BuyTicket(),同样在自己的虚表中去找,此时自己虚表里的BuyTicket实际上就是被重写了的!即调用Student的BuyTicket(),打印”半价买票“。至此实现了多态!

于是我们再来回头看看实现多态的必要条件:

  1. 必须通过基类的指针或者引用调用基函数。这一步是为了子类类型转换为父类类型时”切片“能把子类的虚表给切出来,保证基类的指针或者引用能够指向子类的虚表。如果直接将子类赋值给父类对象,在基类对象上调用一个成员函数,即使该函数在派生类中被重写,也总是基类版本的函数被调用。
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。这一步就是为了体现派生类和基类的不同嘛。

动态绑定和静态绑定

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载
  2. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态

如何理解这个动静态绑定呢?为什么说函数重载是静态绑定呢?调用某个重载函数,编译器在编译期就确定了我们该调用重载函数中的哪一个。这个可以根据参数的类型以及个数提前确定。再比如说模板。我们的模板在编译期其实就已经实例化出所需的模板函数了,这是在运行前就能确定我们到底该使用模板实例化出来的哪个版本。这种在编译期间就能确定函数行为的机制就叫做静态绑定。

动态绑定就是在运行期间才能决定使用哪个版本的函数方法。比如多态,在虚表中找到哪个就运行哪个,这是一个动态的过程。

总结:

一般来说,不需要多态的场景就是静态绑定,否则就应该是动态绑定。静态绑定调用的地址在编译期间就已经确定,性能较好,但是方法固定死了缺乏适应性。动态绑定调用的地址不确定(可能在基类,可能在派生类),还需要额外的开销去”确定地址“,但是比较灵活。这两种绑定方式的选择影响着程序的执行效率、内存使用、以及如何处理多态。

多继承关系的虚函数表

单继承的虚函数表在前面其实已经了解过了。下面再来探究一下多继承关系中的虚函数表。再此之前我们需要自己实现一个查看虚函数表的函数。为什么要自己实现而不用调试窗口呢?这是因为vs的调试窗口又bug,有时并不能完整的呈现出虚表内容。

先给出样例代码:


class Base {
public:
  virtual void func1() { cout << "Base::func1" << endl; }
  virtual void func2() { cout << "Base::func2" << endl; }
private:
  int a;
};
class Derive :public Base {
public:
  virtual void func1() { cout << "Derive::func1" << endl; }
  virtual void func3() { cout << "Derive::func3" << endl; }
  virtual void func4() { cout << "Derive::func4" << endl; }
private:
  int b;
};

int main() {
  Base b;
  Derive d;

  return 0;
}

仔细观察子类Derive对象的虚表,我们发现除了继承下来的func1和func2.子类特有的func3和func4没看到!

那么我们该如何设计一个函数用来查看子类中的虚表呢?

  1. 首先就是先找到虚表指针。虚表指针一般都在对象的首四个字节处。于是我们可以取出对象的头4个字节,就拿到了虚表的指针。
  2. 取到这个虚表指针之后,往后遍历,直到遇到nullptr为止。前面我们说过,虚表最后一个元素是nullptr.

代码:


typedef void (*VFPTR)();//重命名一个函数指针类型

void PrintVTable(VFPTR vTable[]) {
  //取出数组中的元素依次打印
  int i = 0;
  for (; vTable[i]!=nullptr; i++) {
    printf("第%d个虚函数的地址-> %p ", i, vTable[i]);
    VFPTR f = vTable[i];
    f();//调用这个函数
  }
}
int main() {
  Base b;
  Derive d;
  VFPTR* vfp_d = (VFPTR*)(*(int*)&d);//将d的首地址取出来后转换成int*,这样在解引用之后拿到的就是四个字节的值。
                                      //再将这首四个字节的值转换为VFPTR*类型(函数指针数组指针)。
  PrintVTable(vfp_d);
  cout << "-------------------------" << endl;
  VFPTR* vfp_b=(VFPTR*)(*(int*)&b);
  PrintVTable(vfp_b);
  return 0;
}

建立一个多继承模型:

class Base1 {
public:
  virtual void func1() { cout << "Base1::func1" << endl; }
  virtual void func2() { cout << "Base1::func2" << endl; }
private:
  int b1;
};
class Base2 {
public:
  virtual void func1() { cout << "Base2::func1" << endl; }
  virtual void func2() { cout << "Base2::func2" << endl; }
private:
  int b2;
};
class Derive : public Base1, public Base2 {
public:
  virtual void func1() { cout << "Derive::func1" << endl; }
  virtual void func3() { cout << "Derive::func3" << endl; }
private:
  int d1;
};

简单来说,这个多继承模型就是让Derive类同时继承Base1和Base2。根据上面的学习,多继承关系中,派生类会获得多张虚表:

根据输出结果得出以下结论:

  • 多继承中如果有父类的虚函数函数名相同,子类会覆盖所有的父类同名的虚函数。如Base1中的func1Base2中的func1
  • 如果子类有自己特有的虚函数,会放在第一个基类的虚表中。如Derivefunc3就放在Base1部分的虚表中。

菱形继承、菱形虚拟继承的虚表

菱形继承的内存分布

菱形继承又叫砖石型继承,都是指重复继承了超类的成员。

下面给出一个简单的菱形继承模型:

(仅仅是类声明的关系图,并画出及成员继承)

模型代码:

class Base {
public:
  virtual void func0() { cout << "Base::func0" << endl; }
  
private:
  int b0;
};

class Base1:public Base {
public:
  virtual void func1() { cout << "Base1::func1" << endl; }
  virtual void func2() { cout << "Base1::func2" << endl; }
private:
  int b1;
};
class Base2 :public Base{
public:
  virtual void func1() { cout << "Base2::func1" << endl; }
  virtual void func2() { cout << "Base2::func2" << endl; }
private:
  int b2;
};
class Derive : public Base1, public Base2 {
public:
  virtual void func1() { cout << "Derive::func1" << endl; }
  virtual void func3() { cout << "Derive::func3" << endl; }
private:
  int d1;
};

先来分析一下父类Base1,继承了Base的所有成员外,还继承了Base的虚表:

父类Base2跟Base1的内存分布是一样的。这里就不谈了。接下来再看Derive对象的内存分布情况:

注意以上字节计算考虑内存对齐

菱形虚拟继承的内存分布

为了解决数据冗余以及二义性,我们尝试用虚继承解决。于是给出菱形虚拟继承的模型:

给出代码:


class Base {
public:
  virtual void func0() { cout << "Base::func0" << endl; }
  
private:
  int b0;
};

class Base1:virtual public Base {
public:
  virtual void func1() { cout << "Base1::func1" << endl; }
  virtual void func2() { cout << "Base1::func2" << endl; }
private:
  int b1;
};
class Base2 :virtual public Base{
public:
  virtual void func1() { cout << "Base2::func1" << endl; }
  virtual void func2() { cout << "Base2::func2" << endl; }
private:
  int b2;
};
class Derive : public Base1, public Base2 {
public:
  virtual void func1() { cout << "Derive::func1" << endl; }
  virtual void func3() { cout << "Derive::func3" << endl; }
private:
  int d1;
};

先来分析一下父类Base1,继承了Base的虚表。由于是虚拟继承,Base1还有一个虚基指针,用来指向虚基表,虚基表中有着找到父类成员的偏移量:(由于vs中虚基表指针与虚表指针都是__vfptr,为了区分,下面我用vvfptr表示虚基表指针)

这里要注意的是,由于是虚继承,Base类的所有成员包括虚表指针,都算是共享成员。所以Base1、Base2还需要自己生成一个虚表和虚表指针。

接下来再看Derive对象的内存分布情况:

#关于多态的面试题

8.

9.

对10题,为什么是选B,因为声明是用父类的,定义才是子类(多态重写的是实现)。所以val=1。

问答题:

相关文章
|
2月前
|
搜索推荐
用通俗易懂的方式解释一下多态
【10月更文挑战第13天】多态,就像是编程世界里的一场神奇魔术,它让不同的对象在面对相同的操作时,能够展现出各自独特的表现。
23 2
|
6月前
|
编译器 C++ 开发者
通俗讲解 初学者一文看懂!虚函数、函数重载、重写的区别
函数重载允许在同一作用域内定义同名但参数列表不同的函数,提高代码灵活性和可读性,避免命名冲突。通过参数类型自动选择合适版本,如C++中的`print()`可处理整数、浮点数和字符串。虚函数实现运行时多态,基类指针调用时调用实际对象的版本。抽象类至少有一个纯虚函数,不能实例化,用于定义接口规范。抽象类和纯虚函数是构建多态和继承体系的基础,提供接口标准,减少代码冗余,增强代码清晰性和可维护性。
|
6月前
|
编译器 C++
C++进阶之路:何为默认构造函数与析构函数(类与对象_中篇)
C++进阶之路:何为默认构造函数与析构函数(类与对象_中篇)
36 0
面向对象-理解多态看这一篇博客就行了
面向对象-理解多态看这一篇博客就行了
36 0
|
7月前
|
存储 编译器 程序员
【C++学习】类和对象(中)一招带你彻底了解六大默认成员函数
【C++学习】类和对象(中)一招带你彻底了解六大默认成员函数
|
编译器 C++
《C++避坑神器·六》多继承下问题处理(同名变量,信号槽,多态内存释放)
《C++避坑神器·六》多继承下问题处理(同名变量,信号槽,多态内存释放)
59 0
|
存储 编译器 C++
C++中的多态(图文详解)
C++中的多态(图文详解)
144 0
|
设计模式
代码学习-多态
代码学习-多态
61 0
|
Java
【Java面向对象】多态的详细介绍,简单易懂,看这一篇就够了
【Java面向对象】多态的详细介绍,简单易懂,看这一篇就够了
169 0
|
编译器 C语言 C++
C嘎嘎~~ [类 下篇之运算符重载]
C嘎嘎~~ [类 下篇之运算符重载]

热门文章

最新文章