C++ 面向对象(一)继承:继承、对象切割、菱形继承、虚继承、继承与组合


继承

继承的概念

继承,是面向对象三大特性之一,是可以使代码复用的最重要的手段之一。我们可以在保持原有结构的基础上,在对类的功能进行进一步的拓展,使得创建和维护一个类变得更加的高效和简单
当创建一个类时,我们可以继承一个已有类的成员和方法,并且在原有的基础上进行提升,这个被继承的类叫做基类,而这个继承后新建的类叫做派生类。

继承的方法很简单

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

例如:

class Human
{
public:
	Human(string name = "张三", int age = 18) : _name(name), _age(age)
	{}

	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}

protected:
	string _name;
	int _age;
};

class Student : public Human
{
public:
	Student(string stuNum = "123456") : Human(), _stuNum(stuNum)
	{}

	void Print()
	{
		Human::Print();
		cout << "stuNum:" << _stuNum << endl;
	}

protected:
	string _stuNum;
};

int main()
{
	Human h1;
	Student s1;

	h1.Print();
	cout << endl;
	s1.Print();
	return 0;
}

在这里插入图片描述
这里的派生类Student就复用了Human的方法和成员,并在里面增加了新内容。


继承方式

![在这里插入图片描述](https://img-blog.csdnimg.cn/20200607205503368.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3FxXzM1NDIzMTU0,size_16,color_FFFFFF,t_70
继承的方式和类的访问限定符一样,分为public(公有继承),private(私有继承), protected(保护继承)三种。
不同的继承方式,在派生类中继承下来的基类成员的访问权限也不一样。

主要特点就是,继承之后的成员访问权限是选取继承方式和原有权限中最私密的那个。(除原私有成员会不可见)
在这里插入图片描述

原基类的private成员无论以什么方式继承下来后都是不可见的。不可见并不是没有继承,而是在派生类中被隐藏了,无法访问。


基类与派生类的赋值转换

派生类可以赋值给基类的对象、指针或者引用,这样的赋值也叫做对象切割。
例如上面的Human类和Student类
在这里插入图片描述
从这幅图可以看出来,当把派生类赋值给基类时,可以通过切割掉多出来的成员如_stuNum的方式来完成赋值。
但是基类对象如果想赋值给派生类,则不可以,因为他不能凭空多一个_stuNum成员出来。
但是基类的指针却可以强制类型转换赋值给派生类对象, 如:

int main()
{
	Human h1;
	Student s1;

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

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

	Student* pPtr = (Student*)hPtr2;//有时候没有问题,但是会存在越界风险
	
	return 0;
}
  • 派生类可以赋值给基类的对象、指针或者引用
  • 基类对象不能赋值给派生类对象
  • 基类的指针可以通过强制类型转换赋值给派生类的指针。但是必须是基类的指针是指向派生类对象时才
    是安全的,否则会存在越界的风险。这里基类如果是多态类型,可以使用RTT的dynamic_cast来
    进行识别后进行安全转换。

作用域与隐藏

基类和派生类都具有他们各自的作用域,那如果出现同名的成员,此时会怎么样呢?这里就要牵扯到一个概念——隐藏

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

class Human
{
public:
	Human(string name = "张三", int age = 18) : _name(name), _age(age)
	{}

	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}

protected:
	string _name;
	int _age;
};

class Student : public Human
{
public:
	Student(string stuNum = "123456") : Human(), _stuNum(stuNum)
	{}

	void Print()
	{
		Human::Print();
		cout << "stuNum:" << _stuNum << endl;
	}

protected:
	string _stuNum;
};

例如这里的Print就构成了隐藏

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


派生类的默认成员函数

在每一个类中,都会有6个默认的成员函数,这些函数即使我们自己不去实现,编译器也会帮我们实现。
之前有写过
类的默认六个成员函数
在这里插入图片描述

class Human
{
public:
	Human()
	{
		cout << "Human 构造函数" << endl;
	}

	~Human()
	{
		cout << "Human 析构函数" << endl;
	}

protected:
	string _name;
	int _age;
};

class Student : public Human
{
public:
	Student()
	{
		cout << "Student 构造函数" << _name << endl;
	}

	~Student()
	{
		cout << "Student 析构函数" << endl;
	}
protected:
	string _stuNum;
};

int main()
{
	Student s1;

	return 0;
}

在这里插入图片描述
可以看到,调用派生类的默认成员函数时都会调用基类的默认构造函数。

  1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函 数,则必须在派生类构造函数的初始化列表阶段显示调用。
  2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
  3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
  4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类 对象先清理派生类成员再清理基类成员的顺序。
  5. 派生类对象初始化先调用基类构造再调派生类构造。
  6. 派生类对象析构清理先调用派生类析构再调基类析构

这里还有个需要注意的地方,在派生类的析构函数中不需要自己去调用基类的析构函数,编译器会在派生类析构函数结束后自动调用。

	~Student()
	{
		//~Human();//报错, 这里基类的析构函数会被隐藏
		cout << "Student 析构函数" << endl;
	}

同时,在派生类中,基类的析构函数会被隐藏,虽然它们这里的名字不同,但是为了实现多态, 它们都会被编译器重命名为destructor。


友元与静态成员

友元

友元关系是不会继承的,可以这样理解,你长辈的朋友并不是你的朋友,之前的关系不会继承。所以基类的友元不能访问子类的私有和保护成员

静态成员

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


多继承

