• 多态的使用以及多态底层的实现(上)


    什么是多态

    我们让不同的对象去完成同一件事情,这件事情的结果是不一样的,例如买火车票,我们学生买火车票,普通人买火车票,或是军人买火车票最后结果都是不一样的。

    多态的要求是什么

    首先一定是要在继承中

    1. 虚函数重写
    2. 必须是父类的指针或是引用去调用

    例如下面的这个代码就构成了多态:

    1. class Person {
    2. public:
    3. virtual void BuyTicket()
    4. {
    5. cout << "买票-全价" << endl;
    6. }
    7. };
    8. class Student : public Person {
    9. public:
    10. virtual void BuyTicket()//这里的Student和person为父子关系,满足了继承的条件
    11. //其次这里的BuyTicket函数完成了函数的重写
    12. {
    13. cout << "买票-半价" << endl;
    14. }
    15. };
    16. void Func(Person& p)//这里的引用为父类的引用
    17. {
    18. p.BuyTicket();
    19. }
    20. int main()
    21. {
    22. Person p;
    23. Student s;
    24. Func(p);
    25. Func(s);//这里使用的是切片
    26. return 0;
    27. }

    那么多态的特点就是会根据实际的类型去调用特定的函数,即上述说的对待同一件事情,做出不同的结果。

    请看代码的执行图像:

    对于Person对象p就是全价,对于Student对象s就是半价。

    下面总结一下普通调用和多态调用的区别:

    那么下面我修改一下上面的Func函数的参数将这个引用换成一个对象会发生什么事情呢?那么不符合多态的条件,那么就是一个普通的调用普通调用看的就是类型。

    所以最后会打印两个买票-全价。

    对于多态的条件一定要理解并且记牢。

    但是构成多态的条件中有几个例外其中协变我会在下面讲这里

    我在给出一个代码

    1. class Person {
    2. public:
    3. virtual void BuyTicket()
    4. {
    5. cout << "买票-全价" << endl;
    6. }
    7. };
    8. class Student : public Person {
    9. public:
    10. void BuyTicket()//这里的Student和person为父子关系,满足了继承的条件
    11. //其次这里的BuyTicket函数完成了函数的重写
    12. {
    13. cout << "买票-半价" << endl;
    14. }
    15. };
    16. void Func(Person& p)//这里的引用为父类的引用
    17. {
    18. p.BuyTicket();
    19. }
    20. int main()
    21. {
    22. Person p;
    23. Student s;
    24. Func(p);
    25. Func(s);//这里使用的是切片
    26. return 0;
    27. }

    这里我将子类的virtual去掉了,但是此时还是可以构成多态的,但是如果你是讲父类的virtual去了,那么就直接不构成多态了。

    那么为什么计算机的底层要这么设计呢?是为了专门预防有下面的这种场景:

    1. class Person {
    2. public:
    3. virtual Person& BuyTicket()
    4. {
    5. cout << "买票-全价" << endl;
    6. return *this;
    7. }
    8. ~Person()
    9. {
    10. cout << "~Person" << endl;
    11. }
    12. };
    13. class Student : public Person {
    14. public:
    15. virtual Student& BuyTicket()//这里的Student和person为父子关系,满足了继承的条件
    16. //其次这里的BuyTicket函数完成了函数的重写
    17. {
    18. cout << "买票-半价" << endl;
    19. return *this;
    20. }
    21. ~Student()
    22. {
    23. cout << "~Student" << endl;
    24. }
    25. };
    26. void Func(Person& p)
    27. {
    28. p.BuyTicket();
    29. }
    30. int main()
    31. {
    32. //Person p;
    33. //Student s;
    34. //Func(p);
    35. //Func(s);
    36. Person* str = new Person;//如果我这里父类的指针指向的是一个父类的对象
    37. //那么不会存在任何的问题
    38. delete str;
    39. str = new Student;//但是如果我这里的父类的指针是指向的一个子类呢?
    40. delete str;
    41. return 0;
    42. }

    从运行的截图可以看到我的子类的析构并没有被调用,如果此时的子类中恰好有一些空间资源,那么这不就造成内存泄漏了吗?

    那么为了解决这个问题编译器底层将所有的析构函数都修改成了一个同名字的函数,那么现在此时的父类和子列的析构函数已经构成了重写。距离实现多态只差一步了,那就是虚函数的声明。

    那么为了解决某些人忘了给子类的析构函数添加virtual,所以编译器就说明了,如果父类的析构函数声明了virtual那么子类的析构就不需要再增加virtual声明了。如果我在上面代码的父类析构增加了virtual

    下面就是运行截图:

    那么现在如果我们父类的析构函数我们自己实现了,但是如果你在实现子类的析构函数就不需要使用virtual了,只要你的父类添加了virtual声明。对于继承的子类如果你手动写了析构,但是没有些父类的析构,那么也会出现上面的问题,所以如果你写了父子类任意一个的析构函数,那么必须要让父类增加virtual声明。

    虚函数重写的条件

    那么要满足虚函数重写需要什么条件呢?

    1. 首先必须要求是父子类,并且函数都是虚函数
    2. 函数必须要满足三同(函数名,参数列表,返回值)【协变例外】

    那么什么是协变呢?

    协变就是在虚函数德三同中返回值可以不同,但是必须是父子类的指针或是引用如下代码

    1. class Person {
    2. public:
    3. virtual Person& BuyTicket()
    4. {
    5. cout << "买票-全价" << endl;
    6. return *this;
    7. }
    8. };
    9. class Student : public Person {
    10. public:
    11. virtual Student& BuyTicket()//这里虚函数的返回值就是不同的,但是构成的是协变
    12. //所以依旧满足多态的条件
    13. {
    14. cout << "买票-半价" << endl;
    15. return *this;
    16. }
    17. };
    18. void Func(Person& p)
    19. {
    20. p.BuyTicket();
    21. }
    22. int main()
    23. {
    24. Person p;
    25. Student s;
    26. Func(p);
    27. Func(s);
    28. return 0;
    29. }

    当然,你返回别的父子类的指针和引用也可以。

    下面来看一道及其坑人的题目

    对于这道题目我们来一步一步的分析,首先它使用父类的指针去指向了一个子类的空间,然后使用这个指针去调用test函数,首先我们来看test函数并不是虚函数,所以并不符合多态的要求,所以这里就是直接去调用谷类的func函数,那么现在就存在了一个问题,当我们在func函数中时,此时的this指针是A* this 还是B* this呢?

    这里你无论是使用A去调用还是B去调用,这里的答案都是A* this。那么原因是什么呢?

    这里我的B虽然继承了A,但是这里并不会去改变父类的函数原型,那么这里继承的意思就是我子类可以用(不是private修饰),并不是会让这个函数拷贝到子类里面。这显然是不可理的。所以这里的test函数依旧是A*this指针。

    那么这里的func函数就是A*this->func();那么现在这里就是父类的指针或是引用去调用函数,现在再看这个func函数是否是虚函数

    ,刚好这个func函数就是虚函数重写(满足三同,这里的函数列表看的是参数的类型和数量,并不要求是变量名字都是一样的),所以这里就是一个多态调用。此时这个指针指向谁就会调用谁的func函数,这个指针指向的是子类所以这里调用的是子类的func函数。但是这里也存在一个巨坑。这里选择的答案是B,因为虚函数的重写继承的是父类的函数声明,重写的是函数的内容。所以这里虽然函数体内部已经变成了子类的func函数,但是函数的声明(包括缺省参数任然是1)所以这里的选择是B。

    这也解释了为什么派生类的虚函数声明可以不用加virtual,因为虚函数的重写,重写了父类的函数实现,但是还是使用了父类的函数声明,而父类的声明是存在virtual关键字的。

    总结就是多态调用的时候是一个接口继承,子类会使用父类的声明重写的是实现。

    如果你将上面的p->test(),变成p->func(),那么就会打印B->0,因为此时没有构成多态(非父类的指针或引用)。

    但是请不要在现实中写这个代码!!!

    重写,重载,重定义的区别

    这里需要记住的一点就是不构成重写就是重定义,因为重写和重定义的条件是存在相同之处的。可以说重写是在重定义的要求上关系可以看成下面的图:

    所以这里我们看父类和子类的函数名是否是相同的,如果相同就看是否是符合重写的要求,如果不满足那就是重定义。

    这里还要提出两个关键字

    第一个关键字就是final,这个关键字的作用是:修饰虚函数,表示该虚函数不能被重写。

    可以看到如果你强制的去重写被final修饰的虚函数那么会直接在编译的时候就报错,当然final还有一个功能修饰类不能被被继承

    。当然final只能修饰虚函数,如果修饰普通的函数会直接编译报错。

    第二个关键字override,这个关键字是用来修饰派生类的虚函数,用来检查是否完成重写,如果这个虚函数没有完成重写就会报错,完成重写函数就正常。

    完成重写正常

    抽象类

    在讲解抽象类之前,还需要了解一下什么是纯虚函数,纯虚函数也就是在虚函数的后面加一个=0,那么包含纯虚函数的类,就叫做抽象类(也叫做接口类),抽象类不能实例化出对象。

    那么这个抽象类有什么意义呢?

    如果你有一个子类继承了这个抽象类呢?

    如果你需要让这个子类能够创建对象:那么你就需要在子类中重写这个纯虚函数。

    那么此时是没有父类的对象的。这里就又显示了多态的一种方法,虽然没有父类的对象,但是父类可能不止有一种子类,请看下面的代码:

    1. class Car
    2. {
    3. public:
    4. virtual void Drive() = 0;
    5. };
    6. class Benz :public Car
    7. {
    8. public:
    9. virtual void Drive()
    10. {
    11. cout << "Benz-舒适" << endl;
    12. }
    13. };
    14. class BMW :public Car
    15. {
    16. public:
    17. virtual void Drive()
    18. {
    19. cout << "BMW-操控" << endl;
    20. }
    21. };
    22. void test(Car* ptr)
    23. {
    24. ptr->Drive();
    25. }
    26. int main()
    27. {
    28. test(new Benz);
    29. test(new BMW);
    30. return 0;
    31. }

    我们可以看到汽车这个父类因为是抽象类,所以是不产生对象的。此时这个指针(ptr)指向哪个子类,就调用哪个子类的函数

    那么有使用抽象类的必要吗?在某些情况下是存在这个必要的

    第一抽象类中的纯虚函数,间接强制派生类去重写(override是放在子类),如果子类不能产生对象那么这个子类多半也是没有意义的。

    那么在什么情况下我们会去使用抽象类这个东西呢?例如现实生活中的某一些物件,拥有一些共同的属性,我们将这个属性拿出来形成一个抽象类,抽象类在现实生活中没有具体的实体的。

    例如人就能作为一个抽象类。我们将人作为一个抽象类,那么我们可以将人这个类继承到医生,司机,程序员等等,不同的职业上。那么这里人是不是一个具体的职业?很明显不是,人需不需要实例化出对象?显然也不需要。还有一个例子动物是不是具体的对象?很明显不是,马,狗,猫等等才是具体的动物。所以我们这里的马继承动物,然后马能够实例化出很多的对象。

    这也可以理解为什么抽象类不能实例化成具体的对象。

    那么如果某一个类被定义成了抽象类,那么这个类有两个意义,第一个意义这个类不能实例化成对象,第二个这个类会存在多个子类。

    我们在之前所使用的多态是在父子类间实现的,而这里的多态是在多个子类之间存在的。此时没有父类的对象,所以此时的多要就不存在使用父类调用父类的函数,指向子类调用子类的对象。但是这里又存在虚函数,虚函数的意义就是实现重写实现多态,所以这里玩法就是存在多个子类继承父类,多个子类重写这个父类,然后根据子类对象的不同来调用不同的函数。

    对于普通函数的继承和虚函数继承,我们只需要记住一点:普通函数的继承是一种实现继承,而虚函数的继承是一种接口继承。

    我子类继承了你的普通函数(非private),那么我子类的对象就能够复用父类的这个函数,而父类的虚函数,我子类只是继承了你父类虚函数的声明,至于这个虚函数的具体实现,被我子类重写了。

    虚函数表指针

    在讲解这个底层实现之前,我们先来了解一下这里的这个sizeof的结果是什么

    1. class Base
    2. {
    3. public:
    4. virtual void Func1()
    5. {
    6. cout << "Func1()" << endl;
    7. }
    8. private:
    9. int _b = 1;
    10. char ch;
    11. };
    12. int main()
    13. {
    14. cout << sizeof(Base) << endl;
    15. return 0;
    16. }

    这里的答案是16.(64位下,指针大小为8)

    这是为什么呢?我们可以从监视窗口看到为什么。

    可以看到在对象b中(我这里创建了一个Base对象),存在了一个虚函数指针。

    这里的对象是没有继承的,也会有这个指针,只要的你的类中是存在了虚函数的,那么就会存在这个指针,这个指针就是虚函数表指针。这个指针里面保存的就是虚函数表(简称虚表)的地址。

    现在我们给上面的那个代码在增加几个虚函数:

    然后我们再通过监视窗口查看一下:

    可以看到这个虚函数指针指向的是一张表,这张表里面就是虚函数的地址。只有虚函数会被存入到表中,不是虚函数,就不会被存到表中。

    那么这里就有一个问题,虚函数编译以后会被存在内存当中的什么位置?

    那么这里问题的答案是普通函数和虚函数在编译以后都会被放在内存中的代码段。只不过虚函数是单独做了一个处理,虚函数将虚函数的地址,单独拿出来了,放在了一个表中。此时的这个表严格意义上来说就是一个函数指针数组。这个数组里面保存的就是函数的地址

    那么此时的内存场景应该是下面的这样:

    多态的原理

    下面我们先来了解一下构成多态的父类和子类里面的成员构成。使用下面的代码:

    1. class Person {
    2. public:
    3. virtual void BuyTicket() {
    4. cout << "买票-全价" << endl;
    5. }
    6. virtual void func(){}
    7. private:
    8. int a = 0;
    9. };
    10. class Student : public Person {
    11. public:
    12. virtual void BuyTicket() {
    13. cout << "买票-半价" << endl;
    14. }
    15. private:
    16. int b = 1;
    17. };
    18. void Func(Person& p)
    19. {
    20. p.BuyTicket();
    21. }
    22. int main()
    23. {
    24. Person p;
    25. Student s;
    26. Func(p);
    27. Func(s);
    28. return 0;
    29. }

    首先来看Person对象从整体来看Persoon对象和我们之前所了解的是一样的。一个虚函数表指针,一个成员变量。

    下图就是在Person对象在物理上真实的样子

    下面我们再来看一下派生类,首先我们要明确一下在派生类中存在几个虚表指针呢?只存在一个。

    为什么呢?

    因为派生类的内存是由两部分构成的:一部分是父类的,一部分是自己的。那么派生类的父类那一部分,也可以说是我的一部分(我具有使用权)此时父类的那一部分已经存在一个虚函数指针了,所以我的派生类也就不需要再改一个虚函数表指针了,但是这个虚函数表指针和父类对象的那个虚函数表指针肯定是不一样的。那么为什么不是同一个呢?不要忘了我们在子类中完成了什么?是虚函数的重写,而虚函数的重写还有一个名字也叫做虚函数的覆盖。这里你可以这么理解子类首先将父类的虚函数指针完全的拷贝下来,然后查看哪一个虚函数完成了重写,对于完成了重写的虚函数就用重写后的地址,覆盖掉原先父类虚函数的那个地址。

    从上图我们也能看到,覆盖了buyticket的函数地址,对于func函数的地址没有进行覆盖。因为func函数在派生类中没有完成重写。

    那么我们在这里再去理解多态的原理到底是什么?为什么可以做到和对象有关?

    此时如果你传过来的是一个父类指针,那么这个指针就会去虚函数表里面寻找这个函数,最后通过虚函数表里面这个函数的地址,去调用这个函数,如果你传过来的是一个子类的指针,那么这里有切片,从子类中拿取父类的一部分,然后依旧是通过虚函数表去寻找对应函数地址然后去执行相对应的函数。

    此时我们就能够明白在编译器的底层是怎么实现多态的了。

    这里我们就知道了所谓的多态调用在底层也就是去单一的执行指令而已。而能够做到指向父类调用父类,指向子类,调用子类。是因为我指向了谁,我就去谁的虚函数表中去寻找这个函数。需要注意的是编译器是在运行起来的时候去对应的表里面去找。

    从汇编也可以看到多态调用和普通调用的区别:

    如果这是一个普通调用我们是在编译链接的时候,就能够确定地址所以此时是看类型调用函数。

    而多态则是在运行时才会确定地址,运行时去虚表里面去确定你要调用函数的地址。同理引用也是一样。

    这也是为什么要存在切割和切片这一个特别的类型转换例子(不会产生临时变量)。如果中间产生了临时对象也就没有了多态。

    现在我们再来思考最后一个问题如果把上面的func函数修改成下面这样为什么就不可以构成多态

    即为何父类对象不能实现多态?

    那么为何不拷贝虚函数表指针呢?

    为何不一定呢?如果假设成立那么此时的父类对象不能保证一定时父类虚表,因为多态调用只是机械的去调用而已。

    例如下面的这种场景,如果假设是成立的,此时的下面两个函数都只会调用子类了。并且此时底层地址会很混乱。

    还有一个更恐怖场景析构函数可能都会被调错(假设成立)

    此时的父类对象p就去调用子类的析构了。所以在子类对象赋值给父类对象的时候,不敢拷贝虚表指针。因为就会造成上面所说的问题。

    那么最后还有一个问题:如果子类不重写父类的虚函数,那么子类和父类的虚表指针是一样的吗?

    答案是不一样

    虽然虚表里面的内容是一样的,但是虚表指针不一样的。

    那么最后一个问题:同类对象的虚表指针是一样的吗?

    答案是一样的。

    这里可以理解成同一个类的多个对象公用一张虚表,但是对于不同的类(即使子类没有重写父类虚函数)也不会公用同一张虚表(非多继承)。

    希望这篇文章能对您有所帮助,写的不好请见谅,如果发现了任何错误,欢迎指出。

  • 相关阅读:
    陪诊小程序的市场潜力与发展趋势研究
    HTML文本内容 转化为纯文本
    Apache 网站服务基础
    音视频 - 视频编码原理
    【java】java线程池
    Tomcat 源码解析一容器加载-大寂灭指(下)
    登录ip地址异常怎么办
    vue 使用 js 监听监听键盘按钮事件并阻止按键默认事件
    Spring面试大全——(有这一篇就够了)
    纯Vue实现网页日常任务清单小功能(数据存储在浏览器)
  • 原文地址:https://blog.csdn.net/D223546/article/details/133943748