【C++】继承

1.继承的概念

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

2.继承定义

Person是父类,也称作基类。Student是子类,也称作派生类。

继承关系和访问限定符:

继承基类成员访问方式

总结:

保护和私有作为访问限定符作用一样,继承中不同

1.基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2. protected在派生类中可以访问,不能在类外面被访问。可以看出保护成员限定符是因继承才出现的。

cpp 复制代码
#include<iostream>
using namespace std;
class Person
{
//public:
protected:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "peter"; // 姓名
	int _age = 18;  // 年龄
};
class Student : protected Person
{
public:
	Student()
		:_stuid(520304)
	{ }
protected:
	int _stuid; // 学号
};
class Teacher : protected Person
{
public:
	Teacher()
		:_jobid(521024)
	{ }
protected:
	int _jobid; // 工号
};


3. 总结表格发现基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,最好显示的写出继承方式。
5.成员变量会继承下去,因为每个对象的成员变量都是独一无二的。成员函数就没必要继承下去·,定义在公共部分,使用时调用即可。
6. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强

cpp 复制代码
#include<iostream>
using namespace std;
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "peter"; // 姓名
	int _age = 18;  // 年龄
};
class Student : public Person
{
public:
	Student()
		:_stuid(520304)
	{ }
protected:
	int _stuid; // 学号
};
class Teacher : public Person
{
public:
	Teacher()
		:_jobid(521024)
	{ }
protected:
	int _jobid; // 工号
};
int main()
{
	Student s;
	Teacher t;
	s.Print();
	t.Print();
	return 0;
}

3.基类和派生类对象赋值转换

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


2.基类对象不能赋值给派生类对象:因为基类和派生类的内存布局不同,基类对象缺少派生类中新增的成员变量和方法。这种赋值会导致信息丢失或未定义行为。

3.与变量赋值存在隐式类型转换和显示类型转换不同,派生类对象可以赋值给基类的引用或指针,这种赋值没有中间量,因为派生类对象本身就是基类对象的扩展。这种赋值是隐式的,编译器会自动处理类型转换。

4(了解).基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用,后续多态会深入学习。

4.继承中的作用域

  1. 在继承体系中基类和派生类都有独立的作用域
  2. 子类和父类中有同名成员子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义 。(在子类成员函数中 ,可以使用 基类::基类成员 显示访问

    这里_num是同名成员,构成隐藏。
  3. 需要注意的是如果是成员函数的隐藏 ,只需要函数名相同 就构成隐藏。

    B中的fun和A中的fun不是构成重载,因为不是在同一作用域
  4. 注意在实际中在继承体系里面最好不要定义同名的成员
    5.域的本质 是在编译器工作时指导编译器去"查找"的规则,编译阶段检查语法。后续代码段的"查找是"链接阶段,需要区分"查找"
    6.使用同名变量时遵循就近原则子类域--父类域--全局域

5.派生类的默认成员

"默认"的意思就是指我们不写,编译器会帮我们自动生成一个。在派生类中这几个成员函数是生成方式如下:

1.初始化把父类看成一个整体,子类初始化自己的,父类的由其在初始化列表自动调用父类默认构造完成,若父类没有默认构造时会像匿名对象一样进行初始化,否则父类无默认构造函数将报错。

初始化按声明顺序,继承相当于声明在派生类之前,先初始化

该段代码父类没有默认构造函数。

初始化列表的作用:

1.子类的构造函数可以通过初始化列表显式调用父类的构造函数。如果子类构造函数的初始化列表中没有显式调用父类的构造函数,编译器会尝试调用父类的默认构造函数。

2.如果父类没有默认构造函数,而子类构造函数的初始化列表中也没有显式调用父类的其他构造函数,编译器将报错,因为无法找到合适的父类构造函数。
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。

3. 派生类的operator=必须要调用基类的operator=完成基类的复制。

赋值注意父类和子类的调用顺序 ,只在公有中使用,保护和私有中权限会发生变化

  1. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员 。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。

规定父先构造,子先析构 ,为什么?

因为子类有可能析构后还用到父类,父类不可能用到子类。

  • 整体代码
cpp 复制代码
class Person
{
public:
	//构造
	/*Person(const char*name="mint")
		:_name(name)
	{ 
		cout << "Person()" << endl;
	}*/
	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=()" << endl;
		//检查自赋值
		if (this!=&p)
		{
			_name = p._name;
		}
		return *this;
	}
	//析构
	~Person()
	{
		cout << "~Person()" << endl;
		delete _pstr;
	}
protected:
	string _name;
	string* _pstr = new string("10240304");
};
class Student :public Person
{
public:
	//派生类构造函数,初始化顺序先父后子
	Student(const char* name = "小鹅", int id = 0)
		:Person(name)
		, _id(id)
	{
		cout << "Student()" << endl;
	}
	Student(const Student& s)
		:Person(s)
		, _id(s._id)
	{
		cout << " Student(const Student& s)" << endl;
	}
	Student& operator=(const Student&s)
	{
		if (this != &s)
		{
			Person::operator=(s);
			_id = s._id;
		}
		return *this;
	}
	~Student()
	{
		// 由于多态的原因(具体后面讲),析构函数的函数名被
		// 特殊处理了,统一处理成destructor

		// 显示调用父类析构,无法保证先子后父
		// 所以子类析构函数完成就,自定调用父类析构,这样就保证了先子后父P
		//Person::~Person();
		cout << *_pstr << endl;
		delete _ptr;
	}
protected:
	int _id;
	int* _ptr = new int;
};
int main()
{
	//Person p;
	Student s1;
	Student s2(s1);
	Student s3("喵喵", 24);
	s1 = s3;
	return 0;
}