如果一个子类同时继承两个或以上的父类时,此时就是多继承。

多继承虽然能很好的继承多个父类的特性,达到复用代码的效果,但是他也有着很多的隐患,例如菱形继承的问题,这也就是为什么后期的一些语言如java把多继承去掉的原因。


菱形继承

那么, 什么是菱形继承呢?这里就举一个例子
为了方便观看我就只保留成员变量

class Human
{
public:
	int _age;
};

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

class Teacher : public Human
{
public:
	int _teaNum;
};

这里有着人类,学生类,老师类。在学校中,还同时具有老师和学生这两个属性的人,也就是助教,助教可能是本科的老师助理,同时也是研究生或者博士生在读。所以我们可以让他同时继承teacher类和student类
在这里插入图片描述

class Assistant : public Teacher, public Student
{
};

按照道理来说,各个类的大小应该是这样的。human类4个字节,teacher和student都是8个字节,而assistant是12个字节。
但是实际上assistant却是16字节
在这里插入图片描述

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

倘若我们想要给那个_age赋值
在这里插入图片描述在这里插入图片描述
因为里面存在两个一样的_age,这是编译器就会报错通知我们指定的不够明确。我们还需要指定作用域
在这里插入图片描述
这也就是二义性问题。

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

	cout << " Human: " << sizeof(h) << " Teacher: " << sizeof(t) << " Student: " << sizeof(s) << " Assistant: " << sizeof(Assistant) << endl;
}

那么怎样才能解决这个问题呢,那就得用到虚继承来解决。


虚继承

解决这个问题很简单,当多个类继承同一个类时,就在继承这个类时,为其添加一个虚拟继承的属性。
如:

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

class Teacher : virtual public Human
{
public:
	int _teaNum;
};

在这里插入图片描述
这时就可以看到,它只继承了一次。
接下来看看大小
在这里插入图片描述
按照道理来说,他应该是12字节,为什么呢?

这里就牵扯到了C++的对象模型

因为我还没有详细的了解C++的对象模型,只是草草的读了几篇博客和看了一点点的C++对象模型中的其中一小节。所以这里引用一下别的大佬的博客,我再稍微总结一下。
从内存布局看C++虚继承的实现原理

这里多出来的8个字节,其实是两个虚基表指针。
因为这里Human中的_age是teacher和student共有的,所以为了能够方便处理,在内存中分布的时候,就会把这个共有成员_age放到对象组成的最末尾的位置。然后在建立一个虚基表,这个表记录了各个虚继承的类在找到这个共有的元素时,在内存中偏移量的大小,而虚基表指针则指向了各自的偏移量。

这里打个比方:
在这里插入图片描述
通过这个偏移量,他们能够找到自己的_age的位置。

为什么需要这个偏移量呢?

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

	return 0;
}

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

从这里就可以看出来,多继承存在着很多缺点,尤其是菱形继承,他在底层引出了很多复杂的模型和概念,这也是为什么很多面向对象语言都删除了多继承的原因,因为他很难掌控。所以在使用是需要非常谨慎,尽量不要使用多继承,更不要构造出菱形继承。


继承和组合

什么是组合

那除了继承还有什么好的代码复用方式吗?那答案肯定是有的,就是组合
组合是什么呢?组合就是将多个类组合在一起,实现代码复用。

继承和组合又有什么区别呢?这里就举几个例子

1.继承
继承是一种is a的关系,就是基类是一个大类,而派生类则是这个大类中细分出来的一个子类,但是他们本质上其实是一种东西。

如:

class Human
{
public:
	int _age;
};

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

学生也是人,所以他可以很好的继承人的所有属性,并在其实增加学生独有的属性。

2.组合
组合是一种has a的关系,就是一种包含关系,比如对象a是对象b的成员,那么他们的关系就是对象b的组成中包含了对象a,对象a是对象b中的一部分,对象b包含对象a。
例如:

class Study
{
public:
	void ToStudy()
	{
		cout << "Study" << endl;
	}
};

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

这里的Student类中包含了一个Study类,学习是学生中非常重要的一部分,并且是不可缺少的一部分,每个学生都需要学习,学习是学生的本职。


如何选择组合和继承

如果综合考虑的话,其实应该多使用组合,因为在组合中,几个类的关联不大,你是我的一部分,所以我也只需要用到你那部分的某个功能,我并不需要了解你的实现和细节,只需要你开放对应的接口即可,并且如果我要修改,只修改那一部分功能即可。所以这就导致了组合的依赖关系弱,耦合度低,十分符合软件工程的低耦合,高类聚。这样保证了代码具有良好的封装性和可维护性。

那么继承呢?继承的依赖关系就非常的强,耦合度非常高。因为你要想在子类中修改和增加某些功能,就必须要了解父类的某些细节,并且有时候甚至会修改到父类,父类的内部细节在子类中也一览无余,严重的破坏了封装性。并且一旦基类发生变化时,牵一发而动全身,所有的派生类都会有影响,这样的代码维护性会非常的差,因为很难在不了解具体细节的情况下能够不影响到子类的实现。但是继承也不是无用的,很多关系都很适合用继承,并且多态的实现也需要用到继承,这个还是得参考具体场景。

但是大部分场景下,如果继承和组合都可以选择,那就优先选择组合。

这个大佬讲的也非常好,可以借鉴学习一下
优先使用对象组合,而不是类继承

猜你喜欢

转载自blog.csdn.net/qq_35423154/article/details/106606610