13. C++继承 | 详解 | 虚拟继承及底层实现

目录

1.定义

1.1继承的概念

1.2 继承的定义

2. 对象赋值转换

3. 继承中的作用域

a. 隐藏/重定义 (Hiding/Redefinition)

b. 重载 (Overloading)

c. 重写/覆盖 (Overriding)

d. 编译报错 (Compilation Error)

4. 派生类的默认成员函数

构造

拷贝构造

运算符重载

析构

5. 继承与友元

6. 继承与静态成员

7.菱形继承与虚拟继承

难点

虚拟继承

大端存放(Big-Endian)

小端存放(Little-Endian)

8. 继承反思

9.常见问题answer

1. 什么是菱形继承?菱形继承的问题是什么?

菱形继承(Diamond Inheritance)

菱形继承的问题

2. 什么是菱形虚拟继承?如何解决数据冗余和二义性的问题?

菱形虚拟继承(Virtual Inheritance)

解决的数据冗余和二义性的问题

3. 继承和组合的区别?什么时候用继承?什么时候用组合?

继承(Inheritance)和组合(Composition)

继承

组合

什么时候用继承?什么时候用组合?


1.定义

1.1继承的概念

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

我们可以以下面录入学校学生和教职工信息为例

对于公共信息,每次都要初始化一遍吗?

写一个 person 来存储公共信息,student,teacher...继承 person 即可

// 定义Person基类
class Person {
public:// 构造函数Person(const std::string& name, int age) : _name(name), _age(age) {}// 打印个人信息void Print() const {std::cout << "_name: " << _name << std::endl;std::cout << "_age: " << _age << std::endl;}
protected:std::string _name;int _age;
};// 继承自Person的Student类
class Student : public Person {
public:// 构造函数Student(const std::string& name, int age, int stuid): Person(name, age), _stuid(stuid) {}// 打印学生信息void PrintStudentInfo() const {Print();std::cout << "_stuid: " << _stuid << std::endl;}
private:int _stuid;
};// 继承自Person的Teacher类
class Teacher : public Person {
public:// 构造函数Teacher(const std::string& name, int age, int jobid): Person(name, age), _jobid(jobid) {}// 打印教师信息void PrintTeacherInfo() const {Print();std::cout << "_jobid: " << _jobid << std::endl;}
private:int _jobid;
};// 主函数
int main() {Student s("张三", 18, 12345);Teacher t("李四", 30, 67890);// 打印学生信息s.PrintStudentInfo();// 打印教师信息t.PrintTeacherInfo();return 0;
}

这样一个对象就有两份数据了,一份是自己的,一份是父类的

继承:复用 的好处?

简化代码

1.2 继承的定义

Person是父类,也称作基类。Student是子类,也称作派生类。

访问方式

  1. 基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected >private。

   2.不可见,语法上限制访问(类里面和外面都不能用),private 是类外面不能使用,类里面可以

基类的 private 在派生类中不可见,即(基类) 父类的私有成员,子类无论如何都用不了

#include <iostream>
#include <string>class Person {
protected:std::string _name = "zhangsan";int _age = 18;
public:void Print() const {std::cout << "_name: " << _name << std::endl;std::cout << "_age: " << _age << std::endl;}
};class Student : public Person {
public:void Func() const {std::cout << "name: " << _name << std::endl;std::cout << "age: " << _age << std::endl;}
protected:int _stuid;
};int main() {Student s;// 测试Student的Func()方法s.Func();// 测试继承自Person的Print()方法s.Print();return 0;
}

所以父类中不想被子类使用的部分,就可以设置为 private

  1. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过显示的写出继承方式。
  2. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

2. 对象赋值转换

派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用(子给父)。这里有个形象的说法叫切片 或者切割。寓意把派生类中父类那部分切来赋值过去。

测试:

#include <iostream>
#include <string>using namespace std;class Person {
protected:string _name; // 姓名string _sex;  // 性别
public:int _age = 20;     // 年龄// 构造函数Person() = default;Person(const string &name, const string &sex, int age): _name(name), _sex(sex), _age(age) {}// 虚析构函数,确保正确调用派生类的析构函数virtual ~Person() = default;// 显示信息的方法virtual void display() const {cout << "Name: " << _name << ", Sex: " << _sex << ", Age: " << _age << endl;}
};class Student : public Person {
public:int _No; // 学号// 构造函数Student() = default;Student(const string &name, const string &sex, int age, int No): Person(name, sex, age), _No(No) {}// 显示信息的方法void display() const override {cout << "Name: " << _name << ", Sex: " << _sex << ", Age: " << _age << ", No: " << _No << endl;}
};int main() {Person p("Alice", "Female", 30);Student s("Bob", "Male", 22, 1001);// 派生类对象赋值给基类对象p = s;// 显示基类对象的信息p.display(); // 只会显示基类的成员信息// 我们知道d赋值给i,会产生临时变量double d = 1.1;int i = static_cast<int>(d); // 显式类型转换cout << "Double value: " << d << ", Integer value: " << i << endl;return 0;
}

