面向对象三大特性之一——继承详解

目录

前言

一、继承的概念与定义

1、继承的概念

2、继承的定义

2.1定义格式

 2.2继承关系和访问限定符

 二、基类和派生类的赋值转换

 三、继承中的作用域

四、派生类的默认成员函数

1、构造函数

2、拷贝构造函数

3、赋值运算符 

4、析构函数 

 五、继承和友元

六、继承与静态成员

七、菱形继承

1、继承的分类

2、菱形继承的问题

3、虚拟继承

3.1虚拟继承的概念

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

 八、继承的总结与反思

1、继承的缺陷

2、继承和组合


前言

hello,小伙伴们大家好。相信对面向对象有一定了解的小伙伴都知道面向对象的三大特性为封装、继承、多态。封装特性我们已经在类和对象中体验到了,那么今天我们就来接着学习面向对象的继承特性。


一、继承的概念与定义

1、继承的概念

继承是面向对象程序设计的重要代码复用手段,它允许我们在保持原有类特性的基础上进行扩展,形成新的类,叫做派生类。继承体现了面向对象程序设计的层次结构。我们以前接触的都是函数复用,而继承是类的复用。

2、继承的定义

2.1定义格式

下面我们看到的Person是父类,也称为基类。Student是子类,也成为派生类。

 继承后父类的Person成员(成员变量和成员函数)都会成为Student的一部分。

class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18;  // 年龄
};


class Student : public Person
{
protected:
int _stuid; // 学号
};

 2.2继承关系和访问限定符

继承基类成员访问方式的变化:

类成员/继承方式 public继承 protected继承 private继承
基类的public成员 派生类的public成员 派生类的protected成员 派生类的private成员
基类的protected成员 派生类的protected成员 派生类的protected成员 派生类的private成员
基类的private成员 在派生类中不可见 在派生类中不可见 在派生类中不可见

 总结:

(1)当基类是private成员时,不论是什么继承,在派生类中都不可见。这里的不可见是指基类的私有成员虽然继承到了派生类当中,但由于语法的限制派生类对象不管在类里面还是在类外面都不能去访问它。

(2)如果想要基类成员不能在类外面被访问,但可以在派生类中被访问,可以把成员定义为protected。可以看出,protected这个概念就是专门为继承而生的。

(3)从上面的表格可以总结出,private成员在派生类中都不可见。而其它成员都适合派生类做对比,取最小权限做派生类的访问方式。

(4)使用关键字class默认的继承方式是private,使用关键字struct默认的继承方式是public,但一般最好写清楚继承方式。

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

 二、基类和派生类的赋值转换

  • 派生类的对象可以赋值给基类的对象/基类的指针/基类的引用。这里有个形象的说法叫做切割,意思是把派生类中基类那部分切过来赋值过去。
  • 基类的对象不能赋值给派生类对象。

 三、继承中的作用域

  1. 继承体系中的父类和子类都有独立的作用域。
  2. 子类如果和父类有同名成员,在子类中会屏蔽掉继承下来的父类的同名成员,这叫做隐藏,也叫做重定义。(如果想要在子类中访问的话,可以用 父类::父类成员显示访问)
  3. 需要注意成员函数的隐藏,父类和子类的同名函数不会因为参数不同就构成重载,只要是函数名相同就构成隐藏。
  4. 注意在实际的继承体系中,最好不要定义重名的成员。 
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected :
    string _name = "zxy"; // 姓名
    int _num = 111;  // 身份证号
};
class Student : public Person
{
public:
    void Print()
    {
        cout<<" 姓名:"<<_name<< endl;
        cout<<" 身份证号:"<<Person::_num<< endl;
        cout<<" 学号:"<<_num<<endl;
    }
protected:
    int _num = 999; // 学号
};

四、派生类的默认成员函数

6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类
中,这几个成员函数是如何生成的呢?

1、构造函数

派生类的构造函数不能自己初始化继承的基类成员,因为在继承后的所有基类成员被看作一个整体,编译器会自动调用基类的默认构造函数。如果基类不存在默认构造函数,则需要我们在初始化列表种手动调用。

class Person
{
	public:
		Person(const char* name)
			: _name(name)
		{

		}
	protected:
	string _name; // 姓名
};


class Student : public Person
	{
	public:
		Student(const char* name, int id)
			: Person(name)
			,_id(id)
		{
			// 调用父类构造函数初始化继承的父类部分
			// 再初始化自己的成员
		}
	private:
	int _id;
};

2、拷贝构造函数

拷贝构造同样需要对基类部分单独处理,只需要将派生类直接传给基类部分即可,会完成切割,类似于调用了基类部分的构造函数。

	//拷贝构造
		Student(const Student& s)
			:Person(s)//直接把派生类赋值给基类部分,会完成切割
			,_id(s._id)
		{

		}

3、赋值运算符 

在写派生类的赋值运算重载时候要特别注意,基类的赋值运算符会被隐藏,如果要调用的话必须加作用域限定运算符。

	Student& operator=(const Student& s)
		{
			if (this != &s)
			{
				Person::operator=(s);
				//这里必须加作用域运算符,否则基类的赋值运算符会被隐藏,
				//函数种调用的是派生类的赋值运算,也就是自身,会无限递归下去

				_id = s._id;
			}
		}

