【C++】拆分详解 - 继承

文章目录


一、基本概念

1. 概念

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

子类继承父类,

  • 父类的成员拷贝一份给子类

  • 父类的成员函数与子类共用(存放在公共代码区)

  • 构造函数相互独立

2. 语法

  1. 定义格式

    基类又称父类,派生类又称子类

  2. 继承关系和访问限定符

    ​​

    ​​

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

      • 不可见是指父类的私有成员确实被继承给子类(子类中实际存在这一份拷贝),但是语法上限制子类不能直接访问(可以通过调用父类的成员函数间接访问)
      • private 成员可以在类内访问,但不能在类外访问。如果只有 private 和 public 两种权限限定方式,那么继承后的成员要么子类的类中和类外都能访问(public),要么子类类中和类外都不能访问(private)。如果有一种需求是 父类的成员要能在子类的类中可以访问,类外不能访问呢?protected 访问限定因继承而产生
    2. 使用关键字 class 时默认的继承方式是 private,使用 struct 时默认的继承方式是 public,不过最好显示的写出继承方式。

    3. 在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用 protetced/private 继承,因为 protetced/private 继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

二、父子类对象的赋值兼容转换

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

    1. 这不是类型转换,中间并没有产生临时对象(可以理解为一种特殊规则)
    2. 类似于将子类中父类的那部分成员切割给 父类的对象/指针/引用(只指向切割出的那部分)
  • 父类对象不能赋值给子类对象。​

三、继承中的作用域

  1. 在继承体系中父类和子类都有独立的作用域(两个不同的类域)

  2. 子类和父类中有同名 成员,优先查找当前类域,即在子类中优先访问子类中的同名成员,这种情况叫做隐藏,也称重定义。 但可以通过域作用限定符改变编译器搜索规则指定访问父类成员父类名::同名成员

  3. 注意在实际中在继承体系里面最好不要定义同名的成员,容易混淆

  • 示例1
cpp 复制代码
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected:
	string _name = "小李子"; // 姓名
	int _num = 111; //父类同名成员,身份证号
};

class Student : public Person
{
public:
	void Print()
	{
		cout << " 姓名:" << _name << endl;
		cout << " 身份证号:" << Person::_num << endl; //指定父类域访问父类的同名成员
		cout << " 学号:" << _num << endl;  //优先访问所在类域的同名成员
	}
protected:
	int _num = 999;  // 子类同名成员,学号
};

void Test()
{
	Student s1;
	s1.Print();
};
  • 示例2 (区分重载与隐藏)

函数重载是在同一个作用域,隐藏是两个不同的类域

  • 示例3

隐藏只要求同名,所以此时构成隐藏,子类B对象优先在子类中寻找fun函数,找到后参数无法匹配,编译报错(不会再去父类寻找同名函数看参数是否匹配,这也是和重载的区别之一)

四、子类的默认成员函数

6个默认成员函数,"默认"的意思就是指我们不写,编译器会变我们自动生成一个,那么在子类

中,这几个成员函数是如何工作的呢?

  1. 构造 / 拷贝构造 / 赋值重载

    • 子类中继承的父类那部分,使用父类的默认成员函数,子类自己独有的部分使用子类自己的默认成员函数(如果显式写,也必须显式调用父类的默认成员函数)

      调用顺序:先调用父类构造初始化继承的父类成员,再调用子类构造(语法规定)

      原因:

      1. 创建子类对象时,先创建继承的父类成员,再创建子类自己独有的(语法规定)
      2. 子类构造可能会使用父类成员,所以规定继承的父类成员先创建,先初始化
    • 注意同名构成隐藏时,需要指定父类域调用(显式实现时)

  2. 析构

    • 父类部分使用父类的析构,子类部分使用子类的析构

      调用顺序:子类独有成员先析构,再析构继承的父类成员(语法规定)

      原因:子类析构中可能会调用父类成员

    • 不需要显式调用父类析构,在子类析构后编译器会自动调用父类析构

      如果在子类构造中显式调用,就不能保证先析构父类,所以调用父类析构的工作交给编译器自动干了

cpp 复制代码
class Person
{
public:
	//Person(const char* name = "")
	Person(const char* name = "")
		: _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;
		delete[] _str;
	}
protected:
	string _name; // 姓名

	char* _str = new char[10]{ 'x','y','z'};
};

