【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; // 轮胎
};


相关文章
|
10月前
|
人工智能 机器人 编译器
c++模板初阶----函数模板与类模板
class 类模板名private://类内成员声明class Apublic:A(T val):a(val){}private:T a;return 0;运行结果:注意:类模板中的成员函数若是放在类外定义时,需要加模板参数列表。return 0;
248 0
|
安全 C++
【c++】继承(继承的定义格式、赋值兼容转换、多继承、派生类默认成员函数规则、继承与友元、继承与静态成员)
本文深入探讨了C++中的继承机制,作为面向对象编程(OOP)的核心特性之一。继承通过允许派生类扩展基类的属性和方法,极大促进了代码复用,增强了代码的可维护性和可扩展性。文章详细介绍了继承的基本概念、定义格式、继承方式(public、protected、private)、赋值兼容转换、作用域问题、默认成员函数规则、继承与友元、静态成员、多继承及菱形继承问题,并对比了继承与组合的优缺点。最后总结指出,虽然继承提高了代码灵活性和复用率,但也带来了耦合度高的问题,建议在“has-a”和“is-a”关系同时存在时优先使用组合。
797 6
|
存储 编译器 C++
【c++】类和对象(下)(取地址运算符重载、深究构造函数、类型转换、static修饰成员、友元、内部类、匿名对象)
本文介绍了C++中类和对象的高级特性,包括取地址运算符重载、构造函数的初始化列表、类型转换、static修饰成员、友元、内部类及匿名对象等内容。文章详细解释了每个概念的使用方法和注意事项,帮助读者深入了解C++面向对象编程的核心机制。
391 5
|
程序员 C++ 容器
在 C++中,realloc 函数返回 NULL 时,需要手动释放原来的内存吗?
在 C++ 中,当 realloc 函数返回 NULL 时,表示内存重新分配失败,但原内存块仍然有效,因此需要手动释放原来的内存,以避免内存泄漏。
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
732 6
|
编译器 C++ 开发者
【C++篇】深度解析类与对象(下)
在上一篇博客中,我们学习了C++的基础类与对象概念,包括类的定义、对象的使用和构造函数的作用。在这一篇,我们将深入探讨C++类的一些重要特性,如构造函数的高级用法、类型转换、static成员、友元、内部类、匿名对象,以及对象拷贝优化等。这些内容可以帮助你更好地理解和应用面向对象编程的核心理念,提升代码的健壮性、灵活性和可维护性。
|
12月前
|
编译器 C++ 容器
【c++11】c++11新特性(上)(列表初始化、右值引用和移动语义、类的新默认成员函数、lambda表达式)
C++11为C++带来了革命性变化,引入了列表初始化、右值引用、移动语义、类的新默认成员函数和lambda表达式等特性。列表初始化统一了对象初始化方式,initializer_list简化了容器多元素初始化;右值引用和移动语义优化了资源管理,减少拷贝开销;类新增移动构造和移动赋值函数提升性能;lambda表达式提供匿名函数对象,增强代码简洁性和灵活性。这些特性共同推动了现代C++编程的发展,提升了开发效率与程序性能。
457 12
|
10月前
|
存储 编译器 程序员
c++的类(附含explicit关键字,友元,内部类)
本文介绍了C++中类的核心概念与用法,涵盖封装、继承、多态三大特性。重点讲解了类的定义(`class`与`struct`)、访问限定符(`private`、`public`、`protected`)、类的作用域及成员函数的声明与定义分离。同时深入探讨了类的大小计算、`this`指针、默认成员函数(构造函数、析构函数、拷贝构造、赋值重载)以及运算符重载等内容。 文章还详细分析了`explicit`关键字的作用、静态成员(变量与函数)、友元(友元函数与友元类)的概念及其使用场景,并简要介绍了内部类的特性。
387 0
|
设计模式 安全 C++
【C++进阶】特殊类设计 && 单例模式
通过对特殊类设计和单例模式的深入探讨,我们可以更好地设计和实现复杂的C++程序。特殊类设计提高了代码的安全性和可维护性,而单例模式则确保类的唯一实例性和全局访问性。理解并掌握这些高级设计技巧,对于提升C++编程水平至关重要。
231 16
|
编译器 C语言 C++
类和对象的简述(c++篇)
类和对象的简述(c++篇)