• 【C++】C++面向对象编程三大特性之一——继承


    ❤️前言

            本篇博客主要是关于C++面向对象编程中的三大特性之一的继承,希望大家能和我一起共同学习进步!

    正文

            我们刚刚学习一块全新的知识,首先简单关注一下它的概念和简单的使用方法。

    继承的概念及定义

    继承的概念

            继承的概念:继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用

            继承是类设计层次的复用,让我们在一个类的基础上拓展出许多不同的新类,它们之间的关系大概是新类即包含原类的特性,也有自己独特的特性,例如狗类可以拓展出卷毛狗类、直毛狗类等派生类。这样的拓展就是继承,对于派生类来说,我们就是复用了它的基础类的代码。

    继承的定义

            了解了继承的概念之后,我们继续来看继承的定义方式,下面以人类和学生类做演示:

    1. // 派生类 继承方式 基类
    2. class Student : public Person
    3. {
    4. public:
    5. int _stuid; // 学号
    6. int _major; // 专业
    7. };

            上面的代码就是继承的定义方式,其中的人类Person被称作基类或者父类,学生类被称作派生类或者子类。

            继承方式和我们的访问限定符共用关键字,public 是公有继承,protected 是保护继承,private 是私有继承。

    继承基类成员访问方式的变化

            当子类使用不同的继承方式继承了基类的成员之后,子类访问父类成员的情况(也就是父类成员在子类这里的访问权限相当于子类的哪种成员)如下表:

            我们在学校学习继承相关知识的时候,老师一定会给我们一个类似的表让我们去记,但是其实我们一般只需要记住 public 继承即可,这是因为 private 继承和 protected 继承在现实中几乎不太会使用。而且由于 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强,也不太推荐使用它们。

            这里有一个偏门的小知识点:其实在定义继承时,使用关键字class定义类默认的继承方式private,使用struct时默认的继承方式是public,但是我们可以看到平时我们都是显式写出继承方式的,默认的方式并不被推荐。

    基类和派生类对象之间赋值转换

            通过上面的一些知识的学习,我们可以发现:当我们定义一个继承关系之后,如果我们有一个子类对象,其实可以将父类的内容看做是子类对象的一部分,那么我们就可以看看父子类之间是否能进行赋值转换。

            在尝试之后我们可以发现,派生类对象可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。大概情景如下:

            子类可以赋值给父类,但是父类却不能赋值给子类,想想也十分的合理,父类中并没有子类特有的某些信息,那么凭什么赋值给子类呢?

            测试代码如下:

    1. class Person
    2. {
    3. protected:
    4. string _name; // 姓名
    5. string _sex; // 性别
    6. int _age; // 年龄
    7. };
    8. class Student : public Person
    9. {
    10. public:
    11. int _No; // 学号
    12. };
    13. int main()
    14. {
    15. Student sobj;
    16. // 1.子类对象可以赋值给父类对象 / 指针 / 引用
    17. Person pobj = sobj;
    18. Person* pp = &sobj;
    19. Person& rp = sobj;
    20. //2.基类对象不能赋值给派生类对象
    21. // 错误 sobj = pobj;
    22. return 0;
    23. }

    继承中的作用域

            现在我们继续来看继承中的作用域相关知识:

    1. 在继承体系中基类和派生类都有独立的作用域。
    2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用基类::基类成员显式访问)
    3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
    4. 注意在实际中在继承体系里面最好不要定义同名的成员。

    测试代码:

    1. class Person
    2. {
    3. public:
    4. void func(int x)
    5. {
    6. cout << "P::func" << endl;
    7. }
    8. protected:
    9. string _name; // 姓名
    10. string _sex; // 性别
    11. int _age; // 年龄
    12. };
    13. class Student : public Person
    14. {
    15. public:
    16. void func()
    17. {
    18. cout << "S::func" << endl;
    19. }
    20. int _No; // 学号
    21. };
    22. int main()
    23. {
    24. Student stu;
    25. stu.func();
    26. return 0;
    27. }

    派生类的默认成员函数

            派生类继承了基类的特征,那么在派生类中的默认成员函数与普通的类会有什么不同呢?

            派生类的默认成员函数需要我们记住的点大概有这些:

    1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显式调用。
    2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
    3. 派生类的operator=()必须要调用基类的operator=()完成基类的复制。
    4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
    5. 派生类对象初始化先调用基类构造再调派生类构造。
    6. 派生类对象析构清理先调用派生类析构再调基类的析构。
    7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会学到)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

            关于派生类和基类的构造和析构函数,它们的调用情况大概如下图:

            这里构造和析构的顺序对应着栈空间的先进后出,我们可以根据这个进行记忆,除此之外,还有一些原因就是派生类的一些构造析构行为可能会用到基类的数据。

    继承与友元

            友元在继承中的特点就是:友元关系不能被继承,形象的比喻就是:父亲的朋友一开始不一定是我的朋友,他必须自己和我们确认朋友关系才能成为朋友。

    继承与静态成员

            基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。

            这也可以做比喻,人数是人类的静态成员,小孩是人类的子类,小孩增加了难道人数会为这个小孩重新计数吗?小孩也是人类中的一种,具有人类共同的一些特征,这些特征可以对应静态成员。

            也可以说,派生类只继承了静态成员的使用权,但是并不会增加静态成员的实例。

    测试代码:

    1. class Person
    2. {
    3. public:
    4. Person() { ++_count; }
    5. protected:
    6. string _name; // 姓名
    7. public:
    8. static int _count; // 统计人的个数。
    9. };
    10. int Person::_count = 0;
    11. class Student : public Person
    12. {
    13. protected:
    14. int _stuNum; // 学号
    15. };
    16. class Graduate : public Student
    17. {
    18. protected:
    19. string _seminarCourse; // 研究科目
    20. };
    21. void TestPerson()
    22. {
    23. Student s1;
    24. Student s2;
    25. Student s3;
    26. Graduate s4;
    27. cout << " 人数 :" << Person::_count << endl;
    28. Student::_count = 0;
    29. cout << " 人数 :" << Person::_count << endl;
    30. }
    31. int main()
    32. {
    33. TestPerson();
    34. return 0;
    35. }

     复杂的菱形继承和菱形虚拟继承

            继承的种类可以由派生类继承基类的数量分为单继承和多继承。

            单继承:一个子类只有一个直接父类时称这个继承关系为单继承。

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

            多继承的定义只需要在原有的单继承关系之后加上逗号和新的继承关系即可。

            而当我们使用多继承的时候,就会引出一个比较复杂的问题,也就是菱形继承问题:

            菱形继承似乎在现实中确实会有应用场景,但是它会引发一些问题,当我们使用如上的菱形继承关系,那么在Assistant类中似乎就会存在两个Person类,也就是说他可能具有两份人的特征,这会造成数据冗余和二义性的问题,更详细的我们可以通过如下的对象成员模型进行分析:

            那我们是否有方法去解决这样的问题呢?这时候我们就可以使用虚拟继承,虚拟继承可以很好地解决菱形继承所带来的数据冗余和二义性的问题,但需要注意的是,虚拟继承也只在菱形继承中有用,其他地方不建议使用。

            上述关系的菱形虚拟继承的代码如下:

    1. class Person
    2. {
    3. public:
    4. string _name; // 姓名
    5. };
    6. // 要使用菱形虚拟继承需要在中间位置的继承方式前加上 virtual 关键字
    7. class Student : virtual public Person
    8. {
    9. protected:
    10. int _num; //学号
    11. };
    12. class Teacher : virtual public Person
    13. {
    14. protected:
    15. int _id; // 职工编号
    16. };
    17. class Assistant : public Student, public Teacher
    18. {
    19. protected:
    20. string _majorCourse; // 主修课程
    21. };
    22. void Test()
    23. {
    24. Assistant a;
    25. Teacher& t = a;
    26. Student& s = a;
    27. a._name = "Joker";
    28. t._name = "Peter";
    29. s._name = "张三";
    30. }
    31. int main()
    32. {
    33. Test();
    34. return 0;
    35. }

            调试这段代码,我们可以发现通过菱形虚拟继承,Assistant对象中最终只含有一个Person类的数据,成功的解决了数据冗余和二义性的问题。

            那么虚拟继承是如何解决这个问题的呢?我们现在来了解菱形虚拟继承的底层原理。

    菱形虚拟继承的底层原理

            下面是菱形虚拟继承的测试代码,我们可以通过调试下面的代码来研究底层原理。

    1. class A
    2. {
    3. public:
    4. int _a;
    5. };
    6. // class B : public A
    7. class B : virtual public A
    8. {
    9. public:
    10. int _b;
    11. };
    12. // class C : public A
    13. class C : virtual public A
    14. {
    15. public:
    16. int _c;
    17. };
    18. class D : public B, public C
    19. {
    20. public:
    21. int _d;
    22. };
    23. int main()
    24. {
    25. D d;
    26. d.B::_a = 1;
    27. d.C::_a = 2;
    28. d._b = 3;
    29. d._c = 4;
    30. d._d = 5;
    31. return 0;
    32. }

            我们调试上面的代码时需要结合d对象的内存来看,这样可以更清晰的得到对象的存储模型,具体的内存分布如下图:

            我们可以看见d对象的b部分和c部分除了他们本身的数据成员以外还多出了一份数据,那么这个数据代表着什么意思呢?我们可以看到这个数据得大小是四个字节,而且有点不规整,那么很有可能就是一个指针,也确实猜对了,这个数据就是一个指针。它的名字叫做虚基表指针,指向一个叫做虚基表的东西,那么现在让我们看看这两个指针指向的虚基表:

            这两个虚基表中分别存了两个有效数据从b部分到c部分为20和12,它们的意思分别为这两个部分到a部分的偏移量,单位为字节。通过这样的模型,我们就可以通过保存的偏移量的值和地址来控制公有数据,也就是a部分的数据。

            将上面的内存结构整理成如下的结构示意图:

    继承的总结和反思

    1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
    2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java。

    继承和组合

            继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
            因此我们尽量会选择另一种类设计层次复用的方式,也就是组合——对象组合是类继承之的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。

            简单来说,继承关系就是is-a,而组合关系则是has-a,继承中子类是父类的一种,而组合则是一个类是另一个类的成员。实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。

    🍀结语

            今天的博客就到此为止啦,希望能对大家有用。

  • 相关阅读:
    【JavaWeb】第五章 jQuery
    nestJs(一) 创建node项目
    【Java】采用 Tabula 技术对 PDF 文件内表格进行数据提取
    论文解读(SUBG-CON)《Sub-graph Contrast for Scalable Self-Supervised Graph Representation Learning》
    Linux环境下redis安装及远程ip访问
    JS——利用js实现模态框的拖拽
    【技巧】如何保护PDF文件不被随意修改?
    C++类与对象——封装
    废了,一开始学建模拥有这些资料就好了
    企业数字化转型需要深入研究,不能为了转型而转型
  • 原文地址:https://blog.csdn.net/MO_lion/article/details/132652576