C++【多态】

简介: C++多态,多态的概念、定义、实现和原理,以及虚函数和虚表的详细讲解,干货满满!

C++ 多态

前面介绍了封装和继承,本文就来介绍面向对象三大特征的最后一个—多态,多态就是面对不同对象时所展现的不同状态,下面就一起来看看吧

1. 多态的概念

多态通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

举个例子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票

多态就是指不同继承关系的类对象,去调用同一函数,产生了不同的行为

2. 多态的定义和实现

实现多态需要借助虚函数表(虚表),而构成虚表又需要虚函数,最后再使用虚表指针来进行函数定位和调用

2.1 构成多态的必要条件

  1. 必须通过基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

2.2 虚函数和重写

virtual修饰的类成员函数称为虚函数

举个例子

class A 
{
   
   
public:
    virtual void fun() {
   
    cout << "A::fun()" << endl; }
};

上面的func() 就是虚函数,下面再来见见多态吧

#include <iostream>
using namespace std;

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

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

void Func(Person& p)
{
   
   
    p.BuyTicket();
}

int main()
{
   
   
    Person ps;
    Student st;
    Func(ps);
    Func(st);
    return 0;
}

是否满足多态:满足继承前提,Func()函数中通过基类的引用p调用函数,被调用的函数BuyTicket()是虚函数,且派生类对基类的虚函数BuyTicket()进行了重写

上面说到了重写,那到底什么是虚函数的重写呢?

  • 虚函数的重写也叫覆盖,派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
  • 虚函数的作用是在目标函数(想要构成多态的函数)之间构成 重写,一旦构成了 重写,那么子类对象在实现此虚函数时,会继承父类中的虚函数接口(返回值、函数名、参数列表),然后覆盖至子类对应的虚函数处,因此重写又叫做覆盖

下面我们去掉派生类中的virtual关键字看看运行结果

这里为什么还是构成重写呢?

  • 这是因为在重写基类虚函数后,派生类的虚函数在不加virtual关键字时,也可以构成重写,这里叫做接口继承(因为继承后基类的虚函数被继承下来了,继承了参数,也就是拷贝了一份基类的虚函数的参数),在派生类依旧保持虚函数属性,但是该种写法不是很规范,不建议这样使用。

这里如果只是把基类中的virtual修饰关键字去掉能否构成虚函数重写呢?

显然是不能的,这是因为此时已经不满足派生类对基类的虚函数进行重写了,也就是不满足多态了,这里p.BuyTicket()中的p对象是Person类型的,所以两次调用都是调用的基类的BuyTicket()函数

最后再来看看父子类中的函数都不加virtual的情况

结果是必然的,因为此时已经不满足多态条件中的任何一条了。但是观察得到这里构成了继承中的隐藏,但是这里是基类对象去调用的基类成员函数,这里虽然构成隐藏,但是这里没有用子类对象去调用子类的成员函数BuyTicket(),也就不体现隐藏关系,只有子类调用才会体现隐藏关系

讲个经验:

满足多态看调用对象,不满足多态看调用对象类型

2.3 虚函数重写的两个例外

例外一:析构函数重写(虚函数名不同)

#include <iostream>
using namespace std;

class Person 
{
   
   
public:
    virtual ~Person() {
   
    cout << "~Person()" << endl; }
};

class Student : public Person 
{
   
   
public:
    virtual ~Student() {
   
    cout << "~Student()" << endl; }
};

int main()
{
   
   
    Person* p1 = new Person;
    Person* p2 = new Student;
    delete p1;
    delete p2;
    return 0;
}

上述中满足多态两个条件:通过基类Person的指针调用虚函数,同时也Person()构成了虚函数,并且子类的虚函数Studen()对父类进行了重写,但是这里的虚函数重写违反了派生类虚函数与基类虚函数的三同(返回值类型、函数名字、参数列表完全相同)中的函数名相同,但是为什么还成立呢?

  • 这是编译器会对析构函数的名称做特殊处理,编译后析构函数的名称统一处理成destructor,这就可能存在析构错误调用的问题,因此可以利用 virtual 修饰父类的析构函数,这样子类在继承时,会自动形成多态,确保不同对象的析构函数能被成功调用,避免内存泄漏

这里如果把基类的析构函数中的virtual关键字去掉呢?

这里发现程序直接崩溃了

  • 这是因为这里没有构成多态,导致看对象类型调用函数,又因为p1p2都是Person类型,所以都是调用的~Person()函数,析构了两次导致程序崩溃

