继承相关介绍
1.继承的概念及定义
今天来说一下继承,继承是面向对象的三大特性之一。为什么要有继承呢?这和我们的现实世界有关系:比如现在要写一个学校的管理系统,定义一个学生,需要定义名字、年龄、住址、电话等:
除了以上大众信息外,还要定义一些学生独有的信息,比如宿舍号、学号、专业等。学校里还要定义老师,老师前面的信息也有,自己也有独有的信息:
除了以上一会还要定义学校里的一些角色,如保安大叔、食堂阿姨等,他们都有一些公共的信息,也有一些独有的信息。那些公共信息每个类都要写一份,和公共信息有关的接口在每个类中也都要写一遍,这样是比较麻烦的。基于以上的原因,为了方便,继承的语法就出来了:我们可以定义一个Person,把大家都有的信息放进去,相关的方法也放进去。这样定义出学生/老师等直接继承它就可以了:
继承的本质是一种复用,Person让下面类去继承,这样下面的类就能省劲一些,这是类设计层的复用。下面来看看继承的语法:
把被继承的类一般叫父类或基类,继承的类叫子类或派生类。下面来感受一下,定义一个人,然后完成继承:
那学生里面有没有名字?有没有prnit函数?单独看学生类是看起来没有的,但是实际有,因为可以继承"家业":
再看下一个问题,继承这里会有个新东西叫访问限定符。以前我们了解过三种访问限定符,之前说保护和私有做为访问限定符是一样的,但继承这里就不一样了:
祖师爷设计了三种继承方式加三种访问限定符。这就涉及了父类成员在子类的访问方式是什么样的,子类定义了自己的成员,也继承了父类的成员,继承方式和父类访问限定符进行了组合:
最终组合了9种访问方式。下面来解读一下:基类的私有成员在派生类中都是不可见的,不论以什么方式继承(这里的不可见是语法上限制访问,类里和类外都不能用,跟private不一样;单独的private是类外面不能用,类里面可以用)。这样设当初可能想的是现实世界中也不能什么都让子类继承了:
基类的公有和保护在派生类可见的是访问限定符和继承方式取权限小的那一个(成为派生类的什么成员),权限关系是public>protected>private。因此也出现了这样的原则:
所以保护和私有以前没有区别,都是可以在类里面用,类外面不能用,现在就有区别了。再看下一部分:
class如果没有继承方式默认是私有继承,print原来是公有的,碰上私有继承下来成为私有,因此类外面s.print()调不到;struct默认是公有继承。但是实际中不用考虑这么多:
2.基类和派生类对象赋值转换
继承以后父类的成员子类就可以用了,相当于子类中有一份成员变量(因为对象不存成员函数)。下面来看基类的派生类的赋值转换,赋值转换指的是正常情况下两个不同的对象去赋值一般是不允许的。如果允许,只有一种可能性是类型转换:
C++继承这里有个特殊的点,如有一个Person和Student,它们之间存在着类型转换,子类可以给父类,但父类不能给子类:
因为子类有些成员父类没有,给不了。这里想说的是,一般把这两种转换叫向上转换和向下转换:
上面是父,下面是子。子给父一般叫向上转换,向上转换是可以的,子是可以给父的。按以前的说法两个不同类型赋值要走类型转换,中间会产生临时变量。这里在语法上进行了特殊处理,认为没有发生转换,而是天然发生的一个赋值兼容转换,有些地方也叫切割或切片。(一个子类对象一定是一个特殊的父类对象,赋值等会把子类中属于父类的一部分切出来拷贝给父类)那怎么证明中间没有产生临时变量呢?来看这个场景:
i给d的时候中间产生了临时变量,临时变量具有常性,权限是不可以放大的;s给p1和s给rp都可以,说明这里是切片;Person p1 = s,p1这里是对象,就是把子类中父类那一部分切出来拷贝过去;Person& rp = s,rp这里是引用,就是引用变成子类对象里父类的那一部分的别名;中间没有产生临时变量,这里语法支持的。这里可以故意改一下,证明一下是否变为父类那一部分的别名:
默认是peter,这里改为了张三。总结一下:向上转换都是可以的,子类对象可以给父类对象/引用/指针。感受一下给指针:
3.继承中的作用域
下面来看继承中的作用域,作用域指的是我们定义一个类,这个类会有类域,基类和派生类各有各的类域,那它们能否定义同名成员?语法上是可以的:
Person有一个叫_name,一个叫_num;Student也有一个叫_num,这样Student就有两个叫_num的变量。此时有个小问题:定义Student对象,调用print打印的是999还是111?
答案是999,因为先查的是子类作用域,子类没有才去父类查找。如果想用父类那一个可以用吗?可以(域的本质是编译的时候指导编译器去查找,优先去局部->子类->父类->全局,找不到就报错),直接指定作用域去找(找不到报错,因为指定过了):
这有一个特殊的语法,C++把子类和父类同名成员取了个名字叫隐藏,也叫重定义。指的是子类和父类有同名成员时,子类的成员隐藏了父类的成员。来看下图:
子类的fun函数会隐藏父类的函数,这的隐藏不仅体现在成员变量,也体现在成员函数;main中调的时候调不到父类的,若指定调可以调到。再看一个问题:
子类的fun和父类的fun构成什么关系?a.隐藏/重定义 b.重载 c.重写/覆盖 d.编译报错。这里很容易选b,函数名相同,参数不同构成重载,感觉没什么问题,但实际选a。要注意的是重载有一个限定是同一个作用域,它底层是函数名修饰规则,因为在同一个作用域不加修饰规则编译器区分不开;不同作用域不需要通过修饰区分,按照域查找规则直接找;父子类域中,只要函数名相同就构成隐藏;但注意实际中继承体系里最好不要定义同名的成员。
4.派生类的默认成员函数
下一部分来看派生类中的默认成员函数,指的是我们有6个默认成员函数,默认指我们不写编译器会自动生成一个(以前在类和对象部分学过普通类的六个默认成员函数,当时说最主要的是前4个):
下面看一下这4个在派生类中是什么样子:
上图写了一个父类,写了构造等接口;再简单提供一个Student。传统理解认为派生类自己初始化自己的成员,但实际不是这样的,编译器规定不能显示的在初始化列表初始化父类或基类成员(在函数体内可以):
再看一个问题:
main中没有定义父类对象,但为啥调用父类构造函数呢?因为C++有规定,派生类必须调用父类构造函数初始化父类成员(可通过调试观察),它是自动在初始化列表调的(若没有提供默认构造报错)。相当于派生类要初始化就初始化自己的,父亲的那一部分交给父类构造函数去完成。默认情况会调父类默认构造,若父类没默认构造怎么办:
可以像定义匿名对象一样。那是Person先初始化还是id先初始化?调式看到先走Person,初始化顺序与初始化列表出现的顺序没有关系,和声明顺序有关系。从声明的角度,继承的成员会放在自己定义成员的前面,所以先走的是Person。在看看拷贝构造呢?
也不能这样写,父亲的拷贝构造还是像匿名对象一样去调。但是有个问题:
父亲的拷贝构造要传一个父类对象,但这里只有一个子类对象怎么办?
不用管,可以直接传。因为子类对象可以传给父类的指针或引用,会进行切割或切片。如果不写会怎么样?
拷贝构造也是构造函数,不写会在初始化列表调默认构造函数。看到s2中的Person里的name是peter,s1的是张三,因为不写把s2中的父亲单独当一个整体去调构造函数,若没有默认构造这里会报错。再看赋值重载:
由于子类和父类函数名一样构成了隐藏,会不断调用自己的,在自己域里面找,所以这样写:
这样把s传过去进行了切片。下面来看析构:
直接调父类的调不到,必须指定类域去调,因为由于后面多态的原因,析构函数的函数名被特殊处理了,被统一处理成了destructor。这里看起来两个析构的名字不一样,实则统一后构成了隐藏,所以需要指定。下面运行一下:
发现有些问题,总共才3个对象,但调用了6次析构函数。把析构屏蔽试一下:
发现屏蔽后反而又对了。这是因为析构函数会自动调用,目的是要保证这里的析构顺序:
把一个子类对象分为父的部分和子的部分,构造时父的部分先构造,下来是子部分;为了保证后定义的先析构,因此子部分先析构,下来是父部分。前面写了析构函数后显示调用父类析构函数,没办法保证先子后父的顺序,所以子类析构函数完成后会自动调用父类的析构函数,这样就可以保证先子后父了(这样也保证了由于父不能用子,防止父先析构子可能会访问父的成员)。
5.继承与友元
下一部分来看继承和友元,就是友元关系不能继承:
有个Display函数,它是父亲的友元,但它不是孩子的友元,所以会报错。想不报错怎么办呢?就再定义一个友元:
6. 继承与静态成员
下一部分来看继承与静态成员,静态成员可以继承吗?(父亲中有静态成员,继承下来可以用吗)
这里要分两个角度辩证的看该问题:可认为能继承,也可认为不能继承。前面的继承是对象里会存一份,并且和其它没有关系。如:
Person对象里有个name,Student对象里也有个name,这两个name不是同一个name。静态成员可用对象访问,也可用类名访问:
发现它并不是给派生类也弄了个count,它们是同一份。因此静态成员属于父类和派生类,在派生类中不会单独拷贝一份;子类能直接使用,行为上像继承了,但是子类没有自己的静态成员副本,本质共享父类的。继承的静态成员这可以有这样的场景,比如前面的例子,想记录Person及它的派生类对象创建了多少个,可以在父类定义一个静态成员变量,在父类的构造中++就可以了,因为子类的构造函数规定了一定要调父类的构造函数:
7.多继承
C++的下一个大坑叫多继承,多继承会引发一个复杂的问题叫菱形继承,菱形继承会引发菱形虚拟继承,菱形虚拟继承会把这里衍生的很复杂。什么是多继承呢?可以想想有没有可能一个类同时具有两个类的特征?是有的,符合现实世界,所以祖师爷设计了多继承:
上图左边的方式是单继承,只有一个直接父亲的都不是多继承;上图右边才是多继承,一个子类有两个或以上直接父类。多继承的语法是继承方式+类名 逗号 继承方式加类名(继承多少个类都行,只不过需要用逗号进行分割)。正常情况多继承没什么问题,比如有个学生类和老师类,有个人既是学生也是老师:
但是有多继承可能就会引发菱形继承:
一个人既是学生也是老师没什么问题,但有时可能没注意到的是学生继承了人,老师也继承了人,这样就形成了菱形继承。来看看菱形继承的对象模型:
对Student和Teacher没问题,对Assistant有问题;Assistant有自己的成员,还有Student和Teacher两个父类对象;Teacher有个名字是继承来的,Student也有个名字是继承来的,这样就有两个名字,会导致数据冗余(浪费空间)和二义性(不知道要访问谁)。比如看下图:
看到报错说对age的访问是不明确的,二义性的问题可以想办法解决:
虽然指定访问某种程度上可以缓解,但从面向对象角度是违背常理的;数据冗余也不好解决。为了解决这些坑,C++引入了多继承的解决方案叫虚继承,在腰部位置新增一个关键字virtual:
下面来感受一下加了virtual的变化:
发现现在age都是一份,这样就解决了数据冗余和二异性的问题。下面来详细看看通过什么样的方式解决问题:
D里面有两个_a,一个_b,一个_c,一个_d,随意给些值看看:
观察发现和我们预想的情况一样。下面取d的地址看看内存:
通过内存看到就是挨着放的。下面看看菱形虚拟继承:
此时是同一份_a(监视窗口看起来是三分,实则是一份),发现内存也和前面相比有变化。用B访问的是_a,用C访问的也是_a,相当于把A单独拿出来,既没有放B中,也没有放C中,这样没有了数据冗余和二义性。但仔细发现B和C中多了些东西:
这两个东西看起来像指针,把地址输入看一下:
发现这两个地址指向的位置存的是0,下一个位置存了有效值分别为20和12。再仔细观察发现:
20是B开头和A开头位置偏移量,12是C开头和A开头位置偏移量:
意味着指针指向的位置存的是一个A的相对距离,存距离是为了找A。还发现都是第二个位置存,没用第一个,第一个都是用0标记,因为第一个位置为其它位置进行了预留。这里B和C多出来地址叫虚基表指针,指向的表叫偏移量表,有些地方也叫虚基表,通过虚基表可以找到A。同一个类不同对象的虚基表指针是一样的:
再说说存偏移量的意义是什么?比如d1._a = 1,不需要按照偏移量找,因为在最终派生类里,编译器在编译阶段就已经知道A的固定位置;它主要是为切片或切割的场景准备的,比如B* pb = &d pb->_a = 1,此时d中B和A的部分被切了出来,编译器需要通过B的虚基表指针找到偏移量,才能确定A在切片后对象的位置,确保访问不出错。再看下图:
虚继承以后B对象变了,正常以为只有a和b,但B对象虚继承之后和D对象保持了一样的模型。它里面也有虚基表指针,指向偏移量表:
发现8是B开头和A开头的偏移量。继续看这样的场景:
上图左边中,D和B对象有_a,然后有个B类型的指针,可能指向b访问a++,也可能指向d访问a++。单独看上图右边的ptr->_a++,编译时ptr是不知道自己指向对象类型是B还是D,转为指令也没法知道a在哪。因此不管指向谁,都按照B类型里_a的偏移位置去内存访问_a,这就是偏移量的作用。
8.题目练习
下面来看一个题目:
先来看看对象构成,先继承的在上面:
整体是D对象,p3指向对象的开始没有任何问题;p1和p2是一个指针切片,由于Base1的位置在前面,p1也指向开始,但和p3的意义不一样,一个是看Base1切片,一个是看整体对象;p2发生指针偏移和切片指向Base2,所以选C。下面把题目变一下看看选什么:
改变后对象模型也变了一下,所以是p3 == p2 != p1。
下面再看一个题目:
先想想A的构造函数被调了几次?肯定只有一次,因为D是菱形虚拟继承,D里有一份A。A既属于B又属于C,但既不在B也不在C,所以在创建D对象调用构造的时候要去走一下A的构造函数。虽然在对象模型中A在最下面,但A是最先被声明的(因为先被继承),所以D对象先调A的构造,再调B;B中不会再去调A的构造,编译器会处理干净;再调C,最后走完了调D,所以是ABCD。思考个小问题:既然B和C都不调用A的构造函数,能否里面把A的构造函数去掉?不可以,除非A中有默认构造函数,因为可能有单独定义B或C对象的情况;D也不能不初始化A,虚拟基类A的构造是由派生类D来负责的,所以不能依赖B和C,必须D自己来。
库中有人玩过菱形继承:
IO流整个类是个菱形继承,ios为顶,istream和ostream为腰,iostream为底的关系形成了菱形继承(cin是istream对象,cout是ostream对象,可能觉得把它们分开不好就用iostream继承了)。
9.继承的总结和反思
下面对继承进行总结和反思:1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱 形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承,否则在复杂度及性能上都有问题。 2. 多继承可以认为是C++的缺陷之一,很多后来的OO(面向对象)语言都没有多继承,如Java。
下一部分看看继承和组合,继承不用多介绍了。重点看看组合:
继承和组合都是完成了对C类的复用,但是:1.public继承是一种is-a的关系,也就是说每个派生类对象都是一个基类对象。 2.组合是一种has-a的关系,假设B组合了A,每个B对象中都有一个A对象。3.继承允许你根据基类的实现来定义派生类的实现,这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。4.对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。5.实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。(is-a:植物是花,一个子类就是一个父类;has-a:车有轮胎,每个B都有A)。