目录
场景2:类对象作为另一个类的成员变量,同时该类没有提供无参构造函数;
上节学习了类和对象,本节开始学习继承和派生!
继承的概念inherit
继承是类与类之间的关系,是一个很简单很直观的概念,与现实世界中的继承类似,例如儿子继承父亲的财产。
继承(Inheritance)可以理解为一个类从另一个类获取成员变量和成员函数的过程。例如类 B 继承于类 A,那么 B 就拥有 A 的成员变量和成员函数。被继承的类称为父类或基类,继承的类称为子类或派生类。
派生类除了拥有基类的成员,还可以定义自己的新成员,以增强类的功能。
C++继承的一般语法为:
class 派生类名:[继承方式] 基类名
{
派生类新增加的成员
};
比如,如果我们给人写一个类,抽象出来的属性一般是年龄和名字,然后再写一个学生的类,学生也有年龄和名字的属性,如果我们重复写,那就太麻烦了
于是我们可以不用重复写,我们让一个类继承另一个类的这些属性,这样我们就可以不用重复写了
虽然现在这样Student的类里面什么也还没有写,但是它已经占了36个字节,和person的大小一样。
这里的public表示公有继承,是一种继承的权限,之后会详细讲到。
Student继承了person,所以我们把Student称为父类或者基类,,把person称为子类或者派生类
如果父类中有成员函数,那子类也继承了父类的成员函数
继承的使用场景
以下是两种典型的使用继承的场景:
1、当你创建的新类与现有的类相似,只是多出若干成员变量或成员函数时,可以使用继承,这样不但会减少代码量,而且新类会拥有基类的所有功能。
2、当你需要创建多个类,它们拥有很多相似的成员变量或成员函数时,也可以使用继承。可以将这些类的共同成员提取出来,定义为基类,然后从基类继承,既可以节省代码,也方便后续修改成员。
继承的权限
继承方式限定了基类成员在派生类中的访问权限,包括 public(公有的)、private(私有的)和 protected(受保护的)。此项是可选项,如果不写,默认为 private(成员变量和成员函数默认也是 private)。
刚刚我们的这段代码里面Student虽然继承了person,但是在Student的成员函数还不能访问继承过来的这两个成员变量,因为在父类中,这两个成员变量是私有的
因此如果我们希望父类中的成员被子类继承后子类能够正常使用这些成员,那么我们在父类中就要将这些被继承的成员设置为protected(受保护的)权限。
protected保护权限介于私有和公有之间,pubilc公有权限可以在类的内部访问,也可以在类的外部访问,private私有权限只能在类的内部访问,protected保护权限既能在类的内部访问也能在它的子类中访问,但是不能在类的外部访问。
这里的继承权限也可以写public、protected和private
接下来看看public、protected和private这三种继承权限的区别
在以下这段代码中使用了私有继承
TestA确实继承了Base的三个成员变量(三个变量都占内存),但是变量a在TestA不能用的,因为它在基类Base中是私有的成员变量。记住,不管什么继承方式,私有成员在派生类中都不能被访问。
私有成员a在派生类中都不能访问了,那么在的类的外部就更不能访问了,那么再看TestA继承了Base的三个成员变量,TestA中就有个成员变量b和c,那在类的外部能不能访问这个和b和c?
答案是不能
c是公有成员,按理来说它是可以在外部被访问的,可以这里为什么不能访问了呢?
原因就是因为TestA这里是私有继承
私有继承过来就相当于这样的:
b被继承后从protected保护权限变成了私有权限,降级了。
因此继承权限的作用就是限制被继承过来的成员在派生类中的最高权限。也就是说这个b最高权限现在是private,达不到protected这个保护级别了。
以下这段是保护继承,c原来是public权限,现在继承过来降级为protected了,但是这个c还是能在TestB内部使用的。但是被继承过来的b和c都不能在外部通过TestB的对象访问。
以下这段是公有继承,记住,在继承中成员的访问权限不能升级,比如原来的b在基类Base中是保护成员,它被继承过来后不能升级为public成员
那在这里,b就不能在外部被访问,而c可以在外部通过TestB的对象访问。
所以在继承基类的成员时,一般都是直接就写成public公有继承,可以省去很多麻烦。
继承对象的模型
继承过来的成员在内存中是放在前面,自己内部增加的成员直接跟在后面
继承中的构造和析构
1、子类对象在创建时会首先调用父类的构造函数
2、父类构造函数执行结束后,执行子类的构造函数
3、当父类的构造函数有参数时,需要在子类的初始化列表中显示调用
4、析构函数调用的先后顺序与构造函数相反
以下这段代码打印出来出现了一个神奇的现象
我们只创建了Student这个类的对象,并没有创建Person的对象,也就是按理来说它是不会调用Person的构造函数的,当时运行结果就是创建Student的对象时,同时调用了Person和Student的构造函数。
结论就是:创建派生类对象,一定会先调用基类的构造函数。
原理也很容易理解,构造函数的作用就是初始化对象所在的内存,Student的36个字节是从Person那继承过来的,创建Student的对象时,它自己并不知道如何初始化,谁知道这块内存如何初始化呢?基类的构造函数完成的就是初始化这36个字节的功能。所以派生类继承过来的36个字节无法通过自己初始化,要借助基类的构造函数进行初始化。
析构函数调用的先后顺序与构造函数相反
如果我们基类这里改成有参构造函数
而我们创建对象的时候没有传参
会如何?
编译报错了,说没有匹配的函数,也就是找不到Person这个基类的无参构造函数
那子类的对象如果给他传参呢?
这就涉及到对象初始化列表的第三个使用场景(前2个使用场景之前讲过了,不记得回去翻一翻)
初始化列表的第三个使用场景
场景1:类成员变量被const修饰;
场景2:类对象作为另一个类的成员变量,同时该类没有提供无参构造函数;
注:其实普通成员也可以通过初始化列表初始化
现在学习第三个场景:
场景3:基类没有提供无参构造函数;
这里我们可以在子类的构造函数后面使用初始化列表,将参数传给基类Person的构造函数
我们也可以将子类的构造函数改成有参的构造函数,这时初始化列表可以这样写
如果是场景2的情况,也可以在子类的构造函数后面加初始化列表
要注意构造的顺序,先构造基类,再构造作为子类的成员变量的类,最后是构造子类。
同名成员变量
1、当子类成员变量与父类成员变量同名时,子类依然从父类继承同名成员
2、在子类中通过作用域限定符::进行同名成员区分(在派生类中使用基类的同名成员,显式地使用类名限定符)
3、同名成员存储在内存中的不同位置
比如这两个类中有同名函数show,编译器并不会报错
Derive自己有一个show,然后又继承了Base的show,而继承过来的show被隐藏了,所以当我们创建Derive的对象d时 (Derive d; d.show( ); ),调用的是Derive自带的show,也就是默认调用派生类里面的函数
如果想要通过Derive的对象调用继承过来的show,可以这样写:
同名变量也是同样的道理。
继承中的static
继承和static关键字在一起会产生什么现象呢?
1、基类定义的静态成员,将被所有派生类共享;
2、根据静态成员自身的访问特性和派生类的继承方式,在类层次体系中具有不同的访问性质 (遵守派生类的访问控制);
3、派生类中访问静态成员,用以下形式显式说明:
类名 :: 成员,或通过对象访问 对象名 . 成员
总结:
1、static函数也遵守3个访问原则
2、static易犯错误(不但要初始化,更重要的显示的告诉编译器分配内存)
在以下这段代码中,派生类里面并没有操作num,而结果通过派生类的三个对象d1,d2,d3操作了三个num,最后打印出来的num等于3,
静态成员变量是共享的,derive继承Base后,静态成员变量也继承过来了,也就是这个num对于derive也是共享的。
接下来看一道继承笔试题
继承笔试题
有如下程序,运行的结果是:
#include <iostream>
using namespace std;
class Base
{
int x;
public:
Base(int n=0):x(n){cout<< n;}
int getx()const{return x;}
};
class Derived:public Base
{
int y;
public:
Derived(int m,int n):y(m),Base(n){cout<< m;}
Derived(int m):y(m){cout<< m;}
};
int main()
{
Derived d1(3),d2(5,7);
return 0;
}
选项:
A 375 ; B 357 ; C 0375 ; D 0357
这道题考查的是继承里面调用构造函数的顺序
解析:
Derived继承了Base,首先第一步是构造Derived的第一个对象d1,只传一个参数3,那么对应调用Derived(int m)这个构造函数,m接收到3,m=3。然后初始化时要先调用基类的构造函数Base(int n=0)(注意:这里如果基类提供的是有参构造函数的话,调用Base的构造函数必须在子类的构造函数后面给Base()传参,否则会报错,而这里提供了一个默认参数的Base(),所以我们不用初始化列表给它传参也不会报错,因为没有参数传给它使,默认参数是0),参数是一个默认参数n=0,这个构造函数后面有个初始化列表:x(n),即将n赋值给x,x=0,之后函数体内打印{cout<< n;},结果是n=0,排除A和B选项。
第二步:基类的构造函数结束后,程序回到派生类的Derived(int m)这个构造函数的初始化列表:y(m),即m赋值给y,然后函数体内打印{cout<< m;},结果m=3。
第三步:构造Derived的第二个对象d2,传了两个参数,对应调用Derived(int m,int n)这个构造函数,m接收5,n接收7,然后初始化时要先调用基类的构造函数,此时因为在Derived(int m,int n)子类的构造函数后面给Base()传了一个参数n,Base(n),则Base(int n=0)中的默认参数n接收到了7,即n=7,然后再函数体内打印{cout<< n;},n=7,排除D选项。
最后第四步,基类的构造函数结束后,程序回到子类的构造函数后面的初始化列表:y(m),把m的值赋值给y,y=5,然后再函数体内打印{cout<< m;},m=5,所以答案是C选项。
多继承
有时候一个类既需要A类的特征,也需要B类的特征,那么两个类都需要继承。
一个类有多个直接基类的继承关系称为多继承。
多继承声明语法:
class 派生类名 : 访问控制 基类名1 , 访问控制(可以和前面的不一样) 基类名2 , … , 访问控制 基类名n
{
数据成员和成员函数声明
};
类 C 可以根据访问控制同时继承类 A 和类 B 的成员,并添加自己的成员。
代码演示:
通过以上这段代码我们可以同时验证了我们之前的结论:继承过来的成员在内存中是放在前面,自己内部增加的成员直接跟在后面
下面再研究一下多继承的构造顺序
构造顺序,先构造Base1,然后是Base2,最后派生类Derived
继承与多个基类的派生类,构造函数可以调用基类构造函数初始化数据成员,执行顺序与单继承构造函数情况类似。多个直接基类的构造函数执行顺序取决于定义派生类时指定的各个继承基类的顺序。
如:class C : public class A, public class B 先构造A再构造B
一个派生类对象拥有多个直接或间接基类的成员。不同名成员访问不会出现二义性。如果不同的基类有同名成员,派生类对象访问时应该加以识别。
我们一般很少使用多继承,因为它容易造成歧义。
比如以下这段代码
此时TestC应该占了20个字节
但是如果我们通过TestC的对象访问Base中的变量m_a时,就出现歧义了(多继承的二义性)
为什么?
因为TestA和TestB都继承了Base,也就是说TestA和TestB里面都有m_a,那tc访问的到底是哪个m_a?
这是它的内存布局:
我们可以加上作用域说明,告诉编译器我们要访问的是哪个类里面的m_a
多继承不仅可能会产生歧义,还有更大的一个问题就是占内存,如果上面的那个m_a不是一个整型,而是一个很大的数组的话,就很浪费空间。
要想解决这个问题,就又涉及到一个新的概念:虚继承。
虚继承
它能保证这个m_a在最终在TestC里面只出现一个。
要使这个公共基类在派生类中只产生一个子对象,必须对这个基类声明为虚继承,使这个基类成为虚基类。虚继承声明使用关键字 virtual
这样我们就不用说明作用域了,因为此时TestC中就出现了一个m_a,那它必定来自于Base的m_a。
注意:要实现这个virtual,就需要一些机制(通过指针来解决),这些机制在内存中也需要一定的空间,所以如果加上virtual计算出来的大小比不加virtual时还大,只能巧合。一般来说加上virtual计算出来的大小都比不加virtual时小很多,可以节省很多空间。
我们可以把TestA的对象的地址和它的m_a和m_b两个成员变量的地址,以及TestA这个类的大小打印出来看看
在内存中布局,(注:由于这个是32位系统,所以指针占4个字节)
正常来说,继承来的变量都是在开头的位置,但是这里用的是virtual虚继承,所以把基类变成了虚基类,加了virtual关键字,那前面4个字节就是虚基类指针,指向了虚基类。
TestB也是一样的
TestC的成员地址
TestC内存布局:
可以看到这个m_a被放到了最后一块内存,虚基类指针在TestA和TestB中指向的都是从基类继承过来的m_a。最一直到TestC中,两个虚基类指针都指向了最后一块区域,也就是m_a所在的空间。
接下来再研究一个问题:
如果我们将TestC的对象tc的地址都赋值给这四个指针,结果会如何?
为什么不一样?
结论:每个指针只能指向属于自己的部分
因为编译的时候有一个静态联编的过程,防止通过我们有pbase这个指针去访问m_c(Base*pbase=&tc; pbase->m_c;)这种操作。因为编译的时候编译器会去分析pbase是Base*类型,而Base里面只有m_a这一个成员,所以不让我们通过pbase这个指针去访问m_c,因此编译的时候就报错。
所以编译器指针这样操作:每个指针只能指向属于自己的部分。
C++向上转型(有点向上兼容的意思)
这个有点类似于赋值语句。
比如这两种赋值哪一种合适?
编译后两个都没有错(只是警告),为什么?
C++编译器知道怎么操作,当f赋值给a时,编译器会把f的小数去掉.22,当a赋值给f时,编译器会给a后面加上小数.00
再看这个代码,哪个是可以的?
答案是c=p;这句不行
因为Parent占4个字节,Child占8个字节,如果把p赋值给c,就相当于创建了一个新的对象,当p赋值给c时,编译器就知道用p中的a覆盖c中的a,但是b不知道用什么覆盖,此时这个c是一个不完整的对象了。
如果反过来讲c赋值给p的话,用c中的a覆盖p中的a,此时p已经是一个完整的对象了,b丢掉。所以p=c是可以的。
结论:在C++里面,
可以把派生类赋值给基类;
可以把派生类指针赋值给基类指针;
基类指针可以指向派生类对象(反之不可以)
可以把派生类引用赋值给基类引用;
小结:类型兼容
类型兼容规则是指在需要基类对象的任何地方,都可以使用公有派生类的对象来替代。通过公有继承,派生类得到了基类中除构造函数、析构函数之外的所有成员。这样,公有派生类实际就具备了基类的所有功能,凡是基类能解决的问题,公有派生类都可以解决。
类型兼容规则中所指的替代包括以下情况:
子类对象可以当作父类对象使用
子类对象可以直接赋值给父类对象
子类对象可以直接初始化父类对象
子类对象地址可以赋值给父类对象指针
子类对象引用可以赋值给父类对象引用
在替代之后,派生类对象就可以作为基类的对象使用,但是只能使用从基类继承的成员。
类型兼容规则是多态性的重要基础之一。
下节开始学习多态!
如有问题可评论区或者私信留言,如果想要进扣扣交流群请私信!