class Student : public Person
{
public:
	// 父类构造显示调用,可以保证先父后子
	Student(const char* name = "", int x = 0, const char* address = "")
		:_x(x)
		,_address(address)
		,_name(Person::_name+'x')
		, Person(name)
	{}

	Student(const Student& st)
		:Person(st)
		,_x(st._x)
		, _address(st._address)
	{}

	Student& operator=(const Student& st)
	{
		if (this != &st)
		{
			Person::operator=(st);
			_x = st._x;
			_address = st._address;
		}

		return *this;
	}

	// 由于多态,析构函数的名字会被统一处理成destructor(),
    // 具体原理在多态时讲解,这里直接上结论

	// 父类析构不能显示调用,因为显示调用不能保证先子后父
	~Student()
	{
		// 析构函数会构成隐藏,所以这里要指定类域
		//Person::~Person();

		cout << "~Student()" << endl;
		// delete [] _ptr;
		cout << _str << endl;
	}

protected:
	int _x = 1;
	string _address = "西安高新区";
	string _name;

	//int* _ptr = new int[10];
};

// 子类默认生成的构造
// 父类成员(整体) -- 默认构造
// 子类自己的内置成员 -- 一般不处理
// 子类自己的自定义成员 -- 默认构造

// 子类默认生成的拷贝构造  赋值重载跟拷贝构造类似
// 父类成员(整体) -- 调用父类的拷贝构造
// 子类自己的内置成员 -- 值拷贝
// 子类自己的自定义成员 -- 调用他的拷贝构造
// 一般就不需要自己写了,子类成员涉及深拷贝,就必须自己实现

// 子类默认生成的析构
// 父类成员(整体) -- 调用父类的析构
// 子类自己的内置成员 -- 不处理
// 子类自己的自定义成员 -- 调用析构
//

int main()
{
	Student s1;
	Student s2("张三", 1, "西安市碑林区");

	//Student s3 = s2;

	//s1 = s3;

	return 0;
}

五、继承与友元

友元关系不能继承,也就是说父类友元不能访问子类私有和保护成员

cpp 复制代码
class Student;
class Person
{
public:
	friend void Display(const Person& p, const Student& s);
protected:
	string _name;
};
class Student : public Person
{
protected:
	int _stuNum;  //保护成员
};

void Display(const Person& p, const Student& s)
{
	cout << p._name << endl;
	cout << s._stuNum << endl;  //报错,不可访问
}
int main()
{
	Person p;
	Student s;
	Display(p, s);
}

六、继承与静态成员

父类定义了static静态成员,则整个继承体系里面只有一个这样的成员,父类和所有子类共有

cpp 复制代码
class Person
{
public:
	Person() {  }
protected:
	string _name; 
public:
	static int _count; //父类的静态成员变量
};

int Person::_count = 6;

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;
}

七、菱形继承与虚拟继承

1. 概念

  • 单继承:一个子类只有一个直接父类时称这个继承关系为单继承

    ​​

  • 多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承

    ​​

  • 菱形继承:菱形继承是多继承的一种特殊情况。

    ​​

2. 数据冗余和二义性

菱形继承具有数据冗余和二义性的问题。如下图所示,西红柿类 继承了蔬菜类和水果类,而蔬菜类和水果类又继承了植物类,则蔬菜类和水果类中各继承了一份植物类的成员,这就导致西红柿类中会有两份植物类的成员。

​​

cpp 复制代码
class Person
{
public:
	string _name; // 姓名
};
class Student : public Person
{
protected:
	int _num; //学号
};
class Teacher : public Person 
{
protected:
	int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};

void Test()
{
	Assistant a;
	a._name = "peter"; //二义性,访问student类的_name,还是techer类的_name

	// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";
}

3. 虚拟继承

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用,会有开销。

3.1 使用

cpp 复制代码
class Person
{
public:
	string _name; // 姓名
};
class Student : virtual public Person
{
protected:
	int _num; //学号
};
class Teacher : virtual public Person
{
protected:
	int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
	string _majorCourse; // 主修课程
};

void Test()
{
	Assistant a;
	a._name = "peter";
}

3.2 原理

为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助监视和内存窗口观察对象成员的模型。

cpp 复制代码
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; //最后一次修改_a值为2
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}
1. 简单判断数据是否冗余(监视窗口看值是否同步变化)
  • 不使用虚拟继承:

    可以看到d中存储了两份A类的_a成员,分别继承于B类和C类,数据冗余

    ​​

  • 使用虚拟继承:

    利用监视窗口可以看到d中只有一份_a成员了(值相同,说明是同一份)解决了数据冗余问题

    ​​

