【C++进阶】继承

在这里插入图片描述

个人主页:@Weraphael
✍作者简介:目前学习C++和算法
✈️专栏:C++航路
希望大家多多支持,咱一起进步!
如果文章对你有帮助的话
欢迎 评论 点赞 收藏 加关注✨


目录

  • 一、继承的概念
      • 1.1 概念
      • 2.2 作用
  • 二、继承的定义
      • 2.1 定义格式
      • 2.2 继承关系和访问限定符
  • 三、基类和派生类对象赋值转换
  • 四、继承中的作用域
      • 4.1 概念
      • 4.2 继承中的隐藏/重定义
      • 4.3 易错题
  • 五、子类的默认成员函数
      • 5.1 概念
      • 5.2 默认构造函数
      • 5.3 拷贝构造
      • 5.4 赋值运算符重载
      • 5.5 析构函数
  • 六、继承与友元
  • 七、继承与静态成员
  • 八、菱形继承
      • 8.1 概念
      • 8.2 虚拟继承
      • 8.3 虚拟继承原理(底层)
  • 九、继承和组合
  • 十、面试题
  • 十一、总结

一、继承的概念

1.1 概念

  • 继承是面向对象三大特性之一(封装、继承、多态)。
  • 继承的本质是代码复用。以前我们接触的复用都是函数复用,继承是类设计层次的复用

假设需要设计学校教务系统,可以简单划分为:老师和学生,但如果更深层次继划分的话,还可以划分出:校领导、各专业院长、辅导员等,那么就要对每个角色写一个类,那么代码量也未免太大了。

为了复用代码、提高开发效率,可以从各种角色中选出共同点,组成基类(父类),比如一个人都有姓名、年龄、性别、联系方式等基本信息,而老师与学生的区别就在于学号和工号。于是,继承允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类(子类)。

2.2 作用

子类继承父类后,子类可以享有父类中的所有公开public/保护protected属性,除了私有private内容外。为什么是这样定义的,在【继承的定义】会讲解

#include 
using namespace std;

class Person // 父类
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "张三"; // 姓名
	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;
}

【C++进阶】继承_第1张图片

二、继承的定义

2.1 定义格式

继承的格式很简单,格式为class 子类 : 继承方式 父类

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

【C++进阶】继承_第2张图片

2.2 继承关系和访问限定符

在类和对象我们说过,protectedprivate是没有区别的,但在继承中,它们的区别大了!

【C++进阶】继承_第3张图片

访问权限和继承权限都是三种,根据排列组合,可以列出以下多种搭配方案:

【子类的可访问情况】

访问权限 / 继承权限 public protected private
父类的public成员 派生类变成public成员 派生类变成protected成员 派生类变成private成员
父类的 protected成员 派生类变成protected成员 派生类变成protected成员 派生类变成private成员
父类的 private成员 不可见 不可见 不可见

