【C++】继承


前言

本篇文章我们主要讲解的是C++中三大特性之一继承,继承是面向对象语言中非常重要的一个特性,它体现了极强的代码复用和关联性!!下面就让我们一起来学习继承吧。

一、继承的概念及定义

1.1 继承的概念

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

下面我们简单来看看继承的复用:

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

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

class Teacher : public Person
{
protected:
	int _jobid; // 工号
};

int main()
{
	Student s;
	Teacher t;
	s.Print();
	t.Print();

	return 0;
}

我们从监视窗口中看到了Student以及Teacher类对象中都包含了Person类对象的成员属性。

在这里插入图片描述

子类继承后父类的成员(成员函数+成员变量)都会变成子类的一部分,派生类Student以及Teacher继承了Person类,也就继承了Person类中的成员属性,另外还新增了自己的成员属性。
在这里插入图片描述

1.2 继承定义

1.2.1 定义格式

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

在这里插入图片描述

1.2.2 继承关系和访问限定符

在这里插入图片描述
在这里插入图片描述

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

总结:

  1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
  2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
  3. 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected> private
  4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显式的写出继承方式。
  5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

我们通过下面这个例子一起来验证下上述的结论:

class Person
{
public:
	void Print()
	{
		cout << _name << endl;
	}
protected:
	string _name; // 姓名
private:
	int _age; // 年龄
};

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

int main()
{
	Student s;
	s.Print();
	s._age = 10;
	s._name = "peter";

	return 0;
}

在这里插入图片描述
在这里插入图片描述

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

  • 派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去
  • 基类对象不能赋值给派生类对象
  • 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用,但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(RunTime Type Information)dynamic_cast 来进行识别后进行安全转换。这一点我们以后再来具体讲解。

如何理解切片我们来看下它的示意图:
在这里插入图片描述

class Person
{
private:
	string _name; // 姓名
	string _sex; // 性别
	int _age; // 年龄
};

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

int main()
{
	Student sobj;

	// 1.子类对象可以赋值给父类对象/指针/引用   天然的不存在类型转换的发生
	Person pobj = sobj;
	Person* pp = &sobj;
	Person& rp = sobj;

	// 不是天然的  存在隐式类型转换的发生
	double d = 1.1;
	int i = d;
	const int& ri = d;

	//2.基类对象不能赋值给派生类对象
	//sobj = pobj;
	
	// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
	pp = &sobj;
	Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
	ps1->_No = 10;
	pp = &pobj;
	Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题
	ps2->_No = 10;

	return 0;
}

在这里插入图片描述

好了,看完上述例子我们大概知道了切片的意思,通俗点的意思就是将子类继承父类的那部分成员属性赋值给父类,那这一步不是等于啥也没干吗?父类的成员属性还是原来的成员属性啊,这一步的意义究竟是啥?

它对我们下一篇讲的多态就有用武之地了,其实继承部分很多东西也是为了后面的多态做铺垫!!

三、继承中的作用域

1. 在继承体系中基类和派生类都有独立的作用域
2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员显式访问)。
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员。

我们来看一组代码:

class Person
{
public:
//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; // 学号
};

int main()
{
	Student s;
	s.Print();

	Person* p = &s;  // 切片操作
	cout << p->_num << endl;

	return 0;
}

在这里插入图片描述
我们继续来看看监视窗口:

在这里插入图片描述

我们进行了切片操作p指向了子类继承父类的那一部分的起始地址,此时我们发现打印出来的值p->_num依然为原来父类的_num,这说明子类的同名成员变量_num并没有将基类的_num进行覆盖,如果是覆盖的话那么p->_num此时就不是原先的值了而是子类的_num值,所以其实同名的子类成员变量就只是对基类的同名成员变量进行了隐藏使其不能直接通过父类对象访问到而已。

在这里插入图片描述


接着我们来看看同名成员函数构成的隐藏关系:

class A
{
public:
	void fun()
	{
		cout << "func()" << endl;
	}
};

class B : public A
{
public:
	void fun(int i)  // 隐藏
	{
		A::fun();
		cout << "func(int i)->" << i << endl;
	}
};

