目录
前言:面向对象三大特性是:封装、继承、多态,封装初阶的时候已经讲了,进阶开始讲解继承和多态和一些更复杂的结构,今天的篇章是讲解继承
继承的概念及定义
1.1 继承概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。
继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用
比如,一个学生管理系统,里面有不同的角色,如学生、老师、管理者等等,每个角色都要定义一个类:
classStudent{ string_name; string_tel; string_address; int_age; // ...string_stuID; // 学号}; classTeacher{ string_name; string_tel; string_address; int_age; // ...string_wordID; // 工号}; ...
不难发现其存在大量冗余的代码,有些信息是共有的,有些信息是每个角色独有的,要对这些共有的代码进行复用就要使用 “继承”,下面使用继承展示一下:
// 把大家共有的东西写进来classPerson{ public: string_name; string_tel; string_address; string_age; }; //Student类 继承了 Person类classStudent : publicPerson{ string_stuID; // 学号}; //Teacher类 继承了 Person类classTeacher : publicPerson{ string_wordID; // 工号};
Student 和 Teacher 类就是使用了继承,继承于 Person 类,下面开始进行讲解继承
1.2 继承定义
以上面的代码为例,Student类和 Teacher类继承了 Person类,Person类称为基类,也叫父类,而 Student类和 Teacher类 则称为派生类,也叫子类
要让一个子类进行继承父类,需要在子类的类的类名后加上冒号,并跟上继承方式和父类类名即可,比如上面的子类 Student
class Student : public Person
冒号右边的 public 是子类进行继承的继承方式,Person 则是父类的类名
初阶的时候已经学过,访问限定符有以下三种
- public(公有访问)
- protected(保护访问)
- private(私有访问)
public 修饰的成员变量,可以在类外面直接访问,protected 和 private 修饰的成员变量,不能在类外访问,但可以在类里面进行访问
继承方式也有三种:
- public(公有继承)
- protected(保护继承)
- private(私有继承)
这三个不仅能当访问限定符,也能当继承方式
1.3 继承基类成员访问方式的变化
基类当中被不同访问限定符修饰的成员,以不同的继承方式继承到派生类当中后,该成员最终在派生类当中的访问方式将会发生变化,如下图
(1)基类 private 成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它,如果进行访问会直接报错
测试代码
//基类classPerson{ private: string_name; }; //派生类classStudent : publicPerson{ public: voidPrint() { //在派生类当中访问基类的private成员,error!cout<<_name<<endl; } protected: string_stuID; // 学号};
(2)因此,基类的 private 成员在派生类中是不能被访问的,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就需要定义为 protected,由此可以看出,protected 限定符是因继承才出现的
(3)实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected> private
比如,基类的成员变量是 protected 访问,派生类进行继承时继承方式如果是 public 继承,那派生类对基继承,继承下来的成员变量的访问方式就是 protected,因为 public > protected> private,访问方式取小的那个(min),public > protected,min为protected
(4)使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式
//基类classPerson{ private: string_name; }; //派生类classStudent : Person//class不写访问方式,默认为 private,但建议还是写出{ protected: string_stuID; // 学号};
(5)在实际运用中一般使用都是 public 继承,几乎很少使用 protetced/private 继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强
所以常用的就两个,其他基本不会用到,大佬设计的时候设计过多了,没有考虑实际会用到的
二、基类和派生类对象赋值转换
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用
测试代码
//基类classPerson{ protected: string_name; string_age; string_sex; }; //派生类classStudent : publicPerson{ public: string_stuID; // 学号}; voidTest() { Students; // 1.子类对象可以赋值给父类对象/指针/引用Personp=s; //派生类对象赋值给基类对象Person*pp=&s; //派生类对象赋值给基类指针Person&rp=s; //派生类对象赋值给基类引用}
这里有个形象的说法叫切片或者切割,寓意把派生类中父类那部分切来赋值过去
派生类对象赋值给基类对象
派生类对象赋值给基类指针:
派生类对象赋值给基类引用;
注意:基类对象不能赋值给派生类对象
//基类classPerson{ protected: string_name; string_age; string_sex; }; //派生类classStudent : publicPerson{ public: string_stuID; // 学号}; voidTest() { //2.基类对象不能赋值给派生类对象Students; Personp; s=p;//error}
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用 RTTI(RunTime Type Information)的 dynamic_cast 来进行识别后进行安全转换。(这个后面再讲解,这里先了解一下)
测试代码,基类和派生类依旧是上面的
voidTest() { //3.基类的指针可以通过强制类型转换赋值给派生类的指针Students; Personp; Person*pp; pp=&s; Student*ps1= (Student*)pp; // 这种情况转换时可以的ps1->_stuID=10; pp=&p; Student*ps2= (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问ps1->_stuID=10; }
三、继承中的作用域
在继承体系中基类和派生类都有独立的作用域。子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。在子类成员函数中,可以使用 基类::基类成员 显示访问)
测试代码
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆classPerson{ protected: string_name="张三"; // 姓名int_num=111111; // 身份证号}; classStudent : publicPerson{ public: voidPrint() { cout<<"姓名: "<<_name<<endl; cout<<"学号: "<<_num<<endl; cout<<"身份证号 :"<<_num<<endl; } protected: int_num=222; // 学号}; voidTest() { Students1; s1.Print(); };
运行结果
如果要使用基类里面的 _num,可以使用 基类::基类成员 显示访问,修改代码:
下面看同名函数的隐藏
测试代码
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。classA{ public: voidfun() { cout<<"func()"<<endl; } }; classB : publicA{ public: voidfun(inti) { A::fun(); cout<<"func(int i)->"<<i<<endl; } }; voidTest() { Bb; b.fun(10); };
运行结果
如果 fun不传参数就会报错,因为B中的 fun和A中的 fun构成隐藏,无参的 fun 调不到
voidTest() { Bb; b.fun(); };
注意:需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏,所以在实际中在继承体系里面最好不要定义同名的成员
四、派生类的默认成员函数
默认成员函数,即我们不写编译器会自动生成的函数,类当中的默认成员函数有以下六个:
最后两个基本不使用,对于前四个(构造和析构,拷贝和赋值重载),普通类 默认生成的的四个成员函数:构造函数和析构函数对于内置类型不做处理,自定义类型则调用对应的构造函数和析构函数;
拷贝构造函数和赋值重载函数对于内置类型进行浅拷贝(值拷贝),对于自定义类型则调用对于的拷贝构造和赋值重载函数
对于派生类,除了内置类型和自定义类型外,还多了基类对象,对于基类对象则调用基类对应的函数完成初始化、清理、拷贝
派生类当中的默认成员函数,与普通类的默认成员函数的不同之处:
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用
- 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化
- 派生类的 operator= 必须要调用基类的 operator= 完成基类的复制
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序
- 派生类对象初始化先调用基类构造再调派生类构造
- 派生类对象析构清理先调用派生类析构再调基类的析构
- 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系
(1)对于派生类的构造函数
//基类classPerson{ public: //构造函数Person(conststring&name="peter") :_name(name) { cout<<"Person()"<<endl; } private: string_name; //姓名}; //派生类classStudent : publicPerson{ public: //构造函数Student(conststring&name, intid) :Person(name) //调用基类的构造函数初始化基类的那一部分成员 , _id(id) //初始化派生类的成员 { cout<<"Student()"<<endl; } private: int_id; //学号}; intmain() { Students("zhangsan", 1111); return0; }
(2)对于派生类的拷贝构造函数
//基类classPerson{ public: //构造函数Person(conststring&name="peter") :_name(name) {} //拷贝构造函数Person(constPerson&p) :_name(p._name) { cout<<"Person(const Person& p)"<<endl; } private: string_name; //姓名}; //派生类classStudent : publicPerson{ public: //构造函数Student(conststring&name, intid) :Person(name) , _id(id) {} //拷贝构造函数Student(constStudent&s) :Person(s) //调用基类的拷贝构造函数完成基类成员的拷贝构造 , _id(s._id) //拷贝构造派生类的成员 { cout<<"Student(const Student& s)"<<endl; } private: int_id; //学号}; intmain() { Students("zhangsan", 1111); Students2(s); return0; }
(3)对于派生类的赋值重载函数
//基类classPerson{ public: //构造函数Person(conststring&name="peter") :_name(name) {} //赋值运算符重载函数Person&operator=(constPerson&p) { cout<<"Person& operator=(const Person& p)"<<endl; if (this!=&p) { _name=p._name; } return*this; } private: string_name; //姓名}; //派生类classStudent : publicPerson{ public: //构造函数Student(conststring&name, intid) :Person(name) , _id(id) {} //赋值运算符重载函数Student&operator=(constStudent&s) { cout<<"Student& operator=(const Student& s)"<<endl; if (this!=&s) { Person::operator=(s); //调用基类的operator=完成基类成员的赋值_id=s._id; //完成派生类成员的赋值 } return*this; } private: int_id; //学号}; intmain() { Students1("zhangsan", 1111); Students2("xioahong", 2222); s2=s1; return0; }
(4)对于派生类的析构函数,派生类析构先子后父,派生类对象的析构清理是先调用派生类析构再调基类析构。派生类析构函数完成后会自动调用基类的析构函数,所以不需要我们显式调用
//基类classPerson{ public: //构造函数Person(conststring&name="peter") :_name(name) {} //析构函数~Person() { cout<<"~Person()"<<endl; } private: string_name; //姓名}; //派生类classStudent : publicPerson{ public: //构造函数Student(conststring&name, intid) :Person(name) , _id(id) {} //析构函数~Student() { cout<<"~Student()"<<endl; //派生类的析构函数会在被调用完成后自动调用基类的析构函数 } private: int_id; //学号}; intmain() { Students1("zhangsan", 1111); return0; }
五、继承与友元
友元关系不能继承,也就是说基类的友元可以访问基类的私有和保护成员,但是不能访问派生类的私有和保护成员
classStudent; classPerson{ public: //声明Display是Person的友元friendvoidDisplay(constPerson&p, constStudent&s); protected: string_name; //姓名}; classStudent : publicPerson{ protected: int_id; //学号}; voidDisplay(constPerson&p, constStudent&s) { cout<<p._name<<endl; //可以访问cout<<s._id<<endl; //无法访问} intmain() { Personp; Students; Display(p, s); return0; }
六、继承与静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例
测试代码
//基类classPerson{ public: Person() { ++_count; } protected: string_name; // 姓名public: staticint_count; // 统计人的个数。}; intPerson::_count=0;//静态成员变量在类外进行初始化//派生类classStudent : publicPerson{ protected: int_stuNum; // 学号}; //派生类classGraduate : publicStudent{ protected: string_seminarCourse; // 研究科目}; voidTestPerson() { Students1; Students2; Students3; Graduates4; cout<<" 人数 :"<<Person::_count<<endl;//4Student::_count=0; cout<<" 人数 :"<<Person::_count<<endl;//0} intmain() { TestPerson(); return0; }
七、菱形继承及菱形虚拟继承
7.1 继承的分类
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况
多继承本身没有问题,但多继承形成的菱形继承就有问题,从上面的菱形继承的模型构造就可以看出,菱形继承的继承方式存在数据冗余和二义性的问题
例如,对于上面菱形继承的模型,对于菱形继承 Assistant类,实例化出一个对象后,访问成员时就会出现二义性问题
//基类classPerson{ public: string_name; // 姓名}; //派生类classStudent : publicPerson{ protected: int_num; //学号}; //派生类classTeacher : publicPerson{ protected: int_id; // 职工编号}; //菱形继承classAssistant : publicStudent, publicTeacher{ protected: string_majorCourse; // 主修课程}; voidTest() { // 这样会有二义性无法明确知道访问的是哪一个Assistanta; a._name="peter"; }
Assistant 对象是多继承的 Student 和 Teacher,而 Student 和 Teacher 当中都继承了 Person,因此 Student 和 Teacher 当中都有 _name 成员,若是直接访问 Assistant 对象的 _name 成员会出现访问不明确的报错
对于此,可以显示指定访问 Assistant 哪个父类的 _name 成员,二义性解决了,但是数据冗余无法解决
voidTest() { Assistanta; // 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决a.Student::_name="xxx"; a.Teacher::_name="yyy"; }
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份
7.2 菱形虚拟继承
为了解决菱形继承的问题,出现了菱形虚拟继承。虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在 Student 和 Teacher 的继承 Person 时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
菱形虚拟继承在继承方式前加 virtual 即可,注意加 virtual 的位置
测试代码
//基类classPerson{ public: string_name; // 姓名}; //派生类classStudent : virtualpublicPerson{ protected: int_num; //学号}; //派生类classTeacher : virtualpublicPerson{ protected: int_id; // 职工编号}; //菱形继承classAssistant : publicStudent, publicTeacher{ protected: string_majorCourse; // 主修课程}; voidTest() { Assistanta; a._name="peter";//编译通过}
7.3 菱形虚拟继承原理
在此之前,我们先看虚拟继承,有A、B、C、D四个类,B、C继承A,D继承B、C,也就是菱形继承
测试的代码
//基类classA{ public: int_a; }; //派生类classB : publicA{ public: int_b; }; //派生类classC : publicA{ public: int_c; }; //菱形继承classD : publicB, publicC{ public: int_d; }; intmain() { Dd; d.B::_a=1; d.C::_a=2; d._b=3; d._c=4; d._d=5; return0; }
进行调试,通过内存窗口查看,注意这里不要通过监视窗口查看,监视窗口被编译器优化过了,不好看
查看如下
从内存窗口可以看出 d对象的分布情况 ,D类对象 d当中各个成员在内存当中的分布情况如下:
通过这里就可以看出为什么菱形继承导致了数据冗余和二义性,根本原因就是 D类对象当中含有两个 _a 成员
下面,看菱形虚拟继承
测试代码
//基类classA{ public: int_a; }; //派生类classB : virtualpublicA{ public: int_b; }; //派生类classC : virtualpublicA{ public: int_c; }; //菱形继承classD : publicB, publicC{ public: int_d; }; intmain() { Dd; d.B::_a=1; d.C::_a=2; d._b=3; d._c=4; d._d=5; return0; }
调试内存窗口查看
其中D类对象当中的 _a 成员被放到了最后,而在原来存放两个 _a 成员的位置变成了两个指针,这两个指针叫虚基表指针,它们分别指向一个虚基表。虚基表中存的偏移量,通过偏移量可以找到下面的A
虚基表中包含两个数据,第一个数据(全为0的)是为多态的虚表预留的存偏移量的位置(暂时不理会),第二个数据就是当前类对象位置距离公共虚基类的偏移量(由于VS是小端,地址需要成对的倒着读,比如 14 00 00 00 ,读的话就是0x00 00 00 14,十进制就是20)
也就是说,这两个指针经过一系列的计算,最终都可以找到成员 _a
八、继承总结
很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承,否则在复杂度及性能上都有问题。
多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java
继承和组合
- public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象
- 组合是一种 has-a 的关系。假设B组合了A,每个B对象中都有一个A对象
- 优先使用对象组合,而不是类继承
例如,车类和宝马类就是 is-a 的关系,它们之间适合使用继承
classCar{ protected: string_colour; //颜色string_num; //车牌号}; classBMW : publicCar{ public: voidDrive() { cout<<"this is BMW"<<endl; } };
而车和轮胎之间就是 has-a 的关系,它们之间则适合使用组合
classTire{ protected: string_brand; //品牌size_t_size; //尺寸}; classCar{ protected: string_colour; //颜色string_num; //车牌号Tire_t; //轮胎};
若是两个类之间既可以看作is-a的关系,又可以看作has-a的关系,则优先使用组合
原因:
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。
- 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
- 实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合
笔试面试题
上面已有解释,不再解释
----------------我是分割线---------------
文章到这里就结束了,下一篇即将更新