admin管理员组

文章数量:1579922

   🔥 订阅量破千的火热 C++ 教程
👉 火速订阅
《C++要笑着学》 

 🔥 CSDN 累计订阅量破千的火爆 C/C++ 教程的 2023 重制版,C 语言入门到实践的精品级趣味教程。
了解更多: 👉 "不太正经" 的专栏介绍 试读第一章
订阅链接: 🔗《C语言趣味教程》 ← 猛戳订阅!
  • 💭 写在前面:本系列 C++ 教学博客的基础知识已经告一段落了,下面的章节我会先把面向对象三大特性讲完,然后穿插一些数据结构的教学以方便我们继续讲解 STL 的 map 和 set。对于面向对象三大特性 —— 封装、继承、多态,我们已经在之前讲解过封装了,本章将开始讲解继承,详细探讨多继承引发的钻石继承问题,并用虚继承解决钻石继承问题。阅读本章需要掌握访问限定符以及默认成员函数的知识,如果阅读过程中感到有些许生疏建议先去复习一下。

Ⅰ. 继承(inheritance)

0x00 知识回顾

回顾一下面向对象三大特性:封装、继承、多态。

面向对象还有其它特性:反射、抽象。

① C++ Stack 类设计和 C 设计 Stack 对比,封装更好、访问限定符 + 类   狭义。

② 迭代器设计,如果没有迭代器,容器访问只能暴露底层结构。 -> 使用复杂、使用成本很高,对使用者要求极高。

封装了容器底层结构,不暴露底层结构的情况,提供统一的访问容器的方式,降低使用成本,简化使用。

③ stack/queue/priority_queue 的设计 —— 适配器模式。

今天我们的主角是继承。

0x01 继承的概念

 继承(inheritance)机制是面向对象程序设计,使代码可以复用的最重要的手段。

它允许程序员在保持原有类特性的基础上进行扩展,以增加功能。这样产生新的类,称为派生类。

继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。

以前我们接触的复用都是函数复用,而继承是类设计层次的复用。

💭 举例:比如我们要设计一个图书管理系统,每个角色的权限是不同的。

角色类:学生、老师、保安、保洁、后勤…… 为了区分这些角色,我们就要设计一些类出来:

class Student {
    string _name;
    string _tel;
    string _address;
    int _age;
    // ...

    string _stuID;  // 学号
};

class Teacher {
    string _name;
    string _tel;
    string _address;
    int _age;
    // ...

    string _wordID;  // 工号
};

...

 不难发现其存在大量冗余部分,有些信息是公共的,有些信息是每个角色独有的。

对于有些数据和方法是每个角色都具有的,我们每次都写一边,这就导致设计重复了。

我们说了代码是要讲究复用的,我们要想办法去做一个 "提取" ,把共有的成员变量提取出来。

💡 解决方案:设计一个 Person

// 把大家共有的东西写进来
class Person {
    string _name;
    string _tel;
    string _address;
    string _age;
};

 然后使用 "继承" 去把这些大家公有的东西运送给各个角色,先看操作:

class Person {
    /* 共有的信息 */
    string _name;
    string _tel;
    string _address;
    string _age;
};

/* Student 公有继承了 Person */
class Student : public Person {
    string _stuID ;  // 学号
};

/* Teacher 公有继承了 Person */
class Teacher : public Person {
    string _wordID;  // 工号
};

 这就是继承。在需要称为子类的类的类名后加上冒号,并跟上继承方式和父类类名即可。

比如说我们这里希望让 Student public 的继承方式继承自 Person

💬 为了能够演示继承的效果,我们给 Person 类加上个 Print 打印函数:

class Person {
public:
    void Print() {
        cout << "name: " << _name << endl;
        cout << "age:  " << _age << endl;
        cout << endl;
    }

    /* 共有的信息 */
    string _name = "user";
    string _tel;
    string _address;
    string _age = "null";
};

/* Student 公有继承了 Person */
class Student : public Person {
    string _stuID ;  // 学号
};

/* Teacher 公有继承了 Person */
class Teacher : public Person {
    string _wordID;  // 工号
};

int main(void)
{
    Person p;
    p.Print();

    Student s;
    s.Print();

    Teacher t;
    t.Print();

    return 0;
}

🚩 运行结果:

0x02 继承的定义格式

我们还是拿刚才的 Person Student 举例:

       派生类  继承方式  基类
         👇       👇     👇
