【C++进阶篇】C++继承进阶:深入理解继承的复杂性

devtools/2024/11/30 7:22:49/

文章目录

须知

💬 欢迎讨论:如果你在学习过程中有任何问题或想法,欢迎在评论区留言,我们一起交流学习。你的支持是我继续创作的动力!

👍 点赞、收藏与分享:觉得这篇文章对你有帮助吗?别忘了点赞、收藏并分享给更多的小伙伴哦!你们的支持是我不断进步的动力!
🚀 分享给更多人:如果你觉得这篇文章对你有帮助,欢迎分享给更多对C++感兴趣的朋友,让我们一起进步!

接上篇:【C++进阶篇】像传承家族宝藏一样理解C++继承-CSDN博客 

 前言

在C++编程中,继承是面向对象编程(OOP)的一项核心特性,它让我们能够通过创建类的层次结构来实现代码重用,提高开发效率。然而,继承并不总是那么简单,尤其是在我们深入到更复杂的场景时。本文将带领你从基础继承走向更为进阶的继承应用,探讨C++中继承的多个高级概念,帮助你在理解继承机制的同时,避免常见的陷阱,提升代码的可维护性和可扩展性。

 6. 继承与友元、静态成员

6.1 继承与友元

在 C++ 中,友元是一种特殊机制,它允许指定的非成员函数或者其他类访问类的私有成员和保护成员。然而,友元关系不能继承,也就是说,基类友元不能访问子类私有和保护成员,反之亦然。

6.1.1 友元函数定义

如果基类定义了一个友元函数,该友元函数只能访问基类的私有和保护成员,而不能访问派生类的私有或保护成员。反之,如果友元函数在派生类中定义,它也无法访问基类的私有和保护成员。

示例代码:

#include<iostream>
#include<string>
using namespace std;class Student;  // 前向声明 Student 类class Person
{
public:// 声明友元函数,友元函数需要访问 Person 和 Student 的私有成员friend void ShowData(const Person& s, const Student& t);protected:string _name="Jack";
};class Student : public Person
{
public:// 声明友元函数,友元函数需要访问 Person 和 Student 的私有成员friend void ShowData(const Person& s, const Student& t);protected:int _stuNum=1234567890;
};// 定义友元函数 ShowData
void ShowData(const Person& s, const Student& t)
{cout << s._name << endl;  // 访问 Person 类的 _name 成员cout << t._stuNum << endl; // 访问 Student 类的 _stuNum 成员
}int main()
{Person p;Student s;ShowData(p, s);return 0;
}

 输出:

Jack
1234567890

在以上代码中,ShowData 函数既是 Person 类的友元,又是Student类的友元,它可以访问 Person 的保护成员 _name,也可以访问Student的保护成员_stuNum

 6.2 继承与静态成员

C++ 中的静态成员在继承关系中具有一些特殊的行为。无论继承了多少次,基类中的静态成员在整个继承体系中始终只有一个实例。派生类可以共享访问基类中的静态成员

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

所有的派生类都只共享一个基类的静态成员,有且仅是唯一的。

示例代码:

 

#include<iostream>
#include<string>
using namespace std;class Person
{
public:Person()//Person类的构造函数{++_scout;}
protected:string _name = "";
public:static int _scout ;//基类中的静态成员变量
};
int Person::_scout = 0;//所有派生类共享的静态变量class Student :public Person
{
public:
protected:int _age = 18;
};class Graduate : public Student
{protected:string _seminarCourse; // 研究科目
};int main()
{Student s1;//派生类实例化出对象时会自动基类的构造函数Student s2;Student s3;Graduate s4;cout << " 人数 :" << Person::_scout << endl;Student::_scout = 0;//Person::_scout = 0;cout << " 人数 :" << Person::_scout << endl;return 0;
}

输出:

 人数 :4
 人数 :0