调用顺序如下

6.继承与友元

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

若派生类中不加友元声明将报错

了解前向声明的概念:

作用:

1.声明但不定义:告诉编译器某个类或函数的存在,但不需要其完整的实现细节。

2.减少头文件包含:避免通过 #include 引入其他头文件,从而减少编译时的依赖关系。

核心目的:通过减少不必要的头文件包含,降低模块间的依赖关系。

适用场景:仅需类型声明(如指针、引用、函数参数)时优先使用前向声明。

注意事项:在需要完整类型信息时必须包含头文件。

7.继承与静态成员

基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例,所有父类对象共享同一个static成员实例

静态成员属于父类和派生类,在派生类中不会单独拷贝一份 ,可以理解为继承的是使用权

可以利用这样的特性来统计人的个数,去计算总共创建了多少个对象

Person::_count 被设置为0,因为 Student 类继承自 Person 类,并且 _count 是 protected 访问权限,这意味着 Student 类可以访问和修改 _count。

所以最后输出结果为0

8.菱形继承与虚拟继承(重点)

继承方式

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

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

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

菱形继承问题

从下面的对象成员模型构造,可以看出菱形继承有数据冗余二义性的问题。在Assistant的对象中Person成员会有两份。

通过显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决。

二义性可理解为当一个人在生活中有两个不同身份时,但其基本特征不变,例如年龄,身份证号码等。二义性问题出在当分饰两个不同角色时,基本特征有两份,但实际上是一样的

虚拟继承

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。虚拟继承在"腰部"的位置去使用,即在继承方式前加一个virtual修饰。如上图在Student和Teacher的继承Person时使用虚拟继承

cpp 复制代码
class Person
{
public:
	string _name; // 姓名
	int _age;
};
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; // 主修课程
};

使用后派生类共享_age同一块空间,都可以进行修改。
没使用虚拟继承问题

虚拟继承节省空间问题

非虚拟对象中B和C包含一个父类成员指针大小(副本),加上自身,各自都为8字节,D对象自身为4字节,总共为20字节。