4、析构函数 

析构函数是比较特殊的,派生类的析构函数和基类函数的析构函数虽然看上去名字不同,但是依旧会构成隐藏。这是因为后面多态的一些原因,任何类析构函数名都会被统一处理成destructor()。

	//析构函数
		~Student()
		{
			Person::~Person();//如果想要调用,同样需要指定
            //接下来如果派生类成员需要处理,再继续处理
		}

但是这样写是有问题的,如果我们在基类和派生类的析构函数中都加一个打印的话就会发现基类被析构了两次。这是为什么呢?

首先我们要清楚类和对象有这样一个原则,后构造的先析构。对于派生类对象初始化来说,是先构造基类,再构造派生类。所以按照析构顺序,应该先析构派生类,再析构基类。如果我们自己调用基类的析构函数,很容易控制不好这个顺序,所以编译器帮我们做了一件事,在调用派生类的析构函数后,会自用去调用基类的析构函数,就不需要我们管了。

//析构函数
		~Student()
		{
			//Person::~Person(); 不需要,编译器会自己调用,处理派生类成员即可
        }

 五、继承和友元

友元关系不能继承,也就是说基类友元不能访问派生类的私有和保护成员。就好比你爸爸的朋友不一定是你的朋友。

六、继承与静态成员

基类定义了static成员,static成员可以被继承,但整个继承体系里只有这一个成员,无论派生出多少个子类,都只有一个static成员实例。

七、菱形继承

1、继承的分类

单继承:一个子类只有一个父类,称这种继承方式为单继承。

多继承:一个子类有两个或两个以上直接父类,称这种继承方式为多继承。

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

2、菱形继承的问题

菱形继承会出现二义性代码冗余的问题。

从图中我们可以看出,类中有两个name,这导致二义性的出现,编译器分不清我们想使用那个name。而且这个类中只需要一个name,大量相同的成员会导致代码冗余。

void main()
{
	//为了避免二义性,必须指定作用域
	Assistant a;
	a.Student::_name = "小张";
	a.Teacher::_name = "张老师";
	a._name = "xxx";
}

3、虚拟继承

二义性可以通过指明作用域解决,那么代码冗余怎么解决呢?c++对此增加了虚拟继承来解决这个问题。

3.1虚拟继承的概念

虚拟继承:我们需要在菱形继承的中间层加入关键字virtual,如下面代码所示,在teacher和student定义时加入virtual,这样在最后的assistant中就只有一个name。

class parent
{
public:
	string _name;
};
class teacher:virtual public parent
{
protected:
	int _id;
};
class student :virtual public parent
{
protected:
	int _num;
};
class assistant :public teacher, public student
{
protected:
	string _course;
};

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

由于编译器的监视窗口会对数据的存放位置进行优化,所以我们通过内存窗口来探究一下数据分布。

class A
{
public:
	int _a;
};

class B : virtual public A
{
public:
	int _b;
};

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;
	d._b = 3;
	d._c = 4;
	d._d = 5;
    return 0;
}

打开内存窗口,输入d的地址观察,发现数据的内存按以下分布。

通过内存分布我们可以发现,D对象把A对象放到了最下面,这个A对象同时属于B和C,那么B和C是怎样找到这个公共的A的呢?这是通过B和C两个指针,这两个指针叫做虚基表指针,分别指向两个虚基表,虚基表中存放了偏移量,通过偏移量找到A。

 下面我们继续通过内存窗口来验证。上面的内存窗口显示c和d的数值上面对应着一串地址,这串地址中存的是什么呢,我们再打开一个监视窗口。

我们输入b上面的地址,发现这个地址下面的地址存着一个值,经过计算,这个值恰恰是B上面地址到A地址的距离。c同理。 

下面是之前person关系的菱形虚拟继承的原理图:

 八、继承的总结与反思

1、继承的缺陷

有很多人都说c++的语法复杂,多继承就是一个很好的体现。多继承可以看作c++设计时候踩的一个坑,有了多继承就会产生菱形继承,因为菱形继承又产生了虚拟继承,导致语法越来越复杂。所以最好不要设计出多继承和菱形继承,否则复杂度和性能都会受到影响。

2、继承和组合

  • public继承是一种is-a的关系,就是说每一个派生类都是一个基类
  • 组合是一种has-a的关系。如果B组合了A,那每个B对象中都有一个A对象。
  • 父类的内部细节对子类可见,继承一定程度上破坏了类的封装,基类的改变对派生类影响很大,继承的耦合性高。
  • 组合是除继承外的另一种复用方式,也称为黑箱复用。因为对象的内部细节是不可见的,所以组合的类之间没有很强的依赖关系,关联性低。
  • 软件设计类之间的关系或模块之间的关系强调高内聚,低耦合。所以大多数情况下尽量使用组合。不过继承也是有用武之地的,有些关系很适合用继承,包括后面的多态也要用到继承。

 总结

本次的内容就到这里啦。本章主要想大家介绍了继承的概念以及使用,希望小伙伴们看完之后都能有所收获。山高路远,来日方长,期待下次见面。

猜你喜欢

转载自blog.csdn.net/weixin_59371851/article/details/125882585
今日推荐