【总结】

  1. 父类的private成员在子类中无论以什么方式继承都是不可见的。这里的不可见是指父类的私有成员还是被继承到了子类对象中,但是语法上限制子类对象不能访问父类的private成员。

  2. 如果父类成员需要在子类中能访问,就将父类的成员定义为protected或者public

  3. 无论是哪种继承方式,父类中的私有成员始终不可被访问;当子类成员可访问父类成员时,最终权限将会变为访问权限与继承权限中的较小者(public > protected> private
    【C++进阶】继承_第4张图片

  4. 注意:使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式

  5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承。还有的就是父类的访问权限一般都是public/protected。因为继承的本质就是代码复用,要是访问权限是private,子类还访问个pi啊。

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

在继承中,允许将子类对象直接赋值给父类,但不允许父类对象赋值给子类。

class A //父类
{
protected:
	int a = 666;
};

class B : public A //子类
{
private:
	int b = 999;
};

int main()
{
	A aa; // 实例化
	B bb; // 实例化
	aa = bb; // 子给父
	bb = aa; //非法,erro
	
	return 0;
}

并且这种赋值兼容转化是非常自然,虽然类型不一样,但没有发生隐式类型转换(表达式中自动进行的类型转换,无需进行特殊的语法操作。这种转换是由编译器根据类型兼容性自动完成的)

那么如何证明没有发生隐式类型转化呢?

int main()
{
	int i = 0;
	double d = i; // 发生隐式类型转化

	// 对i取别名
	//double& c = i; //erro
	
	// 正确
	const double& c = i;
	return 0;
}

如果对于整型变量取别名,并且别名的类型和整型不同,那么就会发生报错。因为i是整型,当i赋值给c时,中间会产生一个临时变量,并且这个临时变量具有常性,因此只要在double前加上const就不会发生报错。

那么我们可以用类似于以上的方法来查看子类赋值给父类时有没有发生隐式类型转化:

【C++进阶】继承_第5张图片

还有的就是,子类对象可以赋值给·父类的对象 / 父类的指针 / 父类的引用。这里有个形象的说法叫切片(切割)。就是它会把子类中属于父类的成员切割出来赋值给父类

【C++进阶】继承_第6张图片

四、继承中的作用域

4.1 概念

子类虽然继承父类,但他们有独立的作用域。因此可以出现同名成员。但是默认会将父类的同名成员隐藏,进而执行子类的同名成员,这种情况叫隐藏,也叫重定义。

4.2 继承中的隐藏/重定义

#include 
#include 
using namespace std;

class Person
{
protected:
	string _name = "张三";
	int _id = 111;
};

class Student : public Person
{
public:
	void print()
	{
		cout << "姓名:" << _name << endl;
		cout << "身份证:" << _id << endl;
	}
protected:
	int _id = 999; // 子类和父类出现同名变量
};

int  main()
{
	Student s1;
	s1.print(); // 若出现同名变量,输出的是子类成员
	return 0;
}

【输出结果】

【C++进阶】继承_第7张图片

出现同名成员时,默认会将父类的同名成员隐藏,进而执行子类的同名成员

若出现同名成员,但就想用父类的成员,该怎么办?

方法:指定作用域,加个域作用限定符::

【C++进阶】继承_第8张图片

成员函数也是一样的,需要在调用时指定作用域。

#include 
using namespace std;

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

int  main()
{
	B b;
	b.fun();
	b.A::fun();
	return 0;
}

【输出结果】

【C++进阶】继承_第9张图片

虽然子类和父类可以有同名成员,但注意在实际中,继承体系里面最好不要定义同名的成员。不然就是自己坑自己!

4.3 易错题

问:子类中的fun和父类中的fun构成什么关系?

#include 
using namespace std;

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

class B : public A
{
public:
	void fun(int i)
	{
		cout << "B::fun(int i)" << endl;
	}
};

很多人都以为是函数重载(函数名相同,参数个数不同)。在这我想说,不是构成重载,因为 构成函数重载的前提是同一作用域

答案:B的funA中的fun还是构成隐藏

注意:在继承中,只要是命名相同,都构成隐藏 ,与返回值、参数无关。

五、子类的默认成员函数

5.1 概念

【C++进阶】继承_第10张图片

  • 子类也是类,程序员未显示定义的情况下,同样会生成六个默认成员函数

  • 不同于单一的类,子类是在父类的基础之上创建的,因此它在进行相关操作时,需要为父类进行考虑

5.2 默认构造函数

大家有没有想过一个问题,当子类实例化后可以调用它的默认构造函数来进行初始化(初始化列表)?那么父类该怎么初始化呢?

有的类和对象的基础,大家可能就会向下面一样:

#include 
#include 
using namespace std;

class Person
{
public:
	Person(const char* name = "张三")
		:_name(name)
	{
		cout << "Person()" << endl;
	}
protected:
	string _name;
};

class Student : public Person
{
public:
	Student(const char* name = "李四", int num = 111)
		:_name(name)
		,_stuid(num)
	{
		cout << "Student()" << endl;
	}
protected:
	int _stuid;
};

但是以上的方法是错的,报错结果如下:

【C++进阶】继承_第11张图片

因此,C++规定:子类对象创建后,必须先调用父类的默认构造函数初始化父类成员

#include 
#include 
using namespace std;

class Person
{
public:
	Person(const char* name = "张三")
		:_name(name)
	{
		cout << "Person()" << endl;
	}
protected:
	string _name;
};

class Student : public Person
{
public:
	Student(int num = 111)
		:_stuid(num)
	{
		cout << "Student()" << endl;
	}
protected:
	int _stuid;
};

int main()
{
	// 子类对象在实例化后,会先调用父类的默认构造函数初始化其成员变量
	Student s1; 
	return 0;
}

【输出结果】

【C++进阶】继承_第12张图片

注意:自动调用是由编译器完成的,前提是父类存在对应的默认成员函数;如果不存在默认构造,会报错。

【C++进阶】继承_第13张图片

那么问题来了,父类没有默认构造函数该怎么初始化呢?

父类的默认构造函数其实是在子类的初始化列表调用的,如果父类没有默认的构造函数,则必须在子类构造函数的初始化列表阶段显示调用

但是显示调用不能向一开始那样,在子类的构造函数显示写出父类的成员变量

正确方法:类似定义一个匿名对象

【C++进阶】继承_第14张图片

而我们知道初始化列表是有初始化顺序的,其顺序是和声明的顺序有关,C++规定:默认父类的成员是在子类成员前面

5.3 拷贝构造

和默认构造函数一样,子类的拷贝构造函数必须先调用父类的拷贝构造完成父类的拷贝初始化

#include 
#include 
using namespace std;

class Person
{
public:
	Person(const char* name = "张三")
		:_name(name)
	{
		cout << "Person()" << endl;
	}
	// 父类的拷贝构造
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
protected:
	string _name;
};
class Student : public Person
{
public:
	Student(int num = 111)
		:_stuid(num)
	{
		cout << "Student()" << endl;
	}

	// 拷贝构造本质也是一个构造函数,所以也要写初始化列表
	Student(const Student& s)
		:Person(s)
		,_stuid(s._stuid)
	{
		cout << "Student(const Student& s)" << endl;
	}
protected:
	int _stuid;
};

int main()
{
	Student s1; 
	// 拷贝构造
	Student s2(s1);
	return 0;
}

【输出结果】

【C++进阶】继承_第15张图片

注意:和默认构造不同的区别是,拷贝构造的初始化列表要显示写出Person(s)。如果不写,编译器会默认调用默认构造构造,那么就可能导致构造的对象内容不一样,因此最好显性给出拷贝构造。例如以下案例

#include 
#include 
using namespace std;

class Person
{
public:
	Person(const char* name = "张三")
		:_name(name)
	{
		cout << "Person()" << endl;
	}
	Person(const Person& p)
		: _name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}
protected:
	string _name;
};
class Student : public Person
{
public:
	Student(const char* name = "李四", int num = 111)
		:Person(name)
		,_stuid(num)
	{
		cout << "Student()" << endl;
	}

