当前位置: 首页 > news >正文

C++三大特性——继承性(超万字详解)

目录

前言

一、封装

1. 封装(Encapsulation)

二、继承

1. 构造函数的调用顺序

原理:

2. 析构函数的调用顺序

原理:

3、派生类的隐藏 

1. 成员函数隐藏

2. 成员变量隐藏

3. 基类函数的重载隐藏

三、多重继承问题

1. 构造函数的调用顺序

2. 析构函数的调用顺序

3. 多重继承中的命名冲突

4. 菱形继承问题(Diamond Problem)

四、虚继承

1. 菱形继承问题(Diamond Problem)

2. 虚继承解决菱形继承问题

3. 虚基类表(Virtual Base Table,VBTBL)

虚基类表的主要功能:

4. 虚基类指针(Virtual Base Pointer,VBPTR)

虚基类指针的主要功能:

5. 虚基类表和虚基类指针的工作机制

6. 虚继承的内部工作过程

 五、虚继承问题例子

1、实例        

2. 虚基类指针(VBPtr)的生成

3. FinalDerived的继承情况

4. 虚基类指针如何工作

虚基类指针的作用:


前言

    在C++中,三大特性通常是指面向对象编程(OOP)的三大基本特性,它们是 封装(Encapsulation)继承(Inheritance)多态(Polymorphism)。本文重点讲述继承性,也简单介绍一些封装性。

一、封装

       

1. 封装(Encapsulation)

  • 定义:封装是将数据(成员变量)和操作数据的方法(成员函数)结合在一起,组成一个类,从而实现对数据的隐藏和保护。
  • 目的:通过封装,类的内部细节对外部隐藏,外部只能通过类提供的公有接口(如public的成员函数)来访问和操作内部数据。这种数据隐藏和访问控制机制,增强了程序的安全性和可维护性。
  • 实现
    • 数据成员通常声明为私有的(private),只有通过公共成员函数(如gettersetter)才能访问。
    • 通过访问控制符(publicprotectedprivate)来控制类的成员访问权限。
class Person {
private:std::string name;int age;public:void setName(std::string newName) {name = newName;}std::string getName() {return name;}
};

        像上面这个代码体现了C++中的封装,他将函数,变量放入了一个类当中,作为了类的成员,像我们之前用的友元函数就破坏了封装,使得外部函数能够调用类中的成员。

二、继承

        是指,一个新的类继承/获取已存在的一个类或多个类的属性和行为

若一个类继承其他的一个类,称为单一继承,如果一个类继承其他多个类,称为多继承

当类B继承类A后:

称类A为父类,或者基类

称类B为子类,或者派生类

         那么如果你了解类的结构的话,应该知道,类当中每个成员都有对应的权限,当一个类继承了另一个类的时候,这些对应成员权限继承之后,在子类当中应该是什么权限呢?

        其实,我们在继承类的时候,也有一个继承方式,是按照public(公共),protected(保护),private(私有),这三中方式来进行继承,但是在父类当中的成员来说,他们也有自己的权限,其中的继承方式如下:

类的成员访问权限:

1、public:公有权限,类中、类外、子类都可以访问

2、protected:受保护权限,类中、子类中能够访问,类外不能访问

3、private:私有权限,类中能够访问,类外、子类都不能访问

继承方式:也有3种

1、public继承方式:基类的成员是什么权限,继承到派生类也是对应权限(除了private)

        a、基类中public的成员,继承到派生类中public下

        b、基类中protected的成员,继承到派生类中protected下

        c、基类中private的成员,虽然继承到派生类,但是子类无法访问(没有继承到private下)

2、protected继承方式:基类的权限会提高到protected(除了private)

        a、基类中public的成员,继承到派生类中protected下

        b、基类中protected的成员,继承到派生类中protected下

        c、基类中private的成员,虽然继承到派生类,但是子类无法访问(没有继承到private下)