在以上代码中,_scoutPerson 类的静态成员,用于统计创建的 Person 对象数量。由于 Student继承Person,因此 Student 也可以访问 _scout。无论是通过 Person::_scout 还是 Student::_scout,它们都指向同一个静态成员。

  • 第一次输出:输出 4,因为一共创建了 4 个对象(s1, s2, s3, s4),每个对象的创建都导致 _scout 增加。
  • 第二次输出:输出 0,因为在程序中已经通过 Student::_scout = 0; 将静态成员 _scout 重置为 0。

图解步骤说明

  1. Person

    • Person 类有一个静态变量 _scout,每当 Person 类或派生类的对象被创建时,这个静态变量会增加 1
  2. Student

    • Student 类是 Person 类的派生类。当创建 Student 对象时,Person 类的构造函数被调用,从而导致 _scout 的自增。
  3. Graduate

    • Graduate继承Student 类,因此创建 Graduate 对象时,Person 类的构造函数同样会被调用,使 _scout 继续增加。
  4. main 函数

    • main 函数中,我们创建了 3Student 对象(s1, s2, s3)和一个 Graduate 对象(s4)。每个对象的创建都会使 _scout 增加 1
    • 然后,通过 Person::_scout 输出 _scout 的值。

 7. 复杂的菱形继承及虚拟继承

7.1 菱形继承问题

菱形继承是 C++ 多重继承中的一种特殊情况。当一个类从两个基类继承,而这两个基类又有共同的基类时,就会形成一个菱形结构。菱形继承会导致基类的多次实例化,进而引发数据冗余和二义性问题

 7.1.1 菱形继承基本结构

菱形继承是指一种特殊的多重继承关系,其中两个子类分别继承自同一个父类,而另一个子类同时继承自这两个子类。其形状类似菱形,因此得名“菱形继承”。 

 

 简单展示一下:

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

图结构:

在上述代码中,派生类BC继承A,且D继承BC间接导致D继承A,存在两份A。这就导致了数据冗余和访问的二义性。 

7.2 菱形继承的二义性问题 

在上述代码中,假如B类中存在与A类相同的成员变量名,使用D构造出一个对象,当D访问该变量名时,无法确定访问哪一个。 

示例代码:

int main()
{D d;d._a;//error:对“_a”的访问不明确return 0;
}

编译器报出错误:对“_a”的访问不明确,编译器无法确定_a是从B还是C中继承过来的,这种二义性问题在实际开发中会带来严重的维护和理解困难。

7.3 解决方案:虚拟继承

 虚拟继承可以解决菱形继承中的数据冗余和二义性问题。通过虚拟继承,派生类会共享同一个虚基类的实例,从而避免基类被多次实例化。

7.3.1 什么是虚拟继承

虚拟继承通过在继承时使用 virtual 关键字,指示编译器在继承关系中只生成一个基类实例,从而解决数据冗余和二义性问题。

示例代码:

#include <iostream>
using namespace std;class A {
public:A() { cout << "A's constructor" << endl; }virtual void show() { cout << "A's show()" << endl; }
};class B : virtual public A {
public:B() { cout << "B's constructor" << endl; }void show() override { cout << "B's show()" << endl; }
};class C : virtual public A {
public:C() { cout << "C's constructor" << endl; }void show() override { cout << "C's show()" << endl; }
};class D : public B, public C {
public:D() { cout << "D's constructor" << endl; }void show() override { cout << "D's show()" << endl; }
};int main() {D d;  // 创建 D 的对象d.show();return 0;
}

输出:

A's constructor
B's constructor
C's constructor
D's constructor
D's show()

解释

  • 构造顺序:在 D 的构造过程中,A 的构造函数只调用一次,因为 BC 都通过虚继承继承 A。因此,A 的构造函数先于 BC 被调用。
  • show 方法:由于 D 重写了 show 方法,因此调用 d.show() 输出的是 D's show()

7.4 虚基表(VBTable)与虚基类指针(VBPTR)

在虚拟继承中,编译器会在每个虚基类对象中加入一个指向虚基表(VBTable)的指针,即虚基类指针(VBPTR),用于存储偏移量信息。