例外二:协变(返回值类型不同)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(不常用,了解即可)

#include <iostream>
using namespace std;

class A {
   
   };
class B : public A {
   
   };

class Person 
{
   
   
public:
    virtual A* f() {
   
   
        cout << "Person() -> f()" << endl;
        return new A;
    }
};

class Student : public Person 
{
   
   
public:
    virtual B* f() {
   
   
        cout << "Student() -> f()" << endl;
        return new B;
    }
};

void fun(Person& p)
{
   
   
    p.f();
}

int main()
{
   
   
    Person p;
    fun(p);
    Student s;
    fun(s);
    return 0;
}

2.4 例题运用

下面来看一个题目

#include <iostream>
using namespace std;

class A
{
   
   
public:
    virtual void func(int val = 1) {
   
    std::cout << "A->" << val << std::endl; }
    virtual void test() {
   
    func(); }
};
class B : public A
{
   
   
public:
    void func(int val = 0) {
   
    std::cout << "B->" << val << std::endl; }
};

int main(int argc, char* argv[])
{
   
   
    B* p = new B;
    p->test();
    return 0;
}

请问结果是什么?

  • A: A->0
  • B: B->1
  • C: A->1
  • D: B->0
  • E: 编译出错
  • F: 以上都不正确

解析:

题目改编一:

#include <iostream>
using namespace std;
class A
{
   
   
public:
    virtual void func(int val = 1) {
   
    std::cout << "A->" << val << std::endl; }
    virtual void test() {
   
    func(); }
};
class B : public A
{
   
   
public:
    void func(int val = 0) {
   
    std::cout << "B->" << val << std::endl; }
};
int main(int argc, char* argv[])
{
   
   
    A* p = new B; //B* --> A* 对象赋值转换
    p->test(); 
    return 0;
}

最终结果和原题一样输出 B->1

题目改编二:

#include <iostream>
using namespace std;
class A
{
   
   
public:
    virtual void func(int val = 1) {
   
    std::cout << "A->" << val << std::endl; }
};
class B : public A
{
   
   
public:
    void func(int val = 0) {
   
    std::cout << "B->" << val << std::endl; }
    virtual void test() {
   
    func(); }
};
int main(int argc, char* argv[])
{
   
   
    B* p = new B;
    p->test();
    return 0;
}

解析:不满足多态

2.5 final 和 override

finaloverrideC++11中新增的两个多态相关的关键字

override关键字:

  • 修饰子类的虚函数,检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错

可见因为参数列表不同,无法完成重写,override关键字可以在编译之前就将其检查出来,以保证完成实现多态

final关键字:

  • 修饰父类的虚函数,表示该虚函数不能再被子类的虚函数重写

可见父类的虚函数加上了final关键字后无法被子类的虚函数重写

2.6 重载、重写、重定义

三者总结:

  • 重载:就是函数重载,函数名相同参数不同构成重载,不同的函数参数最终修饰结果不同,确保链接时不会出错
  • 重写(覆盖):父子类中,当出现虚函数且符合重写的三同原则时,就会发生重写(覆盖)行为

  • 重定义(隐藏):父子类中,当子类中的函数名与父类中的函数名相同时,会隐藏父类同名函数,默认调用子类的函数,可以通过 :: 指定调用

3. 抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类)

纯虚函数也可以与普通虚函数构成重写,也能实现多态,但是抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

#include <iostream>
using namespace std;

class Car //抽象类
{
   
   
public:
    virtual void Drive() = 0; //纯虚函数
};
class Benz :public Car
{
   
   
public:
    virtual void Drive() //虚函数重写
    {
   
   
        cout << "Benz-舒适" << endl;
    }
};
class BMW :public Car
{
   
   
public:
    virtual void Drive()
    {
   
   
        cout << "BMW-操控" << endl;
    }
};

int main()
{
   
   
    Car* pBenz = new Benz;
    pBenz->Drive();
    Car* pBMW = new BMW;
    pBMW->Drive();
    return 0;
}

抽象类适合用于描述无法拥有实体的类,比如动物、植物等,这些都是不能直接使用的,需要经过继承赋予特殊属性后,才能作为一个对象

4. 多态的原理

4.1 虚表指针

先来看一道笔试题,这里问sizeof(Base)是多少?

#include <iostream> 
using namespace std;

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

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

