C++:多态

简介: C++:多态



先看到多态的定义:

C++的多态是指在面向对象程序设计中,允许使用基类的指针或引用来调用派生类的虚函数的特性。这样的调用将根据对象的实际类型来动态绑定到适当的函数实现,实现了不同对象调用相同函数的不同行为

上面的句子确实有点晦涩,但是重点就是最后一句话:不同对象调用相同函数时,函数展现不同的行为。

C++的多态是基于虚函数的,所以在讲解多态前,我们要先了解什么是虚函数。


虚函数

通过使用虚函数,可以实现在程序运行时根据对象的实际类型来确定调用的函数。

看到以下案例:

class person
{
public:
  void func()
  {
    cout << "func被person调用" << endl;
  }
};
class student : public person
{
public:
  void func()
  {
    cout << "func被student调用" << endl;
  }
};

这段代码中,存在着personstudent的父子关系,两者都存在一个func函数,由于函数同名,此时person的函数被隐藏。

接下来我们用不同的引用来调用这个函数:

student s;
person& rp = s;
student& rs = s;
rp.func();
rs.func();

输出结果:

func被person调用
func被student调用

student& rs = s;中,我们把student类的对象s交给了一个student的引用rp维护,此时再使用rs调用func函数,那么这个student类就会调用自己的函数,输出“func被student调用”


person& rp = s;中,我们把student类的对象s交给了一个person的引用rp维护,此时再使用rp调用func函数,那么这个student类就会被当作一个person类来处理,输出“func被person调用”

但是在实际应用应用中,这是一个不合理的行为。我们把一个把student类的对象s交给了一个person的引用,此时这个student类的对象就可以访问person的函数了。

类比:这个行为就好像一个小偷偷走了别人的银行卡,这笔钱就属于这个小偷了。银行在取钱时,不应该通过这个身份证来确定允不允许这个人取钱,而是通过对方是不是这笔钱真正的主人。

以上类比中,身份证就是 引用/指针,而拿着身份证去取别人钱的行为,就是利用别人类型的 指针/引用 去调用别人函数。

既然多态要根据对象是谁,从而展现同一个函数的不同形态,那么当然要先解决 “确认对象是谁” 这个问题,不能让一个对象拿着其他类型的 指针/引用,导致调用了错误的函数。为此C++推出了虚函数,虚函数可以识别到这个对象的真实类型,调用正确的函数。

虚函数是构成多态的必要条件之一,现在我们来讲解虚函数的语法,帮助大家了解如何构成虚函数。

虚函数语法

在类中被virtua关键字修饰的函数,就是虚函数

class person
{
public:
  virtual void func()
  {
    cout << "func被person调用" << endl;
  }
};

以上代码中,func函数就是一个虚函数了。

虚函数重写

当派生类中有一个与基类完全相同的虚函数,则会发生虚函数的重写

完全相同包括:

  • 函数名相同
  • 参数列表相同
  • 返回值相同

示例:

class person
{
public:
  virtual void func()
  {
    cout << "func被person调用" << endl;
  }
};
class student : public person
{
public:
  virtual void func()
  {
    cout << "func被student调用" << endl;
  }
};

上述代码中,personstudentfunc函数构成了重写:

  • 函数名都是func
  • 返回值都是void
  • 参数都是没有参数
  • personfuncvirtual修饰,是一个虚函数
  • studentfuncvirtual修饰,是一个虚函数

即函数完全相同 + 两个函数都是虚函数,此时就构成了虚函数的重写。

虚函数重写后,此时再调用func函数,就已经可以通过对象到底是谁来调用对应的函数了。

同样的代码再执行一次:

student s;
person& rp = s;
student& rs = s;
rp.func();
rs.func();

输出结果:

func被student调用
func被student调用

此时哪怕我们把student的对象交给person的引用来维护,虚函数func依然会根据对象本身来调用函数,两次都输出func被student调用

但是虚函数的重写有两个特例:

协变

协变是指子类可以将父类中的返回类型进行适当的改变,以适应不同的使用场景。

在讲解协变之前,先来看一个简单的例子。

class person{
public:
    virtual void eat() {
        cout << "person is eating" << endl;
    }
};
class student : public person{
public:
    void eat() override {
        cout << "student is eating" << endl;
    }
};

在上述代码中,我们定义了一个基类 person 和派生类 student 。基类中有一个虚函数 eat(),派生类中对这个函数进行了重写。

现在,我们可以创建一个 person 的指针,指向一个 student 类型的对象,并调用 eat() 函数:

person* p = new student();
p->eat();  // 输出:person is eating

这是因为虚函数允许基类指针指向派生类对象,调用虚函数时会根据指针所指向的对象类型来动态调用合适的函数。

接下来,我们来讨论协变。假设我们在 person 类中添加一个新的虚函数 sleep()

class person{
public:
    virtual void eat() {
        cout << "person is eating" << endl;
    }
    
    virtual person* sleep() {
        cout << "person is sleeping" << endl;
        return this;
    }
};

现在,我们在 student 类中对 sleep() 函数进行重写:

class student: public person{
public:
    void eat() override {
        cout << "student is eating" << endl;
    }
    
    student* sleep() override {
        cout << "student is sleeping" << endl;
        return this;
    }
};

注意到在 student 类中对 sleep() 的返回类型进行了改变,从 person* 修改为 student*。这就是协变的体现,子类可以将父类中的返回类型进行适当的改变。

然后,我们可以创建一个 person 的指针,指向一个 student 类型的对象,并调用 sleep() 函数:

person* p = new student();
student* c = p->sleep();  // 输出:student is sleeping

尽管 sleep() 函数的返回类型在基类中是 person*,在派生类中是 student*,但由于协变的特性,我们仍然可以将 person* 类型的指针赋值给 student* 类型的指针变量,而不会发生编译错误。

那么如果可以随意改变基类与派生类之间的返回值,那不就违背了一开始的返回值必须相同吗?

其实协变是有条件的:

在进行协变时,子类中的返回类型必须要是父类中返回类型的派生类。

也就是说,如果我们想要返回值不同,也是有要求的,返回值之间必须构成父子关系,才能进行协变。

接口继承

在C++中,接口继承是指一个类继承另一个类的接口部分,即只继承虚函数而不继承函数体部分。这样做的目的是为了在派生类中重写虚函数,以实现特定的功能。

我们看到以下代码:

class person
{
public:
  virtual void func(int a = 5)
  {
    cout << "被person调用 a = " << a << endl;
  }
};
class student : public person
{
public:
  virtual void func(int a = 10)
  {
    cout << "被student调用 a = " << a << endl;
  }
};

以上代码中,两个虚函数func构成重写,但是person中的func,参数a的默认值为5;student中的func,参数a的默认值为10。这不影响的参数列表相同,参数列表是指参数的类型要相同,与默认值无关

执行以下代码:

student s;
person& rp = s;
student& rs = s;
rp.func();
rs.func();

输出结果:

被student调用 a = 5
被student调用 a = 10

奇怪的事情发生了:我们确实使用student对象调用了函数func,所以两次调用都显示了被student调用,说明调用了student中的函数。但是为什么通过person&调用的函数,a的值是5?

这就涉及到了接口继承。

两个函数构成虚函数时,并且通过 基类的引用/指针 调用函数,此时根据多态,会调用到派生类对应的函数,同时会发生接口继承。

如下:

上面的virtual void func(int a = 5)会被继承给派生类,把下面的virtual void func(int a = 10)替换掉,所以最后虽然我们最后通过多态调用到了正确的函数,但是由于接口继承,我们的接口依然是基类的,所以a = 5

但是如果我们直接通过,student&来调用student的函数,此时就是自己调用自己的函数,没有发生多态,所以没有发生接口继承,最后a = 10

所以发生接口继承的要求就是:

  1. 两个虚函数构成重写
  2. 通过基类的 指针 / 引用 来调用

其实这也是多态的要求,我们等下会详细讲解。

也因为这个接口继承规则,我们派生类中的virtual关键字可以省略!

class person
{
public:
  virtual void func()
  {
    cout << "被person调用" << endl;
  }
};
class student : public person
{
public:
  void func()
  {
    cout << "被student调用" << endl;
  }
};

上述代码中,两个函数func构成重写,此时基类的接口virtual void func()会继承给派生类,导致void func()被替换为virtual void func(),最后变成虚函数。

此处共讲解了两个知识点:

  1. 接口继承
  2. 派生类中的virtual关键字可以省略(虚函数重写特例)

两者之间是因果关系。

最后进行一次虚函数重写语法总结:

基本语法:

当派生类中有一个与基类完全相同的虚函数,则会发生虚函数的重写

  • 函数名相同
  • 参数列表相同
  • 返回值相同

特例:

1.当返回值在构成协变的情况下,可以不同(返回值是父子关系)

2. 派生类的virtual可以不写(因为继承了基类接口的virtual)


多态构成

