C++ 继承:父子类赋值转换、菱形继承、虚继承、继承与组合

x33g5p2x  于2022-06-06 转载在 其他  
字(4.8k)|赞(0)|评价(0)|浏览(279)

⏰1.继承的概念

🍁继承,是面向对象的三大特性之一。继承可以理解成是类级别的一个复用,它允许我们在原有类的基础上进行扩展,增加新的功能。

当创建一个类时,我们可以继承一个已有类的成员和方法,并且在原有的基础上进行提升,这个被继承的类叫做基类,而这个继承后新建的类叫做派生类

用法如下:

class [派生类名] : [继承类型] [基类名]

例如:

class Person
{
public:
	string _name;
	int _age;
};

class Student : public Person
{
protected:
	string _stuNum;
};

这里的派生类Student就复用了Person的方法和成员,并在此基础上扩展补充。

⏰2.继承方式

继承的方式和类的访问限定符一样,分为public(公有继承),private(私有继承), protected(保护继承)三种。
不同的继承方式,在派生类中继承下来的基类成员的访问权限也不一样。

基类的其他成员在子类的访问方式 = Min(成员在基类的访问限定符,继承方式)
备注:
1.在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能 在派生类的类里面使用,实际中扩展维护性不强。
2.使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。

⏰3.基类与派生类的赋值转换

派生类可以赋值给基类的对象、指针或者引用,这样的赋值也叫做对象切割。
例如上面的Person类和Student类

这种赋值只能是派生类赋给基类(但需要割掉多出来的成员例如_ stuID),而基类对象不能赋给派生类。

🍁基类的指针可以强制类型转换赋值给派生类的指针, 如:

int main()
{
	Person p1;
	Student s1;

	Person* hPtr1 = &s1;//指向派生类对象
	Person* hPtr2 = &p1;//指向基类对象

	Student* pPtr = (Student*)hPtr1;//没问题

	Student* pPtr = (Student*)hPtr2;//有时候没有问题,但是会存在越界风险
	
	return 0;
}

🍁小结:

1.派生类可以赋值给基类的对象、指针或者引用
2.基类对象不能赋值给派生类对象
3.基类的指针可以通过强制类型转换赋值给派生类的指针。**🍁但是必须是基类的指针是指向派生类对象时才是安全的,否则会存在越界的风险。**这里基类如果是多态类型,可以使用RTT的dynamic_cast来进行识别后进行安全转换。

⏰4.作用域与隐藏

🎄隐藏隐藏,也叫做重定义,当基类和派生类中出现重名的成员时,派生类就会将基类的同名成员给隐藏起来,然后使用自己的。(但是隐藏并不意味着就无法访问,可以通过指明基类作用域来显式访问隐藏成员。)

class Person
{
public:
	void f(int age)
	{
		cout << "姓名" << _name << endl;
		cout << "年龄" << _age << endl;
	}
protected:
	string _name;
	int _age;
};

class Student : public Person
{
public:
	void f()
	{
		Person::f(32);//需显式调用f函数
		cout << "学号" << _stuNum << endl;
	}
private:
	string _stuNum;
};

例如这里的f( )就构成了隐藏

同时,这里还有个需要注意的问题,在基类与派生类中,同名的方法并不能构成重载,因为处于不同的作用域中。而只要满足方法名相同,就会构成隐藏

⏰5.派生类的默认成员函数

在每一个类中,都会有6个默认的成员函数,这些函数即使我们自己不去实现,编译器也会帮我们实现。

🎄这里有两点需要注意(笔试题常考):

1.构造函数,拷贝构造,operator=三种情况,都要调用父类对应的构造函数/拷贝构造/operator=进行对父类的成员变量的初始化,并且倘若父类没有默认的构造函数的时候(比如父类写了带参的构造函数),我们就要显式调用(Person(参数…),Person::operator=(参数…))

🍁构造函数显示调用
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函 数,则必须在派生类构造函数的初始化列表阶段显示调用。

Student()
    	:People()
	{
		cout << "Student()" << endl;
	}

🍁拷贝构造显示调用

建议拷贝构造都用显示调用,不然免不了出现子类拷贝构造当中调用了父类的构造函数的情况(因为拷贝构造也是构造,在初始化列表处,对于子类而言,父类相当于一个自定义类型对象,子类会调用父类的构造函数对父类的资源进行初始化。)

Student(const Student& s)
		:People(s)
	{
		cout << "Student(const Student& s)" << endl;
	}

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

Student& operator=(const Student& s)
	{
		cout << "Student& operator = (const Student& s)" << endl;
		if (this != &s)
		{
			Person:: operator=(s);
		}
	}

🍁析构函数

由于编译器会将析构函数的名字处理成destructor,因此派生类和基类的析构函数会构成隐藏关系,故若要派生类要调用基类的析构函数,那么需要显式调用,但是编译器会默认在派生类的析构函数调用结束后调用基类的析构函数,这样就析构两次了。

