• C++多态的认识与理解


    多态的概念

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

    比方说买高铁票时,如果你是学生的话,买票就有优惠。如果你是军人的话,就可以优先买票。普通人的话,那买票就是正常价了。

    多态定义和实现

    首先我们要知道没有继承就没有多态,多态是建立在继承之上的。多态是在不同继承关系的类对象中去调用同一函数,产生不同结果的行为。


    构成多态的条件

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

    虚函数重写

    虚函数就是被virtual关键字修饰的成员函数

    虚函数的重写就是派生类中有一个跟基类完全相同的虚函数,这就称子类的虚函数重写了基类的虚函数。而这里的完全相同是派生类虚函数与基类虚函数的返回值类型、函数名、参数列表类型完全相同 

    1. class Person
    2. {
    3. public:
    4. virtual void test() { ... }//虚函数
    5. };
    6. class Student : public Person
    7. {
    8. public:
    9. virtual void test() { ... }//虚函数重写
    10. };

    注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也是可以构成重写,因为继承时基类的虚函数被继承下来了,而在派生类依旧保持虚函数属性,所以可以不加virtual关键字修饰。但是不建议

     父类的指针或引用调用虚函数

    为什么是父类的指针不是子类的指针或引用???

    首先假设如果是子类的指针的话,首先创建子类的对象,指针调用虚函数,不用想,肯定调用子类的虚函数,如果想要调用父类就显示调用了呗。但是如果是父类指针的话,那么该父类指针可以进行分割处理(不会中间生成临时对象,属于自然赋值),接受子类传的对象,此时调用虚函数就变得有意义了。如果是子类传的对象,父类引用,就会调用子类的虚函数,反之就调用父类的虚函数。那么此时调用虚函数的时候就形成了多态。

    虚函数重写的两个例外

    协变

    协变就是虚函数重写的时候,基类和派生类的函数返回值类型可以不同但是必须是父子类关系的指针或引用。

    1. class A {};
    2. class B :public A{};
    3. class Person
    4. {
    5. public:
    6. virtual A* test() {return nullptr; }//
    7. };
    8. class Student : public Person
    9. {
    10. public:
    11. virtual B* test() {return nullptr; }//
    12. };

    就像上面的例子,返回值 必须是父子类关系的指针或引用,因为A类与B类也是父子关系,所以虚函数的返回值也应该将person类与student类的父子关系对应起来。如果返回值类型反着写的话编译器是会报错的。

    析构函数的重写

    其实基类与派生类的析构函数也是可以构成虚函数重写的。

    看以下代码运行:

    1. class Person {
    2. public:
    3. ~Person()
    4. {
    5. cout << "~Person()" << endl;
    6. }
    7. };
    8. class Student : public Person {
    9. public:
    10. ~Student()
    11. {
    12. cout << "~Student()" << endl;
    13. }
    14. };
    15. int main()
    16. {
    17. Person* p1 = new Person;
    18. Person* p2 = new Student;
    19. delete p1;
    20. delete p2;
    21. return 0;
    22. }


     首先我们知道new对象时,会为该对象分配空间,所以会自动调用该对象的构造函数。但是student类继承了person类,创建student的对象时,别忘了内部还有一部分属于preson的空间,此时子类可以被分割从而可以被父类接收。

    我们的目的是想要delete函数调用该指针空间所属对象的析构函数并释放空间。但是delete是根据类型去调用的,是父类的指针就调用父类的析构,是子类的指针就调用子类的析构。但是我们创建的是子类的对象:父类的指针不仅仅是源于父类也可能经过子类分割源于子类。所以此时析构的调用实际上就与我们意想中的不同了,因为并没有调用子类成员的析构函数,仅仅并且指针类型去调用父类的析构函数。这种情况就极有可能造成了内存泄漏。

     

    解决:

    首先我们要知道在子类中是不能显示调用析构函数的,其原因就是父子类的析构函数构成隐藏,又由于多态,析构函数的名称其实是被统一处理成destructor。所以delete的功能就相当于是两步:p->destructor()+ operator delete()。此时不难发现父子类的析构函数名其实是相同的,不加virtual修饰的话父子类的析构函数就构成隐藏关系,所以将父子类的析构函数+virtual修饰就形成了虚函数。至此上代码的情况就得以解决。

    (调用两次是因为子类继承了父类的成员,所以析构时会先析构子类再析构父类)

    使用

    1. class Person
    2. {
    3. public:
    4. virtual void BuyTicket() { cout << "买票-全价" << endl; return nullptr; }//
    5. };
    6. class Student : public Person
    7. {
    8. public:
    9. virtual void BuyTicket() { cout << "买票-半价" << endl; return nullptr; }//
    10. };
    11. void Func(Person& p)//父类的引用为形参
    12. {
    13. p.BuyTicket();
    14. }
    15. int main()
    16. {
    17. Person ps;
    18. Student st;
    19. Func(ps);
    20. Func(st);
    21. return 0;
    22. }

    多态调用虚函数时看的并不是调用参数的类型,而看的是该参数(指针或引用)的指向,指向父类对象就调用父类的虚函数,指向子类对象就调用子类的虚函数。而且在派生类中将基类虚函数重写时,在函数前面不加关键字virtual也是没问题的。


     例题解析

    1. class A
    2. {
    3. public:
    4. virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
    5. virtual void test() { func(); }
    6. };
    7. class B : public A
    8. {
    9. public:
    10. void func(int val = 0) { std::cout << "B->" << val << std::endl; }
    11. };
    12. int main(int argc, char* argv[])
    13. {
    14. B* p = new B;
    15. p->test();
    16. return 0;
    17. }

    首先分析p->test(),这里p指向的是B类,而B类将A类继承了下来,而A类中test()函数内部又调用了func()函数,这时不就直接调用B类中的func函数了吗。


    解析:其实在调用test函数时,test有一个隐藏的参数(this指针),指向类型是A*但是是B对象的指针调用的,所以就发生切割,可以构成多态。但最关键的一点是,子类在虚函数重写时重写的是函数内部的实现,而函数声明接口的部分是从父类继承下来的

    所以参数部分就取决于A类的func()函数,而内部的实现就看B类的func()函数。

    重写隐藏重载的区别 

    函数重载:重载函数发生在同一作用域里,函数名相同,参数不同。

    函数隐藏(重定义):发生在基类和派生类中,只要求函数名相同即可。

    函数重写(覆盖):发生在基类和派生类中,函数名相同,函数参数类型相同,返回值相同(除了协变)


    基类和派生类的同名函数不是重写就是隐藏(绝不是函数重载)。

    抽象类 

    在了解抽象类之前要知道什么是纯虚函数,纯虚函数就是在虚函数后面写上=0;而该纯虚函数所在的类就是抽象类。(包含纯虚函数的类就是抽象类)

    抽象类的特点:不能实例化出对象,而且继承了抽象类的子类也不能实例化对象,除非该子类重写纯虚函数        

    多态的原理

    虚函数表

    虚函数是形成多态的重要条件,我们知道类的成员函数是存在公共代码区的,其实虚函数也不例外,但是虚函数的地址是单独被拿出来了,放到了一个虚函数表(相当于一个函数指针数组)当中,而一个类中不仅仅存放成员变量,还存放这个虚函数表的地址

    在VS的X64环境下演示

    1. class Base
    2. {
    3. public:
    4. virtual void Func1()
    5. {
    6. cout << "Func1()" << endl;
    7. }
    8. virtual void Func2()
    9. {
    10. cout << "Func2()" << endl;
    11. }
    12. void Func3()//非虚函数
    13. {
    14. cout << "Func3()" << endl;
    15. }
    16. private:
    17. int _b = 1;
    18. };
    19. int main()
    20. {
    21. Base b;
    22. cout << sizeof(Base) << endl;
    23. }

    多态下的虚函数表

     我们只知道,有成员虚函数就有虚函数表,在多态中我们知道虚函数的重写是构成多态的重要条件,下面我们就来看一下多态下的虚函数表是什么样子:

    1. class Base
    2. {
    3. public:
    4. virtual void Func1()
    5. {
    6. cout << "Func1()" << endl;
    7. }
    8. virtual void Func2()//非虚函数
    9. {
    10. cout << "Func2()" << endl;
    11. }
    12. private:
    13. int _a = 1;
    14. };
    15. class Child:public Base
    16. {
    17. public:
    18. virtual void Func1()
    19. {
    20. cout << "Func1()" << endl;
    21. }
    22. private:
    23. int _b = 3;
    24. };

    首先我们能发现父子类的虚函数表不是共有的,是各自独有一份的。

    父类的虚函数表就中规中矩,因为父类中有两个虚函数,所以虚函数表中就有两个数据(指针)。而子类继承了父类,但是子类的func1()函数与父类的func1()函数构成重写,子类的虚函数表就是上图的样子,虚函数表中子类的非重写虚函数继承了父类的非重写虚函数,所以地址不变,所以可以说子类重写虚函数就相当于父类虚函数被子类覆盖。

    编译器在编译时给类创建一个虚函数表,去虚表里面找函数地址的过程其实是在编译器运行时(构造函数初始化列表阶段)所执行的(虚函数在运行时动态绑定)


    所以言归正传,当我们用指向子类的父类指针调用虚函数时,首先就类型而言父子类不匹配,所以会进行切片处理,就相当于将子类的空间数据切成与父类相同的部分,此时调用虚函数,访问的自然就是子类的虚函数表,所以此时自然调用的就是子类重写好的虚函数。

     其实尽管子类中没有虚函数,全是继承父类的虚函数的情况下(父子类虚表存放的函数指针是一模一样的),父类也不会套用子类的虚表,也是单独存一份虚函数表。而且一个类的虚表与对象个数无关。    


    为什么必须是父类指针或引用调用才能形成多态???

    有了上面的理解,回答这个问题就比较容易了,我们父类指针或者引用指向谁就去谁的虚函数表中调用谁的虚函数。

    那么我们用子类对象初始化父类再去调用虚函数就为什么行不通了呢???假如我们让父类对象去初始化子类,我们知道这是一个赋值的过程,并不会发生切片处理(切片处理通常发生在通过基类指针或引用操作派生类对象时),而赋值就要发生拷贝,但是我们的虚表会发生拷贝吗???

    如果虚表不发生拷贝的话自然是无法形成多态的,所以我们假如虚表指针会发生拷贝。首先我们将子类对象赋值给父类,此时就发生值拷贝,该对象的虚表就和子类的虚表是一模一样的,此时调用该对象自然是可以达到目的。

    但是 这样就完全不符合逻辑,因为就单独父类对象调用虚函数时,其虚表可能并不是父类的虚表,极可能是经过子类对象赋值给父类,而拷贝的父类的虚表。从而就无法满足父类对象调用父类的虚表,子类对象调用子类的虚表。尤其是在析构函数重写时这样调用的话就十分危险了。

    实际上是不允许拷贝虚函数表指针这种情况的。

    所有虚函数都是存在虚表内吗?

    我们知道如果一个类中有虚函数的话就存在虚函数表。所以而我们监视窗口中还可以看到类中是存放有一个指向虚表首元素地址的指针,虚表中存放的是虚函数的指针,所以当我们计算类的大小时要将虚指针也计算在内。

    回到问题,我们先用监视窗口查看一下虚表的内容:

    1. class Base {
    2. public:
    3. virtual void func1() { cout << "Base::func1" << endl; }
    4. virtual void func2() { cout << "Base::func2" << endl; }
    5. private:
    6. int a;
    7. };
    8. class Derive :public Base {
    9. public:
    10. virtual void func1() { cout << "Derive::func1" << endl; }
    11. virtual void func3() { cout << "Derive::func3" << endl; }
    12. virtual void func4() { cout << "Derive::func4" << endl; }
    13. private:
    14. int b;
    15. };

    此时从监视窗口中可以看出Derive类中虚表只存放了两个虚函数一个是继承并重写的func1 虚函数,另一个是继承下来的func2 函数,但是为什么不见Derive类中的func3和func4呢。其实监视窗口并不是检验的标准,不得已时还是直接看虚拟内存空间的内容:(X64平台)

     很明显全0的位置就是分界线的地方(仅猜测),而且虚表中存放了四个函数指针的值,自然知道分别是重写的func1继承的func2,以及原有的func3和func4.为了验证这一问题我们可以先初步的通过函数指针去调用对应的虚函数,如果调用没问题则证明我们的猜想是正确的,反之则是错误的:

    1. typedef void(*VF)();//将无参的函数指针类型重命名成VF
    2. void Print(VF* p)//该函数是为了实现打印虚表内的函数指针并且直接调用
    3. {
    4. int i = 1;
    5. while (*p)
    6. {
    7. printf("func%d = %p->",i++, *p);
    8. (*p)();//*p就是虚表里的函数指针,可以代替函数名直接调用函数
    9. p++;
    10. }
    11. }
    12. int main()
    13. {
    14. Base b;
    15. Derive d;
    16. Print((VF*)*(long long*)&b);//传虚表的地址
    17. cout << endl;
    18. Print((VF*)*(long long*)&d);
    19. //*(long long*)&b 是拿到该对象前八个字节的内容,也就是虚表指针
    20. //防止传参时类型不匹配,强转
    21. return 0;
    22. }

    所以可以证明:一个类中所有的虚函数都会存在该类对应的虚函数表中

     多继承下的虚表

    1. class Base1 {
    2. public:
    3. virtual void func1() { cout << "Base1::func1" << endl; }
    4. virtual void func2() { cout << "Base1::func2" << endl; }
    5. private:
    6. int b1;
    7. };
    8. class Base2 {
    9. public:
    10. virtual void func1() { cout << "Base2::func1" << endl; }
    11. virtual void func2() { cout << "Base2::func2" << endl; }
    12. private:
    13. int b2;
    14. };
    15. class Derive : public Base1, public Base2 {
    16. public:
    17. virtual void func1() { cout << "Derive::func1" << endl; }
    18. virtual void func3() { cout << "Derive::func3" << endl; }
    19. private:
    20. int d1;
    21. };
    22. typedef void(*VF)();//将无参的函数指针类型重命名成VF
    23. void Print(VF* p)//该函数是为了实现打印虚表内的函数指针并且直接调用
    24. {
    25. int i = 1;
    26. while (*p)
    27. {
    28. printf("func%d = %p->", i++, *p);
    29. (*p)();//*p就是虚表里的函数指针,可以代替函数名直接调用函数
    30. p++;
    31. }
    32. }

    首先我们要了解,多继承的派生类中不是只有一个虚表。就拿上面代码而言就可以知道多继承下不仅仅将基类成员变量继承下来了还将基类各自的虚表也继承下来了。因为如果只有一张虚函数表的话会很会乱,并且造成不必要的麻烦,例如在父子类赋值切片处理的时候就无法直观地有效切片,而且多态调用时也会出问题。 

    但是此时问题来了,在Derive类中的func1函数重写了其父类的func1函数,但是func3函数是存在哪张虚表里的呢??? 

    其实经过虚表内容的打印后,不难看出func3其实是放在第一个继承下来的虚表内部的。对于重写的func1函数,我们可以看到Derive里重写的func1虚函数在两个虚表上的地址是不一样的,但是我们重写的的可是同一个函数啊。

    其实我们忽略了一点,类中成员虚函数也是成员函数,而且成员函数都有一个默认的参数:指向类对象的this指针,所以尽管我们在多态调用虚函数时,调用的是哪个类的成员函数传的就是哪个this指针,所以说,当我们多态调用时如果是第一个父类指针调用子类重写虚函数时,此时的this指针指向的也是起始位置,调用的也就是第一个虚表的重写函数。但是第二个父类指针调用子类重写虚函数时,此时的this指针指向的依旧也是起始位置,但是需要调用的是第二个虚表的重写函数,此时其实底层汇编代码自动发生了指针偏移,使得this指针指向第二张虚表,再调用对应的虚函数,所以汇编代码的jmp指令就在不同的位置,所以函数的指针也不同


     疑问

    1.构造函数可以是虚函数吗?

    编译报错,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的,虚函数调用要在虚表中去寻找函数指针,但此时虚表指针还未初始化。

    2.inline函数可以是虚函数吗?

    可以,内联函数没有函数地址,但是多态调用时会忽略inline的作用,只有普通调用inline才会起作用。
    3.虚函数表是在什么阶段生成的,存在哪的?

    虚函数表是在编译阶段就生成的,但是虚函数表指针是在构造函数初始化列表阶段初始化的。一般情况下存在代码段(常量区)的


    题目解析 

    test_1

    1. class A
    2. {
    3. public:
    4. A() :m_iVal(0) { test(); }
    5. virtual void func() { cout << m_iVal <<' '; }
    6. void test() { func(); }
    7. public:
    8. int m_iVal;
    9. };
    10. class B : public A
    11. {
    12. public:
    13. B() { test(); }
    14. virtual void func()
    15. {
    16. ++m_iVal;
    17. cout << m_iVal <<' ';
    18. }
    19. };
    20. int main(int argc, char* argv[])
    21. {
    22. A* p = new B;
    23. p->test();
    24. return 0;
    25. }

     

    这其实是因为在new一个B类对象时,会先初始化A类,调用A的默认构造函数,所以最后会调用func函数,此时虽然是动态开辟B类对象,且func成员函数的this指针指向的是A对象的指针(即父类指针指向子类对象), 但是此时并不会形成多态,因为B类还未初始化,虽然会形成B类虚表,但是还没形成虚表指针,所以此时多态机制还未生效,所以调用的其实是A类的func虚函数,而不是B类的func虚函数。

     

     test_2

    1. class A
    2. {
    3. public:
    4. virtual void f()
    5. {
    6. cout << "A::f()" << endl;
    7. }
    8. };
    9. class B : public A
    10. {
    11. private://私有限定符
    12. virtual void f()
    13. {
    14. cout << "B::f()" << endl;
    15. }
    16. };
    17. int main()
    18. {
    19. A* pa = (A*)new B;
    20. pa->f();
    21. B b;
    22. return 0;
    23. }

    多态不会受到访问限定符的限制。

    在C++中,访问限定符(如public、private、protected)用于控制类中成员的访问权限。

    但是,在多态中,派生类必须能够访问基类中的虚函数,以便实现多态性,无论这个虚函数的访问限定符是什么派生类都可以通过继承方式获取基类中的所有成员函数,包括私有成员函数。

  • 相关阅读:
    Python爬取数据分析
    01、Docker入门
    c++ 11:二叉树练习
    【面试题】http协议
    基于uni-app框架的小程序——点击跟随事件
    计算机毕业设计ssm+vue基本微信小程序的电影票务系统-电影票预订系统
    SQL sever命名规范
    【系统架构设计】架构核心知识: 5 系统安全性与保密性设计
    开源大数据比对平台设计与实践—dataCompare
    H3C S7000/S7500E/10500系列交换机Console密码忘记处理方法
  • 原文地址:https://blog.csdn.net/C_Rio/article/details/133915802