运行:

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

student s;
//向上兼容//	Person p1 = s;
//	Person& rp = s;
//	rp._name = "张三";
//
//	Person* ptrp = &s;
//	ptrp->_name = "李四";

切割没有产生中间变量

赋值兼容转换(切割/切片)

子可以给给父,向上转换都是可以的(缩小),生成了别名

3. 继承中的作用域

遵循规则:

  1. 在继承体系中基类和派生类都有独立的作用域。
  2. 就近原则 //先在子类当中找

子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏
也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)

  1. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏
  2. 注意在实际中在继承体系里面最好不要定义同名的成员。//要注意避免

猜猜下面代码身份证号打印的是 111 还是 999?

// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
#include <iostream>
#include <string>using namespace std;class Person {
protected:string _name = "张三"; // 姓名int _num = 111;        // 身份证号
};class Student : public Person {
public:void Print() const {cout << "姓名: " << _name << endl; // 访问基类的_namecout << "身份证号: " <<_num << endl; // 明确访问基类的_num}
protected:int _num = 999; // 身份证号
};int main() {Student s;s.Print();return 0;
}

会发现是就近子类当中的 999

这个地方有非常多的考点,我们不妨来看一下下面的例题

例题 1:

两个fun构成什么关系?

a、隐藏/重定义 b、重载 c、重写/覆盖 d、编译报错

class A
{
public:void fun(){cout << "A::func()" << endl;}
};
class B : public A
{
public:void fun(int i)//类型不相同了,是不是感觉有点像重载{cout << "B::func(int i)->" << i << endl;}
};

答案:a (父子类域中,成员函数名相同就构成隐藏)

这里一定不能和重载搞混淆了,重载要在同一作用域
上面是构成隐藏,成员函数满足函数名相同就构成隐藏!

以下是对隐藏/重定义、重载、重写/覆盖以及编译报错的解释和示例:

a. 隐藏/重定义 (Hiding/Redefinition)

隐藏/重定义是指在派生类中定义一个与基类成员同名的新成员。这样会隐藏基类的成员,使其在派生类对象中不可见。隐藏可以发生在数据成员、成员函数和类型别名等方面。

示例:

#include <iostream>class Base {
public:void func() {std::cout << "Base func()" << std::endl;}
};class Derived : public Base {
public:void func() { // 隐藏基类的 func()std::cout << "Derived func()" << std::endl;}
};int main() {Derived d;d.func(); // 调用的是 Derived::func()d.Base::func(); // 明确调用 Base::func()return 0;
}

b. 重载 (Overloading)

重载是指在同一个类中定义多个同名的函数,但这些函数具有不同的参数列表(参数个数或类型不同)。重载不依赖于继承关系。

示例:

#include <iostream>class Example {
public:void func(int x) {std::cout << "func(int x): " << x << std::endl;}void func(double y) {std::cout << "func(double y): " << y << std::endl;}void func(int x, double y) {std::cout << "func(int x, double y): " << x << ", " << y << std::endl;}
};int main() {Example ex;ex.func(10);ex.func(3.14);ex.func(10, 3.14);return 0;
}

c. 重写/覆盖 (Overriding)

重写/覆盖是指在派生类中重新定义基类中已经存在的虚函数。重写函数的签名必须与基类中被覆盖的虚函数的签名完全一致。

示例:

#include <iostream>class Base {
public:virtual void func() { // 虚函数std::cout << "Base func()" << std::endl;}
};class Derived : public Base {
public:void func() override { // 覆盖基类的虚函数std::cout << "Derived func()" << std::endl;}
};int main() {Base* b = new Derived();b->func(); // 调用的是 Derived::func()delete b;return 0;
}

d. 编译报错 (Compilation Error)

编译报错是指代码在编译阶段出现的错误,通常是由于语法错误、类型不匹配或其他规则违背。

示例:

#include <iostream>class Example {
public:void func(int x) {std::cout << "func(int x): " << x << std::endl;}
};int main() {Example ex;// ex.func("Hello"); // 编译报错,类型不匹配return 0;
}