class Student : public Person {
public:
    string _stuID;  // 学号
};

Student子类,我们也称之为派生类。Person 是父类,我们也称之为 基类

个人觉得,把 PersonStudent 看作是父子关系是比较容易理解的。

子承父业,孩子 Student 从父亲 Person 那里继承一些 "资产" ,

这里的继承方式是 public,即公有继承,还有其他的一些继承方式。

(这里我们先做一个铺垫,复习和补充一下访问限定符的知识)

0x03 访问限定符:public / protected / private

🔗 链接:【C++要笑着学】访问限定符​​​​​​

🎈 知识回顾:对之前没讲的 protected 进行补充

 三种访问限定符,分别是 public(公有)、protected(保护)、private(私有)。

这一听名字就能知道,公有就是随便玩,保护和私有就是藏起来一点点不让你随便玩得到。

public 修饰的成员,可以在类外面随便访问(直接访问)。

protected private 修饰的成员,不能在类外随便访问。

③ 定义成 protected 可以让父类成员不能在类外直接访问,但可以在子类中访问。

publicprotectedprivate 不仅仅是访问限定符,它们也可以表示继承的三种继承方式:

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

三种访问限定符和三种继承方式相碰撞,就产生了  种情况:

父类的 private 成员在子类种无论以何种方式继承都是不可见的。 这里的不可见指的是父类的私有成员还是被继承到了子类对象中,但是语法上限制了子类对象不管在类里面还是类外卖呢都不能去访问父类的 private 成员。

② 父类 private 成员在子类种不能被访问,如果父类成员不想在类外被直接访问,但是想让它们在子类中能被访问,可定义为 protected 不难看出,保护成员限定符是因继承才出现的。

③ 实际上,上面的表格我们通过观察不难发现,父类的私有成员在子类都是不可见的,父类的其他成员在子类的访问方式 == Min(成员在父类的访问限定符,继承方式):

 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,但是最好还是显式的写出继承方式,提高代码可读性。

⑤ 一共 9 种组合,实际上是大佬们早期设计的时候想复杂了,实际中父类成员基本都是保护和公有,继承方式基本都是用公有继承,几乎很少使用 protected / private 继承。 而且也不提倡使用 protected / private 继承,因为 protected / private 继承下来的成员都只能在子类里使用,实际扩展维护性不强。

class Person {
public:
    void Print() {
        cout << "name: " << _name << endl;
    }
protected:
    /* 共有的信息 */
    string _name;   
private:
    string _age;
};

// class Student : protected Person {
// class Student : private Person {
class Student : public Person {
protected:
    string _stuID;  // 学号
};

0x05 父类和子类对象赋值转换

子类对象可以赋值给父类的对象、父类的指针、父类的引用:

class Person {
protected:
    string _name;   
    string _age;
};

class Student : public Person {
public:
    string _stuID;  // 学号
};

int main(void)
{
    Student s;
    // 子类对象可以赋值给父类对象/指针/引用
    Person p = s;
    Person* pp = &s;
    Person& rp = s;

    return 0;
}

这种操作我们称之为 "切割"(或切片),寓意是把子类中父类的那部分切过来赋值过去。  

📌 注意事项:

① 父类对象不能赋值给子类对象(儿子不能抢父亲的钱)

Student s;  // 子类
Person p;   // 父类

s = p; ❌   

父类的指针可以通过强转赋值给子类的指针,但是必须是父类的指针是指向子类对象时才是安全的。这里父类如果是多态类型,可以使用 RTTI(Run-Time Type Information,即运行时类型识别)dynamic_cast 来进行识别后进行安全转换。

Student s;
Person* pp = &s;

// 父类的指针可以通过强制类型转换赋值给子类的指针
pp = &s;
Student* ps1 = (Student*)pp;
ps1->_stuID = 10001;

pp = &p;
Student* ps2 = (Student*)pp;  // 这种情况虽然可以,但是会存在越界访问问题
ps2->_stuID = 20002;

0x06 继承中的作用域

 继承体系中的父类和子类都有独立的作用域,如果子类和父类有同名成员,

此时子类成员会屏蔽父类对同名成员的直接访问,这种情况叫做 "隐藏" (也叫重定义)。

💭 在子类成员函数中,可以使用如下方式进行显式访问:

基类::基类成员

📌 注意事项:

① 如果是成员函数的隐藏,只需要函数名相同就构成隐藏。

② 实际运用中在继承体系里最好不要定义同名的成员。父类成员名称不要和子类成员名称冲突。