7.4.1 虚基表的工作机制

虚基表中存储的是虚基类相对于派生类对象的偏移量。通过虚基类指针,派生类对象可以在运行时计算出虚基类在内存中的实际位置。

示例内存布局:

  • 0x005EF75C 处存储了 D 对象的起始地址。
  • 虚基类指针(VBPTR)指向虚基表(VBTable)。
  • 虚基表中的偏移量帮助定位虚基类 A 在 D 对象内存中的实际位置。

 

7.4.2 偏移量的用途

偏移量的设计让编译器能够在运行时调整虚基类的位置,确保派生类在访问基类成员时能够定位到唯一的基类实例。

在虚拟继承中,虚基表中的偏移量解决了菱形继承中的访问问题,使得派生类 D 能够直接访问基类 A 的成员,而不会再有二义性。

 int main() {
    D d;
    d._a = 7;  // true:通过虚基表解决了二义性
    return 0;
}

D对象通过虚基表定位到A的唯一实例,访问A类的成员变量 _a 合法。 

 7.5 虚拟继承优缺点

7.5.1 优点:
  1. 避免冗余:虚拟继承解决了多重继承中的冗余问题,确保多个派生类共享同一个基类实例。
  2. 消除二义性:解决了菱形继承中的二义性问题,使得派生类可以明确继承父类。
  3. 控制构造函数:通过虚拟继承,派生类避免了多次调用同一基类的构造函数。
  4. 更清晰的继承结构:通过共享基类实例,继承关系变得更加清晰。
7.5.2 缺点:
  1. 增加复杂性:虚拟继承增加了类层次结构的复杂度,使得代码更难理解和维护。
  2. 性能开销:虚拟继承引入了额外的内存和时间开销,可能影响性能。
  3. 构造顺序不可控:虚拟继承的构造顺序是固定的,可能不符合实际需求。
  4. 成员访问复杂:通过虚拟继承访问基类成员时可能需要显式指定,增加了访问的复杂性。
  5. 不必要的共享:某些情况下虚拟继承可能导致不必要的基类共享,增加了复杂性和开销。

 8.虚拟继承与传统继承对比

8.1  定义和区别

  • 传统继承Non-Virtual Inheritance):

    • 在传统继承中,子类会直接继承父类的成员。如果一个类有多个基类,子类会继承基类的多个实例。这样,继承关系中每个基类都会有一个独立的实例。
  • 虚拟继承Virtual Inheritance):

    • 虚拟继承通过 virtual 关键字来声明,它用于解决多重继承中的菱形继承问题。在虚拟继承中,子类与其他派生类共享父类的唯一实例。虚拟继承确保在继承链中只有一个基类实例。

8.2 构造函数和析构函数的差异

  • 传统继承
    • 在传统继承中,基类的构造函数会按照继承顺序被调用。如果类 D 继承了类 B 和类 C,那么 BC 会各自调用基类 A 的构造函数(重复调用)。
  • 虚拟继承
    • 在虚拟继承中,基类的构造函数只会被调用一次,并且由最底层的派生类(即最派生类 D)来调用。这是因为虚拟继承确保了所有的派生类共享一个基类实例。
    • 这样,虚拟继承避免了基类构造函数的重复调用。

8.3 访问基类成员的方式

  • 传统继承
    • 在传统继承中,派生类会直接继承基类的成员,且每个继承链中的类拥有独立的基类实例。因此,派生类中访问基类成员时不需要特别指定哪个基类的成员。
  • 虚拟继承
    • 在虚拟继承中,由于派生类共享基类的唯一实例,访问基类成员时,可能需要显式指定基类。例如,如果 D继承了多个虚拟继承的基类,访问基类成员时可能需要通过作用域解析符(::)明确指定。