int main()
{
	B b;
	b.fun(10); 
	//b.fun();   //编译报错,实际派生类隐藏了基类的fun函数,此时使用的是派生类的fun函数,但参数不对
	b.A::fun(); //显式调用基类fun函数  --> 建议:不要使用同名成员

	return 0;
}

首先A类和B类的fun函数是重载函数吗?不是,虽然重载函数也是形参不同函数名相同但是有一个重要的条件是它们是在同一作用域当中,而此时的AB类是两个独立的作用域所以不构成函数重载!!此时的AB类fun函数满足函数名相同就构成隐藏关系。我们想使用A类中的fun函数就必须显式调用!!

在这里插入图片描述

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

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

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

7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系

在这里插入图片描述
在这里插入图片描述
Q:为什么派生类的默认成员函数基本都是从基类的默认成员函数出发呢?

原因也很简单,因为继承最大的特点就是派生类拥有了基类的成员属性,那么在子类进行构造、拷贝以及赋值重载之前是不是要先将基类的成员属性先拷贝过来呢!!

下面我们来简单见识一下派生类的默认成员函数与基类默认成员函数之间的关系:

class Person
{
public:
	Person(const char* name = "peter")
		: _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;
	}
protected:
	string _name; // 姓名
};

// 派生类必定会去调用基类的默认成员函数来完成初始化
class Student : public Person
{
public:
	Student(const char* name, int num)
		: Person(name)
		, _num(num)
	{
		cout << "Student()" << endl;
	}

	Student(const Student& s)
		: Person(s)
		, _num(s._num)
	{
		cout << "Student(const Student& s)" << endl;
	}

	Student& operator=(const Student& s)  // 与父类构成隐藏
	{
		if (this != &s)
		{
									// 我们应该显式去调用基类的赋值重载函数,将基类的成员赋值给派生类成员
			Person::operator=(s);  // operator=(s)会发生栈溢出,无穷递归调用派生类的赋值重载函数,隐藏关系。
			_num = s._num;
		}
		cout << "Student& operator= (const Student& s)" << endl;

		return *this;
	}

	~Student()   // ~Person(),析构函数都会处理成destructor,构成隐藏关系。先子后父,子类析构函数完成时,会自动调用父类析构函数,保证子类先析构再析构父
	{
		cout << "~Student()" << endl;
	}
protected:
	int _num; //学号
};

void Test()
{
	Student s1("jack", 18);
	Student s2(s1);
	Student s3("rose", 17);
	s1 = s3;
}

int main()
{
	Test();

	return 0;
}

在这里插入图片描述

Q: 为何构造是先父后子?析构是先子后父?

假设A为基类B为子类,A类中有num这个数据,此时B类想使用这个数据那么A类就需要提前被构造出来,所以先A后B,先父后子。
反观析构时,因为B要使用类A的数据,如果A先被析构了,那么B就无法调用这个数据,因此就需要先析构B再析构A,保证安全。子类的析构函数负责释放子类自己的资源,比如释放动态分配的内存或关闭文件等。然后,为了保证完整的对象清理过程,子类会调用父类的析构函数,以便父类可以释放其自己的资源。

五、继承与友元

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

我们来看一个例子:

在这里插入图片描述
要想在Display函数中访问Student类中的protected或private成员我们就必须在Student类中声明Display为它的友元函数!!

在这里插入图片描述

有句话说的好:爸爸的朋友不是你的朋友,不可乱套了!!同时我们也要谨慎使用友元,它会破坏封装性!

六、继承与静态成员

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

利用这样的特性我们可以设计一个程序轻松的统计出一共实例化出了多少个对象:

class Person
{
public:
	Person() { ++_count; }
public:
	string _name; // 姓名
public:
	static int _count; // 统计人的个数。
};

int Person::_count = 0;

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

class Graduate : public Student
{
protected:
	string _seminarCourse; // 研究科目
};

int main()
{
	Student s1;
	Student s2;
	Student s3;
	Graduate s4;
	cout << "人数 :" << Person::_count << endl;
	Student::_count = 0;
	cout << "人数 :" << Person::_count << endl;

	// 基类定义了static静态成员,则整个继承体系里面只有一个这样的成员
	/*Person p;
	Student s;
	cout << &(p._name) << endl;
	cout << &(s._name) << endl;

	cout << &(p._count) << endl;
	cout << &(s._count) << endl;*/  

	return 0;
}