💬 代码演示:父类和子类的成员函数同名的场景(注意父类和子类的 _num)

class Person {
protected:
    string _name = "小明";                // 姓名
    string _num = "320103xxxxxxxxxx14";  // 身份证号
};

class Student : public Person {
public:
    void Print() {
        cout << "姓名:" << _name << endl;
        cout << "身份证号: " << Person::_num << endl;  // 指定是Person的_num
        cout << "学号:" << _num << endl;  // 默认在自己作用域内找_num
    }
protected:
    string _num = "10001";  // 学号
};

int main(void)
{
    Student s1;
    s1.Print();

    return 0;
}

🚩 运行结果:

❓ 思考:观察下列代码,A::func B::func 的关系是重载还是隐藏?

class A {
public:
	void func() {
		cout << "func()" << endl;
	}
};

class B : public A {
public:
	void func(int i) {
		A::func();
		cout << "func(int i) -> " << i << endl;
	}
};

int main(void) {
	B b;
	b.func(10);
}

🚩 运行结果:

💡 解读:函数重载要求在同一作用域,我们说了,子类和父类都有独立的作用域,因为不是在同一作用域,B 中的 func A 中的 func 不可能构成重载,正确答案是构成隐藏。B 中的 func A 中的 func 构成隐藏,成员函数满足函数名相同就构成隐藏。

(从语言的设计角度来说,如果出现同名直接报错,就没这么多事了)

0x07 继承与友元

 友元关系不能继承,也就是说父类友元不能访问子类私有和保护成员!

class Student;
class Person {
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name; // 姓名
};

class Student : public Person {
protected:
	int _stuNum; // 学号
};

void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	cout << s._stuNum << endl;   ❌
}
void main()
{
	Person p;
	Student s;
	Display(p, s);
}

🚩 运行结果:

“Student::_stuNum”: 无法访问 protected 成员(在“Student”类中声明)

0x08 继承与静态成员

父类定义了 static 静态成员,则整个继承体系里面中有一个这样的成员。

可以理解为共享,父类的静态成员可以在子类共享,父类和子类都能去访问它。

无论派生出多少个子类,都只有一个 static 成员实例:

class Person {
public:
	Person() {
		++_count;
	}
protected:
	string _name; // 姓名
public:
	static int _count; // 统计人的个数
};

int Person::_count = 0;
class Student : public Person {
protected:
	int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
	string _seminarCourse; // 研究科目
};

void TestPerson()
{
	Student s1;
	Student s2;
	Student s3;
	Graduate s4;
	Person s;

	cout << "大家都可以访问" << endl;
	cout << "人数 : " << Person::_count << endl;
	cout << "人数 : " << Student::_count << endl;
	cout << "人数 : " << s4._count << endl;

	cout << "大家也都可以变动" << endl;
	s3._count = 0;
	cout << "人数 : " << Person::_count << endl;

	cout << "并且他们的地址也都是一样的,因为所有继承体系中只有一个" << endl;
	cout << "人数 : " << &Person::_count << endl;
	cout << "人数 : " << &Student::_count << endl;
	cout << "人数 : " << &s4._count << endl;
}

🚩 运行结果:

Ⅱ. 子类默认成员函数

0x00 引入:默认成员函数

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

 (不含C++11)

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

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

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);
}

🚩 运行结果:

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

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

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

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

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);
}

🚩 运行结果:

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;
}

🚩 运行结果:

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);
}

🚩 运行结果:

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

0x00 多继承的概念

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

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

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

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

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

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

 

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

0x01 钻石继承的概念

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

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

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

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

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

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

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 有关多继承的思考 

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

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

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

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

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

❓ 思考:既然如此,为什么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; // 轮胎
};


📌 [ 笔者 ]   王亦优
📃 [ 更新 ]   2022.8.7 | 2023.10.20(重制)
❌ [ 勘误 ]   /* 暂无 */
📜 [ 声明 ]   由于作者水平有限,本文有错误和不准确之处在所难免,
              本人也很想知道这些错误,恳望读者批评指正!

📜 参考资料 

C++reference[EB/OL]. []. http://www.cplusplus/reference/.

Microsoft. MSDN(Microsoft Developer Network)[EB/OL]. []. .

百度百科[EB/OL]. []. https://baike.baidu/.

比特科技. C++[EB/OL]. 2021[2021.8.31]. 

本文标签: 组合子类笑着函数钻石