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
),只有通过公共成员函数(如getter
和setter
)才能访问。- 通过访问控制符(
public
、protected
、private
)来控制类的成员访问权限。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: 42
return 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 called
return 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; // 输出派生类的 var
cout << "Base class var: " << d.Base::var << endl; // 使用作用域运算符访问基类的 var
return 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
类是一个基类。Derived1
和Derived2
分别虚继承自Base
。FinalDerived
类通过Derived1
和Derived2
间接继承Base
。
那么虚基类表指针是怎么来的呢?
2. 虚基类指针(VBPtr)的生成
当一个类通过虚继承继承基类时,编译器为该类的对象插入一个虚基类指针(VBPtr),该指针指向虚基类表(VBTBL),虚基类表存储了基类成员在派生类对象内存布局中的偏移量。
在继承链中,Derived1
和 Derived2
都虚继承了 Base
,因此:
Derived1
的对象会有一个虚基类指针,指向虚基类表,用于确定Base
类成员在Derived1
对象中的位置。Derived2
的对象也有类似的虚基类指针,指向虚基类表,用于确定Base
类成员在Derived2
对象中的位置。
3. FinalDerived的继承情况
当FinalDerived
继承了Derived1
和Derived2
时,由于Base
是通过虚继承共享的,FinalDerived
只会拥有Base
类的一个实例。在这种情况下,FinalDerived
继承了Derived1
和Derived2
中的虚基类指针(VBPtr),但这两个指针指向的是同一个虚基类表,用于管理Base
类的唯一实例。
那么那么肯定有这样一个问题:
FinalDerived
派生类在继承Derived1
后,是不是也继承了虚基类表指针,那么这个指针存放的是Base
和Derived1
对象的偏移量,还是FinalDerived
和Base
的偏移量?”
答案是:虚基类表中的偏移量指的是Base
类成员相对于最终派生类(在此例中是FinalDerived
类)对象起始地址的偏移量。
- 当
FinalDerived
继承了Derived1
和Derived2
时,虚基类表记录的是Base
类成员相对于FinalDerived
对象的偏移量。这是因为FinalDerived
对象是实际使用的类,而Base
类的成员在FinalDerived
对象中的具体位置需要通过虚基类表的偏移量来确定。 - 虚基类表中的偏移量并不会记录
Derived1
和Base
之间的偏移量,因为虚基类表的任务是帮助确定虚基类成员在最终派生类对象中的位置,而不是中间派生类对象中的位置。这样你理解了吗。
4. 虚基类指针如何工作
在FinalDerived
中,Base
类的成员(例如baseValue
)只存在一份副本。编译器会确保通过虚基类指针,派生类对象可以正确访问Base
类的成员。
虚基类指针的作用:
Derived1
的虚基类指针在FinalDerived
对象中指向虚基类表,该表包含Base
类相对于FinalDerived
的偏移量。Derived2
的虚基类指针同样指向同一个虚基类表,确保无论是通过Derived1
路径还是通过Derived2
路径访问Base
类成员,访问的都是FinalDerived
对象中的唯一Base
实例。
关于虚继承中析构函数和构造函数的调用顺序,这里简单说明一下,和多重继承中的顺序是一样的