【C++要笑着学】继承 | 子类默认成员函数 | 单继承与多继承 | 钻石继承 | 虚拟继承 | 继承和组合(二)

简介: 本系列 C++ 教学博客的基础知识已经告一段落了,下面的章节我会先把面向对象三大特性讲完,然后穿插一些数据结构的教学以方便我们继续讲解 STL 的 map 和 set。对于面向对象三大特性 —— 封装、继承、多态,我们已经在之前讲解过封装了,本章将开始讲解继承,详细探讨多继承引发的钻石继承问题,并用虚继承解决钻石继承问题。阅读本章需要掌握访问限定符以及默认成员函数的知识,如果阅读过程中感到有些许生疏建议先去复习一下。

Ⅱ. 子类默认成员函数


0x00 引入:默认成员函数

🔗 复习:【C++要笑着学】类的默认成员函数详解

5350f6faad1e6265fe4ad28d157e4122_5e1ba242ab8a45fa91b2318b25a34c39.png

(不含C++11)

383937cf13abe926c25d89dd169938b3_3c26cc0116a74612a33c950ef4bb7080.png


我们知道,对于默认成员函数,如果我们不主动实现,编译器会自己生成一份。


那么这些默认成员函数在子类中,它们又是如何生成的?

b60a306aea3b6a8a4965ecbf529a4669_1c30baa6cd5c4c20936adc4714e2c27e.png


0x01 子类构造函数

① 父类成员需调用自己的构造完成初始化。 即子类的构造函数必须调用父类的构造函数初始化父类的那一部分成员。


② 如果 父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显式调用。


③ 子类对象初始化先调用父类构造再调子类构造。


💬 代码演示:

class Person {
public:
    /* 父类构造函数 */
    Person(const char* name = "foxny")
        : _name(name)
    {
        cout << "Person()" << endl;
    }
protected:
    string _name;
};
class Student : public Person {
public:
    /* 子类构造函数 */
    Student(const char* name, int num)
        : Person(name)  // 父类成员,调用自己的构造完成初始化
        , _num(num)
    {
        cout << "Student()" << endl;
    }
protected:
    int _num;   // 学号
};
void test() {
    Student s1("小明", 18);
}

🚩 运行结果:

e495d52991d745c0cd17868871234044_a6ebe7c1a9d4402f96a4ff72fdc4ab3d.png


调用父类构造函数初始化继承自父类的成员,自己再初始化自己的成员(规则参考普通类)。


析构、靠别构造、赋值重载也是类似的。


❓ 思考:如何设计一个不能被继承的类?


💡 将父类的构造函数私有化:

class A {
private:    // 将A的构造函数私有化
    A() {}
};
class B : public A {
};
int main(void) 
{
    B b;    ❌ 
    return 0;
}

父类 A 的构造函数私有化后 B 就无法构造对象,因为 B 的构造函数必须要调用 A 的。

A a;  ❌   但是好像A也没办法构造了

这波属于是自损八百了,A 也没法构造了,但是我们可以这么玩(后期讲单例模式会细说):

class A {
public:
    static A CreateObject() {  // 提供一个获取对象的方式
        return A();
    }
private:
    A() {}
};
class B : public A {};
int main(void) 
{
    A a = A::CreateObject();
    return 0;
}

此时我们提供一个获取对象的成员函数即可,这里加上 static 解决先有鸡还是先有蛋的问题。


0x02 子类拷贝构造函数

子类的拷贝构造函数必须调用父类的拷贝构造完成拷贝初始化。


💬 代码演示:

