C++中的多态

简介: C++中的多态,了解多态的构成条件,多态的运用、多态的原理、虚表等等,对比重载、重写。重定义。

多态的概念

态换句话来说就是多种形态,具体点就是不同的对象去完成某一个行为时会产生不同的状态。比如买票这个行为,成年人去买是全价,未成年人去买是半价,而军人去买则是优先购票。

多态的定义及实现

构成多态的条件

首先来看看构成多态的条件:

多态是建立在继承之上的,多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。

那么在继承中,需要以下条件才能构成多态:

①被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写/覆盖。

②必须通过基类的指针或引用调用虚函数。

class Person
{
public:
  //虚函数
  virtual void BuyTick() { cout << "Person--买票-全价" << endl; }
};
//虚函数的重写/覆盖
//重写的条件是:三同,即函数名、参数、返回值都相同
class Student :public Person
{
  virtual void BuyTick() { cout << "Student--买票-半价" << endl; }
};
class Soldier :public Person
{
  virtual void BuyTick() { cout << "Soldier--买票-优先" << endl; }
};
//构成多态:1.虚函数重写2.基类的指针或引用去调用虚函数
void Func(Person& p)//也可以是Perosn* p,但不可以Person p
{
  p.BuyTick();
}
int main()
{
  Person pn; 
  Student st;
  Soldier sd;
  Func(pn);
  Func(st);
  Func(sd);
  return 0;
}

image.gif

结果显示:

A3UWCDYDBQW]ZQVT)]6}QWG.png

结论:

对于普通调用,跟调用对象的类型有关。对于多态调用,跟指针/引用--指向的对象有关。即如果上面代码不构成多态,那么这三个结果都是"Person--买票全价",因为它调用对象的类型是Person&或Person*,然后在传入st和sd的时候,切片之后拷贝了一份给参数p。但是构成多态后,就便是与sd、st有关了。

虚函数

什么是虚函数

构成多态条件是1.虚函数重写2.基类的指针或引用去调用虚函数。那么虚函数就是被virtual修饰的类成员函数称为虚函数。

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

image.gif

虚函数的重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同,以下简称三同),称子类的虚函数重写了基类的虚函数。

需要注意的是:

①如果需要构成重写,那么基类就必须是虚函数,即必须加上virtual。

②派生类对基类重写的虚函数,可以不加上virtual,因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性,但一般而言最好加上去,提高代码的阅读性。

虚函数重写/覆盖的两个特殊情况

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

三同中,返回值可以不同,但是要求返回值必须是一个父子类关系的指针或者引用。

//协变:返回值可以不同,但是返回值必须是一个父子类关系的指针或引用
class Person
{
public:
  virtual Person* BuyTick() { cout << "Person--买票-全价" << endl; return this; }
};
class Student :public Person
{
  virtual Student* BuyTick() { cout << "Student--买票-半价" << endl; return this;}
};
class Soldier :public Person
{
  virtual Person* BuyTick() { cout << "Soldier--买票-优先" << endl; return this;}
};

image.gif

只要是父子类关系的都可以,不一定是Person、Student和Soldier。

class A
{};
class B:public A
{};
//协变:返回值可以不同,但是返回值必须是一个父子类关系的指针或引用
class Person
{
public:
  virtual A* BuyTick() { cout << "Person--买票-全价" << endl; return this; }
};
class Student :public Person
{
  virtual B* BuyTick() { cout << "Student--买票-半价" << endl; return this;}
};
class Soldier :public Person
{
  virtual A* BuyTick() { cout << "Soldier--买票-优先" << endl; return this;}
};

image.gif

2.析构函数的重写(基类与派生类析构函数的名字不同)

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。

class Person
{
public:
   virtual ~Person()//虚析构函数
  {
    cout << "Person delete:" << _p << endl;
    delete[] _p;
  }
protected:
  int* _p = new int[10];
};
class Student : public Person
{
public:
  ~Student()
  {
    cout << "Student delete:" << _s << endl;
    delete[] _s;
  }
protected:
  int* _s = new int[20];
};
int main()
{
  Person* ptr1 = new Person;//类型是Person*,指向的对象是Person类型
  Person* ptr2 = new Student;//类型是Person*,指向的对象是Student类型
  delete ptr1;//会调用Person的析构
  //因为构成多态,析构函数重写了,即使是Person*类型的,但是在调用的时候,不看类型,是看
  //指向的对象,指向的是Student,因此会去调用Student的析构函数
  //又因为Student类是Person的子类,所有会在Student类的析构函数结束后
  //再去调用Perosn类的析构函数
  delete ptr2;
  return 0;
}