讲完了虚函数,其实多态就已经讲完了一大半了,想要构成多态,条件是:

  1. 必须通过基类的指针或者引用调用虚函数
  2. 基类必须存在相应的虚函数,子类必须对虚函数重写

多态的结果:

多态会根据 指针/引用 指向的对象的类型来调用对应的函数,而不是根据 指针/引用 本身的类型

讲完如何构成多态,接下来我们对比一下C++的类中,成员函数有哪些特殊形态:


成员函数状态对比

在C++的类中,我们的成员函数之间可以构成:重载,重写,重定义(隐藏)。接下来我们对比三者:

函数重载:

功能:当函数传入不同类型的参数时,执行不同的效果

要求:

  1. 重载的函数要在同一个作用域
  2. 函数名相同
  3. 参数列表不同

函数重写:

功能:派生类的虚函数将基类的虚函数重写,以达成多态

要求:

  1. 两个函数分别处于基类与派生类
  2. 函数名相同
  3. 参数列表相同
  4. 返回值相同
  5. 两个函数都是虚函数

函数重定义:

功能:派生类的同名函数屏蔽了基类的同名函数的直接访问

要求:

  1. 两个函数分别处于基类与派生类
  2. 函数名相同
  3. 当函数名相同,只要不构成重写,那就是重定义

抽象类

C++中的抽象类是一种特殊的类,它不能被实例化,只能用作其他类的基类。抽象类的目的是为了定义通用的接口,并强制派生类实现这些接口中的方法。

要创建一个抽象类,需要在类的定义中至少有一个纯虚函数(没有函数体的虚函数)。

当一个虚函数没有函数体,以 = 0 结尾,这个函数就是一个纯虚函数。

如下:

class person
{
public:
  virtual void func() = 0;
};

此时func就是一个纯虚函数。

当一个类有纯虚函数,那么这个类就是一个抽象类。

抽象类不能实例化出对象,其派生类也不能实例化出对象。除非派生类对这个纯虚函数进行重写,派生类才可以实例化出对象

抽象类存在的意义,就是强制派生类进行重写函数


多态原理

那么C++是如何实现多态的呢?

这就和虚函数重写的底层有关了。

虚函数重写,是基于虚函数表的。虚函数表是一个用于存储虚函数指针的数组,其用于存储一个类中所有的虚函数指针,简称虚表

对于一般的类,如果没有虚函数,那么它的函数是不会存储在对象中的。但是虚函数不一样,为了保证可以在对象中确定这个对象对应的函数,我们要想办法在对象中标识出这个对象的虚函数。

于是含有虚函数的类,会多出一个指针,这个指针指向虚函数表,而虚函数表内部存储了这个类所有虚函数的地址。而这个指针叫虚函数表指针,简称虚表指针

每个类都有自己独立的虚表,所以派生类和基类的虚表是独立的

我们看看派生类的虚表是如何生成的:

  1. 先将基类的虚表拷贝一份到派生类的虚表中
  2. 如果派生类重写了虚函数,那么用重写的虚函数覆盖掉原先的虚函数
  3. 如果派生类自己还有额外的虚函数,依次添加到虚表的末尾

第二条至关重要,这是虚函数重写的底层原理:派生类重写了虚函数后,将虚表中相应的虚函数地址替换为重写后的地址。

当我们调用虚函数时,其会通过对象虚函数表指针找到虚函数表,再通过虚函数表定位函数。

将派生类的对象交给基类的 指针/引用 维护时,不会发生拷贝,而是进行一次切片,此时指针依然指向原先的对象,访问虚函数时,通过派生类对象的虚表来访问

当指针/引用指向基类对象:访问基类的虚表,调用重写前的虚函数

当指针/引用指向派生类对象:访问派生类的虚表,调用重写后的虚函数

此时不论是通过基类还是派生类的 指针/引用,都会通过对象本身对应的虚表来调用函数,这样就不会被 指针/引用 影响调用错误了

那么为什么将派生类的对象切片为基类对象,不能调用到派生类的函数呢?

当我们将一个派生类的对象切片为基类对象,此时不是直接进行拷贝,基类在拷贝派生类中的基类成员时,不会拷贝派生类的虚表,而是用基类自己的虚表

因此当我们将一个派生类对象切片为基类对象,由于虚表不是派生类的虚表,所以访问到的虚函数是基类的虚函数,无法构成多态。

虚表的特性:

虚表在编译阶段生成

虚表存储在代码段(常量区)中

只有虚函数才进虚表,普通函数不会进入虚表

