【C++】多态 -- 详解(上)

简介: 【C++】多态 -- 详解(上)

⚪前言

声明一下,下面的代码和解释都是在 VS2019 下的 X86 程序中进行的,涉及的指针都是 4 bytes。如果要其他平台下,部分代码需要改动。比如:如果是 X64 程序,则需要考虑指针是 8 bytes 问题等等。其它编译环境下,可能会有一些些底层实现的小细节不一样。

一、多态的概念

1、概念

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

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

再比如,最近为了争夺在线支付市场,支付宝年底经常会做诱人的扫红包 - 支付 - 给奖励金的活动。那么为什么有人扫的红包很大,而有人扫的却都是1毛,5毛....,其实这背后也是一个多态行为。支付宝首先会分析你的账户数据,比如你是新用户,比如你没有经常支付宝支付等等,那么你需要被鼓励使用支付宝,那么就你扫码的金额 = random()%99;比如你经常使用支付宝支付或者支付宝账户中常年没有钱,那么平台就认为不太需要鼓励你去使用支付宝,那么就你扫码的金额 = random()%1;总结一下:同样是扫码动作,不同的用户扫得到的不一样的红包,这也是一种多态行为。


二、多态的定义及实现

1、多态的构成条件

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

那么在继承中要构成多态还有两个条件

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


2、虚函数

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

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

上述的代码中,成员函数 BuyTicket() 即为虚函数。


3、虚函数的重写

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

class Person {
public:
    virtual void BuyTicket()
    {
        cout << "买票-全价" << endl;
    }
};
 
class Student : public Person {
public:
    virtual void BuyTicket()
    {
        cout << "买票-半价" << endl;
    }
 
    //void BuyTicket() { cout << "买票-半价" << endl}
};
 
void Func(Person& p)
{
    p.BuyTicket();
}
 
int main()
{
    Person ps;
    Func(ps);
 
    Student st;  
    Func(st);
 
    return 0;
}

注意在重写基类虚函数时,派生类的虚函数在不加 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;
    }
};

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

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

class Person {
public:
   ~Person()
    {
        cout << "~Person()" << endl;
    }
};
 
class Student : public Person {
public:
   ~Student()
    {
        cout << "~Student()" << endl;
    }
};
 
int main()
{
  Person* p1 = new Person;
  Person* p2 = new Student;
 
  delete p1;
  delete p2;
 
  return 0;
}

结果好像跟我们想的不太一样,那为什么会出现上面的结果呢?

别忘记了,子类的指针赋给父类指针时,会发生切割。p2 指针只会指向属于父类的那一部分。所以调用的是父类的析构函数。 并不能正确的释放掉动态开辟的空间。

下面是修改后的代码:

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

只有派生类 Student 的析构函数重写了 Person 的析构函数,下面的 delete 对象调用析构函
数,才能构成多态,才能保证 p1 和 p2 指向的对象正确的调用析构函数。


4、C++11 override 和 final

从上面可以看出,C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此:C++11 提供了 override final 两个关键字,可以帮助用户检测是否重写

(1)final:修饰虚函数,表示该虚函数不能再被重写
class Car
{
public:
    virtual void Drive() final
    {}
};
 
class Benz :public Car
{
public:
    virtual void Drive()
    {
        cout << "Benz-舒适" << endl;
    }
};
(2)override:检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
class Car
{
public:
    virtual void Drive()
    {}
};
 
class Benz :public Car 
{
public:
    virtual void Drive() override
    {
        cout << "Benz-舒适" << endl;
    }
};

5、重载、覆盖(重写)、隐藏(重定义)的对比


三、抽象类

1、概念

在虚函数的后面写上 = 0 ,则这个函数为 纯虚函数 包含纯虚函数的类叫做 抽象类 (也叫接口 类),抽象类不能实例化出对象派生类继承后也不能实例化出对象, 只有重写纯虚函数,派生类才能实例化出对象

纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

// 基类 - 抽象类 - 不能实例化出对象
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;
}

2、接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口(声明),目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

可以这样理解:派生类中重写基类的虚函数可以不加 virtual 关键字,因为基类的虚函数的接口(声明)被继承下来了,在派生类依旧保持虚函数属性。


四、多态的原理

1、虚函数表

// sizeof(Base)是多少?
class Base
{
public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
private:
    int _b = 1;
};
 
int main()
{
  cout << sizeof(Base) << endl;
 
  return 0;
}

       通过观察测试我们发现 b 对象是 8 bytes,除了 _b 成员,还多一个 _vfptr 放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做 虚函数表指针 (v 代 表 virtual,f 代表 function)

       一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。那么 派生类中这个表放了些什么呢? 请看下面的详细分析。

针对上面的代码我们做出以下改造:

  1. 我们增加一个派生类 Derive 去继承 Base。
  2. Derive 中重写 Func1。
  3. Base 再增加一个虚函数 Func2 和一个普通函数 Func3。
// 基类
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;
}

Base 类对象模型:

Base 类对象和 Derive 类对象内存模型:

通过观察和测试,我们发现了以下几点问题:

【总结】

1、虚函数表创建的时机是在编译期间。

编译期间编译器就为每个有虚函数的类确定好了对应的虚函数表里的内容。