~Person()
	{
		cout << "~Person()" << endl;
	}
	~Student()
	{
		Person:: ~Person();
		cout << "~Student()" << endl;
	}

在派生类中,基类的析构函数会被隐藏,虽然它们这里的名字不同,但是为了实现多态, 它们都会被编译器重命名为destructor。在调用子类的构造函数时,我们是先调用父类的构造函数,后对子类的成员进行构造。由先构造后析构的顺序,所以我们是在析构函数当中析构子类的资源,析构函数调用完后编译器自动帮我们调用父类的析构函数。

⏰6.友元、静态成员

🍁1.友元

友元关系是不会继承的,如果子类要使用父类的友元,则子类自己也要将其定义为友元。

🍁2.静态成员

基类定义了static静态成员,无论继承了多少次,派生了多少子类,静态成员在这整个继承体系中有且只有一个。静态成员不再单独属于某一个类亦或者是某一个对象,而是属于这一整个继承体系。

⏰7.菱形继承与虚继承

首先简单介绍下单继承、多继承的概念
单继承:一个子类只有一个直接父类时称这个继承关系为单继承

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

菱形继承:菱形继承是多继承的一种特殊情况,下面简单举例介绍下菱形继承及其带来的二义性问题:

class Human
{
public:
	int _age;
};

class Student : public Human
{
public:
	int _stuNum;
};

class Teacher : public Human
{
public:
	int _teaNum;
};
class Assistant : public Teacher, public Student
{
};

哦豁!!!菱形继承这个关系感受到了吧!!!

🍁直接讲下这个菱形继承带来的二义性问题:
按照道理来说,各个类的大小应该是这样的。human类4个字节,teacher和student都是8个字节,而assistant是12个字节,但是实际上assistant却是16字节。

🍁为什么assistant会有16字节?

这就是菱形继承的数据冗余和二义性问题的体现。
这里的teacher和student都从human中继承了相同的成员 _age。但是assistant再从teacher和student继承时,就分别把这两个 _age都给继承了过来,导致这里有了两个一样的成员。

在这样的情况下,后续想给 _age赋值,也会被编译器提示指示不明确,报错。

🍁菱形继承的二义性是很致命的问题,如何解决呢?

虚继承,在腰部的类继承时添加virtual关键字。

class Student : virtual public Human
{
public:
	int _stuNum;
};

class Teacher : virtual public Human
{
public:
	int _teaNum;
};
class Assistant : public Teacher, public Student
{
};

这次,二义性问题解决了,teacher和student都是12个字节,而assistant是20个字节。

为什么?这里引用“拾遗”大佬的博客:从内存角度看待虚继承

🍁简单小结一下
1.可以看到没有虚继承的情况下,Assitant中的成员连续排列出现了Teacher和Student中的_age是两个不同的值,但实际上一个人不会有两个年龄,所以这就出现了数据冗余。

2.这里多出来的8个字节,其实是两个虚基表指针。因为这里Human中的 _age是 teacher和 student共有的,所以为了能够方便处理,在内存中分布的时候,就会把这个共有成员 _age放到对象组成的最末尾的位置。然后通过了Teacher和Student的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的Human。由此可见,使用了虚拟继承后,就可以解决菱形继承导致的问题。

🍁为什么Teacher、Student需要去找属于自己的 _age?

基类与派生类的赋值转换时,需要进行切片。

int main()
{
	Assistant a;
	Teacher t = a; 
	Student s = a;

	return 0;
}

当把对象a赋值给t和s的时候,因为他们互相没有对方的 _stuNum和 _teaNum,所以他们需要进行对象的切割,但是又因为 _age存放在对象的最尾部,所以只有知道了自己的偏移量,才能够成功的在切割了没有的元素时,还能找到自己的 _age。

⏰8.继承和组合

🍁继承是一种复用的方式,但不是唯一方式!
1.public继承是一种is-a的关系,就是基类是一个大类,而派生类则是这个大类中细分出来的一个子类,但是他们本质上其实是一种东西。
2.组合是一种has-a的关系,就是一种包含关系,比如对象a是对象b的成员,那么他们的关系就是对象b的组成中包含了对象a,对象a是对象b中的一部分,对象b包含对象a。
3.继承方式的复用常称之为白箱复用,在继承方式中,基类的内部细节对子类可见,这一定程度上破坏了基类的封装,伴随着基类的改变,对派生类的改变很大。并且两者依赖关系强,耦合度大。
4.对象组合式继承之外的复用选择,对象组合要求被组合对象提供良好的接口定义。这种复用称之为黑箱复用,对象的内部实现细节是不可见的。耦合度低。

实际工程中能用继承和组合就用组合,组合的耦合度低,代码的维护性好,但是继承在有些关系就适合用继承就用继承,并且要实现多态就一定要用继承。

相关文章