	// 拷贝构造本质也是一个构造函数,所以也要写初始化列表
	Student(const Student& s)
		//未显示写出Person(s)
		:_stuid(s._stuid)
	{
		cout << "Student(const Student& s)" << endl;
	}
protected:
	int _stuid;
};

int main()
{
	Student s1; 
	// 拷贝构造
	Student s2(s1);
	return 0;
}

【监视结果】

【C++进阶】继承_第16张图片

5.4 赋值运算符重载

C++规定:子类的operator=必须要先调用父类的operator=完成父类的复制

因此不难可以写出以下代码

#include 
#include 
using namespace std;

class Person
{
public:
	// 父类默认构造
	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=(const Person& p)" << endl;
		if (this != &p)
			_name = p._name;
		return *this;
	}
protected:
	string _name;
};
class Student : public Person
{
public:
	Student(const char* name = "李四", int num = 111)
		:Person(name)
		,_stuid(num)
	{
		cout << "Student()" << endl;
	}

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

	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			// 调用父类的运算符重载
			operator=(s);
			_stuid = s._stuid;
		}
		return *this;
	}
protected:
	int _stuid;
};

int main()
{
	Student s1; 
	// 拷贝构造
	Student s2(s1);
	return 0;
}

但是程序发生栈溢出了(可以查看调用堆栈)

