👦个人主页:@Weraphael
✍🏻作者简介:目前学习C++和算法
✈️专栏:C++航路
🐋 希望大家多多支持,咱一起进步!😁
如果文章对你有帮助的话
欢迎 评论💬 点赞👍🏻 收藏 📂 加关注✨
目录
一、继承的概念
1.1 概念
- 继承是面向对象三大特性之一(封装、继承、多态)
- 继承的本质是代码复用。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
假设需要设计学校教务系统,可以简单划分为:老师和学生,但如果更深层次继划分的话,还可以划分出:校领导、各专业院长、辅导员等,那么就要对每个角色写一个类,那么代码量也未免太大了。
为了复用代码、提高开发效率,可以从各种角色中 选出共同点,组成基类(父类),比如一个人都有姓名、年龄、性别、联系方式等基本信息,而老师与学生的区别就在于学号和工号。于是,继承允许程序员 在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类(子类)。
2.2 作用
子类继承父类后,除了父类私有private
成员不可访问,子类可以享有所有父类公开public/保护protected
属性。(在【继承关系和访问限定符】有详细总结)
二、继承的定义
2.1 定义格式
class 子类 : 继承方式 父类
2.2 继承关系和访问限定符
访问限定符(类外是否能访问)protected
和private
是没有区别的;但在继承中,它们就有区别了!
访问权限和继承权限都是三种,根据排列组合,可以列出父类成员在子类成员的访问方式有九种:
【子类的可访问父类成员的情况】
访问权限 / 继承权限 | public | protected | private |
---|---|---|---|
父类的public 成员 | 派生类变成public 成员 | 派生类变成protected 成员 | 派生类变成private 成员 |
父类的 protected 成员 | 派生类变成protected 成员 | 派生类变成protected 成员 | 派生类变成private 成员 |
父类的 private 成员 | 不可见 | 不可见 | 不可见 |
【总结】
-
父类的
private
成员在子类中无论以什么方式继承都是不可见的。这里的不可见是指:父类的私有成员还是被继承到了子类对象中,但是语法上限制子类对象不能用父类的private
成员。 -
如果父类成员需要在子类中能访问,将父类的成员定义为
protected
或者public
即可,并且继承权限不能是private
。 -
【规律】 子类是否可访问父类成员的情况,取【访问权限符】和【继承权限】中的较小者(
public > protected> private
) -
在实际运用中一般使用都是
public
继承,几乎很少使用protetced/private
继承。还有的就是父类的访问权限一般都是public/protected
。因为继承的本质就是代码复用,要是访问权限是private
,子类还访问个毛啊。 -
注意:使用关键字
class
时默认的继承方式是private
,使用struct
时默认的继承方式是public
,不过最好显示的写出继承方式。
三、基类和派生类对象赋值转换
- 在继承中,允许将子类对象直接赋值给父类(向上转换),但不允许父类对象赋值给子类。
类型不一样的赋值编译器会自动发生隐式类型转化,但子类对象直接赋值给父类是没有发生隐式类型转换,并且这种转化是非常自然。
那么如何证明没有发生隐式类型转化呢?
int main()
{
int i = 0;
double d = i; // 发生隐式类型转化
// 对i取别名
//double& c = i; //erro
// 正确
const double& c = i;
return 0;
}
对一个对象取别名,必须要类型统一,不然会报错。当然也可以类型不统一,只是要给引用对象加上cosnt
。因为i
是整型,当i
赋值给c
时,中间会产生一个临时变量,最后会通过临时变量再拷贝给c
,但临时变量具有常性,如果不加上const
,会导致权限放大的问题。
那么我们可以用类似于以上的方法来查看子类对象赋值给父类对象时有没有发生隐式类型转化:
子类对象可以赋值给 父类的对象 / 父类的指针 / 父类的引用。这里有个形象的说法叫切片(切割)。就是它会 把子类中属于父类的成员切割出来赋值给父类。
- 赋值给父类的指针:指向子类中属于父类的成员变量。
- 赋值给父类的引用:对子类中属于父类的成员变量取别名。
四、继承中的作用域
4.1 概念
- 子类虽然继承父类,但他们有独立的作用域。因此可以出现同名成员。
- 但是默认会将父类的同名成员隐藏,进而执行子类的同名成员。这种情况叫隐藏,也叫重定义。
4.2 继承中的隐藏(重定义)
#include <string>
#include <iostream>
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;
}
【输出结果】
出现同名成员时,默认会将父类的同名成员隐藏,进而执行子类的同名成员。
若出现同名成员,但就想用父类的成员,该怎么办?
解决方法:指定作用域,加个域作用限定符::
成员函数也是一样的,需要在调用时指定作用域。
#include <iostream>
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;
}
【输出结果】
虽然子类和父类可以有同名成员,但注意在实际中,继承体系里面最好不要定义同名的成员。不然就是自己坑自己!
4.3 易错题(面试题)
问:子类中的fun
和父类中的fun
构成什么关系?
#include <iostream>
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
中的fun
和A
中的fun
还是构成隐藏
注意:在继承中,只要是函数名/变量名相同,都构成隐藏 ,与返回值、参数无关。
五、子类的默认成员函数
5.1 概念
-
子类也是类,程序员未显示定义的情况下,同样会生成六个默认成员函数
-
不同于单一的类,子类是在父类的基础之上创建的,因此它在进行相关操作时,需要为父类进行考虑
5.2 默认构造函数
大家有没有想过一个问题,当子类实例化后可以调用它的默认构造函数来进行初始化(初始化列表)?那么父类该怎么初始化呢?
- 因此,C++规定:子类对象实例化后,必须先调用父类的默认构造函数初始化父类成员(自动调用)
#include <string>
#include <iostream>
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++规定:默认父类的成员声明在子类成员前面。
5.3 拷贝构造
- 拷贝构造也是一种构造,因此和默认构造函数一样,子类的拷贝构造函数必须先调用父类的拷贝构造完成父类的拷贝初始化
#include <string>
#include <iostream>
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;
}
【输出结果】
注意:要在子类的拷贝构造的初始化列表要显示初始化父类的成员变量(以父类匿名对象的方式)。如果不写,编译器会默认调用父类的默认构造来构造父类的成员变量(拷贝构造也是一种构造),那么就可能导致构造的对象内容不一样。例如以下案例
【监视结果】
5.4 赋值运算符重载
- C++规定:子类的
operator=
必须要先手动调用父类的operator=
完成父类的赋值。
但是程序发生栈溢出了(可以查看调用堆栈)
为什么会发生栈溢出呢?我们可以先来分析子类的operator=
这下我们知道了,子类和父类出现了同名函数(隐藏/重定义),当要调用父类的operator=
,编译器会默认将父类的同名成员隐藏,进而执行子类的同名函数。因此这里其实一直在调用子类的operator=
。
解决办法也很简单,指定类域就行。
5.5 析构函数
C++规定:子类的析构函数会自动调用父类的析构完成父类对象资源的清理。
当代码运行起来后,竟然报错了
为什么会报错呢?父类的析构和子类的析构并没有重名,按常理来看这是可以的。
但是这里牵扯另一个知识:由于多态的原因,析构函数名被特殊处理了,都被统一处理成destructor
,因此还是存在重定义。
- 解决办法:指定类域。
虽然代码运行起来,但结果还是不对,只创建了两个对象却析构4次,而把代码屏蔽后析构的次数却刚刚好。
因此,子类的析构函数会自动调用父类的析构函数。
六、继承与友元
- 友元关系不能继承。
也就是说:子类继承父类后,如果父类有友元关系,子类是不继承友元关系。
Print
函数是父类A
的友元。所以在Print
函数中访问父类的公有成员是没问题的,但由于友元关系不能继承,因此Print
中直接使用子类B
的保护成员变量会报错。
也就是说,父亲的朋友不一定是孩子的朋友。若想和父亲的朋友成为朋友,就在子类加上友元
七、继承与静态成员
如果父类定义了static
静态成员,那么子类和父类对象会共享这个静态变量。
下面有一个例题:统计父类和子类一共创建了多少变量?
【输出结果】
子类实例化时,会先调用父类的默认构造函数。
八、菱形继承(C++的大坑)
8.1 概念
- 单继承:一个子类只有一个直接父类时称这个继承关系为单继承
- 多继承:一个子类有两个或以上的父类就称这个继承关系为多继承
【语法】class 子类 : 访问限定符 父类1, 访问限定符, 父类2....
一个对象可以有多个角色,使其基础信息更为丰富,但凡事都有双面性,多继承在带来巨大便捷性的同时,也带来了个巨大的坑:【菱形继承问题】。
- 菱形继承:菱形继承是多继承的一种特殊情况
菱形继承有什么问题呢?从下面的【对象模型】中可以看出,在Assistant
的对象中Person
成员会有两份名字,因此可以看出菱形继承有数据冗余(浪费空间)和二义性(访问不明确)的问题。
【对象模型】
【代码样例】
【程序结果】
程序提示_name
不明确,因此我们可以指定为其指定类域
int main()
{
Assistant at;
at.Student::_name = "李四";
at.Teacher::_name = "李老师";
return 0;
}
指定访问只能解决二义性问题,但还没有解决数据冗余问题。
真正的解决方法:虚拟继承。
8.2 虚拟继承
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。加上virtual
关键字修饰被继承的父类(如下代码所示),即可解决问题。但需要注意的是,虚拟继承不要在其他地方去使用
【调试结果】
8.3 虚拟继承的底层原理
那么虚拟继承是如何解决二义性和数据冗余的问题?为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成员的模型(对象在内存中的布局)。
先看普通的菱形继承为什么会存在冗余和二义性:
【内存窗口】
再来看看菱形虚拟继承是如何解决二义性和冗余的。
【分析图】
【对象模型】
九、继承和组合
- 继承是一种
is-a
的关系(子类是父类)。例如,花是植物。 - 组合是一种
has-a
的关系。假设B组合了A,每个B对象中都有一个A对象。例如,汽车有轮胎。 - 继承允许根据父类的实现来定义子类的实现。这种通过生成子类的复用通常被称为白箱复用。术语“白箱”是可见;在继承方式中,父类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。子类和父类间的依赖关系很强,耦合度高。而在一个程序中,要尽量做到高内聚(模块内的功能联系),低耦合(模块依赖量度)
- 组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用,术语“黑箱”是不可见。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系即可以用继承,也可以用组合,就用组合。
class A
{
public:
int _a;
};
// 继承
class B : public A
{
// ...
};
// 组合
class C
{
private:
A _c;
};
十、面试题
- 什么是菱形继承?菱形继承的问题是什么?
菱形继承:一个类同时继承自两个类,并且这两个类又继承自同一个父类时所产生的继承结构。
问题:二义性(访问不明确)和冗余(浪费空间)。当子类需要调用父类的某个成员时,由于存在多条继承路径,系统无法确定应该调用哪个父类的成员,从而导致歧义。这会导致编译器无法解析成员的访问路径,进而产生编译错误。
- 什么是菱形虚拟继承?如何解决数据几余和二义性的?
为了解决菱形继承的冗余和二义性,C++引入了虚拟继承。虚拟继承可以通过在中间类的承声明前加上关键字virtual
来实现。通过使用虚拟继承,可以确保子类类只包含一份来自基类的数据成员,避免了数据几余。同时,通过虚拟继承,可以解决二义性问题,子类可以明确指定使用哪个基类的成员。
- 继承和组合的区别?什么时候用继承?什么时候用组合?
十一、总结
- 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承 -> 菱形继承 -> 菱形虚拟继承,底层实现就很复杂。所以多继承谨慎使用,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
- 多继承可以认为是C++的缺陷之一,很多后来的
OO
语言(面向对象语言)都没有多继承,如Java。