2. 类对象成员内存模型
  1. 如何查看:利用VS2022查看类的内存布局功能

  1. 不使用虚拟继承,类对象内存模型

    继承的父类成员优先放置在类对象的靠前区域,然后才放置子类独有的成员



    ​​

    ​​

    ​​

  2. 使用虚拟继承,类对象内存模型(父类没有虚函数)



    子类独有的成员优先放置在类对象的靠前区域,继承的父类的成员产生偏移,放置在靠后区域

    • 注意:这里显示的内存布局并不完全,没有显示出虚指针,实际虚指针应该存放在偏移0的区域,在子类独有成员之前。

    • d对象中将_a成员放到的了对象组成的最下面,这个_a成员同时属于d中继承的父类B,C d.B::_a == d.C::_a

  3. 虚拟继承,父类含有虚函数

    cpp 复制代码
    class person
    {
    public:
    	virtual void test_function() = 0; //父类有虚函数
    protected:
    	int _id;
    };
    
    class Student : virtual public person
    {
    public:
    	void test_function();
    protected:
    	int stu_id;
    };
    
    class Techer : virtual public person
    {
    public:
    	void test_function();
    protected:
    	int tec_id;
    };
    
    class teaching_assistant : public Student, public Techer
    {
    public:
    	void test_function();
    protected:
    	int ta_id;
    };

    此时我们可以清晰地观察到虚指针的存在,且有两个,一个指向虚函数表(多态),一个指向虚基表

    ​​

3. 原理
  1. 虚拟继承要解决的就是菱形继承时出现的数据冗余的问题,也就是去重。

  2. 我们先回顾一下继承的概念,继承的子类中,父类部分和子类独有部分是独立的两个部分(它们开辟空间和初始化的顺序都不相同,详见本文第四部分子类的默认成员函数)

    如果要将成员_a作为公共成员,那只能将其"移出"父类部分,也就是实际上_a物理空间上既不属于B类部分,也不属于C类部分,那要保证逻辑上_a既属于B类部分,也属于C类部分,怎么办?c++使用了偏移量这一概念,即公共成员与其原先父类部分的偏移距离,通过给使用虚拟继承的类创建虚基表和虚指针,使得继承后的B类部分和C类部分能得到偏移量,找到公共成员

    • 虚基表:用于存储公共成员的偏移量

      虚基表指针:存在于类对象中,用于指向虚基表

      • 虚基表不存储在类对象中,类对象中只存储虚基表指针(节省空间)
      • 每个类都有属于自己的虚基表和虚基表指针

    上文中,B类和C类属于虚拟继承,则B类和C类中各有自己的虚基表指针,则D类继承B,C后也会继承它们的虚基表指针,于是D类对象中继承的B,C部分就可以通过这个虚基表指针找到各自的虚基表,从而找到偏移量,找到该公共成员

  3. 代价:

    虚拟继承会增加虚指针的数量(占用空间),有开销

总结

本文讲解了C++继承相关知识,尤其是虚拟继承和类对象内存模型部分比较深入底层,晦涩难懂,尽管修改了多次,但由于水平有限,难免有不足甚至错误之处,敬请各位读者来评论区批评指正。

相关推荐
潜意识起点3 分钟前
Java数组:静态初始化与动态初始化详解
java·开发语言·python
点云SLAM19 分钟前
C++创建文件夹和文件夹下相关操作
开发语言·c++·算法
2301_8091774724 分钟前
2025.01.15python商业数据分析
开发语言·python
滴_咕噜咕噜25 分钟前
学习笔记(prism--视频【WPF-prism核心教程】)--待更新
笔记·学习·wpf
CodeClimb37 分钟前
【华为OD-E卷 - 猜字谜100分(python、java、c++、js、c)】
java·javascript·c++·python·华为od
_小柏_39 分钟前
C/C++基础知识复习(46)
c语言·开发语言·c++
SomeB1oody44 分钟前
【Rust自学】6.4. 简单的控制流-if let
开发语言·前端·rust
明月逐人归46444 分钟前
输出语句及变量定义
开发语言·python
tatasix1 小时前
Go Redis实现排行榜
开发语言·redis·golang
吴冰_hogan1 小时前
Java虚拟机(JVM)的类加载器与双亲委派机制
java·开发语言·jvm