【C++进阶】继承_第17张图片

为什么会发生栈溢出呢?我们可以先来分析子类的operator=

【C++进阶】继承_第18张图片

这下我们知道了,子类和父类出现了同名函数(隐藏/重定义),当要调用父类的operator=,根据就近原则,子类会优先调用子类里的运算符重载函数。

因此,解决办法也很简单,指定类域(父域)就行。(以下给出核心代码)

Student& operator=(const Student& s)
{
	if (this != &s)
	{
		// 调用父类的运算符重载
		Person::operator=(s);
		_stuid = s._stuid;
	}
	return *this;
}

5.5 析构函数

根据以上经验,我们直接让子类的析构调用父类的析构就行

#include 
#include 
using namespace std;

class Person
{
public:
	// 父类默认构造
	Person(const char* name = "张三")
		:_name(name)
	{
		cout << "Person()" << endl;
	}
	~Person()
	{
		cout << "~Person()" << endl;
	}
	
protected:
	string _name;
};
class Student : public Person
{
public:
	Student(const char* name = "李四", int num = 111)
		:Person(name)
		, _stuid(num)
	{
		cout << "Student()" << endl;
	}

	// 派生类对象析构清理先调用派生类析构再调基类的析构
	~Student()
	{
		~Person();
	}

protected:
	int _stuid;
};

int main()
{
	Student s1;
	Student s2;

	return 0;
}

当代码运行起来后,竟然报错了

【C++进阶】继承_第19张图片

为什么会报错呢?父类的析构和子类的析构并没有重名,按常理来看这是可以的。但是这里牵扯另一个知识,由于多态的原因,析构函数名被特殊处理了,处理成destructor(后期会讲解),解决办法就是指定类域(父类)

【C++进阶】继承_第20张图片

虽然代码运行起来,但结果还是不对,只创建了两个对象却析构4次,

而我们把代码屏蔽后析构的次数却刚刚好

【C++进阶】继承_第21张图片

这么看来 编译器会自动调用析构函数。

六、继承与友元

友元关系不能继承

#include 
using namespace std;

class B;
class A
{
public:
	// 父类的友元
	friend void print(const A& a, const B& b);
protected:
	int i = 1;
};

class B : public A
{
public:
protected:
	char x = 'x';
};

void print(const A& a, const B& b)
{
	cout << a.i << endl;
	cout << b.x << endl;
}

int main()
{
	A a;
	B b;
	print(a, b);

	return 0;
}

Display函数是Person的友元。所以在Display函数中访问Person类成员变量是没问题的,由于友元关系不能继承,因此Display中直接使用Student的成员变量会报错。

【C++进阶】继承_第22张图片

也就是说,父亲的朋友不一定是孩子的朋友。若想和父亲的朋友成为朋友,就在子类加上友元

【C++进阶】继承_第23张图片

七、继承与静态成员

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

#include 
#include 
using namespace std;

class Person
{
public:
	Person() 
	{ 
		++_count; 
	}
protected:
	string _name; 
public:
	// 父类的静态成员变量_count
	static int _count; 
};
// 静态成员变量的初始化(类外)
int Person::_count = 0;

class Student : public Person
{
protected:
	int _stuid; 
};

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

【输出结果】

【C++进阶】继承_第24张图片

再次提醒:不是static修饰的成员,每个对象都是独有一份,是staic修饰的成员,每个对象共用一份。

八、菱形继承

8.1 概念

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

【C++进阶】继承_第25张图片

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

【C++进阶】继承_第26张图片

一个对象可以有多个角色,使其基础信息更为丰富,但凡事都有双面性,多继承在带来巨大便捷性的同时,也带来了个巨大的坑:菱形继承问题

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

【C++进阶】继承_第27张图片

