继承(c++版 非常详细版)
一. 继承的概念及定义
1.1 继承的概念
继承(inheritance)机制是⾯向对象程序设计使代码可以复⽤的最重要的⼿段,它允许我们在保持原有 类特性的基础上进⾏扩展,增加⽅法(成员函数)和属性(成员变量),这样产⽣新的类,称派⽣类。继承 呈现了⾯向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的函数层次的 复⽤,继承是类设计层次的复⽤。
下⾯我们看到没有继承之前我们设计了两个类Student和Teacher,Student和Teacher都有姓名/地址/ 电话/年龄等成员变量,都有identity⾝份认证的成员函数,设计到两个类⾥⾯就是冗余的。当然他们 也有⼀些不同的成员变量和函数,⽐如⽼师独有成员变量是职称,学⽣的独有成员变量是学号;学⽣ 的独有成员函数是学习,⽼师的独有成员函数是授课。
直接上一个例子看看。
这两个类是有很多相同的属性的,我们能不能把相同的只放在一个类中减少冗余呢?
这时候继承就可以完美解决这个问题了。
此时我们就把老师和同学相同的属性全部都抽出来了,放在了一个Person类中,这里你可以先看看继承的格式是怎么写的。
1.2 继承定义
下⾯我们看到Person是基类,也称作⽗类。Student是派⽣类,也称作⼦类。(因为翻译的原因,所以既叫基类/派⽣类,也叫⽗类/⼦类)。
继承方式和访问限定符都是有三类的。
这个就是继承的方式,访问限定符会缩小,但是不会变大。我们下面来看一下。理解一下。
我们来举个例子就很清楚了。
此时这个学生类是通过public继承的,所以父类中各个访问限定符就不会改变,还是原来的,不变。
但是我们换个方式继承看看。
我们通过protected访问限定符来继承,此时创建子类对象访问父类中的方法时,这里的父类的public访问限定符就会变成protected,父类对象不受影响,我们可以验证一下。
我们看到没,子类对象此时访问不到父类中的identity方法了,但是父类对象可以。
我们再改为private继承试一下。
此时子类对象访问父类方法时,父类中的方法全部变为private类型的了。
此时派生类中还是按照原来的访问,只是子类对象的访问限定符改变了。
大家可以好好看看这个例子就可以理解这两种情况了。
1. 基类private成员在派⽣类中⽆论以什么⽅式继承都是不可⻅的。这⾥的不可⻅是指基类的私有成员 还是被继承到了派⽣类对象中,但是语法上限制派⽣类对象不管在类⾥⾯还是类外⾯都不能去访问它。
这句话的意思就是我改为私有的,此时除了在自己类中可以访问的到,在其他类中都是访问不到的,派生类中也是访问不到的。
2. 基类private成员在派⽣类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派⽣类 中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
3. 实际上⾯的表格我们进⾏⼀下总结会发现,基类的私有成员在派⽣类都是不可⻅。基类的其他成员 在派⽣类的访问⽅式 == Min(成员在基类的访问限定符,继承⽅式),public > protected >
private。
4. 使⽤关键字class时默认的继承⽅式是private,使⽤struct时默认的继承⽅式是public,不过最好显 ⽰的写出继承⽅式。
5. 在实际运⽤中⼀般使⽤都是public继承,⼏乎很少使⽤protetced/private继承,也不提倡使⽤
protetced/private继承,因为protetced/private继承下来的成员都只能在派⽣类的类⾥⾯使⽤,实
际中扩展维护性不强。
1.3 继承类模板
这个我们就直接上代码来理解吧。
这个就是我们常用的栈,我们在这的实现是通过让它继承vector容器来实现的,此时我们是可以继承vector中的方法的。
上面的代码可能会有些疑惑的地方,比如,为什么要在方法前面加上vector<T>呢?
最主要有两个方面的作用。
1.避免二义性:当模板类stack继承自std::vector<T>时,stack类中可能会有与父类std::vector<T>成员函数同名的成员函数或者其他成员。如果不使用vector<T>::限定,编译器可能无法确定你要调用的是父类的成员函数还是子类中可能存在的同名成员,加上作用域限定符可以明确指定要调用的是父类std::vector<T>中的成员函数,避免产生二义性。
2.模板的延迟实例化:模板是按需实例化的。在stack类模板中,当实例化stack<int>时,虽然也会实例化vector<int>,但vector<int>中的成员函数如push_back、pop_back等可能并没有立即被实例化。如果直接使用push_back等函数而不加vector<T>::限定,编译器在编译stack类的成员函数时可能无法找到这些函数的定义,因为它们可能还没有被实例化。而加上vector<T>::限定,编译器就能够明确知道要在父类std::vector<T>的作用域中查找这些函数,即使它们还没有被实例化,也能正确地进行编译,在需要的时候再进行实例化。
简单来说就是,你的vector<T>中的T虽然被实例化了,但是vector这个类中的方法可能没有被实例化,你直接使用可能编译器不知道使用哪个push_back方法,因为我们都知道模板的底层还是多个类型的函数吗,只是编译器帮我们写了,我们不需要自己写了,所以我们不加前缀,他不知道调用哪个类型的。
看此时我们不仅可以调用自己的方法,还可以调用基类中的方法,效果都是一样的。
你看如果我们直接说明了T的类型,此时就不用加前缀了,此时确定了是int,它的方法就直接会实例化了。
1.4 基类和派⽣类间的转换
public继承的派⽣类对象 可以赋值给 基类的指针 / 基类的引⽤。这⾥有个形象的说法叫切⽚或者切
割。寓意把派⽣类中基类那部分切出来,基类指针或引⽤指向的是派⽣类中切出来的基类那部分
基类对象不能赋值给派⽣类对象。
举个例子吧。
我们由浅入深来看一下吧。
我们看一下这个代码,前两行的int引用引用int变量是没有问题的,我们发现double引用引用int类型是不行的,这是因为类型转换会产生临时变量,引用无法指向临时变量,必须加const延长临时对象的声明周期。
这样就没问题了。
我们再来看看继承中的类型转换。
这时候为什么没有报错呢?不是需要类型转换吗?生成临时变量为什么可以呢?
首先第一个,我们用普通类型是无法实现的,为什么我们的继承可以呢?还有第二个,我们不是需要加const吗,为什么这里不用加呢?
答案是不会的,你可以把它理解为切割,因为父类中的成员子类都可以访问(这个例子中的),所以当用指针或者引用是,它会只切割自己要的部分,不会产生临时变量,基类指针或者引用能够指向派生类对象。这是因为 public 继承维持了基类接口的公共性,派生类对象可被视为基类对象,所以这种转换是安全的。
此时就把派生类对象看成一个基类对象了。
我们用protected和private继承是不能实现的。
基类指针或者引用不可以直接指向派生类对象。private 继承意味着派生类把基类的接口变成了自己的私有接口,外部无法将派生类对象当作基类对象来处理。
你可以理解为外部对象无法访问到基类中的方法了,此时不能将一个派生类看成一个基类了。
1.5 继承中的作⽤域
1.5.1 隐藏规则:
1. 在继承体系中基类和派⽣类都有独⽴的作⽤域。
2. 派⽣类和基类中有同名成员,派⽣类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。
(在派⽣类成员函数中,可以使⽤ 基类::基类成员 显⽰访问)
举个简单的例子看一下吧。
可以看A中的func函数,如果直接访问id,默认访问的是自己的,因为隐藏规则吗,此时B的id就把A的id给覆盖了,想访问A的可以用下面的那种方法,指定作用域即可。
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
这个我们举个例子,印象能更深刻,我们直接看两个题。
class A
{
public:
void fun()
{
cout << "func()" << endl;
}
};
class B : public A
{
public:
void fun(int i)
{
cout << "func(int i)" <<i<<endl;
}
};
int main()
{
B b;
b.fun(10);
b.fun();
return 0;
};
结合上面的代码看两个题吧。
如果没有上面那句话的提醒,可能好多人都会选A C了,但是结合上面的话我们可以知道,只要名字相同就是隐藏和参数没关系,他不会构成重载,而是隐藏,此时也会编译报错,A类的方法被隐藏了,你调用的func函数都是B类的,你没有传参数,此时就会编译报错了。
4. 注意在实际中在继承体系⾥⾯最好不要定义同名的成员
1.6 派⽣类的默认成员函数
1.6.1 4个常⻅默认成员函数
6个默认成员函数,默认的意思就是指我们不写,编译器会变我们⾃动⽣成⼀个,那么在派⽣类中,这⼏个成员函数是如何⽣成的呢?
1. 派⽣类的构造函数必须调⽤基类的构造函数初始化基类的那⼀部分成员。如果基类没有默认的构造函数,则必须在派⽣类构造函数的初始化列表阶段显⽰调⽤。
我们举个例子。
我们发现这样是没问题的,它的过程就是通过自己的默认构造去调用Person的默认构造,因为Person是有缺省值的构造,你不传参数也是可以的,可以认为就是默认构造。
如果我们没给默认构造呢?
此时就会报错了,因为派生类的默认构造在调用基类的默认构造的时候,发现找不到基类的默认构造,此时就报错了,此时我们该怎么办呢?
只需要自己实现一下派生类的构造就行了。
需要注意一点:子类中父类成员变量当成整体对象,作为子类自定义类型成员看待
此时如果我们像之前那样初始化,此时就又错了,我们不能这样初始化,而是应该把基类的所有变量看成一个对象来初始化。
这样就没问题了,相当于你需要调用基类的构造先初始化基类,否则就会报错了。
此时发现没问题了。
2. 派⽣类的拷⻉构造函数必须调⽤基类的拷⻉构造完成基类的拷⻉初始化。
我们还是看上图的最后一个的拷贝构造,它会调用父类的拷贝构造,id再调用自己的默认拷贝构造,因为这里我们的成员变量不需要深拷贝,此时就不用写子类的拷贝构造了,除非需要深拷贝。
我们给子类加了一个指针变量,此时就需要深拷贝了,我们来写一下子类的拷贝构造吧。
此时还会面临一个问题,我们和构造一样需要调用父类的拷贝构造,需要传入一个父类对象,但是我们此时没有父类的对象呀,此时该怎么办呢?
我们通过上面我们讲的转换原则,直接传入一个派生类对象即可,它会自己切割得到自己想要的基类对象。
3. 派⽣类的operator=必须要调⽤基类的operator=完成基类的复制。需要注意的是派⽣类的
operator=隐藏了基类的operator=,所以显⽰调⽤基类的operator=,需要指定基类作⽤域
这是我们子类写的=运算符我们先来运行一下。
我们发现崩溃了,这是为什么呢?
我们通过调试发现,栈溢出了,这是因为,又是构成隐藏了,一直调用的是自己的operator=,自己调用自己无法结束,就导致栈溢出了。
只需要像这样指定一下类域即可。
4. 派⽣类的析构函数会在被调⽤完成后⾃动调⽤基类的析构函数清理基类成员。因为这样才能保证派⽣类对象先清理派⽣类成员再清理基类成员的顺序。
这里我们写了子类的析构去调用父类的析构为什么报错了呢?
因为调用父类的析构需要指定类域。
为什么调用了四次析构呢?理论应该两次啊,其实这里就是派生类的析构会自动调用基类的析构,又显示调用了一下,此时就会一个对象调用两次,我们两个对象,所以调用了四次。
5. 派⽣类对象初始化先调⽤基类构造再调派⽣类构造。
这个你可以通过调试看看,一直都是先走父类的构造初始化。
6. 派⽣类对象析构清理先调⽤派⽣类析构再调基类的析构。
所以我们不需要写显示调用父类的析构了,这样也可以保证析构的时候先子后父,因为构造的时候是先父后子,先创建的后析构吗。
7. 因为多态中⼀些场景析构函数需要构成重写,重写的条件之⼀是函数名相同(这个我们多态章节会讲解)。那么编译器会对析构函数名进⾏特殊处理,处理成destructor(),所以基类析构函数不加
virtual的情况下,派⽣类析构函数和基类析构函数构成隐藏关系。
1.7 实现⼀个不能被继承的类
一共有两种做法。
一.c++98实现的
我们可以看一下,就是利用了一个语法漏洞,把父类的友元函数弄成私有的,此时调用不到,就会无法实例化对象,但是也有缺陷,此时A也无法使用默认构造了。
二.c++11完成的
使用final关键字,此时就不能被继承,但是不影响A的默认构造。
1.8 继承与友元
这里报了一个错误,大家不要被编译器的报错迷惑,其实不是这里的错误,是上面的原因,因为编译器为了提升编译速度只会向上找,此时找不到B,此时这里就会有问题,我们只需要在上面给上B的声明即可。
此时我们可以看到,父类的友元函数是父类的,但是子类没有继承过来,不能继承,简单来说就是你爸爸的朋友不是你的朋友。
1.9 继承与静态成员
静态成员是可以继承的。