在这里插入图片描述
在这里插入图片描述

七、复杂的菱形继承以及菱形虚拟继承

单继承:一个子类只有一个直接父类时称这个继承关系为单继承
在这里插入图片描述
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
在这里插入图片描述
菱形继承:菱形继承是多继承的一种特殊情况
在这里插入图片描述
菱形继承的问题:从下面的对象成员模型构造可以看出菱形继承有数据冗余和二义性的问题,在Assistant的对象中会有两份Person成员。

在这里插入图片描述

7.1 菱形继承产生的问题

我们来看下下面这个例子:

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; // 主修课程
};

int main()
{
	// 这样会有二义性无法明确知道访问的是哪一个
	Assistant a;
	//a._name = "peter";
	// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
	a.Student::_name = "xxx";
	a.Teacher::_name = "yyy";

	return 0;
}

在这里插入图片描述

如何解决?我们只需显式指定访问哪个父类的成员可以解决二义性问题。

在这里插入图片描述

但是数据冗余的问题我们还无法解决,那如何彻底解决数据冗余的问题呢?

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

在这里插入图片描述

解决二义性以及数据冗余问题其实就是让_name在对象中只存在一份,上述代码我们在Student和Teacher类中加上了virtual关键字就解决了这个问题,那么它的原理是什么呢?下面我们就来一起探究一下。

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

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

class A
{
public:
	int _a;
};

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

class C : 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;
}

首先我们来看下菱形继承的内存对象模型:

在这里插入图片描述

我们在监视窗口和内存中都看到了两份_a,同时我们观察d的内存模型可以看到D先继承谁在前面,所以先B再C,而B、C第一个成员都是基类A的成员再是自己的成员。

下面我们来看看菱形虚拟继承的内存对象模型:

在这里插入图片描述

通过调试我们发现最终_a只存在一份并且值为2,由此就解决了数据冗余及二义性问题,但是在内存窗口中我们并看不懂对象最前面的那一串地址是什么,接下来我们继续进行探究。

在这里插入图片描述

通过上图我们发现了这个地址指向的内容,它的前4个字节为全0至于为什么我们到了多态才能明白,之后存放的就是偏移量,通过这个偏移量我们刚好能从当前位置定位到_a,由此便解决了菱形继承出现的二义性和数据冗余问题。

通过BC对象的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表虚基表中存偏移量,通过偏移量就找到了下面的A。

下图是对于上面菱形虚拟继承的原理解释:

在这里插入图片描述

那么为何设计出这样的定位方式呢?我们直接记录_a的地址来找到唯一的_a不更加简单吗?但是假设基类中有多个成员呢,难道我们也是直接一个一个的记录它们的地址??

为何大佬要设计出这样的定位方式呢,我们来看看接下来的场景:

在这里插入图片描述

我们知道上述场景是在进行切片操作,基类对象指向子类对象中继承基类的那部分,我们知道_a是子类D继承的那部分,那么我们该如何找到它呢?同样的我们也是通过虚基表中的偏移量来找到_a,如下图:

在这里插入图片描述

上述我们看到的是d对象的内存模型,下面我们来看看b对象的内存模型,我们对比观察一下发生了什么变化:

在这里插入图片描述

我们发现此时B跟A的位置发生了变化,但是大家有没有发现B定位_a的方式也跟上面一样啊,都通过虚基表中偏移量来定位_a。所以不管ptrb是指向d还是指向b,虽然b跟a的相对位置发生了变化,但是它们访问_a都是采用统一的方式!!!这就是大佬设计的合理之处。


接下来我们继续看一个问题,在上述出现数据冗余的内存对象模型中我们占的字节大小为:

在这里插入图片描述

上述中我们_a占俩份,_b、_c以及_d各占一份也就是5份20字节,而我们虚拟继承竟然占据还比它大?

在这里插入图片描述