class Person {
public:
    /* 父类构造函数 */
    Person(const char* name = "小明")
        : _name(name)
    {
        cout << "Person()" << endl;
    }
    /* 父类拷贝构造 */
    Person(const Person& p)
        : _name(p._name)
    {
        cout << "Person(const Person& p)" << endl;
    }
protected:
    string _name;
};
class Student : public Person {
public:
    /* 子类构造函数 */
    Student(const char* name, int num)
        : Person(name)
        , _num(num)
    {
        cout << "Student()" << endl;
    }
    /* 子类拷贝构造 */
    Student(const Student& s)
        : Person(s)     // 子类的拷贝构造函数必须调用父类的拷贝构造完成拷贝初始化。
        , _num(s._num)
    {
        cout << "Student(const Student& s)" << endl;
    }
protected:
    int _num;   // 学号
};
void test() {
    Student s1("小明", 18);
    Student s2(s1);
}

🚩 运行结果:

977a0e92ee1feb8c09de718ac2ba9a28_fb2f088487cf44679b15adbe3ec22217.png


0x03 子类的赋值重载

子类的 operator= 必须要调用父类的 operator= 完成父类的复制。


💬 代码演示:

class Person {
public:
    /* 父类构造函数 */
    Person(const char* name = "小明")
        : _name(name)
    {
        cout << "Person()" << endl;
    }
    /* 父类赋值重载 */
    Person& operator=(const Person& p) {
        cout << "Person& operator=(const Person& p)" << endl;
        if (this != &p) {
            _name = p._name;
        }
        return *this;
    }
protected:
    string _name;
};
class Student : public Person {
public:
    /* 子类构造函数 */
    Student(const char* name, int num)
        : Person(name)
        , _num(num)
    {
        cout << "Student()" << endl;
    }
    /* 子类赋值重载 */
    Student& operator=(const Student& s) {
        cout << "Student& operator=(const Student& s)" << endl;
        if (this != &s) {
            // 子类的 operator= 必须要调用父类的 operator= 完成父类的复制
            Person::operator=(s);  
            _num = s._num;
        }
        return *this;
    }
protected:
    int _num;   // 学号
};
void test() {
    Student s1("小明", 18);
    Student s3("小红", 17);
    s1 = s3;
}

🚩 运行结果:

ea5f91cda1f317549da1283f499d2ec1_a57ca05b93204608b90e37f7c96183cd.png


0x04 子类析构函数

为了保证子类对象先清理子类成员再清理父类成员的顺序,先子后父。


子类析构先子后父,子类对象的析构清理是先调用子类析构再调父类析构。


子类析构函数完成后会自动调用父亲的析构函数,所以不需要我们显式调用。

class Person {
public:
    /* 父类构造函数 */
    Person(const char* name = "小明")
        : _name(name)
    {
        cout << "Person()" << endl;
    }
    /* 父类析构 */
    ~Person() {
        cout << "~Person()" << endl;
    }
protected:
    string _name;
};
class Student : public Person {
public:
    /* 子类构造函数 */
    Student(const char* name, int num)
        : Person(name)  // 父类成员,调用自己的构造完成初始化
        , _num(num)
    {
        cout << "Student()" << endl;
    }
    /* 子类析构 */
    ~Student() {
        cout << "~Student()" << endl;
    }  //  -> 自动调用父类析构函数
protected:
    int _num;   // 学号
};
void test() {
    Student s1("小明", 18);
}


🚩 运行结果:

9e454d4565c045b0bc73c61e166e4849_42dd20d8face45e593a5afe9ea29482f.png


Ⅲ. 多继承与钻石继承问题


0x00 多继承的概念

我们先说说单继承,刚才我们讲的其实就是单继承。


单继承:一个子类只有一个直接父类,我们称这种继承关系为单继承。

74e27b1a06390445ac63cbab48cdae7a_8cc982cca13b40f19c3a6faba9ffc7ec.png


多继承:一个子类有两个或以上直接父类,我们称这种继承关系为多继承。

c753671800162a81f58dc8882a277a02_e2a86a527f9840b4a68b896161bbb957.png


大佬早期设计的时候认为多继承挺好的,这也没有出现什么大的毛病。


因为一个子类继承多个父类的情况也挺合理的,比如有的角色,既是学生也是老师;


房车,既是房子也是车;微软 Surface 二合一设备,既是平板也是电脑……

c4723495c990ed1d09ea3c7a833e7eff_e2e2aeeaca2b4b328412c9f8561ece12.png

