• 【C++】继承- 赋值兼容转换、虚基表


    前言

            Hi~大家好呀!欢迎来到我的C++系列学习笔记!

            我上一篇的C++笔记链接在这里哦~:【C++】模板的非类型参数、特化、分离编译_柒海啦的博客-CSDN博客

            C++类与对象博客在这里哦~:【C++】类和对象_柒海啦的博客-CSDN博客_c++类和对象

            本篇,开始介绍C++中类和对象中重要的继承思想,理解面向对象的本质,以及如何回避C++在继承这一方面的一些坑。

            废话不多说,我们直接开始吧~

    目录

    一、继承方式

    1.面向对象三大特性

    2.继承格式

    继承格式定义:

    二、继承中的作用域

    1.父类域与子类域

    重定义(隐藏)

    2.赋值兼容转换(切片)

    3.派生类的默认成员函数

    构造函数

    拷贝构造函数

    赋值重载函数

    析构函数

    总结

    4.继承与静态成员

    三、菱形继承和菱形虚拟继承

    1.多继承

    2.菱形继承的特点

    3.菱形虚拟继承

    四、继承的理解与反思

    1.继承和组合

    2.不被继承的类


    一、继承方式

    1.面向对象三大特性

            我们知道,面向对象有着三大特性,分别是:封装、继承、多态 。

            封装在之前的类与对象中我们可以深刻的体会到,如何对外只开放我们想允许的接口,不暴露一些私有接口,以免让别人乱修改导致此结构造成破坏。比如在类中的private成员只能在本类访问,外面就没有办法访问(除了友元)。

            那么,现在我们就要开始了解面向对象的第二个特性:继承。

            继承,顾名思义,就是后辈继承前辈的一些特点保留下来,方便后辈使用。

            此机制同样被面向对象程序设计让代码可以复用的最重要手段。它能够允许程序员在原来程序设计的基础上进行扩展,增加功能。

            此时被继承的类称作基类(父类),继承的那个类称作派生类(子类)。

            继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。(以前设计的复用是函数复用,继承属类设计层次的复用)

    2.继承格式

            让我们由一个简单的代码入手看看继承的格式吧~

    1. class Person
    2. {
    3. public:
    4. void Init(string name, int height, string birthday) // 基类初始化函数
    5. {
    6. _name = name;
    7. _height = height;
    8. _birthday = birthday;
    9. }
    10. void Print()
    11. {
    12. cout << "------基本信息------\n";
    13. cout << "姓名:" << _name << endl;
    14. cout << "身高:" << _height << "cm" << endl;
    15. cout << "生日:" << _birthday << endl;
    16. cout << endl;
    17. }
    18. protected:
    19. string _name; // 姓名
    20. int _height; // 升高 cm
    21. string _birthday; // 生日 x月x日
    22. };
    23. class PrettyDerby : public Person // 公有继承 基类Person,派生类PrettyDerby
    24. {
    25. public:
    26. void IsAttribute(int speed, int endurance, int power, int willpower, int intelligence) // 初始化属性
    27. {
    28. _speed = speed;
    29. _endurance = endurance;
    30. _power = power;
    31. _willpower = willpower;
    32. _intelligence = intelligence;
    33. }
    34. void Print() // 这里会发生重定义(隐藏)
    35. {
    36. cout << "------赛马娘------\n";
    37. Person::Print(); // 调用继承到派生域的基域被隐藏的函数 访问限定符
    38. cout << "------属性------" << endl;
    39. cout << "速度:" << _speed << endl;
    40. cout << "持久力:" << _endurance << endl;
    41. cout << "力量:" << _power << endl;
    42. cout << "意志力:" << _willpower << endl;
    43. cout << "智力:" << _intelligence << endl;
    44. }
    45. protected:
    46. int _speed; // 速度
    47. int _endurance; // 持久力
    48. int _power; // 力量
    49. int _willpower; // 意志力
    50. int _intelligence; // 智力
    51. // ......
    52. };
    53. int main()
    54. {
    55. Person p;
    56. p.Init("张三", 160, "10月24日");
    57. p.Print();
    58. PrettyDerby d1;
    59. d1.Init("星云天空", 155, "4月26日");
    60. d1.IsAttribute(109, 109, 98, 92, 92);
    61. d1.Print();
    62. return 0;
    63. }

    (诶嘿~调用麻烦了些,之后会对一些部分进行改进,现在主要是看进程相关的东西哦~)

    结果:

     

     (放张图休息休息~)

             通过上面的程序,我们就可以发现,PrettyDerby类公有继承了Person类,然后PrettyDerby类就可以使用基类的成员变量和成员方法。那么关于这些是否存在限制呢?还是说只有这一种继承方式?和public、protected、private属性有没有关呢?

            

             答案是自然有关的,还是大大的有关,我们先来着手于public : Person和public、protected、private这些属性的关系:

    继承格式定义:

    class 派生类 : 继承方式 基类

    继承格式:

    访问限定\继承方式public继承protected继承private继承
    publicpublicprotectedprivate
    protectedprotectedprotectedprivate
    private不可见不可见不可见
     记忆方法:                                                                                                                                        两者选取最小的访问权限。基类若为private在派生类内均为不可见。                               不可见:不可见也就是在派生类里,也不能访问基类的private的成员                                     protected、private 类里可以访问,类外不能访问 不可见类似于隐身,类里类外均不可访问。

             根据上述C++的定义,我们可以得到如下结论:

        注意:
            1权限两个合并为权限小的那一个
            2私有成员的意义:不想被子类继承的成员,可以设计为私有
            3实际开发中,一般使用public继承,很少出现其他继承。同时private基类也很少不想继承给派生类 所以一般使用的是public继承的public    protected成员
            4继承方式不显示写的话,class默认是私有继承,struct默认是公有继承

            了解了继承的格式之后,我们来具体看看重定义(隐藏)是有着什么样的含义呢?按照我们以前的理解,继承子类继承下来的话,父类和子类相同名字,参数不同的话不是构成重载吗,为什么叫重定义(隐藏)呢?这就要和接下来的作用域扯上关系了,我们慢慢道来~

    二、继承中的作用域

    1.父类域与子类域

            之前,我们见过许多作用域:命名空间(namespace)类域等等。同样的,在继承体系中为了更好的区分不同变量之间的关系,也就出现了基类域(父类域)、派生域(子类域)。

    基类域:存放继承下来的成员(基类)

    子类域:存放派生类自己的成员(派生类)

            在不同的作用域取相同的名字的成员是被允许的,同时也是优先局部,其次全局。(如果子类不存在和父类同名的,可以直接进行访问。建议不进行重名)(想要访问父类域的前面必须加上访问限定符 基类::)

            所以,上面我们举的例子程序中,派生类PrettyDerby继承了基类Person,所以在派生类的基类域存放基类的成员与变量(_name...... Init() Print()),派生类域存放着自己的(_speed...... IsAttribute() Print())。

    重定义(隐藏)

            但是此时出现了同名的函数Print。因为就近原则,所以外界访问子类对象的Print函数的时候会优先访问子类的Print,自然的就将父类忽视掉了,故叫做重定义,或者隐藏。而不是重载哦~

            重载要求在同一作用域哦~ 当然,在上面程序中,我们同时也调用了父类域的Print函数,这样子类也能输出和父类同样的功能。 

    2.赋值兼容转换(切片)

            所谓赋值兼容,即两者继承之后,两个类型的对象可不可以相互赋值呢?

        1.子类对象可以赋值给父类对象/指针/引用 -> 大变小 -> 不是同类型,不是隐式类型转换(可以测试 基类& = 派生类对象)
            但是注意,必须是公有继承,其余继承就不行了。
            指针的类型看到的就是子类当中属于父类的那一部分。引用同理
        2.反过来,父类赋值给子类对象是不支持的,强制类型转化也就不行。(指针、引用可以,以后讲)                                

                   

     3.注意:基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。

            即:只能大的给小的,小的给大的话给不全哦。大的自然是指的是继承的派生类,小的就是基类了。

            我们可以通过一下程序调用了解一下:

    1. void test2()
    2. {
    3. Person p;
    4. PrettyDerby d1;
    5. d1.Init("星云天空", 155, "4月26日");
    6. d1.IsAttribute(109, 109, 98, 92, 92);
    7. // 1 子类对象给父类对象
    8. p = d1;
    9. p.Print();
    10. // 2 子类对象给父类引用
    11. Person& p2 = d1;
    12. p2.Print();
    13. // 3 子类对象给父类指针
    14. Person* p3 = &d1;
    15. p3->Print();
    16. // 4 父类强转给子类指针
    17. PrettyDerby* pp = (PrettyDerby*)&p;
    18. pp->Print();
    19. }

             那么在继承之中的默认生成的函数是如何继承实现的呢?针对于作用域是不是分别进行初始化呢?

    3.派生类的默认成员函数

            我们首先可以来复习一下基础的类和对象,几大默认成员函数:

    构造函数

    构造函数        类名(参数)

            编译器默认生成:1.派生类域(本域)内置类型不处理,自定义类型调用其默认构造函数2.基类域调用基类的默认构造函数进行初始化

            显示生成;1.不允许直接初始化基类成员,基类和派生类必须分开处理。基类域调用基类的处理,派生类自己处理。2.类似于匿名对象一样的(初始化:基类(参数))3.如果没有显示调用会调用基类的默认构造函数

            总结:如果不初始化基类属性可不调用基类构造,需要初始化基类域属性就需要显示调用,派生类属性对应初始化即可

            针对上面的描述,我们就可以将我们上面的程序改造一下,使其通过构造函数进行初始化成员。

    1. class Person
    2. {
    3. public:
    4. // 默认构造函数哦~
    5. Person(string name, int height, string birthday)
    6. //Person(string name = "星云天空", int height = 155, string birthday = "4月26日")
    7. :_name(name)
    8. ,_height(height)
    9. ,_birthday(birthday)
    10. {}
    11. // ......
    12. }
    13. class PrettyDerby : public Person // 公有继承 基类Person,派生类PrettyDerby
    14. {
    15. public:
    16. PrettyDerby(string name, int height, string birthday, int speed, int endurance, int power, int willpower, int intelligence)
    17. :Person(name, height, birthday) // 此时不显示调用
    18. ,_speed(speed)
    19. ,_endurance(endurance)
    20. ,_power(power)
    21. ,_willpower(willpower)
    22. ,_intelligence(intelligence)
    23. {}
    24. // ......
    25. }

             注意,因为基类构造函数不存在默认构造函数,(可以重载一下)所以派生类必须要显示调用(编译器调用默认的构造函数)。如果将基类修改成了默认构造函数就可以派生类不用显示调用了:

             此时测试,发现我们想要在构造时修改基类成员不可以,因为没有提供给基类域成员初始化的参数:

    1. void test4()
    2. {
    3. Person p;
    4. p.Print();
    5. PrettyDerby pp("张三", 175, "10月24日", 109, 109, 98, 92, 92); // 此时基类调用的是默认构造函数,此时想给基类域成员传参时不行的哦~
    6. pp.Print();
    7. }

     

    拷贝构造函数

    拷贝构造函数        类名(const 类名&)

            编译器默认生成:1.对于派生类域成员:内置类型,值拷贝;自定义类型调用它的拷贝构造)2.继承的父类成员,必须调用父类的拷贝构造函数初始化

            显示生成:1.调用基类的拷贝对象 :基类(子类传入的对象)  -> 这里就是利用的切片原理(系统支持)(也可以利用指针强转,可是没必要))2.写具体的深浅拷贝

            总结:当派生类对象属性需要深拷贝时,在初始化列表调用基类的拷贝构造(传入的就是派生类对象即可)完成对基类属性的拷贝,其次在对派生类属性进行深拷贝即可

            在当前没有写拷贝构造函数的情况下:

    1. void test5()
    2. {
    3. PrettyDerby pp("星云天空", 155, "4月26日", 109, 109, 98, 92, 92);
    4. //PrettyDerby p1 = pp;
    5. PrettyDerby p2(pp); // 使用系统默认的拷贝构造
    6. p2.Print();
    7. }

             可以看到完全没有问题,因为类对象的属性均为内置类型,所以值拷贝即可,我们可以调试一下看编译器是不是默认调用了基类的拷贝构造函数(可以先写一下,不写的话也是系统默认生成的,当然在非初始化队列也可以写出输出语句,这样不调试也可以查到我们想要查到的信息)

             首先设置断点,调试到拷贝这一步:

             然后F11进入派生类的拷贝构造:

             可以发现率先就是默认调用的就是基类的拷贝构造(此时派生类的拷贝构造并没有显示写出来,表明默认拷贝构造同样调用基类的拷贝构造对基类成员进行拷贝)

            上述实践操作验证成功,现在我们就显示写一下派生类的拷贝构造:

    1. PrettyDerby(const PrettyDerby& p)
    2. :Person(p) // 切片操作
    3. ,_speed(p._speed)
    4. ,_endurance(p._endurance)
    5. ,_power(p._power)
    6. ,_willpower(p._willpower)
    7. ,_intelligence(p._intelligence)
    8. {}

    赋值重载函数

    赋值重载函数        类名& operator=(const 类名&)

            编译器默认生成:1.对于派生类域属性:内置类型,值拷贝;自定义类型调用它的赋值重载。2.对于基类域属性,调用基类的赋值重载函数

            显示生成:1.派生类域属性自己深浅拷贝,2.需要调用派生类的赋值,赋值调用可以显示调用父类的(注意访问限定,默认局部 -- 隐藏关系(子域和父域出现同名函数,指定调用父类的)))

            总结:派生类涉及到深拷贝需要显示写,对于基类属性显示调用其赋值,派生类属性进行深拷贝即可

            和上面默认拷贝构造同理,这里代码不再做演示,这里完善一下显示写赋值重载函数:

    1. PrettyDerby& operator=(const PrettyDerby& p)
    2. {
    3. // 注意这里需要区分基类域和派生类域
    4. // operator=(p); 不行,就近原则,会自己调自己,形成无限递归哦
    5. Person::operator=(p); // 这里使用的是基类赋值重载编译器默认生成 基类没有需要深拷贝的内置类型
    6. _speed = p._speed;
    7. _endurance = p._endurance;
    8. _power = p._power;
    9. _willpower = p._willpower;
    10. _intelligence = p._intelligence;
    11. return *this;
    12. }

     

    析构函数

    析构函数

            编译器默认生成:1.首先调用基类自己的析构函数2.对于派生类域属性,自定义类型调用其析构,内置类型不做处理

            显示生成:1.特殊:派生类的析构函数和基类的析构函数构成隐藏,所以如果要显示写,需要加上作用域(基类::)2.派生类域成员属性内置类型需要析构就析构,自定义类型自动调用其析构函数。3.派生类不需要调用基类的析构函数,即使显示写编译器还会调用一次基类的析构函数。

            注意:1.构成重定义(隐藏)的原因:(隐藏的原因:1.不同作用域 2.同名)因为为了析构函数后面多态的需要,所以编译器会统一的将整个家族的析构函数处理为destructtor()函数,所以构成了隐藏。2.编译器自动调用基类析构函数的原因:保证基类域先创建,后析构,派生类域成员后创建,先析构的原则(先进后出,后进先出),所以编译器会在派生类析构函数结束时调用基类析构函数达到完成该实例化对象的清理。

            总结:只需要在析构函数里写对派生类资源的清理即可

            结论如上,如下我们可以利用上面的程序演示下派生类析构函数会自动调用基类析构函数的特点,首先我们可以将基类和派生类析构的输出语句写出:

    1. // ......
    2. ~Person() // 基类析构函数
    3. {
    4. // 本身不存在需要手动清理的资源
    5. cout << "我是人" << endl;
    6. }
    7. // .......
    8. ~PrettyDerby() // 派生类析构函数
    9. {
    10. Person::~Person(); // 显示调用基类析构函数需要指定作用域
    11. cout << "我是你的赛马娘呀~" << endl;
    12. }
    13. // .......

            1.编译器自动调用基类析构函数:

    1. void test7()
    2. {
    3. PrettyDerby pp("星云天空", 155, "4月26日", 109, 109, 98, 92, 92);
    4. }

            直接实例化对象,查看调用析构时候的输出语句:

            可以看到,“我是人”输出了两次,证明了编译器无论你是否显示调用基类析构函数都会在派生类的析构函数末尾调用基类的析构函数:

             所以,我们不能在派生类自己调用基类析构函数去清理资源,编译器会自动调用。

    总结

    构造函数:存在传入参数,显示写构造函数,如果基类是默认构造函数可不用显示调用,析构函数一定不能自己去调用,其余均需要自己去调用。

    4.继承与静态成员

            如果在基类定义了一个静态成员,那么在此继承体系会共享此成员

            比如,我们在人基类定义一个id,让子类继承,编写测试程序观察是否共享:

    1. void test8()
    2. {
    3. Person p("张三", 160, "10月24日");
    4. PrettyDerby pp("星云天空", 155, "4月26日", 109, 109, 98, 92, 92);
    5. cout << pp.id << endl; // 0
    6. p.id = 1;
    7. cout << pp.id << endl; // 1
    8. }

            上面都只是单方面继承的事,那么C++里面可以多继承吗?

    三、菱形继承和菱形虚拟继承

    1.多继承

            谈到菱形继承,首先就不得先谈谈多继承了:

        单继承:一个子类只有一个直接父类时的继承关系为单继承
        多继承:一个子类有两个或以上的直接父类时,这个继承关系为多继承

    多继承继承格式:派生类 : 继承权限 基类1, 继承权限 基类2, ......

            但是谈到多继承,那么脑阔疼(bushi)的菱形继承就来了,什么是菱形继承?别急,下面用一张图搞定你:

    2.菱形继承的特点

             可以发现,在上图中,首先中间的两个赛马娘继承了第一行的赛马娘,随后第三行的赛马娘右同时继承第二行的两个赛马娘。此时第一行的因子在第三行是否就存在两份了呢?

            答案是正确的。下面我们通过一个简单的程序进一步了解一下:

    1. class A
    2. {
    3. public:
    4. int _a = 1;
    5. };
    6. class B : public A
    7. {
    8. public:
    9. int _b = 2;
    10. };
    11. class C :public A
    12. {
    13. public:
    14. int _c = 3;
    15. };
    16. class D : public B, public C
    17. {
    18. public:
    19. int _d = 4;
    20. };

            比如上述就是一个简单的菱形继承,如下是调试查看变量窗口以及内存窗口:

             首先第一个窗口,黄色和绿色分别代表B基类域和C基类域,外面的蓝色窗口就代表着是D派生类域,就可以发现里面均有相同的A基类域属性,并且同名,这样就会造成二义性问题。

            其次查看内存窗口,也可以发现B和C继承的基类域成员分别在这两个基类域中保存了一份。内置类型还好,如果是很大的一个类型的实例化对象呢?就会造成数据冗余的问题。

            综上,我们就可以发现多继承中的菱形继承所带来的危害:二义性和数据冗余问题。

            当然,我们也可以拿出代码实例来说明这两个问题:

             二义性问题只能靠指定访问域来进行避开:

    1. int main()
    2. {
    3. D d;
    4. //d._a = 0; 二义性
    5. d.B::_a = 0;
    6. d.C::_a = 0;
    7. return 0;
    8. }

             但是这个时候可以看到数据冗余的问题没有得到解决。

            有没有什么办法能在菱形继承这里帮我们解决这些问题呢?

    3.菱形虚拟继承

            在java中解决这种方法采用了直接暴力的方法:去掉多继承。C++中则采用了关键字virtual的虚拟继承。

    虚拟继承:

    格式:        派生类 : virtual 访问限定符 基类 .....

    作用:此时继承的基类不再是直接将数据写入派生类的基类域,而是会形成一个虚基表。(对应位置保存虚基表的指针)

            先不谈虚基表是什么,我们先来验证一下:

            在B继承A类和C继承A类加上virtual后,实现如下程序验证_a是否解决二义性和数据冗余问题:

    1. int main()
    2. {
    3. D d;
    4. d.B::_a = 0;
    5. d.C::_a = 1;
    6. cout << d._a << endl;
    7. return 0;
    8. }

     

             可以看到,这三个_a果然就是同一个_a,这样就解决了二义性和数据冗余问题了。这个时候我们再来看看这里究竟是怎么回事,查看内存和变量窗口:

             可以看到,变量属性窗口和菱形继承类似,因为vs编译器进行了处理,那么我们这里不妨直接看内存窗口,可以和菱形继承的内存窗口相比,会发现在原本的B类域和C类域继承的A类属性_a的值换成了一个地址,而保留_a的在整个内存的最下面。

            我们可以对B类域和C类域保存的指针进行查看;(注意我的机子是小端存储,低位在低地址,高位在高地址)

             按理来说,这两个指针应该指向下面_a的地址(0x00CDFCC8)可是转入指针查看发现结果并不是我们想的那样,而是第一行是一个0,往下走4个字节会存在一个偏移值

            对于B类域的虚基表中偏移量14,B类域虚基表指针此时所在的地址为0xcdfcb4加上14时就可以发现刚好等于0xcdfcc8不就是_a在内存窗口的位置吗。同理,对于C类域中虚基表的偏移量加上对应的地址也可以找到。

            此时我们对虚拟继承就可以通过下面一张图得以窥视:

     

    四、继承的理解与反思

    1.继承和组合

            继承是一种复用方式,我们之前学的组合也是一种,那么在具体的环境中这两种有如何区别呢?

    (public继承)
            继承是一种is a的关系 -- 每一个子类都是一个父类。 人 - 学生 植物-玫瑰花
            组合是has -a的关系 -- 即在一个类里调用另一个类的一个实例化对象。间接的去用。  车 - 轮胎 脑袋 - 眼睛
        适合哪个就用哪个。当有些类型都可以(is a has a)优先使用组合。比如:vector/list/deque
                                              stack
        低耦合,高内聚 :(软件工程)继承是白箱复用(实现的角度)组合是黑箱复用(功能的角度)
                           耦合度高,依赖关系强                 耦合度低,依赖关系低
                        (容易互相影响)                (不容易互相影响)
                    基类的任何改变都会影响派生类        
        但是组合并不能替代继承。--多态,is a的关系。
        一般虚继承放在共用的基类的直接继承类。

    2.不被继承的类

            如果想要让类不被继承的话,除了让构造函数在私有域(这样类的实例化对象无法创造了)还可以加上关键字final,这样就表示这个类为最终类,那么就无法被继承。

  • 相关阅读:
    Servlet----HttpServletResponse类、请求重定向
    Facebook Delos 中的虚拟共识协议
    JavaWeb——Servlet原理、生命周期、IDEA中实现一个Servlet(全过程)
    web前端之uniApp实现选择时间功能
    统信UOS Linux操作系统下怎么删除某个程序在开始菜单或桌面的快捷方式
    项目管理之人力资源管理
    [附源码]计算机毕业设计JAVAjsp运动器材网上销售系统
    ingress 7 层路由机制
    不知道视频怎样提取音频?这里有详细教程分享
    单例模式之懒汉模式和饿汉模式
  • 原文地址:https://blog.csdn.net/weixin_61508423/article/details/127479246