在虚继承中,B 和 C 都虚继承自 A,确保 A 只被实例化一次。所以内存大小为每个对象的指针大小+虚基表指针大小。
每个虚继承的派生类都包含一个虚基表指针。这些指针指向各自的虚基表。不排除编译器的优化,优化模式下会合并虚基表以减少内存开销,不过我们并不考虑

这里B和C都包含一个虚基表指针,32位系统:通常占用4字节;64位系统:通常占用8字节。这里32位系统下,所以占用空间大小4*4+4+4

当存储成员所占空间较大时,虚基表节省空间的作用就愈发明显,因为指针大小固定不变,替换掉了冗余空间。

虚拟继承解决数据冗余和二义性的原理

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

  • 菱形继承


内存地址严格按照多继承顺序存储,可以看出A和B中都存有A值的地址。

  • 虚拟继承


内存第地址左高右低,这里是小端存储,输入时从左往右输入,先输入内存中右边数据(小端)

对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?

这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针 ,这两个表叫虚基表 。虚基表中存的偏移量。通过偏移量可以找到下面的A。

虚继承把冗余数据单独拿出来,具体放哪由编译器决定,但多了一点东西就是虚基表指针

为什么要内存中要存虚基表指针,而不直接存父类成员的地址?

1.例如在基类和派生类对象赋值转换中,会发生切片,就不能像普通场景一样直接在内存地址中找到父类成员变量,而是需要通过偏移量去寻找。

2.如果一个父类有很多成员,那么其派生类将其地址一个个存储显得冗余,通过虚基表指针指向其地址即可

虚基表指针

作用:

虚基表指针用于动态解析基类成员的地址,特别是在存在虚继承的情况下。这是因为虚基类的地址在编译时无法确定,需要在运行时通过虚基表指针来查找。

为什么需要?

动态解析 :在虚继承中,基类的地址在编译时无法确定,因为基类可能被多个派生类共享。虚基表指针允许在运行时动态确定基类的地址。
避免重复实例化 :虚继承确保基类只被实例化一次,无论有多少派生类继承自它。虚基表指针帮助管理这种共享关系。
支持多态:虚基表指针支持多态行为,允许通过基类指针调用派生类的成员函数。

9.继承和组合

多继承可以认为是C++的缺陷之一,很多后来的OO(面向对象)语言都没有多继承,如Java。

这里的百分数指耦合度,即依赖关系强弱,一方改变另一方受不受影响。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse)。术语"白箱"是相对可视性而言:在继承方式中,基类的内部细节对子类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响 。派生类和基类间的依赖关系很强,耦合度高

对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-box reuse),因为对象的内部细节是不可见的 。对象只以"黑箱"的形式出现。组合类之间没有很强的依赖关系,耦合度低 。优先使用对象组合有助于你保持每个类被封装。

实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。例如学生是人

组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。例如车有轮胎

若对象两种关系都具有优先用组合

相关推荐
地球驾驶员几秒前
NX二次开发C#---搭建NX开发环境(NX1926+VS2019)
开发语言·c#
三次拒绝王俊凯几秒前
数据结构day05
数据结构
zx132315 分钟前
chrome提示https不安全, 不能记住账号密码怎么办? 可以利用js输入账号
开发语言·javascript·ecmascript
快乐老干妈22 分钟前
STL-list链表
c++·链表·list
巴巴_羊38 分钟前
React Redux
开发语言·前端·javascript
菜鸡且互啄6942 分钟前
sql 向Java的映射
java·开发语言
长沙红胖子Qt1 小时前
GStreamer开发笔记(二):GStreamer在ubnutn平台部署安装,测试gstreamer/cheese/ffmpeg/fmplayer打摄像头延迟
c++·开源·产品
Murphy_lx1 小时前
排序(1)
数据结构·算法·排序算法
菜树人1 小时前
c/c++ 使用libgeotiff读取全球高程数据ETOPO
c语言·c++
waterHBO1 小时前
python 微博爬虫 01
开发语言·爬虫·python