8.4 内存和性能影响

  • 传统继承
    • 每个派生类都会有自己的基类实例,这会导致内存冗余,尤其是在复杂的继承层次中。对于多层继承的类,每个派生类都会复制基类成员,导致不必要的内存浪费。
  • 虚拟继承
    • 虚拟继承通过共享一个基类实例来避免冗余,但它也引入了性能开销,因为虚拟继承需要使用虚拟表(vtable)来管理共享实例。这会导致额外的内存和运行时开销。

8.5 二义性问题

  • 传统继承

    • 传统继承会产生二义性问题,特别是当多重继承导致多个相同的基类副本时。例如,如果派生类 D 同时继承BC,而 BC继承A,那么在访问 A 的成员时,编译器无法判断应该访问哪个副本。
  • 虚拟继承

    • 虚拟继承消除了二义性问题。通过确保基类实例只有一个,虚拟继承使得子类可以明确访问共享的父类成员。

8.6 代码示例对比

8.6.1 传统继承示例:
class A {
public:void show() { cout << "A's show()" << endl; }
};class B : public A {
public:void show() { cout << "B's show()" << endl; }
};class C : public A {
public:void show() { cout << "C's show()" << endl; }
};class D : public B, public C {// 这里会产生二义性问题,无法确定调用哪个类的 show()
};
 8.6.2 虚拟继承示例:
class A {
public:void show() { cout << "A's show()" << endl; }
};class B : virtual public A {
public:void show() { cout << "B's show()" << endl; }
};class C : virtual public A {
public:void show() { cout << "C's show()" << endl; }
};class D : public B, public C {// 虚拟继承消除了二义性,D 共享 A 的唯一实例
};

 8.7 虚拟继承最佳实践

8.7.1 小心使用多层次的虚拟继承

虚拟继承可以解决菱形继承的问题,但如果继承层次过多,代码的可读性和维护性会大幅降低。因此,在设计类层次结构时,应尽量保持清晰和简洁。

  • 减少继承层次:尽量避免多层次的虚拟继承,保持类的结构简单化。
  • 使用组合替代继承:如果可以使用对象组合(has-a 关系)替代继承is-a 关系),那么优先选择组合,这样可以降低代码的耦合度。

 8.8  实际项目中的案例

假设我们在开发一个多层次的企业管理系统,系统需要表示不同层级的员工(如员工、经理、总经理等),并且系统中有多个部门(如财务、技术等)。这些层级的员工都有一些共同的功能,如工作、考勤等。

#include <iostream>
using namespace std;class Person {
public:void work() {cout << "Person working!" << endl;}
};class Employee : virtual public Person {
public:void attendMeeting() {cout << "Employee attending meeting." << endl;}
};class Manager : virtual public Person {
public:void manageTeam() {cout << "Manager managing team." << endl;}
};class Director : public Employee, public Manager {
public:void makeDecision() {cout << "Director making decision." << endl;}
};int main() {Director d;d.work();  // 正常调用work(),不会出现二义性问题d.attendMeeting();d.manageTeam();d.makeDecision();return 0;
}
8.8.1  解释和分析
  • 虚拟继承:在 EmployeeManager 类中,Person 类是虚拟继承的,这意味着 EmployeeManager 共享同一个 Person 类的实例。因此,Director 类在继承 EmployeeManager 时,只会有一个 Person 类实例。

  • 避免二义性:由于虚拟继承确保了 Person 类的唯一实例,Director 类调用 work() 时不再存在二义性问题,编译器明确知道应该调用哪个 Person 类的 work() 方法。

8.8 总结

总的来说,虚拟继承能够解决传统多重继承中存在的重复继承和二义性问题,尤其适用于复杂的继承关系。尽管虚拟继承增加了代码的复杂性和性能开销,但它提供了一个更清晰的多重继承解决方案,尤其是在菱形继承场景中。

 9. 继承的总结与反思

9.1. 继承的最佳实践与建议

9.1.1 首选组合而非继承

在设计时,优先考虑使用组合来实现功能,而不是过度依赖继承。组合提供了更高的灵活性,可以避免继承中的一些复杂问题,如多重继承带来的二义性。