在这个示例中,ex.func("Hello") 会导致编译报错,因为 func 期望一个 int 类型的参数,而不是 const char* 类型的字符串。

通过以上示例,可以更清楚地理解隐藏/重定义、重载、重写/覆盖以及编译报错的概念及其在 C++ 中的应用。


4. 派生类的默认成员函数

这里为什么只有派生类的默认成员函数,而没有基类的默认成员函数呢?
这是因为基类的默认成员函数和其他类无任何差别。

有六个默认成员函数,分别是构造,析构,拷贝构造,赋值重载,取地址,const取地址,我们一一来看。

遵循创造:先父后子

       消灭:先子后父

构造

派生类的构造函数必须调用父类的构造函数初始化基类的那一部分成员。

自己写构造,不能在派生类的构造函数中直接对父类成员初始化,父类成员的初始化只能调用父类的构造函数完成初始化。

构造:先父后子

class Person
{
public:Person(const char* name="zhangsan"): _name(name){cout << "Person()" << endl;}Person(const Person& p): _name(p._name){cout << "Person(const Person& p)" << endl;}Person& operator=(const Person& p){cout << "Person operator=(const Person& p)" << endl;if (this != &p)_name = p._name;return *this;}~Person(){cout << "~Person()" << endl;}
protected:string _name; // 姓名
};class Student : public Person
{
public:protected:int _num; //学号
};int main()
{Student s;return 0;
}

测试:

修改:

派生类相比于普通类的构造函数,多了一步对基类成员的处理

会默认构造,但不建议,还是显示的调用构造更好

拷贝构造

拷贝构造函数是构造函数的重载,所以它们的特性几乎是一样的。

#include <iostream>
#include <string>using namespace std;class Person {
public:Person() {} // 默认构造函数// 拷贝构造函数Person(const Person& p): _name(p._name) {cout << "Person(const Person& p)" << endl;}protected:string _name;
};class Student : public Person {
public:Student() {} // 默认构造函数// 拷贝构造函数Student(const Student& s): _num(s._num),Person(s) { // 调用基类的拷贝构造函数cout << "Student(const Student& s)" << endl;}protected:int _num; // 学号
};void checkCopyConstruction() {Student originalStudent;Student copiedStudent(originalStudent); // 这里会调用拷贝构造函数
}int main() {checkCopyConstruction(); // 调用函数检查拷贝构造函数的行为return 0;
}

运行

  • 派生类的拷贝构造函数先调用基类的拷贝构造函数。
  • 派生类的拷贝构造函数不能直接处理基类的成员,必须显示调用基类的拷贝构造函数。

运算符重载

就近原则:基类和派生类的运算符重载函数构造了隐藏/重定义
父亲干父亲的活,孩子干孩子的活

#include <iostream>
#include <string>using namespace std;class Person {
public:Person() = default;Person(const Person& p): _name(p._name) {}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() = default;Student(const Student& s): Person(s), _num(s._num) {}//基类部分调用基类来构造Student& operator=(const Student& s) {cout << "Student& operator=(const Student& s)" << endl;if (this != &s) {Person::operator=(s);//!指定一下,父类部分调用父类来实现_num = s._num;}return *this;}// Setter methods to initialize the objectvoid setName(const string& name) {_name = name;}void setNum(int num) {_num = num;}protected:int _num; // 学号
};void checkAssignment() {Student student1;student1.setName("John Doe");student1.setNum(12345);Student student2;student2 = student1; // 这里会调用赋值运算符// 输出应显示两个赋值运算符都被调用过
}int main() {checkAssignment();return 0;
}

运行:

注意子类重载时的调用,要指定一下 Person::operator=(s)

析构

需要在子类中显示构造基类的析构函数吗

#include <iostream>
#include <string>using namespace std;class Person {
public:~Person() {cout << "~Person()" << endl;}
protected:string _name;
};class Student : public Person {
public:~Student() {cout << "~Student()" << endl;}
protected:int _num; // 学号
};int main() {Person a;Student b;// 等待用户按键后退出,以便观察析构函数的调用cout << "Press any key to exit..." << endl;cin.get();return 0;
}

不需要

按照之前几个默认成员函数的做法,在派生类的析构函数中显示调用基类的析构函数,但是发现基类的析构函数一共调用了两次。这显然是不行的,一块动态空间只能被释放一次。