但实际慢慢用起来后问题就慢慢显现出来了,有多继承就会产生 "钻石继承",我们继续往下看。


0x01 钻石继承的概念

📚 概念:钻石继承,又称菱形继承(diamond-inheritance),是多继承的一种特殊情况。


💭 举个例子:研究生助教继承了学生和老师,学生和老师又都继承了人

ee8f76d70dbf4dd03ed2f3f06b0e3bc8_94a0f847bdf142ae9f21cf82e0805d9a.png


这时候就产生了经典的钻石继承,此时会带来一些问题,我们下面会详细探讨。


❓ 为什么叫做钻石继承呢?看图你就知道为什么了:

b1b390d934c35ddadc6982820bb20aaa_6de5dc7243be46a1879959f690d2c8d2.png

📚 钻石继承的问题:钻石继承存在数据冗余和二义性的问题

a8a0e62298c6f19c21018e7565b6a52b_615f7e20f8ab4feea3260bf2dc68bc9d.png


💬 代码:演示一下二义性带来的问题

class Person {
public:
  string _name; // 姓名
};
class Student : public Person {
protected:
  int _num; //学号
};
class Teacher : public Person {
protected:
  int _id; // 职工编号
};
class Assistant : public Student, public Teacher {
protected:
  string _majorCourse; // 主修课程
};
void Test() {
  // 这样会有二义性无法明确知道访问的是哪一个
  Assistant a;
  a._name = "peter";
  // 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
  a.Student::_name = "xxx";
  a.Teacher::_name = "yyy";
}

但是二义性通过指定作用域是可以解决的,告诉编译器是从学生那继承的还是从老师那继承的。


但是数据冗余没有解决,数据冗余带来的最大问题是空间的浪费:

class Person {
public:
  string _name;
  int _hugeArr[10000];   // 如果数据很大,浪费的可不是一点点了
};
如果数据很大

,这可不是闹着玩,这会造成大量的空间浪费。


0x02 通过虚拟继承解决钻石继承问题

对于空间浪费,有什么解决的方法吗?有的,使用虚拟继承去解决钻石继承的数据冗余问题。


💬 代码:在类腰部位置加一个 virtual 关键字

class Person {
public:
  string _name;
  int _hugeArr[10000];
};
// 虚继承
class Student : virtual public Person {
protected:
  int _num;
};
// 虚继承
class Teacher : virtual public Person {
protected:
  int _id;
};
class Assistant : public Student, public Teacher {
protected:
  string _majorCourse;
};

加上 virtual 表示虚继承,此时就能完美解决了钻石继承带来的数据冗余问题。


再配合刚刚我们讲的指定作用域,二义性也可以得到很好的解决:

void Test() {
  // 显示指定访问哪个父类的成员解决二义性
  a.Student::_name = "xxx";
  a.Teacher::_name = "yyy";
}

加上虚继承后我们统称为 —— 钻石虚拟继承。


0x03 有关多继承的思考

19942ad08b273fb28f95a11ac78f84b9_3c547f53f7a541f18c18cdc665eead0d.png

钻石继承存在的根源是因为多继承的存在,有了多继承就会导致钻石继承。


而钻石继承就要使用虚继承去解决,这是比较复杂的,Java 为了躲掉了这个坑,


直接索性取消掉了多继承,这样自然也就不存在钻石继承这些东西了。

99212df890e11a4c8f3b760b50fcf9d7_d57e39cc8c9d4604989a4d65d157f1d6.png

可能是早期设计的时候没有经验,正所谓:


"前人栽树候人乘凉,前人踩坑后人避坑。"


❓ 思考:既然如此,为什么C++不取消多继承机制?


木已成舟,东西都设计出来了,不可能说再取消多继承机制,那人家之前写的代码跑不过去了。


0x04 继承和组合

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

⬛ 黑箱复用:C对象公有成员D可以直接用,C对象保护成员D不能直接用。


