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; // 访问基类的_name
        cout << "身份证号: " <<_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 object
    void 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::value
    return 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

相关推荐

  1. c++中的单继承、多继承虚拟继承

    2024-07-19 01:30:04       37 阅读

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-07-19 01:30:04       67 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-07-19 01:30:04       72 阅读
  3. 在Django里面运行非项目文件

    2024-07-19 01:30:04       58 阅读
  4. Python语言-面向对象

    2024-07-19 01:30:04       69 阅读

热门阅读

  1. 1.虚拟机相关的博文推荐

    2024-07-19 01:30:04       19 阅读
  2. Flink HA

    Flink HA

    2024-07-19 01:30:04      20 阅读
  3. vault正式环境安装部署

    2024-07-19 01:30:04       23 阅读
  4. 【Git】Git解除仓库关联或关联新仓库

    2024-07-19 01:30:04       18 阅读
  5. AIGC笔记--Classifer Guidance的代码理解

    2024-07-19 01:30:04       25 阅读
  6. rust 构建自己的库和模块

    2024-07-19 01:30:04       20 阅读
  7. 大语言模型系列-Transformer

    2024-07-19 01:30:04       24 阅读
  8. Git入门

    2024-07-19 01:30:04       25 阅读
  9. JVM高频面试题

    2024-07-19 01:30:04       23 阅读
  10. 割点(Articulation Point)

    2024-07-19 01:30:04       24 阅读
  11. [C/C++入门][变量和运算]4、带余除法

    2024-07-19 01:30:04       20 阅读
  12. 理解 Nginx 中的 sites-enabled 目录

    2024-07-19 01:30:04       24 阅读