32位平台下,如果按照内存对齐的规则,这里应该是12字节大小,但真是如此吗?

这多出来的4个字节是哪里来的呢,调整到64位平台下,大小会变成24,大小能随平台变化而发生变化,我们第一个想到的自然是指针了,通过监视再来看一下

这里我们发现果然多了一个`void类型的指针_vfptr`的指针**

对象中的这个指针我们叫做虚函数表指针(v代表virtualf代表functionptr代表pointer)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,多态也就是依靠虚表指针+虚表实现了的

多态满足条件中有个条件是:必须通过基类的指针或者引用调用虚函数。那么这里怎么来实现的用引用或者指针就可以调用不同类的成员函数?为什么用普通类型就不能实现调用不用类的成员函数呢?

#include <iostream> 
using namespace std;

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

void Func(Person& p)
{
   
   
    p.BuyTicket();
}

int main()
{
   
   
    Person pxx;
    Func(pxx);
    Student sxx;
    Func(sxx);
    return 0;
}

这就验证了,_vfptr指针就是用来调用不同类的成员函数的

4.2 多态和非多态调用

先来看看非多态调用

不是多态时,直接按照对象的类型来调用对应的成员函数

再来看看多态调用

上述观察到,进行执行p.BuyTicket()语句时,编译器是不知道调用哪个类的成员函数的,当完成p.BuyTicket()语句后, 编译器做出了处理工作,直接跳转到Person::BuyTicket()成员函数。

证明一下在执行p.BuyTicket()语句时编译器是不知道调用哪个类的成员函数

虚函数的调用过程

  • 存在虚函数且构成重写的前提下,使用父类指针或父类引用指向对象,其中包含切片行为
  • 切片后,将子类中不属于父类的切掉,只保留父类指针可调用到的部分函数
  • 在调用时,父类指针的调用逻辑就是,虚表第一个位置调用第一个函数,虚表第二个位置调用第二个函数,但是因为此时的虚表是切片得到的,所以同一位置可以调用到不同的函数,这就是多态

4.3 回想满足条件

满足虚函数重写

  • 我们描述了虚函数表,那么如果父类中没有虚函数的话就不能构成多态,再来看这句话不难理解了,因为没有虚函数,就没有虚函数表,当使用父类的引用和指针来调用对应的函数时,就不会在虚函数表中查找,而是直接依据类型来进行调用

满足父类的指针或引用

  • 为什么指针或者引用可以调用相对应的成员函数,但是对象不行呢?对象也能够完成切片啊?原因还是在虚函数表上,对象的话就要拷贝,这里也就要拷贝虚函数表,如果拷贝就会有很大的问题:就比如子类拷贝给了父类,此时就分不清了,父类的虚函数表也是子类的了

4.4 虚函数表

有虚函数的类的对象中都有虚表指针_vfptr,虚表指针所指向的就是对应类的虚函数表(虚表),即virtual function table -> vft,虚表中存储的是虚函数指针,可以在调用函数时根据不同的地址调用不同的方法

下面来看一段代码

#include <iostream>
using namespace std;

class Person
{
   
   
public:
    virtual void func1() {
   
    cout << "Person::fun1()" << endl; };
    virtual void func2() {
   
    cout << "Person::fun2()" << endl; };
    void func3() {
   
    cout << "Person::fun3()" << endl; };
};
class Student : public Person
{
   
   
public:
    virtual void func1() {
   
    cout << "Student::fun1()" << endl; };
    virtual void func4() {
   
    cout << "Student::fun4()" << endl; };
};

int main()
{
   
   
    Person p;
    Student s;
    return 0;
}

通过监视和内存来详细观察一下虚表指针,虚函数地址以及虚表的关系

再来看看把虚函数重写也叫做覆盖的原理

上述我们观察到BuyTicket()成员函数进行了重写,但是travel()ClaimCoupon()成员函数并没有重写,此时观察到Person类维护的有一个虚函数表,Student维护的也有一个虚函数表,BuyTicket()成员函数重写了也就覆盖了

可以这么理解,原本的子类是继承父类的成员函数的,那么当前子类的虚函数表也就是父类的虚函数表,但是当子类对父类的虚函数重写后,原本的父类的虚函数就被覆盖为新的重写的虚函数,这个例子的体现就在_vfptr[0]位置上的BuyTicket()虚函数指针。_vfptr[1]_vfptr[2]两个虚函数并没有被重写,所以就是继承的父类的虚函数。