⬜ 白箱复用:C对象公有成员D可以直接用,C对象保护成员D也可以直接用。


我们举个旅游的例子来讲解:


团体出行:人和人之间关系太紧密 —— 耦合度高


自由出行:人和人之间关系松散的,没有很多具体要求 —— 耦合度低


继承就是团体出行,A 任何成员的修改都有可能影响 B 的实现。


组合就是自由出行,C 只要不修改公有,就不会对 D 有影响。


🔺 总结:适合 is-a 关系,建议继承。适合 has-a 关系,建议组合。都可以,建议组合。

// Car和BMW Car和Benz构成is-a的关系
class Car {
protected:
  string _colour = "白色"; // 颜色
  string _num = "苏KBD246"; // 车牌号
};
class BMW : public Car {
public:
  void Drive() { cout << "好开-操控" << endl; }
};
class Benz : public Car {
public:
  void Drive() { cout << "好坐-舒适" << endl; }
};
// Tire和Car构成has-a的关系
class Tire {
protected:
  string _brand = "Michelin"; // 品牌
  size_t _size = 17; // 尺寸
};
class Car {
protected:
  string _colour = "白色"; // 颜色
  string _num = "苏KBD246"; // 车牌号
  Tire _t; // 轮胎
};


相关文章
|
4天前
|
安全 前端开发 Java
【C++】从零开始认识继承二)
在我们日常的编程中,继承的应用场景有很多。它可以帮助我们节省大量的时间和精力,避免重复造轮子的尴尬。同时,它也让我们的代码更加模块化,易于维护和扩展。可以说,继承技术是C++的灵魂。
14 1
|
4天前
|
安全 程序员 编译器
【C++】从零开始认识继承(一)
在我们日常的编程中,继承的应用场景有很多。它可以帮助我们节省大量的时间和精力,避免重复造轮子的尴尬。同时,它也让我们的代码更加模块化,易于维护和扩展。可以说,继承技术是C++的灵魂。
24 3
【C++】从零开始认识继承(一)
|
4天前
|
C++ 编译器 程序员
C++ 从零基础到入门(3)—— 函数基础知识
C++ 从零基础到入门(3)—— 函数基础知识
|
4天前
|
存储 编译器 C++
C++中的继承
C++中的继承
11 0
|
4天前
|
自然语言处理 编译器 C语言
【C++】C++ 入门 — 命名空间,输入输出,函数新特性
本文章是我对C++学习的开始,很荣幸与大家一同进步。 首先我先介绍一下C++,C++是上个世纪为了解决软件危机所创立 的一项面向对象的编程语言(OOP思想)。
36 1
【C++】C++ 入门 — 命名空间,输入输出,函数新特性
|
4天前
|
设计模式 算法 编译器
【C++入门到精通】特殊类的设计 |只能在堆 ( 栈 ) 上创建对象的类 |禁止拷贝和继承的类 [ C++入门 ]
【C++入门到精通】特殊类的设计 |只能在堆 ( 栈 ) 上创建对象的类 |禁止拷贝和继承的类 [ C++入门 ]
13 0
|
4天前
|
存储 算法 对象存储
【C++入门到精通】function包装器 | bind() 函数 C++11 [ C++入门 ]
【C++入门到精通】function包装器 | bind() 函数 C++11 [ C++入门 ]
16 1
|
4天前
|
存储 算法 数据安全/隐私保护
【C++入门到精通】 哈希结构 | 哈希冲突 | 哈希函数 | 闭散列 | 开散列 [ C++入门 ]
【C++入门到精通】 哈希结构 | 哈希冲突 | 哈希函数 | 闭散列 | 开散列 [ C++入门 ]
7 0
|
4天前
|
存储 自然语言处理 C++
刷题用到的非常有用的函数c++(持续更新)
刷题用到的非常有用的函数c++(持续更新)
19 1
|
4天前
|
安全 程序员 编译器
【C++】继承(定义、菱形继承、虚拟继承)
【C++】继承(定义、菱形继承、虚拟继承)
15 1