对于虚拟继承我们需要两个虚基表指针指向俩张虚基表记录_a的偏移量、_a、_b、_c、_d各占一份也就是6份24字节,那么其实也就是多出一份_a的大小嘛并没有多出太多的空间。我们的两个指针大小是一直不变的,而当我们的_a变大时此时是不是就可能节省了空间啊,只要_a比8字节大那么我们就能节省空间,这时候有人可能会问了虚基表中存的偏移量也需要消耗空间啊!!的确,记录偏移量也需要消耗空间,但是我们想是不是所有的对象都共享同一张虚基表呢?因为它们内部的偏移量都是一致的啊,从这个角度来看的话虚基表的消耗可以忽略不计!!

我们来测试一下:
在这里插入图片描述

从上图我们可以看到,不管有多少个同一类型的对象它们的虚基表指针总是不变的,因为它们都共享同一张虚基表,虚基表内部的偏移量都一致。

总结:多个同一类型的对象共享同一张虚基表,因为其内部的偏移量都一致!!


这时候我还有一个问题,如果基类中有多个成员变量呢,那么派生类该如何去定位它们??同样的我们也是通过虚基表指针找到虚基表,然后通过虚基表中的偏移量来定位它们,为何能这样?因为虚基表其实就可以看做一个大的数组(所有成员的类型都一致的话)或结构体,这样我们找到了数组或结构体的起始地址了,根据元素类型是不是就能准确定位到它们了呢?起始地址 + 偏移量(不同元素类型可能涉及内存对齐规则) = 某个成员变量!!

在这里插入图片描述

解决菱形继承出现二义性及数据冗余的原理:在派生类对象中添加一个虚基表指针,该指针指向虚基表,虚基表中存的是偏移量,再通过偏移量定位到相应的基类成员!!


学习完上述的菱形继承我们来做几道题:

题一:

#include <iostream>
using namespace std;

class A {
public:
	A(char* s) { cout << s << endl; }
	~A() {}
};

class B :virtual public A
{
public:
	B(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};

class C :virtual public A
{
public:
	C(char* s1, char* s2) :A(s1) { cout << s2 << endl; }
};

class D :public B, public C
{
public:
	D(char* s1, char* s2, char* s3, char* s4) 
		: B(s1, s2)
		, C(s1, s3)
		, A(s1)
	{
		cout << s4 << endl;
	}
};

int main()
{
	D* p = new D("class A", "class B", "class C", "class D");
	delete p;

	return 0;
}

首先我们看向D类的初始化列表,所以调用构造函数的顺序是这样的?BCAD?在类和对象中,我们说初始化列表与声明顺序有关,而在继承中呢?在之前的讲解中我们就提到了先继承的类就在前面,D继承的顺序为BC,所以B一定在C之前,而对于A来说它是最终的基类,所以A的构造一定是在前面的,由此我们再根据构造函数打印出的内容分析应该为ABCD,我们来看看结果:

在这里插入图片描述

如果在初始化列表中将B,C的顺序调换呢?其实结果还是不变的,因为先继承的在前面!!


题二:

class Base1 
{ 
public: 
	int _b1; 
};

class Base2 
{ 
public: 
	int _b2; 
};

class Derive : public Base1, public Base2 
{ 
public:
   int _d; 
};

int main()
{
	Derive d;
	Base1* p1 = &d;
	Base2* p2 = &d;
	Derive* p3 = &d;
	cout << "p1 = " << p1 << endl;
	cout << "p2 = " << p2 << endl;
	cout << "p3 = " << p3 << endl;
	
	return 0;
}

我们通过内存模型来简单的分析一下:
在这里插入图片描述
p1指向的是Base1的起始地址,p2指向的是Base2的起始地址,p3指向的是Derive的起始地址,由于Derive先继承Base1所以Base1在前,而刚好p1与p3指向同一块,它们在数值上一样但是意义不一样,就跟&数组一样,它的数值跟&[0]是一样的但是意义不一样,我们的类跟数组一样是整体开辟的,从低地址到高地址进行分配,因此p2>p1。

八、继承与组合

  • public继承是一种is-a的关系,也就是说每个派生类对象都是一个基类对象。
  • 组合是一种has-a的关系,假设B组合了A,每个B对象中都有一个A对象。
  • 优先使用对象组合,而不是类继承 。
  • 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。 继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
  • 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
  • 实际尽量多去用组合。组合的耦合度低,代码维护性好。 不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
  • 17
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

malloc不出对象

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值