由于后面多态的原因(具体后面讲),析构函数的函数名被特殊处理了,统一处理成destructor

显示调用父类析构,无法保证析构的先子后父,所以子类析构函数完成就自动调用,默认调用父类析构,这样就保证了先子后父

总结:

先的构造后析构的原理,先构父,但先析子

派生类相比于普通类的四类默认成员函数,多了一步对基类成员的处理,而且只能通过基类的默认成员函数去处理,不能由派生类自行处理,重载则遵循就近原则

5. 继承与友元

友元关系不能继承

那些叔叔是爸爸的朋友,但不是我的朋友

测试:

#include <iostream>
#include <string>using namespace std;
class Student;
class Person {
public:Person(const string& name) : _name(name) {}friend void Display(const Person& p, const Student& s);protected:string _name; // 姓名
};class Student : public Person {
public:Student(const string& name, int stuNum) : Person(name), _stuNum(stuNum) {}// 在类体中再次声明Display为友元friend void Display(const Person& p, const Student& s);protected:int _stuNum; // 学号
};void Display(const Person& p, const Student& s) {cout << "Person name: " << p._name << endl;cout << "Student name: " << s._name << endl;cout << "Student number: " << s._stuNum << endl;
}int main() {Person person("John Doe");Student student("Jane Doe", 12345);Display(person, student);return 0;
}

一个小细节:

定义一个Display函数,它是基类的友元函数,可以访问基类内部的保护成员。

  • 由于基类中的友元声明中包含派生类,但是编译器只会向上寻找,所以必须在友元声明之前加上派生类的声明。
  • 否则会报Student未声明的错误。

  • 若想让基类中的友元也成为派生类中的友元,需要在派生类中也进行友元声明

不声明就会报错:

注意: 一般不建议使用友元,因为它会破坏类的封装。


6. 继承与静态成员

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

继承的是使用权,在派生类中,不用再单独拷贝

#include <iostream>using namespace std;class Person {
public:int _Pnum = 1;static int _count; // 静态成员变量的声明
};int Person::_count = 0; // 静态成员变量的定义和初始化class Student : public Person {
public:int _Snum = 1; // 学号
};int main() {Person p;Student s1;Student s2;cout << "Initial Person::_count: " << Person::_count << endl;Person::_count = 5; // 修改静态成员变量的值cout << "Modified Person::_count: " << Person::_count << endl;// 各自是各自的cout << "Address of p._Pnum: " << &p._Pnum << endl;cout << "Address of s1._Pnum: " << &s1._Pnum << endl;cout << "Address of s2._Pnum: " << &s2._Pnum << endl;// 都是同一个地址cout << "Address of Person::_count (via p): " << &p._count << endl; // 引用调用cout << "Address of Person::_count (via s1): " << &s1._count << endl;cout << "Address of Person::_count (via s2): " << &s2._count << endl;cout << "Address of Person::_count (via class name): " << &Person::_count << endl; // 类名调用cout << "Address of Student::_count (via class name): " << &Student::_count << endl;return 0;
}

应用:如何实现学生人数的计数

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 ;cout <<" 人数 :"<< Person ::_count << endl;Student ::_count = 0;cout <<" 人数 :"<< Person ::_count << endl;
}

计数成功啦

借用了全局调用,static 只有一个 的特性


7.菱形继承与虚拟继承

难点

多继承-棱形继承-虚拟继承

面向对象,就是现实世界的描述

单继承:只有一个直接父类

多继承:多个父类,用逗号隔开

一个人有多个身份,例如即是程序员,又是外卖员

由于多继承的存在,就会引起菱形继承的问题

在Assistant的对象中Person成员会有两份。

菱形继承的问题:

从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。 在Assistant的对象中Person成员会有两份。

一个人正常的信息,一份就够了,二义性也容易发生报错不明确

可以通过指定作用域的方法来解决二义性的问题,如上图所示,但是并不符合实际情况,一个人虽然有多种角色,但是名字怎么会有两个甚至多个呢?

单继承并不会形成菱形,如下

class A
class B : public A
class C : public B

二义性已经带来了很多坑,写出一个下面的继承,可能就将会被逐出 C++界了 hhh

虚拟继承

虚拟继承就是专门用来解决菱形继承导致的数据冗余和二义性问题的。

运用:class B : virtual public A

底层:有点小复杂

我们可以建立以下结构来测试一下:

#include <iostream>
#include <string>using namespace std;class A
{
public:int _a;
};class B : public A
//class B : virtual public A
{
public:int _b;
};class C : public A
//class C : virtual public A
{
public:int _c;
};class D : public B, public C
{
public:int _d;
};int main()
{D d;d.B::_a = 1;d.C::_a = 2;d._b = 3;d._c = 4;d._d = 5;return 0;
}

首先我们来看,不使用虚拟继承时的内存模型:

  • 在D对象创建后,通过内存窗口来看它内部的成员分别情况,如上图所示。
  • 最外边的紫色框是整个d对象,它一共有5个int类型的变量。
  • 中间的蓝色框中的成员是分别是从B,C中继承下来的,都有两个int类型的变量。
  • 红色细框中的变量都是从A中继承下来的。

虚拟继承后的内存模型:

菱形继承中原本冗余的成员最后只有一个,而且放在最终派生类对象中的最后位置。

此时数据冗余和二义性是解决了,因为派生类对象中只有一个从A继承下来的成员了,但是相比原来不用虚拟继承多出来4个字节不说,还将原本是成员所在位置内容也发生了改变。

在两个新内存窗口中看到的两个新的框被称为虚基表

我用的是小端存储方式,按照小端模式得到d对象中存放的两个地址。

所以第一行的虚基表到底存的是什么呢?

存找基类偏移量的表,距离 A 的偏移量(相对距离)

补充:

大端存放(Big-Endian)

在大端存放方式中,数据的高位字节存放在内存的低地址处,而数据的低位字节存放在内存的高地 BCBC 址处。可以形象地理解为数据从“大头”开始存放。

例如,对于32位整数 0x12345678

textCopy code
内存地址    值
0x1000      0x12
0x1001      0x34
0x1002      0x56
0x1003      0x78

在大端存放中,最高有效字节(0x12)存放在最低地址(0x1000),依次排列。

小端存放(Little-Endian)

在小端存放方式中,数据的低位字节存放在内存的低地址处,而数据的高位字节存放在内存的高地址处。可以形象地理解为数据从“小头”开始存放。

例如,对于同样的32位整数 0x12345678

textCopy code
内存地址    值
0x1000      0x78
0x1001      0x56
0x1002      0x34
0x1003      0x12

在小端存放中,最低有效字节(0x78)存放在最低地址(0x1000),依次排列。


虚基表中,第一个int类型的数据存放的是0,具体什么意义在多态的时候再讲。

  • 虚基表中第二个int类型的数据存放的是0x14,它是一个偏移量

再看d对象的内存模型:

  • 从B继承下来的成员,起始地址是0x00F9FA98。
  • 从A继承下来的成员,它的地址是0x00F9FAAC。

这两个地址之间相差0x14(十六进制)(所以 B 小端存放的地址指向的也是 14),也就是20。

当使用d.B::_a来访问A继承下来的成员时,就从B继承下来的成员的起始地址处,根据偏移量去访问具体的_a。

同理可以算出:C区域和A的偏移量是12。

由于使用了虚拟继承,所以B对象和C对象同样采用有虚基表的结构,将从A继承下来的成员放在最后,原本的位置存放对应虚基表的地址,虚基表中存放偏移量。

虚基表存在的原因:

现在有个疑问,为什么要根据偏移量来找从A中继承下来的那个成员?B对象C对象,或者是D对象,它们自己肯定会知道自己成员的位置啊。

B的指针拿到的是对象b的地址时,解引用访问_a,此时只是在自己内部寻找,不用偏移量也可以理解。

B的指针拿到的是对象d的地址时,此时会发生切片,但是d中的_a仍然会保留下来,但是此时站在B指针的角度来看,它根本不知道_a在哪里,因为这是d对象安排的。

所以此时就需要通过虚基表获取_a距离B的偏移量来访问_a。

思考:

为什么不直接存 A 的地址,要偏移?

虚基表,一个类可以有很多个对象,方便大家都可以用

虚拟继承是否有节省空间

有,当 A 对象大一些的时候,就可以体现啦

例题:

题目 1:

题目 2:

下面代码 A 调用几次?

#include <iostream>
using namespace std;class A {
public:A(const char* s) { cout << s << endl; }~A() {}
};class B : virtual public A {
public:B(const char* sa, const char* sb) : A(sa) { cout << sb << endl; }
};class C : virtual public A {
public:C(const char* sa, const char* sb) : A(sa) { cout << sb << endl; }
};class D : public B, public C {
public:D(const char* sa, const char* sb, const char* sc, const char* sd) : A(sa), B(sa, sb), C(sa, sc) {cout << sd << endl;}
};int main() {D d("Constructor of A", "Constructor of B", "Constructor of C", "Constructor of D");return 0;
}