2、虚函数表指针 _vfptr 创建的时机:在构造函数中的初始化列表位置,这里才是真正的初始化,这个时候才会生成虚表指针,并把虚函数表的首地址赋给虚表指针。

  • _vfptr 跟着对象走,所以对象什么时候创建出来,_vfptr 就什么时候创建出来,也就是运行的时候。
  • 程序在编译期间,编译器会为构造函数中增加为 _vfptr 赋值的代码(这个是编译器的行为)。
  • 当程序在运行时,遇到创建对象的代码,执行对象的构造函数,那么在这个构造函数里有为这个对象的 _vfptr 赋值的语句。

所以在程序运行时,编译器会把虚函数表的首地址赋值给虚函数表指针,那么这个虚函数表指针就有值了。

3、派生类对象 d 中也有一个虚表指针 _vfptr,d 对象由两部分构成,一部分是基类继承下来的成员,虚表指针也就是存在这部分的,另一部分是自己的成员。

4、基类 b 对象和派生类 d 对象虚表是不一样的,这里我们发现基类虚函数 Func1 在派生类中完成了重写,所以 d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。

所以,派生类对象 d 的虚表中本来该放的是基类虚函数的地址,但是因为派生类重写了基类的虚函数,所以基类虚函数的地址就被覆盖变成了派生类虚函数的地址,本意是调用基类的虚函数,结果却调到了派生类的虚函数,这就实现了多态。

5、另外 Func2 继承下来后是虚函数,所以放进了虚表;Func3 也继承下来了,但它不是虚函数,所以不会放进虚表。

6、虚函数表本质是一个存放虚函数指针的指针数组,一般情况这个数组最后面放了一个 nullptr

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

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

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

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

8、基类和派生类,无论是否完成了虚函数的重写,都有各自独立的虚表。

9、一个类的所有对象共享同一张虚表。(就像一个类的所有对象共享成员函数一样)

10、这里还有一个很容易混淆的问题:虚函数存在哪里?虚表存在哪里?

答:虚函数存在虚表,虚表存在对象中。(错误答案❌)

注意上面的回答是错的。但是很多人都是这样深以为然的。

注意虚表存的是虚函数指针,不是虚函数虚函数和普通函数一样的,都是存在代码段的,只是它的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。

那么虚表存在哪里呢?实际我们去验证一下会发现 VS 下是存在代码段的,Linux G++下大家可以自己去验证一下。

VS 下进行验证:

class Base
{
public:
  virtual void func1()
    {
        cout << "Base::func1" << endl;
    }
private:
  int a;
};
 
int main()
{
  Base b;
 
  int a1 = 0; // 栈帧
  int* p1 = new int; // 堆区
  const char* p2 = "hello"; // 常量区
  auto pf = Test; // 函数地址
  static int a2 = 1; // 静态区
 
  printf("栈帧        :0x%p\n", &a1);
  printf("堆区        :0x%p\n", p1);
  printf("常量区      :0x%p\n", p2);
  printf("函数地址    :0x%p\n", pf);
  printf("静态区      :0x%p\n", &a2);
  printf("虚函数表地址:0x%p\n", *((int*)&b));
 
  return 0;
}

【C++】多态 -- 详解(下)https://developer.aliyun.com/article/1514714?spm=a2c6h.13148508.setting.16.4b904f0ejdbHoA

相关文章
|
1月前
|
C++
C++多态实现计算器
C++多态实现计算器
|
16天前
|
C++
C++中的封装、继承与多态:深入理解与应用
C++中的封装、继承与多态:深入理解与应用
24 1
|
20天前
|
C++
c++多态
c++多态
21 0
|
1月前
|
C++
【C++】从零开始认识多态(二)
面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。 封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。 继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。
28 1
|
1月前
|
C++
【C++】从零开始认识多态(一)
面向对象技术(oop)的核心思想就是封装,继承和多态。通过之前的学习,我们了解了什么是封装,什么是继承。 封装就是对将一些属性装载到一个类对象中,不受外界的影响,比如:洗衣机就是对洗衣服功能,甩干功能,漂洗功能等的封装,其功能不会受到外界的微波炉影响。 继承就是可以将类对象进行继承,派生类会继承基类的功能与属性,类似父与子的关系。比如水果和苹果,苹果就有水果的特性。
36 4
|
1月前
|
编译器 C++
c++的学习之路:22、多态(1)
c++的学习之路:22、多态(1)
25 0
c++的学习之路:22、多态(1)
|
10天前
|
算法 编译器 C++
C++多态与虚拟:函数重载(Function Overloading)
重载(Overloading)是C++中的一个特性,允许不同函数实体共享同一名称但通过参数差异来区分。例如,在类`CPoint`中,有两个成员函数`x()`,一个返回`float`,另一个是设置`float`值。通过函数重载,我们可以为不同数据类型(如`int`、`float`、`double`)定义同名函数`Add`,编译器会根据传入参数自动选择正确实现。不过,仅返回类型不同而参数相同的函数不能重载,这在编译时会导致错误。重载适用于成员和全局函数,而模板是另一种处理类型多样性的方式,将在后续讨论中介绍。
|
18天前
|
存储 编译器 C语言
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(下)
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题
32 1
|
18天前
|
存储 编译器 Linux
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(中)
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题
28 1
|
18天前
|
编译器 C语言 C++
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题(上)
从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题
13 0