9.1.2 尽量使用虚拟继承解决菱形继承问题

在多重继承的情况下,使用虚拟继承来消除基类实例的冗余和二义性,尤其是在菱形继承结构中,这能够避免多个基类副本带来的问题。

9.1.3 避免深层次的继承

尽量避免继承链过深的情况。深度继承容易导致代码难以理解,且修改基类时可能会影响到多个派生类,增加维护成本。适时考虑替换为接口或组合模式。

9.1.4 适当使用抽象类和纯虚函数

多态章节设计此内容,下篇文章将详细展开叙述。

通过抽象类和纯虚函数,我们可以确保派生类实现特定功能。抽象类提供了一种强制约束,要求派生类按照规范进行实现。

9.1.5 清晰的设计与文档

在设计继承关系时,确保类和方法的职责明确,避免继承结构的模糊性。文档化每个类的职责和继承关系,以便后续开发人员理解和维护。

9.2 继承的优势与应用

9.2.1 代码复用

继承允许派生类复用基类的代码,这样我们可以将公共的功能放在基类中,而将特有的功能放在派生类中,避免了代码的重复编写。

8.2.2 扩展性

继承使得系统能够随着需求变化灵活扩展。当我们需要增加新的功能或新类型的对象时,可以通过继承新的类并在其中添加额外的功能来实现,而不必修改已有的代码。

9.2.3 多态性

继承为多态提供了基础,尤其是当基类指针或引用指向派生类对象时,能够实现运行时动态绑定,从而根据对象的真实类型调用适当的方法。

9.3 继承的类型和特性

9.3.1 继承

继承是最简单的继承方式,一个子类从一个父类继承。这种方式结构清晰,容易理解,但在某些复杂场景下可能无法满足需求。

9.3.2 多重继承

多重继承是一个类继承多个父类。它虽然能够提供更丰富的功能,但也带来了问题,尤其是二义性问题,即派生类可能会从多个父类继承相同的成员,这会导致编译器无法决定应该使用哪个成员。

9.3.3 虚拟继承

虚拟继承通过引入共享基类的机制解决了多重继承中的菱形继承问题(即多个派生类从同一基类派生)。虚拟继承确保派生类只会拥有一个基类实例,消除了冗余和二义性问题。

9.3.4 抽象类与纯虚函数

继承还可以与抽象类和纯虚函数一起使用,来实现接口(接口类)和多态行为。抽象类不能直接实例化,只能作为派生类的基类,从而强制派生类实现特定的行为。

9.4 继承的常见问题与反思

9.4.1 二义性问题

在多重继承中,派生类如果继承了多个父类并且父类有相同成员(方法或属性),编译器就无法判断应该调用哪个父类的成员。为了解决这个问题,我们可以使用作用域解析符来明确指定父类的成员,或者使用虚拟继承来确保基类的唯一性。

9.4.2 构造函数与析构函数的调用顺序

继承中的构造函数和析构函数调用顺序是有规则的:

  • 构造函数:从最基类到最派生类依次调用。
  • 析构函数:从最派生类到最基类依次调用。

这个顺序确保了派生类能够在构造时初始化继承的基类部分,在析构时释放基类的资源。

9.4.3 父类构造函数的调用

派生类的构造函数会自动调用父类的构造函数,但如果父类没有默认构造函数,或者需要特殊参数时,派生类的构造函数需要显式调用父类构造函数。

9.4.4 访问控制的影响

基类的成员(如变量和方法)的访问控制(publicprotectedprivate)会影响派生类的访问权限。虽然派生类可以继承基类的成员,但如果基类的成员是私有的,派生类无法直接访问。

9.4.5 继承的代码复杂性

虽然继承能够带来代码复用,但过度使用继承,尤其是多重继承,会使代码变得复杂、难以理解和维护。特别是当继承关系非常深时,可能会出现难以追踪的问题。因此,应该尽量避免深层次的继承链条,提倡使用组合和接口代替复杂的继承关系。

