C++——继承
本次我们来讲解关于继承的知识点。
继承的概念
1.继承是面向对象编程的一个重要概念,指一个类(子类)可以继承另一个类(父类)的属性和方法,可以使得代码复现和拓展,增加功能。子类能够获得父类的特征和行为,并在此基础上增加自己的新特性或改变父类的行为。(即类层次的复用)
- 继承:是面向对象编程中的一种机制,通过继承,子类可以自动拥有父类的属性和方法,实现代码的复用。比如,定义一个“动物”类作为父类,具有“呼吸”等方法,“狗”类作为子类继承“动物”类,那么“狗”类就自然拥有了“呼吸”方法。
继承的定义格式:
继承方式:
看到这里,相信大家会感到非常熟悉,这不是和类里面的访问方式一样吗?
是的,虽然单词都是一样的,但是它们的含义是不一样的。一个是继承方式,一个叫做访问限定符。
证明继承是代码的复用:
由上面我们可以知道:继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。体现了Student继承了Person。
继承的访问方式变化:
类成员/继承方式 | public继承 | protected继承 | private继承 |
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可 |
观察规律:
1.我们看到最后一行,发现基类private的成员在派生类是都不可见的(无论是哪种继承方式)。
但是需要注意的是:这里说的不可见:不是说不能继承,实际上还是被继承到派生类的对象的,只是它受限于语法的原因,它无论是在类内还是类外都不能进行访问罢了。
上面我们了解了:基类private的成员是不能访问(类外和类内)的,基类的public在类内和类外都能访问,那么,如果我们只想在类内进行访问,不能在类外访问,有什么办法呢?
这里就用到了protected(在派生类内进行访问),因此我们可以明白了,protected限定符是为了继承才出现的,这也是为什么之前在普通类是private和protected本质没有什么区别的原因。
3.观察那个表,我们发现一个规律:
基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected>private。
4.关键class与struct的区别
class默认是private私有,structu默认是public公有的。
5.在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
基类和派生类对象赋值转换
基类与派生类的赋值转换有说法叫“切割,切片”。什么意思呢?
相当于:派生类中父类那部分切来赋值过去。下面用一张图片更加直观了解:
我们知道,上面的代码在赋值的时候会生成一个临时拷贝,所以它就不是切片。
所以转移过来,切片切割它是不会另外生成一个临时拷贝的。
现在我们用代码来具体证明一下:
class Person
{
public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}
//protected:string _name = "peter"; // 姓名int _age = 18; // 年龄
};class Student : public Person
{
protected:int _stuid; // 学号
};class Teacher : public Person
{
protected:int _jobid; // 工号
};
因此,我们可以得出:
1.派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用
2.基类对象不能赋值给派生类对象。(你看看上面直观图,如果赋值了,派生类Student可能有多出来的_No,赋啥值给它呢?)
3.基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。
class Person
{
protected :
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public :
int _No ; // 学号
};
void Test ()
{
Student sobj ;// 1.子类对象可以赋值给父类对象/指针/引用
Person pobj = sobj ;
Person* pp = &sobj;
Person& rp = sobj;//2.基类对象不能赋值给派生类对象
sobj = pobj;// 3.基类的指针可以通过强制类型转换赋值给派生类的指针
pp = &sobj //pp(基类) = &sobj(派生类)
Student* ps1 = (Student*)pp; // 这种情况转换时可以的。
ps1->_No = 10;pp = &pobj; //pp(基类) = &pobj(基类);
Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问
题
ps2->_No = 10
}
继承的作用域
1.我们知道每一个类都会有一个单独的作用域,即基类与派生类都要单独的作用域。
2.那么,基类与派生类中,函数名相同的的成员叫做隐藏,也叫重定义。
这时候,子类成员将屏蔽父类对同名成员进行直接访问。如果你想要调用父类的成员,可以显示调用,即基类::基类成员
注意:这里我们很容易跟之前我们学习过的函数重载混淆,那么怎么区分呢?
1.函数重载是在同一个作用域的,而隐藏是在两个不同的作用域下的。
2.我们复习一下,构成函数重载的条件有哪些呢?
如下:参数个数,参数类型,类型顺序。
而现在我们的隐藏(重定义)只有一个条件:函数名相同即可。
3.在实际当中,我们最好不要定义函数名相同的成员,因为很容易混淆,不利于我们辨别,易出错
派生类的六个默认构造
我们之前学习了,默认构造:我们自己不写,编译器默认生成。现在我们来学习一下关于派生类的默认构造是怎么运作的?
首先,先明确有那六个默认构造:
1.构造函数完成初始化
2.析构函数完成释放空间
3.拷贝构造:使用同类对象初始化创建对象。
4.赋值重载operator=:一个对象赋值给另一个对象。
5.普通对象取地址
6.const 对象取地址
1.派生类的构造函数:先去调用基类的构造函数(初始化基类的那一部分成员),再去调用派生类的构造函数 。如果基类没有默认的构造函数,则需要在派生类的构造函数的初始化列表中显示调用。
2.派生类析构函数:先去调用派生类的析构函数,清理,然后再去基类的析构函数去释放。
3.派生类的拷贝构造:必须调用基类的拷贝构造来完成基类的拷贝构造。
4.派生类的赋值重载operator=:必须调用基类的operator=完成基类的复制。
5.派生类的析构函数:在调用完成后,会自动去调用基类的析构函数,来清理基类的成员,这样才能保证派生类对象先清理派生类成员再去清理基类的成员的顺序。
6.对与析构函数需要构成重写(后面多态的时候介绍),而重写的条件之一又是:函数名相同,这时候,编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
上述转化成代码理解:
按照上图的代码我们来测试它是否是向我们上面所说的运行顺序?
![]()
看到运行结果,我们确实我们看到,这跟我们上面所说的是一致的。
现在,我们单拎出来析构函数来分析:
~student(){// 由于后面多态的原因(具体后面讲),析构函数的函数名被// 特殊处理了,统一处理成destructor// 显示调用父类析构,无法保证先子后父// 所以子类析构函数完成就,自定调用父类析构,这样就保证了先子后父//person::~person();cout << *_pstr << endl;delete _ptr;}
当我们的析构函数显示调用是会发生什么呢?
我们发现它析构了六次,而实际上我们需要仅仅是3次。 我们知道多次调用析构函数会出现问题的。而当我们屏蔽掉变成这样时反而对了:
因此,这也证明了,析构函数不需要我们显示调用,它会自己去调用 ,为什么呢?
因为它要保证析构的顺序,显示调用父类析构,无法保证先子后父。
所以子类析构函数完成就,自动调用父类析构,这样就保证了先子后父。
为什么一定要保证先子后父呢?
因为你无法保证它后续的子那里是否还会调用父亲的成员,如上图,像上面,你没有保证先释放子,而先释放父,后面再使用到父的成员,你又释放了,就会成了越界了。
继承与友元
1.友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
证明对比:
继承与静态成员的关系
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。
我们来证明一下:下面说明了它们是同一个static成员。
继承的类型:
有:单继承,多继承,菱形继承
单继承:
一个子类只有一个直接父类时称这个继承关系为单继承
多继承:
1.一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:
1.菱形继承是多继承的一种特殊情况
但是,菱形继承会出现下面问题:
菱形继承有数据冗余和二义性的问题。在C的对象中D成员会有两份。
class D
{int num;
};class A:public D
{int a;int num;
};class B:public D
{int b;int num;
};
class C:public A,public B
{int c;int num;
};
画成图
那么,我们该怎么去解决这个问题呢?
这里我们就使用到虚拟继承来解决它。即再B和C继承的时候加上一个virtual
class D
{int num;
};class A:virtual public D
{int a;
};class B:virtual public D
{int b;
};
class C:public A,public B
{int c;
};
但是,需要注意的是:虚拟继承不要在其他地方去使用。
下图是它们在汇编的情况:可以看到它会存在一个虚基表(存找基类偏移量的表),它存的是距离D的偏移量。我们可以猜测它的过程:在虚基表计算num在对象中的地址,再随着地址去访问
这里是通过了A和B的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的D。
可能有人有疑问为什么C中A和B部分要去找属于自己的D?那么大家看看当下面的赋值发生时,c是不是要去找出A/B成员中的D才能赋值过去?
C c; A a=c; B b=c;
继承的总结:
1.多继承是C++语法复杂一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
2.组合与继承的区别:
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
- 组合:强调的是“有一个”(has - a)的关系。如上述例子中,汽车有一个发动机,组合更注重对象之间的整体与部分的关系,是一种相对松散的关系。
- 继承:体现的是“是一个”(is - a)的关系。例如,卡车是一种车辆,继承建立了严格的父子关系,子类是父类的一种特殊类型,具有更强的耦合性。
继承:派生类和基类间的依赖关系很强,耦合度高
组合:组合类之间没有很强的依赖关系,耦合度低
继承:内部细节可见,继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响
组合:内部细节不可见 ,优先使用对象组合有助于你保持每个类被封装。
继承允许你根据基类的实现来定义派生类的实现(白箱复用)
对象组合是类继承之外的另一种复用选择(黑箱复用)
实现多态的代码:
#include <iostream>
using namespace std;class Vehicle {
public:void drive() {cout << "车辆行驶" << endl;}
};class Truck : public Vehicle { // Truck类继承自Vehicle类
public:void load() {cout << "卡车装载货物" << endl;}
};int main() {Truck myTruck;myTruck.drive(); // 调用从Vehicle类继承的方法myTruck.load(); // 调用Truck类自身的方法return 0;
}
实现组合的代码:
#include <iostream>
using namespace std;class Engine {
public:void start() {cout << "发动机启动" << endl;}
};class Car {
private:Engine engine; // 将Engine类的对象作为Car类的成员变量
public:void startCar() {engine.start(); // 调用Engine类对象的方法}
};int main() {Car myCar;myCar.startCar();return 0;
}
那么,我们什么时候用组合,什么时候用继承?
组合:
1.对象有多种不同的行为组合
2.实现代码复用且避免强耦合性
3.支持动态变化。
多态:
1.表示“是一种”的关系
2.扩展和修改现有类的行为
3.实现多态性
好了,继承的知识点就 分析到这里了,希望你我共进步!
最后,到了我们本次鸡汤环节
下面文字与大家共勉!