虚表指针在构造函数的初始化列表中完成的初始化


多继承与多态

现在我们有以下继承关系:

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

Base1:有func1和func2两个虚函数

Base2:有func1和func2两个虚函数

Derive:继承了Base1和Base2,重写了func1,并增加了一个func3虚函数

多继承的情况下,我们的虚表是如何存放虚函数地址的?

我们看看两张虚表:

Base1继承的虚表:

Derive::func1
[0]:00007FF6750714E2
Base1::func2
[1]:00007FF675071343
Derive::func3
[2]:00007FF6750714C9

Base2继承的虚表:

Derive::func1
[0]:00007FF6750714DD
Base2::func2
[1]:00007FF6750710EB

第一个问题

派生类自己的虚函数func3存储在哪一张虚表?

通过上面两张虚表可以看到,派生类的func3出现在了第一张虚表中

结论:

  1. 多继承时,派生类会继承多张虚表,派生类自己增添的函数会放在第一张虚表中

第二个问题

按理来说,派生类对两个基类的func1进行了重写,那么两张虚表都应该被重写,为什么两张虚表中func1的地址不同?

原因:

对于第一张虚表,它会直接调用func1函数。但是对于第二张虚表,它的指针并不在对象的头部,那么如果直接调用func1函数,就会导致this指针指向错误。所以第二张虚表调用函数时,会先跳转到其它地址,修正自己的this指针,让指针指向对象的开头,再去调用func1函数。

结论:

  1. 如果多个基类中存在同名函数,且函数被派生类重写,此时这个虚函数会被存在多张虚表中,不同虚表会根据对应的地址,来修改自己的this指针,找到对象开头的指针,保证调用函数时this指针正确

虚继承与多态

在虚继承中存在 虚基表/虚基表指针。而多态中存在 虚表/虚表指针,这是一对容易混淆的概念,接下来我们辨析一下:

对比

  1. virtual
    - 虚函数中的virtual 与 虚继承的virtual两者只是共用一个关键字,没有太大关系
  2. 指针
    - 指向虚表的指针叫做虚表指针 / 虚函数表指针
    - 指向虚基表的指针叫做虚基表指针
    - 当一个类同时具有虚表指针与虚基表指针,虚表指针放在虚基表指针前面

  3. - 虚函数通过虚表来找到函数地址
    - 虚继承通过虚基表来找到被共享的间接基类
    - 在虚基表中,第一个位置会空出来,存储一个虚基表指针与虚表指针的偏移量

比如以下结构的虚继承:

那么这个派生类D的对象结构视图如下:

有以下两个注意点:

  1. 当一个类同时具有虚表指针与虚基表指针,虚表指针放在虚基表指针前面

在左侧的对象模型中,对于从同一个类继承下来的指针,虚基表指针会在虚表指针前面。

  1. 在虚基表中,第一个位置会空出来,存储一个虚基表指针与虚表指针的偏移量

在右侧的绿色虚基表中,第一个位置存放的不是到达虚基类成员的偏移量,而是到虚表指针到虚基表指针的偏移量。


相关文章
|
22天前
|
存储 编译器 数据安全/隐私保护
【C++】多态
多态是面向对象编程中的重要特性,允许通过基类引用调用派生类的具体方法,实现代码的灵活性和扩展性。其核心机制包括虚函数、动态绑定及继承。通过声明虚函数并让派生类重写这些函数,可以在运行时决定具体调用哪个版本的方法。此外,多态还涉及虚函数表(vtable)的使用,其中存储了虚函数的指针,确保调用正确的实现。为了防止资源泄露,基类的析构函数应声明为虚函数。多态的底层实现涉及对象内部的虚函数表指针,指向特定于类的虚函数表,支持动态方法解析。
29 1
|
2月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
47 2
C++入门12——详解多态1
|
7月前
|
C++
C++中的封装、继承与多态:深入理解与应用
C++中的封装、继承与多态:深入理解与应用
166 1
|
2月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
87 1
|
4月前
|
存储 编译器 C++
|
5月前
|
存储 编译器 C++
【C++】深度解剖多态(下)
【C++】深度解剖多态(下)
56 1
【C++】深度解剖多态(下)
|
5月前
|
存储 编译器 C++
|
5月前
|
机器学习/深度学习 算法 C++
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
|
5月前
|
Java 编译器 C++
【C++】深度解剖多态(上)
【C++】深度解剖多态(上)
57 2
|
5月前
|
程序员 C++
【C++】揭开C++多态的神秘面纱
【C++】揭开C++多态的神秘面纱