我们可以打印虚函数表来验证其真实性

  • 虚函数表是一个函数指针数组,所以打印这里的函数指针数组即可。虚函数表是以nullptr为结束标记的(vs中)。虚函数表指针是对象前4个或者8个字节
  • 可以先将虚表指针强转为指向首个虚函数的指针,然后遍历虚表打印各个虚函数地址即可
#include <iostream>
using namespace std;
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;
    }
    virtual void Func4()
    {
   
   
        cout << "Derive::Func4()" << endl;
    }
private:
    int _d = 2;
};

typedef void(*_vfptr)(); //void(*_vfptr)() --> 函数指针 --> 起别名为_vfptr

void print_virtual_function(_vfptr table[]) //打印虚函数表 table -> 数组名 -> _vfptr*(类型)
{
   
   
    for (int i = 0; table[i]; ++i) //虚函数表是以nullptr为结束标记的
    {
   
   
        printf("[%d]:%p\n", i, table[i]);
    }
    cout << endl;
}
int main()
{
   
   
    Base b;
    Derive d;
    //只是支持32位机器下
    //&b -> Base* -> (int*)&b -> 强制转换为int* -> (*(int*)&b) -> 拿到b对象的前四个字节 -> (_vfptr*)(*(int*)&b) -> 强制转换为_vfptr*(函数指针的指针)
    print_virtual_function((_vfptr*)(*(int*)&b));
    print_virtual_function((_vfptr*)(*(int*)&d));

    //支持32和64位机器下
    print_virtual_function((_vfptr*)(*(long long*)&b));
    print_virtual_function((_vfptr*)(*(long long*)&d));

    //支持32和64位机器下
    //&b -> Base* -> (_vfptr**)&b -> Base*强制转换为_vfptr**(函数指针的指针) -> (*(_vfptr**)&b) -> _vfptr*
    print_virtual_function((*(_vfptr**)&b));
    print_virtual_function((*(_vfptr**)&d));
    return 0;
}

我们可以发现Func4()这个函数是在Derive这个子类的虚函数表中的,只不过这个监视窗口进行了修饰而已,其实是有的。可以对打印窗口优化:

typedef void(*_vfptr)(); //void(*_vfptr)() --> 函数指针 --> 起别名为_vfptr

void print_virtual_function(_vfptr table[]) //打印虚函数表 table -> 数组名 -> _vfptr*(类型)
{
   
   
    for (int i = 0; table[i]; ++i) //虚函数表是以nullptr为结束标记的
    {
   
   
        printf("[%d]:%p->", i, table[i]);
        _vfptr f = table[i]; //拿到指针
        f(); //调用类中的虚函数
    }
    cout << endl;
}

4.5 知识补充

虚函数表补充:

  • 虚表是在编译阶段生成的,因为编译就有函数的地址了
  • 对象中的虚表指针是在构造函数的初始化列表中初始化的
  • 虚表一般存储在常量区(代码段),比如visual studio 2019,有的平台中可能存储在静态区(数据段)

4.6 多继承中的虚函数表

上面讲解的时单继承中的虚表,总结一下就是子类中的虚函数对父类中对应的虚函数进行重写(覆盖)

单继承中新增虚函数

  • 父类新增虚函数:父类的虚表中会新增,同时子类会继承到自己的虚表中
  • 子类新增虚函数:只有子类的虚表中会新增,父类看不到也无法调用

多继承也就会出现多个虚函数重写的情况,那么父类是如何处理不同虚表中的相同虚函数的重写呢?

#include <iostream>
using namespace std;

class Base1
{
   
   
public:
    virtual void func1() {
   
    cout << "Base1::func1()" << endl; }
    virtual void func2() {
   
    cout << "Base1::func2()" << endl; }
};

class Base2
{
   
   
public:
    virtual void func1() {
   
    cout << "Base2::func1()" << endl; }
    virtual void func2() {
   
    cout << "Base2::func2()" << endl; }
};

class Derive : public Base1, public Base2
{
   
   
public:
    virtual void func1() {
   
    cout << "Derive::func1()" << endl; }
    virtual void func3() {
   
    cout << "Derive::func3()" << endl; }    //子类新增虚函数
};

int main()
{
   
   
    Derive d;
    return 0;
}

可以看到此时子类拥有两张虚表

那么此时就出现了多继承中的两个主要问题了

  1. 子类中新增的虚函数func3位于哪个虚表中?
  2. 为什么重写的同一个 func1 函数,在两张虚表中的地址不相同?