按照声明的顺序来调用

打印结果:

Constructor of A
Constructor of B
Constructor of C
Constructor of D

A 调用了一次

解析:

  1. D 类对象创建时
    • 构造函数调用顺序从最基类开始,然后逐步向派生类调用。
  1. A 类的构造函数
    • 由于 A 类是通过虚继承被 BC 继承的,所以在创建 D 类对象时,会先调用 A 类的构造函数。
    • A(sa) 被调用,打印 "Constructor of A"。
  1. B 类的构造函数
    • B 类的构造函数 B(sa, sb) 被调用。因为 A(sa) 已经在上一步调用过,这里不会再次调用。
    • 打印 "Constructor of B"。
  1. C 类的构造函数
    • C 类的构造函数 C(sa, sc) 被调用。因为 A(sa) 已经在上一步调用过,这里不会再次调用。
    • 打印 "Constructor of C"。
  1. D 类的构造函数
    • 最后调用 D 类的构造函数,并打印 "Constructor of D"。

通过上面题目,可以感受到了比较复杂,所以项目中尽量不要写菱形继承。

应用:

库函数以身试法,使用了菱形继承


8. 继承反思

  1. 多继承可以认为是C++的缺陷之一,很多后来的OO(面向对象)语言都没有多继承,如Java。
  2. 继承和组合

什么时候用继承 or 组合?

  • public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
  • 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。

实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有
些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用
继承,可以用组合,就用组合。

优先使用对象组合,而不是类继承 是为什么?

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

黑盒测试:功能上的

白盒测试:不仅要实现功能上,还要管内部实现

共有越少,耦合度低越好,例如进行增删查改就会更方便

软件工程:高内聚 低耦合 

9.常见问题answer

1.什么是菱形继承?菱形继承的问题是什么?

2.什么是菱形虚拟继承?如何解决数据冗余和二义性的

3.继承和组合的区别?什么时候用继承?什么时候用组合?

解答:

1. 什么是菱形继承?菱形继承的问题是什么?

菱形继承(Diamond Inheritance)

菱形继承是一种特定的多重继承形式,在这种继承结构中,一个基类被两个派生类继承,而这两个派生类又被另一个派生类继承,形成一个菱形结构。

例如:

class A {};
class B : public A {};
class C : public A {};
class D : public B, public C {};

在这个例子中,类 B 和类 C 都继承自类 A,而类 D 同时继承自类 B 和类 C,形成了一个菱形结构。

菱形继承的问题
  1. 数据冗余(Data Redundancy):由于 D 类从 BC 继承,而 BC 又都从 A 继承,这会导致 D 类中包含两份 A 类的成员。这种重复继承会造成内存浪费和数据冗余。
  2. 二义性(Ambiguity):在 D 类中访问 A 类的成员时,编译器会不知道该选择 B 类中的 A 还是 C 类中的 A,导致二义性错误。

2. 什么是菱形虚拟继承?如何解决数据冗余和二义性的问题?

菱形虚拟继承(Virtual Inheritance)

菱形虚拟继承是一种解决菱形继承问题的技术。在 C++ 中,通过虚继承(virtual inheritance)可以确保最基类在菱形继承结构中只被继承一次,从而避免数据冗余和二义性问题。

使用虚继承:

class A {};
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};
解决的数据冗余和二义性的问题
  1. 数据冗余:虚继承确保基类 A 在整个继承链中只存在一份。无论多少次继承 A,最终的派生类 D只会有一份 A 的数据成员(虚基表存地址实现)
  2. 二义性:由于 A 只存在一份,访问 A 的成员时不会产生二义性。编译器明确知道该访问唯一的 A 实例。

例如:

class A {
public:int value;
};class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {};int main() {D d;d.value = 10; // 访问的是唯一的 A::valuereturn 0;
}

3. 继承和组合的区别?什么时候用继承?什么时候用组合?

继承(Inheritance)和组合(Composition)
继承

继承是一个类从另一个类派生而来的关系,表示“是一个”(is-a)的关系。继承主要用于重用代码,通过基类和派生类的层次结构实现功能的扩展和重载。

例如:

class Animal {
public:void eat() { cout << "Eating" << endl; }
};class Dog : public Animal {
public:void bark() { cout << "Barking" << endl; }
};

在这个例子中,Dog 类继承自 Animal 类,表示 Dog 是一种 Animal