1.10 多继承及其菱形继承问题
1.10.1 继承模型
单继承:⼀个派⽣类只有⼀个直接基类时称这个继承关系为单继承
多继承:⼀个派⽣类有两个或以上直接基类时称这个继承关系为多继承,多继承对象在内存中的模型是,先继承的基类在前⾯,后⾯继承的基类在后⾯,派⽣类成员在放到最后⾯。
菱形继承:菱形继承是多继承的⼀种特殊情况。菱形继承的问题,从下⾯的对象成员模型构造,可以 看出菱形继承有数据冗余和⼆义性的问题,在Assistant的对象中Person成员会有两份。⽀持多继承就 ⼀定会有菱形继承,像Java就直接不⽀持多继承,规避掉了这⾥的问题,所以实践中我们也是不建议设计出菱形继承这样的模型的。
这就是菱形继承,二义性就是,我们的Student和Teacher都继承了Person的_name变量,导致,我们实例化Assistance的时候,访问_name此时就会出错了,不知道访问的是Student的还是Teacher的,此时就出现了二义性,就需要指定类域的方式解决了。
这就是个菱形继承,此时出现了二义性。
解决方法:
指定类域即可,但是数据冗余我们无法解决,我们可以看到,Teacher继承的_name我们现在用不到,没啥用,我们只用一个就行了,冗余了一个。
怎么解决呢?
1.10.2 虚继承
很多⼈说C++语法复杂,其实多继承就是⼀个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂,性能也会有⼀些损失,所以最好不要设计出菱形继承。多继承可以认为是C++的缺陷之⼀,后来的⼀些编程语⾔都没有多继承,如Java。
这个就是把两个对象的冗余合并为了一个。
我们知道是Student和Teacher都继承了Person导致的,只需要在这两个类的继承前面加上virtual即可。
就是如图所示。
我们也可以通过调试看出来,确实此时Teacher和Student的_name都是同一个_name了,这只限于自己的派生类继承时这样,创建Student和Teacher对象的时候还是都是独立的_name。
此时创建的这两个对象的变量还是独立的值,只会影响下面的派生类的菱形继承。
我们再举个例子理解一下菱形继承。
这显然也是一个菱形继承了,二义性和数据冗余是由于B,C引起的,所以需要在B,C的后面加上virtual。
这是底层的实现,我们就不写代码了,这个不是特别重要,实践中用的也不多。
我们下面来看一道题吧。
答案是C,下面我们来讲解一下。
内存布局
在多继承的情况下,派生类对象的内存布局是按照基类在派生类定义中出现的顺序依次排列的,接着是派生类自身的成员。以 Derive
类为例,它先包含 Base1
的成员,然后是 Base2
的成员,最后是 Derive
自身的成员。内存布局如下:
指针偏移原因
Base1* p1 = &d;
:由于Base1
是Derive
继承的第一个基类,p1
指向的地址与d
对象的起始地址相同,所以p1
不会发生偏移。Base2* p2 = &d;
:Base2
是Derive
继承的第二个基类,Base2
成员在内存中是紧跟在Base1
成员之后的。为了让p2
正确指向Base2
部分的内存,编译器会对p2
进行偏移操作,使其指向Base2
成员的起始地址。偏移量就是Base1
类对象所占的内存大小。Derive* p3 = &d;
:p3
是Derive
类型的指针,它指向d
对象的起始地址,所以p3
不会发生偏移。
这就是偏移的原因的,简单来说就是基类的指针要指向自己部分内存的首地址。
1.11 继承和组合
组合是⼀种has-a的关系。假设B组合了A,每个B对象中都有⼀个A对象。
我们来举个组合的例子吧。
这是一个组合关系,stack中有一个vector容器,是has-a的关系,它俩的关系就是组合了,不是继承,继承是is-a的关系,stack不是vector。
• 继承允许你根据基类的实现来定义派⽣类的实现。这种通过⽣成派⽣类的复⽤通常被称为⽩箱复⽤ (white-box reuse)。术语“⽩箱”是相对可视性⽽⾔:在继承⽅式中,基类的内部细节对派⽣类可 ⻅ 。继承⼀定程度破坏了基类的封装,基类的改变,对派⽣类有很⼤的影响。派⽣类和基类间的依赖关系很强,耦合度⾼。
• 对象组合是类继承之外的另⼀种复⽤选择。新的更复杂的功能可以通过组装或组合对象来获得。对 象组合要求被组合的对象具有良好定义的接⼝。这种复⽤⻛格被称为⿊箱复⽤(black-box reuse), 因为对象的内部细节是不可⻅的。对象只以“⿊箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使⽤对象组合有助于你保持每个类被封装。
• 优先使⽤组合,⽽不是继承。实际尽量多去⽤组合,组合的耦合度低,代码维护性好。不过也不太 那么绝对,类之间的关系就适合继承(is-a)那就⽤继承,另外要实现多态,也必须要继承。类之间的关系既适合⽤继承(is-a)也适合组合(has-a),就⽤组合。
下面再来讲一下耦合度吧,好好理解一下。
耦合度就是关联关系,我们在项目中肯定是耦合度越低越好,这样你改变一个类,影响的东西就少了。
举个例子就是,一家人出去旅游,如果一块去坐同一辆车,可能这个有个事情,那个有个事情,可能就会耽误,但是如果是两人一辆车,分多辆车去的话,这样,如果两个人有事来不了,但是这个旅游活动还是可以进行的,影响不到其他人的旅游。
二.结束语
感谢大家的查看,希望可以帮助到大家,做的不是太好还请见谅,其中有什么不懂的可以留言询问,我都会一一回答。 感谢大家的一键三连。