10. 最后

继承是面向对象设计中非常强大的工具,能够帮助我们简化代码,促进代码复用和扩展。然而,继承也伴随着一定的复杂性,尤其是多重继承、虚拟继承和访问控制等问题。我们在使用继承时,应该清晰地定义类的职责和关系,尽量避免复杂的继承结构,采用适当的设计模式和编程技巧,以保证代码的可读性、可维护性和灵活性。

继承本身并不是“坏”的,而是要根据具体的应用场景和设计需求来合理使用,避免不必要的复杂性。

路虽远,行则将至;事虽难,做则必成

 亲爱的读者们,下一篇文章再会!!!

 


http://www.ppmy.cn/devtools/138125.html

相关文章

数据预处理方法—特征选择、特征缩放、特征构造

特征选择 1.1 原理 特征选择是选择对模型训练最重要的特征&#xff0c;减少数据维度&#xff0c;去除冗余或不相关特征&#xff0c;提高模型性能的性能和训练速度&#xff0c;减少过拟合。 1.2 核心公式 可以使用基于树模型的特征重要性度量&#xff0c;如在随机森林中计算特…

Git 的使用

Git 初始 个人本机使用&#xff1a;Git 基础命令和概念 多人共享使用&#xff1a;团队开发同一个项目的代码版本管理 Git 安装 检验安装是否成功&#xff1a; 打开 bash 终端&#xff08;git 专用&#xff09; 命令&#xff1a;git -v&#xff08;查看版本号&#xff09;…

ScratchLLMStepByStep——从零一步一步构建大语言模型

前言 在学习大语言模型的时候&#xff0c;总会遇到各种各样的名词&#xff0c;像自注意力、多头、因果、自回归、掩码、残差连接、归一化等等。这些名词会让学习者听的云里雾里&#xff0c;觉得门槛太高而放弃。 本教程将会带你从零开始&#xff0c;一步一步的去构建每一个组…

社群赋能电商:小程序 AI 智能名片与 S2B2C 商城系统的整合与突破

摘要&#xff1a;本文聚焦于社群在电商领域日益凸显的关键地位&#xff0c;深入探讨在社群粉丝经济迅猛发展背景下&#xff0c;小程序 AI 智能名片与 S2B2C 商城系统如何与社群深度融合&#xff0c;助力电商突破传统运营局限&#xff0c;挖掘新增长点。通过分析社群对电商的价值…

计算机毕业设计Python+LSTM天气预测系统 AI大模型问答 vue.js 可视化大屏 机器学习 深度学习 Hadoop Spark

温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 温馨提示&#xff1a;文末有 CSDN 平台官方提供的学长联系方式的名片&#xff01; 作者简介&#xff1a;Java领…

Next.js -服务端组件如何渲染

#题引&#xff1a;我认为跟着官方文档学习不会走歪路 服务器组件渲染到客户端发生了什么&#xff1f; 请求到达服务器 用户在浏览器中请求一个页面。 Next.js 服务器接收到这个请求&#xff0c;并根据路由找到相应的页面组件。服务器组件的渲染 Next.js 识别出请求的页面包含…

stm32单片机个人学习笔记13(USART串口协议)

前言 本篇文章属于stm32单片机&#xff08;以下简称单片机&#xff09;的学习笔记&#xff0c;来源于B站教学视频。下面是这位up主的视频链接。本文为个人学习笔记&#xff0c;只能做参考&#xff0c;细节方面建议观看视频&#xff0c;肯定受益匪浅。 STM32入门教程-2023版 细…

IDEA Mac快捷键(自查询使用)

Editing&#xff08;编辑&#xff09; Control Space 基本的代码补全&#xff08;补全任何类、方法、变量&#xff09;Control Shift Space 智能代码补全&#xff08;过滤器方法列表和变量的预期类型&#xff09;Command Shift Enter 自动结束代码&#xff0c;行末自动添…