image.gif

)%P4~U0(C$16[7TF9LNTA5E.png

析构函数重写而构成多态的作用就是,如果使用了基类指针或引用指向派生类对象,那么delete就会准确地调用析构函数。在上面的代码例子中,只有派生类Student的析构函数重写了Person的析构函数,delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。

C++11 override 和 final

到这里,我们可以看到构成多态的条件比较严格,所有有时候我们会难免疏忽一下,可能是函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来debug会得不偿失。因此,C++11中提供了overrid和final来帮助我们去检查是否构成重写。

final:修饰虚函数,表示该虚函数不能再被重写。

class Car
{
public:
  virtual void Drive() final {}
};
class Benz :public Car
{
public:
  virtual void Drive() { cout << "Benz-舒适" << endl; }//报错,不能构成重写
};

image.gif

final除了可以修饰虚函数以外,还能修饰类,被修饰的类表示不能被继承。这里可以小小地总结两点,关于需要写出一个不能被继承的类。

①私有构造函数;②final修饰类

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

class Car {
public:
  virtual void Drive() {}
};
class Benz :public Car {
public:
  virtual void Drive() override { cout << "Benz-舒适" << endl; }
};

image.gif

注意:final是写在不让被重写的虚函数上,overrid是写在重写了基类的虚函数的派生类的虚函数上。

最后我们来看下重载、隐藏/重定义、重写/覆盖三者的区别:

⭐重载:

①重载的函数必须是同一个作用域中。需要注意的是父子类不是同一作用域,是独立的,就算父子类中有两个看上去是重载的函数,但那不是重载,是构成隐藏!

②重载的函数必须是函数名相同。

如果函数名相同、参数相同,但是返回值不同,不构成重载。

如果函数名相同、参数不同,但是返回值,构成重载。

如果函数名相同、参数不同,但是返回值不同,构成重载。

⭐隐藏/重定义:

①两个函数必须分别在基类和派生类当中。

②函数名相同

③如果基类和派生类的两个同名函数不构成重写那就是隐藏

⭐重写/覆盖:

①两个函数必须分别在基类和派生类当中。

②三同:函数名相同、参数相同、返回值相同

③两个函数必须是虚函数

其实我们可以这样理解重写:因为重写的条件三同,并且重写,写的是函数的主体内容,这就可以理解成,如果我们用基类指针或引用指向派生类对象,那么,在使用这个对象去调用重写了的函数的时候,就是去基类中拿到函数的接口,再到派生类对应的虚函数的里面去指向里面的代码。

INV1[I%{JE~M[[F8JME}QKQ.png

抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。也就是说,抽象类就是用于虚函数的重写的。

//抽象类---不能实例化出对象
class Car
{
public:
  //纯虚函数
  virtual void Drive() = 0;
};
class BMW :public Car
{
public:
  virtual void Drive()
  {
    cout << "别摸我" << endl;
  }
};
int main()
{
  Car c;//error  报错了,不能实例出对象
  BMW b;//通过纯虚函数的重写,可以实例出对象
  return 0;
}

image.gif

接口继承和实现继承

①实现继承:

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现

②接口继承:

虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。这也是上面对重写的小小理解进行了一次总结。

多态的原理

有这样一份代码:

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;
  char _ch;
};

image.gif

sizeof(Base)的结果为:12;我们来分析一下为啥是12:

首先,Base的成员变量有int类型和char类型,内存对齐后,大小为8(在32为平台4字节下)。

接着是虚表的存在,又称虚函数表,虚函数表本质上是一个函数指针数组,存放的是类中虚函数的地址。因此,指向虚函数表的是虚表指针,在32位平台下,指针的大小是4,因此一共是12个字节。

$EM%JF6EEC4@UC)KBE53V7G.png

8I8Z31MNUR8VXADWZLA08)9.png

那么,在派生类中,这个虚函数表存放的是什么呢?我们都知道了,基类的虚函数表里面存放的是虚函数的地址,派生类重写了基类的虚函数,那么派生类的虚函数表是否也存放着跟基类同一份的虚函数地址呢?我们接下来看看:

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;
  char _ch;
};
class Derive :public Base
{
public:
  virtual void Func1()
  {
    cout << "Derive::Func1()" << endl;
  }
private:
  int _d = 2;
};
int main()
{
  Base b;
  Derive d;
  return 0;
}

image.gif

2[JAB6NZ@BK0}HIC5V`VF`I.png

可以看到,派生类对象d自己也有虚表指针_vftptr,并且d对象由两部分,一部分的从父类继承下来的成员,另一部分是自己的,并且虚表指针中存放的虚函数地址也有自己的。

其实基类b对象和派生类d对象虚表是不一样的,Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。而Func2继承下来后是虚函数,所以放进了虚表,但是并没有重写,因此,基类和派生类中的虚函数表,对于Func2的地址是一模一样的!Func3也继承下来了,但是不是虚函数,所以不会放进虚表。

总结派生类的虚表生成:

①派生类先将基类中的虚表内容拷贝一份到派生类虚表中。

②如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数

③派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

④虚表是存放在代码段中的。

[X7LLMIZ63I]$Z57Y`69`_J.png

因此,多态的原理就是:

(这里再用Person类、Student类举例子)

当基类指针Person*或引用Perosn&指向派生类对象,在调用重写的虚函数时,就会到指向的对象的类里面找虚表!从虚表中得到这个虚函数的地址,从而去调用这个函数!如果指向的对象是基类自己,那么就会到基类里面找到基类的虚表。

动态绑定与静态绑定

①静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也就是说已经确定好要调用的函数的地址了。静态绑定也称为静态多态,比如函数重载。

②动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,即上面所说的,会先到虚表中找具体的函数的地址,再去调用。动态绑定也称为动态多态。

反思构成多态的条件

通过分析,我们可以好好反思一下构成多态的条件,为什么要虚函数重写,为什么要基类对象的指针或引用调用虚函数。

①为什么虚函数覆盖/重写:

因为要对派生类的虚表进行覆盖。在调用重写的函数的时候,如果指向的是派生类对象,那么就必须从这个派生类的虚表中拿到这个虚函数的地址。

②为什么要基类对象的指针或引用去调用虚函数:

首先,虚函数必须写在基类中。其次,基类指针或引用派生类对象的时候,在切片后,指向的是派生类对象中属于基类成员的那一部分,但总体来说依然是指向派生类的,当需要调用重写的虚函数的时候,就会去基类成员那一部分中找接口,再去派生类中找定义。不是切片的话,就会自己调用自己的指定的虚函数。

如果不是指针或引用,那么在切片的时候,会将属于基类成员的那一部分拷贝给调用的基类对象,此时就不会构成多态了。

相关文章
|
1月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
38 2
C++入门12——详解多态1
|
6月前
|
C++
C++中的封装、继承与多态:深入理解与应用
C++中的封装、继承与多态:深入理解与应用
150 1
|
1月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
79 1
|
3月前
|
存储 编译器 C++
|
4月前
|
存储 编译器 C++
【C++】深度解剖多态(下)
【C++】深度解剖多态(下)
53 1
【C++】深度解剖多态(下)
|
4月前
|
存储 编译器 C++
|
3月前
|
存储 编译器 C++
C++多态实现的原理:深入探索与实战应用
【8月更文挑战第21天】在C++的浩瀚宇宙中,多态性(Polymorphism)无疑是一颗璀璨的星辰,它赋予了程序高度的灵活性和可扩展性。多态允许我们通过基类指针或引用来调用派生类的成员函数,而具体调用哪个函数则取决于指针或引用所指向的对象的实际类型。本文将深入探讨C++多态实现的原理,并结合工作学习中的实际案例,分享其技术干货。
74 0
|
4月前
|
机器学习/深度学习 算法 C++
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
|
4月前
|
Java 编译器 C++
【C++】深度解剖多态(上)
【C++】深度解剖多态(上)
54 2
|
4月前
|
程序员 C++
【C++】揭开C++多态的神秘面纱
【C++】揭开C++多态的神秘面纱