首先来回答第一个问题,子类中新增的虚函数默认添加到第一张虚表中,可以通过打印虚表来验证

  • 利用直接取地址再类型强转,来打印第一张虚表;在第一张虚表的起始地址处,跳过第一张虚表的大小,来获取第二张虚表的起始地址
typedef void(*VF_T)();

typedef void(*VF_T)();

void PrintVFTable(VF_T table[])
{
   
   
    //虚函数表是以nullptr为结束标记
    int i = 0;
    while (table[i])
    {
   
   
        printf("[%d]:%p->", i, table[i]);
        VF_T f = table[i];
        f();    //调用函数,相当于func()
        i++;
    }
    cout << endl;
}

int main()
{
   
   
    Derive d;

    PrintVFTable(*(VF_T**)&d);    //打印第一张虚表
    PrintVFTable(*(VF_T**)((char*)&d + sizeof(Base1)));    //跳过第一张虚表,打印第二张虚表
    return 0;
}

运行代码可以看出,子类中新增的虚函数func3位于第一张虚表中

也可以通过切片的方式,天然的取到第二张虚表的地址

Base2* table2 = &d;    //切片
PrintVFTable(*(VF_T**)table2);    //打印第二张虚表

再来看看第二个问题的原因,这里会存在多继承中的虚函数冗余调用问题,编译器在调用时,根据不同的地址寻找到同一函数,来解决这一问题

通过反汇编来看看

根据汇编可以看的出来调用p1->func1()p2->func1()最终都是调用Derive::func1()。需要注意的是p2->func1()函数中第一次jmp跳转到sub指令,这里的ecx寄存器就是存的this指针,通过减少4个字节找到Base1类的

这个过程叫做this指针修正,用于解决冗余虚函数的调用问题,这里会修正后继承的父类

这也就得到了第二个问题的答案

  • 两张虚表中同一个函数的地址不同,是因为调用方式不同,后继承类中的虚表需要通过this指针修正的方式调用虚函数,根据不同的地址寻找到同一函数

感谢大佬北 海提供的好图!

4.7 动态绑定与静态绑定

静态绑定

  • 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载

动态绑定

  • 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态

静态绑定就像函数重载,在编译阶段就确定了不同函数的调用;而动态绑定是虚函数的调用过程,需要 虚表指针+虚表,在程序运行时,根据不同的对象调用不同的函数

4.8 多继承例题

class Base1 { public:  int _b1; };
class Base2 { public:  int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };

int main() {
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
    return 0;
}

关于多继承中指针偏移问题,下面说法正确的是什么?

  • A:p1 == p2 == p3
  • B:p1 < p2 < p3
  • C:p1 == p3 != p2
  • D:p1 != p2 != p3

由多继承虚函数表的相关知识可以的出,选择C,Base1和Base2是Derive父类,这里Base1先声明,先继承Base1


C++ 多态进阶到这里就介绍结束了,本篇文章对你由帮助的话,期待大佬们的三连,你们的支持是我最大的动力!

文章有写的不足或是错误的地方,欢迎评论或私信指出,我会在第一时间改正

目录
相关文章
|
1月前
|
编译器 C++
C++入门12——详解多态1
C++入门12——详解多态1
40 2
C++入门12——详解多态1
|
6月前
|
C++
C++中的封装、继承与多态:深入理解与应用
C++中的封装、继承与多态:深入理解与应用
151 1
|
1月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
81 1
|
3月前
|
存储 编译器 C++
|
4月前
|
存储 编译器 C++
【C++】深度解剖多态(下)
【C++】深度解剖多态(下)
54 1
【C++】深度解剖多态(下)
|
4月前
|
存储 编译器 C++
|
4月前
|
机器学习/深度学习 算法 C++
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
C++多态崩溃问题之为什么在计算梯度下降时需要除以批次大小(batch size)
|
4月前
|
Java 编译器 C++
【C++】深度解剖多态(上)
【C++】深度解剖多态(上)
55 2
|
4月前
|
程序员 C++
【C++】揭开C++多态的神秘面纱
【C++】揭开C++多态的神秘面纱
|
4月前
|
机器学习/深度学习 PyTorch 算法框架/工具
C++多态崩溃问题之在PyTorch中,如何定义一个简单的线性回归模型
C++多态崩溃问题之在PyTorch中,如何定义一个简单的线性回归模型