• C++学习之多继承


    目录

    一,继承的特点

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

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

    派生类的默认成员函数

    继承与友元

    继承与静态成员

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

    菱形继承

    虚继承

    虚继承的底层实现

    多继承的例子:

    多继承与组合类

    多继承总结:


    一,继承的特点

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

     简单的来说,私有数据无论是哪种继承方式,继承下来的私有数据都是隐藏的,无法使用的,

    protected继承下来的数据只能用基类的成员函数访问,public继承下来的公有还是公有。

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

    完成继承后,在初始化的时候,我们可以通过派生类来赋值给基类的对象 / 基类的指针 / 基类的引用

    我们可以理解为将派生类切片,切掉继承来的那一部分,将那一部分可以赋值给一个父类对象/父类指针/父类的引用

    基类对象不能赋值给派生类对象。

    派生类的默认成员函数

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

    简单来说父类默认没写的,子类会自动调用,父类显示写了的,在子类当中需要去显示的调用。

    继承与友元

    一句话:友元关系不能被继承,子类访问不到夫类的友元函数。(本质上友元函数是在类外声明定义的,不是成员函数)

    继承与静态成员

    一句话:静态成员不算是被子类继承,子类和父类共享这一份静态成员数据。

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

    首先我们知道单继承---一个类继承另一个类,算作是单继承:

     而对于多继承来说,是一个类同时继承了一个以上的类:

    多继承在上述的方式都是合理的,正常使用。

    菱形继承

    而菱形继承是一种特殊的多继承:

    但是此时的菱形继承区有一个大坑:

    我们知道子类会继承父类的数据,但若子类多继承的父类同时拥有上一继承的类的同一份数据?即菱形继承的最底下这个类拥有两份上上层类的数据。那么在最底下这个类访问的时候就会重现访问冲突这样的问题。

    1. class Person
    2. {
    3. public:
    4. Person(string name)
    5. {
    6. this ->name = name;
    7. }
    8. string name; // 姓名
    9. };
    10. class Student : public Person
    11. {
    12. public:
    13. Student(int num,string name):Person(name)
    14. {
    15. this->_num = num;
    16. }
    17. int _num; //学号
    18. };
    19. class Teacher :public Person
    20. {
    21. public:
    22. Teacher(int num, string name):Person(name)
    23. {
    24. this->_num1 = num;
    25. }
    26. int _num1;//职工编号
    27. };
    28. class Assistant :public Student, public Teacher
    29. {
    30. public:
    31. Assistant(int num, int num1, string name1, string name2, string course) :Student(num, name1), Teacher(num1, name2)
    32. {
    33. this->course = course;
    34. }
    35. string course;//学习课程
    36. };
    37. void Test()
    38. {
    39. string name = "张三";
    40. string name1 = "李四";
    41. string couse = "生物";
    42. Assistant A(001, 002, name, name1,couse);
    43. cout << A.name << endl;
    44. }

    当我们成功初始化A的时候,再次调用他的name,那就会说,访问不明确。那是因为在继承的时候student与teacher类都具有name,此时直接访问就会产生二义性,编译器报错不明确的name,当然我们还是可以解决这个办法:

    通过作用域确定我们访问的name:

    1. void Test()
    2. {
    3. string name = "张三";
    4. string name1 = "李四";
    5. string couse = "生物";
    6. Assistant A(001, 002, name, name1,couse);
    7. //cout << A.name << endl;
    8. cout << A.Student::name << endl;
    9. cout << A.Teacher::name << endl;
    10. }

    那么二义性的问题得到了解决,但是数据冗余的问题得不到解决,那么当被继承的类若果数据够大的话,反而与用继承实现代码复用的初心背道而驰,大大浪费了空间。

    虚继承

    那么该如何解决呢?经过祖师爷们的苦思冥想,c++在之后提供了一个关键字vitual,我们在菱形继承的第一次继承时采用虚继承的方式,这样数据冗余与二义性的问题都得到了解决:

     我们将上述代码使用虚继承修改之后:

    1. class Person
    2. {
    3. public:
    4. Person(string name)
    5. {
    6. this ->name = name;
    7. }
    8. string name; // 姓名
    9. };
    10. class Student :virtual public Person
    11. {
    12. public:
    13. Student(int num,string name):Person(name)
    14. {
    15. this->_num = num;
    16. }
    17. int _num; //学号
    18. };
    19. class Teacher :virtual public Person
    20. {
    21. public:
    22. Teacher(int num, string name):Person(name)
    23. {
    24. this->_num1 = num;
    25. }
    26. int _num1;//职工编号
    27. };
    28. class Assistant :public Student, public Teacher
    29. {
    30. public:
    31. Assistant(int num, int num1, string name, string course) :Student(num, name), Teacher(num1, name),Person(name)
    32. {
    33. this->course = course;
    34. }
    35. string course;//学习课程
    36. };
    37. void Test()
    38. {
    39. string name = "张三";
    40. string course = "生物";
    41. Assistant A(001, 002, name, course);
    42. cout << A.name << endl;
    43. }

    此时可以看到我们是可以直接访问到name的,我们对于最后继承的类也要添加最初父类的构造函数,否则报错,这是为什么呢?

    虚继承的底层实现

    这就要看我们虚继承的底层实现了。

    我们首先简单的看一下name的地址:

    此时发现name只有一份了,Student与Teacher的name是一样的了,即只有一份name了,我们还可以侧面证明:

    1. void Test()
    2. {
    3. string name = "张三";
    4. string course = "生物";
    5. Assistant A(001, 002, name, course);
    6. A.Student::name = "李四";
    7. A.Teacher::name = "王五";
    8. cout << A.name << endl;
    9. }

    再次显示给stdent和teacher的name,结果是输出王五,将student与teacher给的名字交换一下,输出又变成李四,由此可以得出,name只有一份,输出的是最后name赋值的新值,也就是name最后的更新。

    为了更好的分析,我们写一个简化了的菱形虚拟类:

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

    之后再观察内存窗口:&d的内存窗口

    我们的成员变量是整形,四个字节,因此一行的地址代表一个成员变量。

    刚执行完_a=1;所以这里的0x0000007c710FFB60就是我们的_a,

    执行完_a=2,这里的_a就是2了,这两行运行完又证明了虚继承之后_a,是只有一份的。

    执行完_b=3时,此时的内存地址一跃而上来到了0x0000007c710FFB40,其值为3

    执行完_c=4时,内存地址又往下走了,来到了0x0000007c710FFB50,其值为4

    执行完_d=5,内存还是往下走,来到了0x0000007c710FFB58,其值为5.

    之后我们去掉虚继承,再看此时的_a:

     只看_a的话我们可以看到_a的地址发生了变化,即这里是两个_a,分别存储了值。

    其次他的内存一直是递增的,而我们虚继承不是。

    再次观察运行完之后的两者的d对象的内存:

    我们发现不用虚继承时B中就是_a与_b,同理C,之后就是_d,内存是递增的。

    当用虚继承时,_a的内存被单独拿出来也就是A中的内存,且被放在了这一块地址最后面,而B和C没有了_a,却多了一个不知名的地址。之后就是D,里面有一个_d。

    对于这里的不知名地址又是啥呢?

    对于我们之前学习继承,我们可以用子类对象去赋值父类对象,父类指针,父类引用。那是因为地址是按声明顺序存储,我们很容易切片内存而找到父类的那一部分,但此时用了虚继承之后,内存反而不是如此的顺序,切片也就更加困难,那我们该如何解决这个问题呢?

    因此我们需要找到该地址的偏移量,通过偏移量来计算出此时对于这里A的地址,所以真相就是不知名的地址存放了一些信息,包括了对于这里来说就是距离A的偏移量。

    除了对象d,我们创建B的一个对象bb,bb._a=1;bb._b=2;

    可以看到此时B里的也是如此,f6 7f 00 00..对应的地址就是存放了距离A的偏移量。

    通过存放偏移量(虽然还是少量的增加了空间消耗),实现了继承了父类成员不在数据冗杂,且不存在二义性。

    多继承的例子:

    对于我们的io流,实际上就实现了一个菱形继承:

    istream继承ios,ostream继承ios,iostream多继承istream与ostream。

    多继承与组合类

    public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。
    组合是一种 has-a 的关系。假设 B 组合了 A ,每个 B 对象中都有一个 A 对象。
       
    我们在理论当中应该 优先使用对象组合,而不是类继承
        继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称
    为白箱复用 (white-box reuse) 。术语 白箱 是相对可视性而言:在继承方式中,基类的 内部细节对子类可见 。
             继承一定程度破坏了基类的封装,基类的改变,对派生类有很 大的影响。派生类和基类间的依赖关系很强,耦合度高。
           对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象
    来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复 用(black-box reuse) ,因为对象的内部细节是不可见的。对象只以 黑箱 的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被 封装。
    实际尽量多去用组合。 组合的耦合度低,代码维护性好
    不过继承也有用武之地的,有 些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用 继承,可以用组合,就用组合。

    多继承总结:

    那么对于我们,在使用多继承的时候,最好就不要使用菱形继承(对于不是对自己的实力很相信的),我们就使用多继承就行,但不要使用这种特殊的继承。

  • 相关阅读:
    Opencv中的直方图均衡
    JAVA基础(JAVA SE)学习笔记(九)异常处理
    在nvidia-docker容器中测试TensorFlow-Slim 训练图像分类模型
    pbootcms模板标签序数从2开始
    Windows网络模型之异步选择模型(基于消息机制)
    人工智能未来可期:超越人类能力的新科技
    【XSS跨站脚本】存储型XSS(持久型)
    [每日学习]算法学习1——数组二分
    字符串旋转
    元宇宙007 | 沉浸式家庭治疗,让治疗像演情景剧一样!
  • 原文地址:https://blog.csdn.net/qq_61422622/article/details/133863744