前言
继承是面向对象三大特性之一,所有的面向对象的语言都具备这三个性质,我们之前已经介绍过了封装的相关概念,今天我们来学习一下第二大特性:继承。
一.继承的概念
什么是继承?
定义:继承(inheritance)机制是面向对象程序设计使代码可以复用的手段,它允许程序猿在保持原有基类(父类)特性的基础进行拓展,添加功能,这样产生新的类被称为派生类(子类)。
因此,我们可以发现,在继承中是有两个对象的,一个是被继承的对象,另一个是继承方。
那么,这两个对象分别是什么呢?
- 被继承对象:父类/基类 (base)
- 继承方:子类/派生类 (Derived)
1.1本质
继承的本质就是复用代码
现在,我们给出这么一个样例:
某个学校要设计一个学校人员管理系统。
那么,从人员上来说,我们就可以划分出如下两大类:教师和学生
而教师,又可以继续划分出如下:校长、年级主任、普通老师、后勤老师
而学生,也可以这么划分出如下:班长、各种委员、各种课代表、普通学生。
如果我们为每种人员都设计一个类,那么光设计类就要设计几十个了,因此我们要为每种人员设计一个class。而我们在设计的过程中可以发现一点:这些都TM是人阿!,都TM有名字年龄性别手机号!
也就是说,我们会写出如下的代码:
class Faculty//教职工
{
private:string name;int age;int sex;string PhoneNumber;
};
class student//教职工
{
private:string name;int age;int sex;string PhoneNumber;
};
//每个类都这么设计,只是类名不同而已
//......(此处省略一万个类)
这么写,实在是过于冗余了,让人很烦,每个教职工和学生都要把名字、年龄、性别、电话号定义一下。
那么,有没有一种方法,可以做到代码复用呢?
没错,这就是多态的作用!
1.2作用
多态的作用就是代码复用,让程序猿可以偷偷懒,少写两行代码。
在子类继承父类后,就可以继承到父类全部的公开/保护/私有属性,但是,除了私有内容的使用权限子类永远无法使用外,父类的公开/保护属性在子类中随着继承方式的不同会发生不同的变化,这点我们等等再谈。
实例:
class Faculty//教职工
{
public:Faculty():_name("kuzi"), _age(18), _sex("male"), _PhoneNumber("woshiyitiaokuzi"){cout << "我是一名教职工" << endl;};
protected:string _name;int _age;string _sex;string _PhoneNumber;
};
class Teacher : public Faculty
{Teacher(){Faculty();cout << "我是一名教师" << endl;};
};
int main()
{Faculty* a = new Faculty();Teacher* b = new Teacher();
}
下面我们可以通过调试窗口来观察一下子类Teacher的成员
我们发现,子类中继承了父类的全部成员。
1.3实际样例
在我们的实际开发中,经常会用到继承。
很经典的一个例子是,CPP中的IO流就是使用了继承。
二.继承的定义
2.1格式
继承的格式是比较简单的,如下:
//子类:继承方式 父类
//eg:class a :public b 代表了a以public的方式继承了b。
在我们上述的例子中
ps:继承符是可以省略的,省略继承符时
使用class时,默认继承方式为public继承。
使用struct时,默认继承方式为private继承。
2.2权限
下面我们来介绍一下继承的方式:
在CPP中,继承有三种方式:
公有继承(public)、保护继承(protected)、私有继承(private)。
看到这里,你似乎觉得似曾相识,没错,这三种继承方式和类中的访问限定修饰符是一样的,不过这些符号在这里表示的是继承权限。
那么,到了这里,我们就有必要去回忆一下这三个东西有什么用处了。
public:公开的,任何人都能访问
protected:保护的,只有当前类和子类可以访问
private:私有的,只有当前类可以访问
我们现在比较一下权限,显而易见的是:public>protected>private
之前我们说过一句话:保护protected只有在继承中才能体现出价值来,在别的场景下和private的作用是一样的。
也就是说,我们有三种访问权限和三种继承权限。
根据排列组合,我们可以列出如下的多种搭配方案:
父类成员/继承权限 | public | protected | private |
---|---|---|---|
父类的public成员 | 子类对象可见,子类也可见 | 子类对象不可见,子类中可见 | 子类对象不可见,子类中可见。 |
父类的protected成员 | 子类对象不可见,子类中可见 | 子类对象不可见,子类中可见 | 子类对象不可见,子类中可见 |
父类的private成员 | 都不可见 | 都不可见 | 都不可见 |
总结一下:
- 无论是哪种继承方式,父类中的private成员始终不可被[子类/外部]访问。
- 当子类对象试图访问父类成员时,依据min(父类成员权限,子类继承权限)原则,只有最终权限为public时,子类对象才能访问。
下面我们通过代码进行实践:
class A
{
public:int _a;
protected:int _b;
private:int _c;
};
class B :public A
{B(){cout << _a << endl;cout << _b << endl;cout << _c << endl;}
};
int main()
{B b;b._b;
}
我们可以将这段代码复制到你的编辑器中,不出意料的话,会报出如下错误:
因此,我们可以验证:
- public继承中,我们可以访问到protect成员,但是无法在外部使用。
下面我们再来验证protect继承和private继承:
protect继承:
private继承:
我们发现,protect和private的效果是一样的,因此我们认为C++的继承设计是复杂了。
那么,我们应该如何访问到父类的私有成员呢?
答案:我们在父类中设计相应的函数,我们间接调用即可完成任务。
如下:
class A
{
public:int _a;int get_c(){return _c;}
protected:int _b;
private:int _c;
};
class B :public A
{
public:B(){cout << _a << endl;cout << _b << endl;//cout << _c << endl;}
};
int main()
{B b;b.get_c();//b._a;
}
这里,我们通过设计了get_c()函数,即可得到私有成员。
2.3使用
在经过刚刚的学习之后,我们对继承的了解应该深入了很多,那么,我们在设计的时候,如何才能将权限使用的很优雅呢?
对于只想自己查看的成员,设计为private,对于想共享给子类使用的成员,设为protected,其他成员都设为public。
下面给大家举个例子:
有个人叫裤子,他是一名CSDN博主,他写的文章是公开全网的,他的文章收益是只限于家庭成员使用的,他的隐私小网站是只允许他自己知道的。
那么,我们在设计时,即可将文章信息设计为public、收益设计为protected、小网站设计为private
如下:
class Kuzi
{
public:string article;//文章是写给大家看的
protected:string money;//钱是家里人都可以用的
private:string website;//byd我的小网站你还想看?
};
class KuziSon :public Kuzi
{KuziSon(){cout << "我是裤子的儿子" << endl;cout << "爸爸的文章是:" << article << endl;cout << "爸爸的钱是:" << money << endl;cout << "我看不到爸爸的小网站" << endl;}
};
class ZhangSan
{ZhangSan(){ //匿名对象调用cout<<"我是小明,我只能看到裤子的文章:"<<Kuzi().article<< endl;}
};
三.继承的作用域
子类和父类的作用域是不同的,下面我们将通过”隐藏“这个概念详谈
3.1隐藏
假如,在子类中出现和父类相同的变量/函数名时,会怎么样呢?
会发生”隐藏“,默认调用子类的变量/函数名。
这个概念还被称为”重定义“
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,
也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问) - 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
下面我们通过代码来体验一下以上规则:
class Base
{
public:void func(){cout << "Base::val==" << val << endl;}
private:int val = 3;
};
class Derived :public Base
{
public:void func(){cout << "Derive::val==" << val << endl;}
private:int val = 4;
};
int main()
{Derived a;a.func();return 0;
}
输出结果如下:
Derive::val==4
我们发现,父类中的方法和成员都被隐藏了,执行的是子类方法,输出的是子类成员。
总结:
当子类当中的方法出现隐藏行为时,会优先执行子类当中的方法;
当子类当中的成员出现隐藏行为时,会优先选择当前作用域的成员(局部优先原则)
因此,编译器的搜索逻辑是:先搜索当前作用域,再搜索父类作用域。
因此,子类的作用域和父类的作用域是两个不同的作用域。
那么,我们应该如何显式的使用父类的方法或成员呢?
- 使用域作用限定符限定搜索的作用域。
如下:
class Base
{
public:void func(){cout << "Base::val==" << val << endl;}
protected:int val = 3;
};
class Derived :public Base
{
public:void func(){cout << "Derive::val==" << Base::val << endl;}
private:int val = 4;
};
int main()
{Derived a;a.func();return 0;
}
结果:
Derive::val==3
这样,我们便打印出了父类的val值。
这里,我们要给大家提出几点使用继承中函数的建议:
- 只要函数名相同就会构成隐藏,和返回值/参数无关。
- 隐藏可能会干扰到调用者的真实意图
- 因此我们要尽可能少的设计隐藏
四.基类与派生类对象的赋值转换
在继承中,我们允许将子类对象直接赋值给父类对象,但是不允许和父类对象直接赋值给子类。
这个赋值是直接赋值,是编译器允许的,是不需要重载赋值运算符的。
int main()
{Base a;Derived b;a = b;//合法b = a;//不合法
}
其实这种行为是切片,子类对象在赋值给父类对象时,可以触发切片机制,从而完成赋值。
4.1切片
切片操作其实也很简单,就相当于是现在有一个坏了的苹果,现在我们要吃掉这个苹果,就需要切掉坏的部分,只吃好的部分。
因此,最终的结果就是我们拿了把菜刀,把坏的切了,把好的吃了。
对于我们而言:好的部分是原本属于它的,坏的部分是后来才有的。
对于对象而言也是一样的,如下:
这个行为其实非常容易理解,父类有的子类全部都有,但是子类有的父类并不一定全有,因此我们可以用子类赋值给父类,只需要切掉不用的部分即可。
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片,或者切割。寓意把派生类中父类那部分切来赋值过去。
具体原因:
子类中+一个父类指针,刚好跳过了一整个父类,我们把这一整个父类拿出来即可赋值给父类成员,完成切片操作。
切片的效率是非常高的,这是因为切片并不需要产生临时对象,而且只需要进行一步指针操作!
切片的具体实现,后续再专门写一篇文章详谈。
五.派生类的默认成员函数
派生类也是类,是类就会有类的默认成员函数。
但,子类是在父类的基础上构建的类,那么,我们在实际操作的时候,是否会出现一些奇奇怪怪的现象呢?
这里我们介绍如下现象:
- 派生类的构造函数调用之前必须要调用父类的构造函数构造出父类来。如果父类没有默认构造,则必须在子类的构造函数的初始化列表阶段显示调用。
- 派生类的构造拷贝函数必须要调用基类的构造拷贝函数才能够完成基类的拷贝初始化。(浅拷贝问题)
- 派生类的赋值重载必须要调用基类的赋值重载才能完成基类的赋值。(浅拷贝问题)
- 派生类的析构函数会在调用后自动调用父类的析构函数清理基类成员。(栈的先进后出)
- 派生类先初始化调用基类构造再调用派生类构造(栈的先进后出)
- 再一些场景下析构函数需要构成重写,重写需要三同(再多态中会了解)。编译器会对析构函数名做出特殊处理,处理为:destrutor()。这时父类析构不加virtual的情况下会和子类的析构函数构成隐藏关系
5.1隐式调用
子类在继承父类后,构建子类对象时会先调用父类的默认构造函数,子类对象销毁前,还会自动调用父类的析构函数。
我们可以写出如下代码:
class Person
{
public:Person() { cout << "Person()" << endl; }~Person() { cout << "~Person()" << endl; }
};
class Student :public Person
{
public:Student() { cout << "Student()" << endl; }~Student() { cout << "~Student()" << endl; }
};
int main()
{Student a;return 0;
}
打印结果如下:
Person()
Student()
~Student()
~Person()
我们可以看到:
我们的子类构造之前先调用了父类的构造函数。
父类的析构之前,默认调用了子类的析构函数。
这里我们需要注意的是:调用是编译器帮我们完成的,需要父类含有默认的构造函数,如果不含有,则寄!
这里补充一个知识点:
如果我们显示的写了构造函数,但是并没有默认的构造函数,这是我们可以用这串代码来帮助我们生成默认的构造函数。
class MyClass {
public:MyClass() = default; // 显式要求编译器生成默认构造函数MyClass() = delete;//显示要求编译器不生成默认构造函数
};
5.2显示调用
我们在刚刚已经提过了,我们的拷贝构造要先调用父类的,赋值也是。
这是为什么呢?这是因为我们如果不调用一下父类的,则会出现浅拷贝问题等问题。
这是因为,我们子类的赋值重载运算符只会将子类的属性赋值过去,但是父类的属性并不会操作。因此我们需要调用父类的赋值重载运算符来避免这个问题。
如下例,将会出现浅拷贝问题:
class Base {
public:int* data;Base() { data = new int(5); }~Base() { delete data; }Base& operator=(const Base& other) {if (*this != other) {*data = *(other.data);}return *this;}
};class Derived : public Base {
public:// 未调用 Base::operator=,这会导致基类部分浅拷贝Derived& operator=(const Derived& other) {if (this != &other) {// 假设 Derived 有自己的成员赋值逻辑}return *this;}
};
5.3为什么派生类调用赋值重载运算符时要先调用父类的赋值重载?
在 C++ 中,派生类调用赋值重载运算符时,先调用父类的赋值重载运算符,主要是为了保证基类部分的成员变量能够正确赋值。因为派生类不仅包含自己的成员变量,还继承了基类的成员,因此必须正确处理基类的赋值操作,保证对象状态的一致性。
1. 继承关系中的赋值操作
派生类继承了基类的成员变量和函数。如果派生类没有显式定义赋值重载运算符,编译器会自动生成一个赋值运算符,包括基类部分的赋值。如果派生类显式定义了赋值重载运算符,则通常需要手动调用基类的赋值运算符来确保基类成员被正确赋值。我们可以通过域作用限定符显示调用。
示例代码
class Base {
protected:int baseValue;
public:Base(int val = 0) : baseValue(val) {}Base& operator=(const Base& other) {std::cout << "Base assignment operator called" << std::endl;if (this != &other) {baseValue = other.baseValue; // 基类的赋值逻辑}return *this;}
};class Derived : public Base {
private:int derivedValue;
public:Derived(int bVal = 0, int dVal = 0) : Base(bVal), derivedValue(dVal) {}Derived& operator=(const Derived& other) {std::cout << "Derived assignment operator called" << std::endl;if (this != &other) {Base::operator=(other); // 调用基类的赋值运算符derivedValue = other.derivedValue; // 派生类的赋值逻辑}return *this;}
};
最后一点,析构函数必须写成虚函数,这一点会在讲解多态时谈到,并且非常重要。
六.继承与友元函数
直接上结论:友元关系不能被继承
这一点也非常容易理解。假如你爸有一个朋友,这个朋友很喜欢逗小孩,有一天你爸爸让他带你玩,给你带来了非常严重的心理阴影,因此你不会认为他是你的朋友。
我们直接用代码来体验一下这一点即可:
class Base
{friend void MyPrint();
private:static const int a = 10;
};
class Derived :public Base
{
private:static const int b = 100;
};
void MyPrint()
{cout << Base::a << endl;cout << Derived::b << endl;
}
int main()
{MyPrint();return 0;
}
我们会发现出现了如下的报错信息:
成员 “Derived::b” (已声明 所在行数:27) 不可访问
如果我们想让Print函数也可以访问子类的私有成员,那么我们需要将其也声明为子类的友元函数。
代码如下:
class Base
{friend void MyPrint();
private:static const int a = 10;
};
class Derived :public Base
{friend void MyPrint();
private:static const int b = 100;
};
void MyPrint()
{cout << Base::a << endl;cout << Derived::b << endl;
}
七.继承与静态成员
对于这点,我们需要记住的仅仅只是:静态成员是唯一存在的。
在我们的上一个例子中,虽然我们并没有定义成员,但是我们依旧能够打印出静态变量的值,这正是因为它是独立存在于静态区的,而静态区的生命周期很长,一般在程序开始时被创建,在结束时被销毁。
也就是说,如果父类中有一个静态变量,那么子类在继承后,也是可以共享这个变量的。
我们可以利用这个特性完成下题:
问题:请统计创建了多少个父类子类对象?
class Base
{
public:Base(){num++;}static int num;
};
int Base::num = 0;
class Derived :public Base
{
public:Derived(){num++;}
};
int main()
{Base a;Base b;Derived c;//父类1+子类1cout << Base::num << endl;return 0;
}
打印结果:
4
八.菱形继承
这里我们先介绍一下单继承和多继承
单继承:一个子类只能继承一个父类
多继承:一个子类可以继承多个父类(JAVA中不支持此特性)
C++支持多继承,即一个子类可以继承多个父类。多继承在带来了巨大的便捷性的同时也带来了一个巨大的坑:菱形继承问题
8.1概念
C++是允许出现多继承的情况的,给大家打个比方:
神是不用吃饭的。
那么,如果出现以下的继承情况:
这里,普通人1和2通过奇遇相识并学习了神的道法,修得了不用吃饭之道。
他们恋爱了,生下了普通人3。这时普通人3就继承了普通人1和2的属性,但是出现了一个问题:
BYD我爸我妈都长生,我不用吃饭是继承的谁。
8.2现象
关于多继承的父类初始化问题:谁先被声明,谁就会先被初始化,这个与继承顺序是无关的。
现在我们将刚刚说的转换为代码,如下:
class God
{
public:void eat(){cout << "老子是神,老子还用吃饭?" << endl;}
};
class Person1 : public God
{//......
};
class Person2 : public God
{//......
};
class Person3 : public Person1,public Person2
{//......
};
int main()
{Person3 a;a.eat();
}
这时,我们会有如下的报错信息:
8.3原因
在cpp中,我们将这个问题称为数据冗余和二义性问题。这是因为继承时出现了多继承,父亲和母亲都有不用吃饭的属性,这时孩子就会有两个不用吃饭的属性,编译器就会不知所措,不知道孩子的不用吃饭的属性是继承的谁。
8.4解决方法
想要解决二义性的问题是比较简单的,我们直接通过**::**限定访问域即可。
int main()
{Person3 a;a.Person1::eat();
}
这样,我们就可以解决二义性的问题。下面我们就要着手于解决数据冗余的问题。
其实,这里我们真正的解决方法是:虚继承。
PS:虚继承上的专门用来解决菱形继承问题的,与多态中的虚函数没有关系。
虚继承:在菱形继承的腰部继承处加上virtual修饰父类。
class God
{
public:void eat(){cout << "老子是神,老子还用吃饭?" << endl;}
};
class Person1 :virtual public God
{//......
};
class Person2 :virtual public God
{//......
};
class Person3 : public Person1,public Person2
{//......
};
int main()
{Person3 a;return 0;
}
这时,我们就可以解决菱形继承的数据冗余和二义性问题。
问题:虚继承是如何解决菱形继承问题的?
- 利用虚基表将冗余的数据存储起来,并将冗余数据合并为一份。
- 原来存储冗余数据的位置,现在用来存储虚基表指针。
关于虚基表,我们后续再专门详谈。
九.补充
虽然我们学习了继承,但是并不是说我们就一定要使用继承。
我们还可以使用组合的方法来完成类似的效果。
继承,其实只是为了使用父类的成员。
同样的, 我们使用组合也可以达到一样的效果。
下面简单介绍下两类的区别
- 公有继承:is-a 高耦合,可以直接使用父类成员
- 组合:has-a 低耦合,可以间接使用父类成员。
在实际的项目中,我们更推荐使用组合的方式,因为这样可以做到解耦,避免因为父类的改动而直接影响到子类。
如下:
class A {};
class B :public A{};
class C
{
private:A _a;//创建出A类对象,之后可以间接使用。
};