组合

组合是一个类包含另一个类的对象,表示“有一个”(has-a)的关系。组合主要用于构建复杂的对象,从其他对象中组装而成,从而实现功能的复用。

例如:

class Engine {
public:void start() { cout << "Engine started" << endl; }
};class Car {
private:Engine engine;
public:void start() { engine.start(); }
};

在这个例子中,Car 类包含一个 Engine 对象,表示 Car 有一个 Engine

什么时候用继承?什么时候用组合?

在实际开发中,通常推荐优先使用组合,因为组合更加灵活和耦合度低,只有在明确表示 is-a 关系并且需要重用基类行为时才考虑使用继承,多和多态一起使用。


小知识: hc = head count

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://xiahunao.cn/news/3249231.html

如若内容造成侵权/违法违规/事实不符,请联系瞎胡闹网进行投诉反馈,一经查实,立即删除!

相关文章

Windows FFmpeg 开发环境搭建

FFmpeg 开发环境搭建 FFmpeg命令行环境搭建使用FFmpeg官方编译的库Windows编译FFmpeg1. 下载[msys2](https://www.msys2.org/#installation)2. 安装完成之后,将安装⽬录下的msys2_shell.cmd中注释掉的 rem set3. 修改pacman 镜像源并安装依赖4. 下载并编译源码 FFmpeg命令行环境…

Harmony 状态管理 @Local 和 @Param

Harmony 状态管理 Local 和 Param Local 背景 Local 是harmony应用开发中的v2版本中 对标**State**的状态管理修饰器&#xff0c;它解决了 State 对状态变量更改的检测混乱的问题&#xff1a; State 修饰的状态变量 可以是组件内部自己定义的State 修饰的状态 也可以由外部父…

mysql常用函数五大类

mysql常用函数 1. 第一类&#xff1a;数值函数1.1 圆周率pi的值1.2 求绝对值1.3 返回数字的符号1.4 开平方&#xff0c;根号1.5 求两个数的余数1.6 截取正数部分1.7 向上取整数1.8 向下取整数1.9 四舍五入函数1.10 随机数函数1.11 数值左边补位函数1.12 数值右边补位函数1.13 次…

从PyTorch官方的一篇教程说开去(3.1 - GD 梯度下降法)

在openAI以前&#xff0c;我们所讨论的“人工智能”基本上都是“人工智障”&#xff0c;即时在某些方面表现得非常出色&#xff0c;击败了世界冠军&#xff0c;但最多算个某领域的“专才”而不是“通才”。 那么这些“智障”程序们&#xff0c;究竟是靠什么才能做到“专才”呢…

【总结】nginx源码编译安装报错./configure: error: SSL modules require the OpenSSL library.

问题现象 源码编译安装nginx时&#xff0c;执行./configure …… --with-http_ssl_module 命令安装https模块&#xff0c;需要用到openssl&#xff0c;由于机器缺少openssl库&#xff0c;报如下错误。 …… checking for openat(), fstatat() ... found checking for getaddr…

Flink HA

目录 Flink HA集群规划 环境变量配置 masters配置 flink-conf.yaml配置 测试 Flink HA集群规划 FLink HA集群规划如下&#xff1a; IP地址主机名称Flink角色ZooKeeper角色192.168.128.111bigdata111masterQuorumPeerMain192.168.128.112bigdata112worker、masterQuorumPee…

MySQL(5)表的查询

目录 1.表的查询 1.表的查询: 1.1创建表: 语法: create table 表名( 字段 类型 约束, 字段 类型 约束, ... 字段 类型 约束 ); 1.2 单行数据全行插入: &#x1f330; 表的字段都插入数据: insert into student values(100, 1000, 唐三藏, null); 1.3 多行数据 指定列插入:…

Go网络编程-RPC程序设计

gRPC 通信 RPC 介绍 RPC, Remote Procedure Call&#xff0c;远程过程调用。与 HTTP 一致&#xff0c;也是应用层协议。该协议的目标是实现&#xff1a;调用远程过程&#xff08;方法、函数&#xff09;就如调用本地方法一致。 如图所示&#xff1a; 说明&#xff1a; Servi…

STM32智能安防系统教程

目录 引言环境准备智能安防系统基础代码实现&#xff1a;实现智能安防系统 4.1 数据采集模块 4.2 数据处理与控制模块 4.3 通信与网络系统实现 4.4 用户界面与数据可视化应用场景&#xff1a;家庭与企业安防管理问题解决方案与优化收尾与总结 1. 引言 智能安防系统通过STM32…

webrtc QOS方法十三(视频渲染平滑)

一、背景介绍 视频渲染时间的确定需要考虑三方面的因素&#xff1a;网络抖动、网络延时、音视频同步 网络抖动&#xff1a;视频帧在网络上传输&#xff0c;会受到网络抖动的影响&#xff0c;不能收到立刻播放&#xff0c;需要进行适当的平滑 网络延时&#xff1a;一些报文在…

鱼眼相机变普通相机,利用Transform进行球面变换

Abstract 高分辨率广角鱼眼图像在自动驾驶等机器人应用中变得越来越重要。然而&#xff0c;使用普通的卷积神经网络或视觉变换器处理这类数据时会遇到问题&#xff0c;因为在将其投影到平面上的矩形网格时会引入投影和失真损失。为了解决这个问题&#xff0c;我们引入了HEAL-S…

雷达组网拼图3.0数据掌握和python解析处理

废话不多说&#xff0c;先展示雷达图 以反射率为例&#xff1a; 核对数据格式 Z_RADA_C_BABJ_20240705043615_P_DOR_ACHN_CREF_20240705_043000.bin数据分析认识 1. 组网产品分类&#xff1a;组网产品包括组网混合扫描反射率&#xff08;HSR&#xff09;&#xff0c;组网组…

Qt-事件与信号

事件和信号的区别在于&#xff0c;事件通常是由窗口系统或应用程序产生的&#xff0c;信号则是Qt定义或用户自定义的。Qt为界面组件定义的信号往往通常是对事件的封装&#xff0c;如QPushButton的clicked()信号可以看做对QEvent::MouseButtonRelease类事件的封装。 在使用界面组…

Android Studio - adb.exe已停止运作的解决方案

adb.exe 是Android Debug Bridge 的缩写&#xff0c;它是Android SDK 中的一个调试工具&#xff0c;允许开发者通过命令行界面与设备进行交互&#xff0c;执行各种操作&#xff0c;如运行设备的shell、管理模拟器或设备的端口映射、在计算机和设备之间上传/下载文件、将本地APK…

鸿蒙语言基础类库:【@system.mediaquery (媒体查询)】

媒体查询 说明&#xff1a; 从API Version 7 开始&#xff0c;该接口不再维护&#xff0c;推荐使用新接口[ohos.mediaquery]。本模块首批接口从API version 3开始支持。后续版本的新增接口&#xff0c;采用上角标单独标记接口的起始版本。 导入模块 import mediaquery from sy…

PySide在Qt Designer中使用QTableView 显示表格数据

在 PySide6 中&#xff0c;可以使用 Qt Model View 架构中的 QTableView 部件来显示和编辑表格数据。 1、创建ui文件 在Qt Designer中新建QMainWindow&#xff0c;命名为csvShow.ui。QMainWindow上有两个部件&#xff1a;tableview和btn_exit。 2、使用pyuic工具将ui文件转换为…

使用IDEA编写lua脚本并运行

下载lua https://github.com/rjpcomputing/luaforwindows/releases 是否创建桌面快捷方式&#xff1a;我们的目标是使用IDEA编写lua脚本&#xff0c;所以不需要勾选。后面需要的话&#xff0c;可以到安装目录下手动创建快捷方式 环境变量自动配置 安装后会自动配置好环境变量…

从零开始读RocketMq源码(五)Consumer消费Message流程解析

目录 前言 准备 拉取服务和重平衡服务启动 初识PullRequest 重平衡服务 对重平衡资源进行排序 MessageQueue消息队列集合来源 Consumer消费者集合数据来源 确实分配资源策略 执行分配策略 初始化ProcessQueue 初始化PullRequest 内存队列填充PullRequest 消息拉取…

TikTok用户必看:代理IP的优缺点深度剖析

在咱们这庞大的网络世界里&#xff0c;TikTok就像是夜空中最亮的星星&#xff0c;吸引着全世界的人们。它不仅仅是个让大家开心的地方&#xff0c;更是个能让不同地方的人互相了解、分享生活的神奇平台。但你有没有想过&#xff0c;要是能让这个连接更顺畅&#xff0c;让TikTok…

h5点击电话号跳转手机拨号

需要使用到h5的 <a>标签 我们首先在<head>标签中添加代码 <meta name"format-detection" content"telephoneyes"/>然后再想要的位置添加代码 <a href"tel:10086"> 点击拨打&#xff1a;10086 </a> 这样功能就实现…