菱形继承有什么问题呢?从下面的对象成员模型构造,在Assistant的对象中Person成员会有两份名字,因此可以看出菱形继承有数据冗余(浪费空间)和二义性(访问谁)的问题

【C++进阶】继承_第28张图片

【代码样例】

#include 
#include 
using namespace std;

class Person
{
protected:
	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 at;
	at._name = "李四";

	return 0;
}

【程序结果】

【C++进阶】继承_第29张图片

程序提示_name不明确,因此我们可以指定为其指定类域

int main()
{
	Assistant at;
	at.Student::_name = "李四";
	at.Teacher::_name = "李老师";
	return 0;
}

【C++进阶】继承_第30张图片

指定访问只能解决二义性问题,但这没有从本质上解决问题!而且还没有解决数据冗余问题

真正的解决方法:虚拟继承

8.2 虚拟继承

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。加上virtual关键字修饰被继承的父类(如下代码所示),即可解决问题。但需要注意的是,虚拟继承不要在其他地方去使用

【C++进阶】继承_第31张图片

【调试结构】

【C++进阶】继承_第32张图片

8.3 虚拟继承原理(底层)

那么虚拟继承是如何解决二义性和数据冗余的问题?为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型(对象在内存中的布局)。

【C++进阶】继承_第33张图片

首先我们先看普通的菱形继承为什么会存在冗余和二义性

代码如下:

#include 
using namespace std;

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;
	// B和C继承了A
	// 因此它们都有_a
	d.B::_a = 1;
	d.C::_a = 2;

	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

【C++进阶】继承_第34张图片

再来看看菱形虚拟继承

代码如下(稍加改动):

#include 
using namespace std;

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;
	//最后将_a全部改为0
	d._a = 0;
	
	return 0;
}

【C++进阶】继承_第35张图片

总结:
虚继承底层是如何解决菱形继承问题的?

  • 对于冗余的数据位,改存指针,该指针指向相对距离
  • 对于冗余的成员,合并为一个,放置后面(可能前面,取决于编译器),假设想使用公共的成员(冗余成员),可以通过相对距离(偏移量)进行访问。

九、继承和组合

  • public继承是一种is-a的关系。也就是说每个子类对象都是一个父类对象。例如,花是植物。
  • 组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。例如,汽车有轮胎。
  • 继承允许根据父类的实现来定义子类的实现。这种通过生成派生类的复用通常被称为白箱复用。术语“白箱”是可见;在继承方式中,父类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。子类和父类间的依赖关系很强,耦合度高。而在一个程序中,要尽量做到高内聚(模块内的功能联系),低耦合(模块依赖量度)
  • 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用,因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
  • 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系即可以用继承,也可以用组合,就用组合。
class A
{
public:
	int _a;
};

// 继承 
class B : public A
{
	// ...
};

// 组合  
class C
{
private:
	A _c;
};

十、面试题

  1. 什么是菱形继承?菱形继承的问题是什么?

菱形继承是指在面向对象编程中,当一个类同时继承自两个类,并且这两个类又继承自同一个父类时所产生的继承结构。

菱形形继承会引发一个问题,即菱形继承的二义性和冗余,当子类需要调用父类的某个成员时,由于存在多条继承路径,系统无法确定应该调用哪个父类的成员,从而导致歧义。这会导致编译器无法解析成员的访问路径,进而产生编译错误。

  1. 什么是菱形虚拟继承?如何解决数据几余和二义性的?

为了解决菱形继承的冗余和二义性,C++引入了虚拟继承。虚拟继承可以通过在中间类的承声明前加上关键字virtual来实现。通过使用虚拟继承,可以确保子类类只包含一份来自基类的数据成员,避免了数据几余。同时,通过虚拟继承,可以解决二义性问题,子类可以明确指定使用哪个基类的成员。

  1. 继承和组合的区别?什么时候用继承?什么时候用组合?

点击跳转

十一、总结

  1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
  2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言(面向对象语言)都没有多继承,如Java。

你可能感兴趣的:(C++,c++,开发语言,c语言,学习,笔记)