3、private继承方式:基类的权限会提高到private(除了private)

        a、基类中public的成员,继承到派生类中private下

        b、基类中protected的成员,继承到派生类中private下

        c、基类中private的成员,虽然继承到派生类,但是子类无法访问(没有继承到private下)

 基类和派生的关系

        派生类会继承基类的所有成员,除了(基类的构造函数、析构函数);基类的private成员继承到派生类,但是派生类无法访问,如果一定要访问基类的私有成员,基类要有对应的接口函数    

  • 基类的私有成员是派生类无法直接访问的。这是为了实现面向对象编程中的封装性,保证基类的私有数据不被外界(包括派生类)随意修改或访问。
  • 如果派生类确实需要访问基类的私有成员,通常基类会提供公共或受保护的接口函数
class Base {
private:int privateData;protected:int getPrivateData() {return privateData;  // 基类提供受保护的接口函数}public:void setPrivateData(int data) {privateData = data;  // 基类提供公共的接口函数}
};class Derived : public Base {
public:void printPrivateData() {// 可以通过基类的公有或受保护函数访问私有成员std::cout << "Private data: " << getPrivateData() << std::endl;}
};int main() {Derived d;d.setPrivateData(42);  // 通过公有接口访问私有成员d.printPrivateData();  // 输出:Private data: 42return 0;
}

 派生类不会继承基类的构造函数和析构函数,如下:

class Base {
public:Base(int x) {  // 基类的构造函数std::cout << "Base constructor called with " << x << std::endl;}
};class Derived : public Base {
public:Derived(int y) : Base(y) {  // 派生类通过初始化列表调用基类构造函数std::cout << "Derived constructor called" << std::endl;}
};int main() {Derived d(10);  // 输出:Base constructor called with 10// 输出:Derived constructor calledreturn 0;
}

1. 构造函数的调用顺序

当实例化一个派生类对象时,系统会按照以下顺序调用构造函数:

  • 基类的构造函数先被调用,负责初始化基类的成员。
  • 然后调用派生类的构造函数,负责初始化派生类的成员。
原理:
  • 在实例化派生类时,基类的构造函数必须首先运行,因为派生类的对象本质上是一个扩展的基类对象。如果基类没有正确初始化,派生类无法保证其功能的正确性。
  • 如果派生类的构造函数没有显式调用基类的构造函数,则默认会调用基类的默认构造函数(如果有)。
  • 可以通过派生类的构造函数的初始化列表来显式指定调用哪个基类的构造函数。
#include <iostream>
using namespace std;class Base {
public:Base() {cout << "Base constructor called" << endl;}Base(int x) {cout << "Base constructor with argument " << x << " called" << endl;}~Base() {cout << "Base destructor called" << endl;}
};class Derived : public Base {
public:Derived() : Base(10) {  // 在初始化列表中显式调用基类的构造函数cout << "Derived constructor called" << endl;}~Derived() {cout << "Derived destructor called" << endl;}
};int main() {Derived d;  // 实例化派生类对象return 0;
}

2. 析构函数的调用顺序

析构函数的调用顺序与构造函数的顺序相反

  • 首先调用派生类的析构函数,释放派生类对象的资源。
  • 然后调用基类的析构函数,负责清理基类的资源。
原理:
  • 当销毁一个派生类对象时,派生类的资源先被释放,因为派生类的成员依赖基类对象的成员。
  • 只有当派生类对象的析构函数执行完毕后,基类的析构函数才会被调用,确保所有成员都正确销毁。
class Base {
public:Base() {cout << "Base constructor called" << endl;}~Base() {cout << "Base destructor called" << endl;}
};class Derived : public Base {
public:Derived() {cout << "Derived constructor called" << endl;}~Derived() {cout << "Derived destructor called" << endl;}
};int main() {Derived d;  // 实例化派生类对象return 0;
}

3、派生类的隐藏 

1. 成员函数隐藏

        如果派生类中的成员函数与基类的成员函数同名,即使参数不同,基类中的函数也会被隐藏,而不是重载。为了调用基类中的同名成员函数,需要使用作用域运算符::)来显式调用基类的版本。

#include <iostream>
using namespace std;class Base {
public:void display() {cout << "Base class display" << endl;}void display(int x) {cout << "Base class display with argument: " << x << endl;}
};class Derived : public Base {
public:void display() {cout << "Derived class display" << endl;}
};int main() {Derived d;d.display();  // 调用派生类的 display 函数// d.display(10);  // 错误!派生类隐藏了基类的所有同名函数// 显式调用基类的函数d.Base::display(10);  // 调用基类的 display(int) 函数return 0;
}

注意,在调用基类的成员函数的时候,要加上作用域运算符,来表示是个函数是基类的,否则报错,

2. 成员变量隐藏

        与成员函数类似,如果派生类中的成员变量与基类中的成员变量同名,派生类的成员变量也会隐藏基类的成员变量。要访问基类中的同名成员变量,依然需要使用作用域运算符。

class Base {
public:int var = 10;
};class Derived : public Base {
public:int var = 20;
};int main() {Derived d;cout << "Derived class var: " << d.var << endl;      // 输出派生类的 varcout << "Base class var: " << d.Base::var << endl;   // 使用作用域运算符访问基类的 varreturn 0;
}

3. 基类函数的重载隐藏

        在C++中,重载函数的隐藏行为比较特殊。如果派生类中定义了一个与基类同名的函数,即使该函数的参数列表不同,基类中的所有同名函数都会被隐藏。这与函数重载不同,因为在这种情况下,派生类并没有继承基类的同名函数。

class Base {
public:void func() {cout << "Base class func()" << endl;}void func(int x) {cout << "Base class func(int): " << x << endl;}
};class Derived : public Base {
public:using Base::func;  // 显式引入基类的 func 重载void func() {cout << "Derived class func()" << endl;}
};int main() {Derived d;d.func();        // 调用派生类的 func()d.func(10);      // 调用基类的 func(int) 重载return 0;
}

三、多重继承问题

        多重继承是C++中的一个独特特性,允许一个派生类同时继承多个基类。这意味着一个类可以从两个或多个父类派生,从而继承这些基类中的成员。C++通过这种方式实现了极大的灵活性,但同时也引入了潜在的复杂性,如命名冲突菱形继承问题

        

#include <iostream>
using namespace std;class Base1 {
public:void func1() {cout << "Function from Base1" << endl;}
};class Base2 {
public:void func2() {cout << "Function from Base2" << endl;}
};class Derived : public Base1, public Base2 {
public:void derivedFunc() {cout << "Function from Derived" << endl;}
};int main() {Derived d;d.func1();       // 调用 Base1 的函数d.func2();       // 调用 Base2 的函数d.derivedFunc(); // 调用 Derived 类的函数return 0;
}

1. 构造函数的调用顺序

当一个类继承了多个父类(多重继承)时,构造函数的调用顺序是:

  • 父类的构造函数先被调用,然后再调用派生类的构造函数。
  • 如果有多个父类,按照继承的声明顺序调用基类的构造函数。
  • 派生类的构造函数总是在所有基类的构造函数执行完之后才执行。
#include <iostream>
using namespace std;class Base1 {
public:Base1() {cout << "Base1 constructor called" << endl;}
};class Base2 {
public:Base2() {cout << "Base2 constructor called" << endl;}
};class Derived : public Base1, public Base2 {
public:Derived() {cout << "Derived constructor called" << endl;}
};int main() {Derived d;return 0;
}

2. 析构函数的调用顺序

析构函数的调用顺序与构造函数的顺序正好相反

  • 当一个派生类对象被销毁时,首先调用派生类的析构函数
  • 然后按照继承的逆序依次调用各个基类的析构函数。
  • 如果有多个父类,按照继承声明的逆序调用基类的析构函数。
#include <iostream>
using namespace std;class Base1 {
public:~Base1() {cout << "Base1 destructor called" << endl;}
};class Base2 {
public:~Base2() {cout << "Base2 destructor called" << endl;}
};class Derived : public Base1, public Base2 {
public:~Derived() {cout << "Derived destructor called" << endl;}
};int main() {Derived d;return 0;
}

3. 多重继承中的命名冲突

在多重继承中,如果两个基类拥有同名的成员(变量或函数),派生类将面临命名冲突。这种情况下,派生类需要通过作用域运算符显式地指定调用哪个基类的成员。

#include <iostream>
using namespace std;class Base1 {
public:void show() {cout << "Base1 show" << endl;}
};class Base2 {
public:void show() {cout << "Base2 show" << endl;}
};class Derived : public Base1, public Base2 {
public:void display() {cout << "Derived display" << endl;}
};int main() {Derived d;// d.show();  // 错误:编译器无法确定调用哪一个基类的 show()d.Base1::show();  // 显式调用 Base1 的 show()d.Base2::show();  // 显式调用 Base2 的 show()d.display();      // 调用 Derived 类的 display()return 0;
}

4. 菱形继承问题(Diamond Problem)

        菱形继承是多重继承中的一个经典问题,通常在两个基类都有相同的基类时出现。这种情况下,派生类会通过不同路径继承相同的基类,导致基类中的成员被继承多次,出现二义性和冗余。

#include <iostream>
using namespace std;class Base {
public:int value;Base() : value(10) {}
};class Derived1 : public Base {
};class Derived2 : public Base {
};class FinalDerived : public Derived1, public Derived2 {
public:void show() {// value 是从 Derived1 和 Derived2 都继承来的,出现二义性// cout << value;  // 错误:编译器不知道该从 Derived1 还是 Derived2 继承的 Base 使用 value}
};int main() {FinalDerived fd;// fd.show();  // 无法编译通过,二义性问题return 0;
}

 这里就出现了菱形继承问题,那么我们通过虚继承这种机制来解决这种二义性问题

四、虚继承

        虚继承(virtual inheritance)是C++解决菱形继承问题的一种机制。虚继承主要用于处理多重继承中可能出现的重复继承同一个基类的情况,以避免派生类中存在多个相同基类的副本,从而引发的冗余和二义性问题。

1. 菱形继承问题(Diamond Problem)

        菱形继承问题通常在一个类通过多重继承继承自两个基类,而这两个基类又共享同一个基类时出现。这样,最底层的派生类会通过两个不同的路径继承同一个基类,导致该基类的成员在派生类中出现多份副本,从而引发歧义或重复定义的问题。在多重继承中不可避免

        菱形继承结构如下:

       Base/    \
Derived1  Derived2\    /FinalDerived

2. 虚继承解决菱形继承问题

        为了解决这个问题,C++提供了虚继承。通过虚继承,基类的成员在派生类中只存在一个副本,即使通过多个路径继承基类,也不会创建多份冗余副本。

        虚继承: 在中间子类继承公共基类时,在继承方式前面加上关键字 virtual 。 而后派生子类(汇聚到子类),就只会保留一份公共继承的数据 在派生到子类时,汇聚到子类中,在子类的构造函数需要手动指定 公共基类的构造函数

        只要通过 virtual 关键字 进行 虚继承,在子类中额外添加了虚基类指针,指向虚基类表,存储公共基类的成员,

        那么下面我将介绍在虚继承问题中,虚基类表指针和虚基类表的工作原理,以便理解和记忆

 虚基类指的是那些在继承关系中通过虚继承的方式继承的基类,而不是仅仅指“最开始继承的类”。通过虚继承,派生类不会创建多份基类的副本,无论通过几条继承路径,最终派生类中都只有一份虚基类的实例。

3. 虚基类表(Virtual Base Table,VBTBL)

虚基类表是C++编译器在编译过程中生成的数据结构,主要用于虚继承的类。当类使用虚继承时,编译器创建一个虚基类表,用于存储派生类和基类成员之间的偏移量,以确保在多重继承情况下访问基类的成员时,可以正确地找到基类的成员。

        注意:虚基类表(Virtual Base Table, VBTBL)的主要功能就是存储虚基类成员在派生类对象中的偏移量。除此之外,虚基类表本身不存储其他信息。它的作用相对简单,但在虚继承的场景下,它是至关重要的,确保基类的成员能够在多重继承中被唯一、正确地访问。

虚基类表的主要功能:
  • 存储基类成员的偏移量:虚基类表中记录了基类成员相对于派生类对象的内存偏移量。这样,当需要访问虚基类的成员时,能够通过偏移量正确定位。
  • 管理公共基类的访问:当多个类通过虚继承继承同一个基类时,虚基类表确保在最终派生类中,只有一份公共基类的数据。

4. 虚基类指针(Virtual Base Pointer,VBPTR)

虚基类指针是每个使用虚继承的类的对象内部的一个指针,指向该类的虚基类表。通过虚基类指针,编译器可以动态确定派生类中访问基类成员的位置。

虚基类指针的主要功能:
  • 指向虚基类表:虚基类指针存储在每个使用虚继承的类的对象中,指向虚基类表。
  • 确保唯一的基类副本:在复杂的继承关系中,虚基类指针帮助派生类正确访问公共基类,确保派生类只保留一个基类的副本,而不是多次继承同一个基类副本。

5. 虚基类表和虚基类指针的工作机制

        当一个类通过虚继承继承基类时,编译器在每个虚继承类的对象中插入一个虚基类指针,指向该类的虚基类表。在派生类中,虚基类表用于存储公共基类的成员在派生类对象中的相对偏移位置。这样,派生类的对象可以通过虚基类指针找到虚基类表,并通过虚基类表访问公共基类的成员。

6. 虚继承的内部工作过程

  • 虚基类指针:每个虚继承类的对象中都有一个虚基类指针,用于指向虚基类表。虚基类指针帮助派生类正确访问虚基类的成员。
  • 虚基类表:虚基类表记录了公共基类在派生类对象中的位置偏移量,这样在复杂继承关系中,无论从哪条路径访问基类,最终都能访问到同一个基类副本。

 五、虚继承问题例子

1、实例        

看下面的例子:

class Base {
public:int baseValue;Base() : baseValue(42) {}
};class Derived1 : virtual public Base {
public:int derived1Value;Derived1() : derived1Value(100) {}
};class Derived2 : virtual public Base {
public:int derived2Value;Derived2() : derived2Value(200) {}
};class FinalDerived : public Derived1, public Derived2 {
public:int finalValue;FinalDerived() : finalValue(300) {}
};

在这里:

  • Base类是一个基类。
  • Derived1Derived2分别虚继承Base
  • FinalDerived类通过Derived1Derived2间接继承Base

那么虚基类表指针是怎么来的呢?

2. 虚基类指针(VBPtr)的生成

当一个类通过虚继承继承基类时,编译器为该类的对象插入一个虚基类指针(VBPtr),该指针指向虚基类表(VBTBL),虚基类表存储了基类成员在派生类对象内存布局中的偏移量。

在继承链中,Derived1Derived2 都虚继承了 Base,因此:

  • Derived1的对象会有一个虚基类指针,指向虚基类表,用于确定Base类成员在Derived1对象中的位置。
  • Derived2的对象也有类似的虚基类指针,指向虚基类表,用于确定Base类成员在Derived2对象中的位置。

3. FinalDerived的继承情况

FinalDerived继承了Derived1Derived2时,由于Base是通过虚继承共享的,FinalDerived只会拥有Base类的一个实例。在这种情况下,FinalDerived继承了Derived1Derived2中的虚基类指针(VBPtr),但这两个指针指向的是同一个虚基类表,用于管理Base类的唯一实例。

 那么那么肯定有这样一个问题:

FinalDerived派生类在继承Derived1后,是不是也继承了虚基类表指针,那么这个指针存放的是BaseDerived1对象的偏移量,还是FinalDerivedBase的偏移量?”

答案是:虚基类表中的偏移量指的是Base类成员相对于最终派生类(在此例中是FinalDerived类)对象起始地址的偏移量

  • FinalDerived继承了Derived1Derived2时,虚基类表记录的是Base类成员相对于FinalDerived对象的偏移量。这是因为FinalDerived对象是实际使用的类,而Base类的成员在FinalDerived对象中的具体位置需要通过虚基类表的偏移量来确定。
  • 虚基类表中的偏移量并不会记录Derived1Base之间的偏移量,因为虚基类表的任务是帮助确定虚基类成员在最终派生类对象中的位置,而不是中间派生类对象中的位置。这样你理解了吗。

4. 虚基类指针如何工作

FinalDerived中,Base类的成员(例如baseValue)只存在一份副本。编译器会确保通过虚基类指针,派生类对象可以正确访问Base类的成员。

虚基类指针的作用:
  • Derived1的虚基类指针FinalDerived对象中指向虚基类表,该表包含Base类相对于FinalDerived的偏移量。
  • Derived2的虚基类指针同样指向同一个虚基类表,确保无论是通过Derived1路径还是通过Derived2路径访问Base类成员,访问的都是FinalDerived对象中的唯一Base实例。

 关于虚继承中析构函数和构造函数的调用顺序,这里简单说明一下,和多重继承中的顺序是一样的

相关文章:

  • 【推荐一个好用的AI】
  • 小程序-生命周期与WXS脚本
  • Java之线程篇六
  • <Java>String类型变量的使用
  • 构建高可用和高防御力的云服务架构第五部分:PolarDB(5/5)
  • 四款负载均衡工具Nginx、HAProxy、MetalLB、gobetween 比较
  • 【HTTP】认识 URL 和 URL encode
  • Android 使用反射 反射获取activity
  • Godot C# 自定义摄像机
  • 企业级-pdf预览-前后端
  • Qt 常用数据类型
  • 【github remote: Access denied等问题的通用解决方案】
  • EHS管理系统设备安全设施安全监控模块
  • 目标检测-数据集
  • Win11家庭版找不到gpedit.msc文件怎么办
  • ES6语法详解(一)
  • iOS筛选菜单、分段选择器、导航栏、悬浮窗、转场动画、启动视频等源码
  • Java深入 - 深入理解Java集合
  • MySQL几个简单SQL的优化
  • Python 使用 Tornado 框架实现 WebHook 自动部署 Git 项目
  • ReactNative开发常用的三方模块
  • Web设计流程优化:网页效果图设计新思路
  • 基于Volley网络库实现加载多种网络图片(包括GIF动态图片、圆形图片、普通图片)...
  • 老板让我十分钟上手nx-admin
  • 入手阿里云新服务器的部署NODE
  • 问题之ssh中Host key verification failed的解决
  • 主流的CSS水平和垂直居中技术大全
  • - 转 Ext2.0 form使用实例
  • Hibernate主键生成策略及选择
  • 数据可视化之下发图实践
  • ​Kaggle X光肺炎检测比赛第二名方案解析 | CVPR 2020 Workshop
  • ​Z时代时尚SUV新宠:起亚赛图斯值不值得年轻人买?
  • ​决定德拉瓦州地区版图的关键历史事件
  • #pragma pack(1)
  • #我与Java虚拟机的故事#连载08:书读百遍其义自见
  • ( 10 )MySQL中的外键
  • (02)Cartographer源码无死角解析-(03) 新数据运行与地图保存、加载地图启动仅定位模式
  • (C++20) consteval立即函数
  • (delphi11最新学习资料) Object Pascal 学习笔记---第2章第五节(日期和时间)
  • (M)unity2D敌人的创建、人物属性设置,遇敌掉血
  • (Note)C++中的继承方式
  • (保姆级教程)Mysql中索引、触发器、存储过程、存储函数的概念、作用,以及如何使用索引、存储过程,代码操作演示
  • (不用互三)AI绘画工具应该如何选择
  • (超详细)语音信号处理之特征提取
  • (二)PySpark3:SparkSQL编程
  • (附源码)计算机毕业设计ssm基于Internet快递柜管理系统
  • (简单) HDU 2612 Find a way,BFS。
  • (一)WLAN定义和基本架构转
  • (转)IIS6 ASP 0251超过响应缓冲区限制错误的解决方法
  • (转)创业家杂志:UCWEB天使第一步
  • .bat批处理(三):变量声明、设置、拼接、截取
  • .gitignore文件使用
  • .net core 使用js,.net core 使用javascript,在.net core项目中怎么使用javascript
  • .netcore 如何获取系统中所有session_ASP.NET Core如何解决分布式Session一致性问题
  • .net使用excel的cells对象没有value方法——学习.net的Excel工作表问题