本文已收录至《C++语言和高级数据结构》专栏!
作者:ARMCSKGT
面向对象的三大特性:封装,继承和多态,前面我们介绍了类和对象,如何对数据和数据操作方法进行封装,本章将为大家介绍另一大特性-继承,继承可以增强代码的复用性和增强功能的可扩展性,我们将学习如何通过父类衍生出更多特性的子类!
这里,水果类作为父类,衍射出三种水果,苹果,西瓜和荔枝,这三种具体的水果都可以统称为水果,但是各自又有着自己的特性,例如苹果外皮是红色的,西瓜外皮是绿色的等等,这就是继承,继承将一个抽象的父类更加具体的实例表达!
继承的概念及定义
继承的概念
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
其中:
- 被继承的类是:父类/基类 (base)
- 继承的类称为:子类/派生类 (derived)
继承的本质就是复用代码,我们通过一个校园信息管理类代码来展示和说明:#include
#include using namespace std; >//基本人员信息类 class Person { public: string Name; //姓名 int Car_ID; //身份证号 string Date; //生日 long long telephone; //电话号码 }; //教师类 class Teacher : public Person { public: int Employee_ID; //教师员工号 string Subjects; //教师所教科目 double Wages; //工资 //... }; //学生类 class Student : public Person { public: int Student_ID; //学生学号 string Faculties; //学生所属院系 string Specialized; //学生专业 //... }; 我们在学校,最常见的两个身份就是老师和学生,老师和学生都有自己的姓名和身份证号等,但是老师有自己的员工号和任职科目,学生有自己的学号和专业信息,通过继承父类Person可以让老师和学生都拥有人的基本信息,然后在老师和学生类中增加不同身份所独有的特性,这样就不需要每个类都写上人员基本信息的变量条目了,实现了复用,当然函数后亦是如此!
继承的作用: 子类在继承父类后,会继承父类 公开(public)和保护(protected) 的成员,除了父类私有成员外,子类可以继承其余的所有成员!
结合访问限定符,可以让子类合理访问父类成员,互不干扰!
继承的定义
介绍完继承的定义相关知识,我们开始使用继承!
继承的格式如下:class 子类 : 继承方式 父类 { /*子类成员*/ };
关于访问限定符有三种:
- 公有(public):该成员可以被任意访问
- 保护(protected):该成员只能被本类和子类中访问
- 私有(private):该成员只能在本类中被访问
其中:保护在没有继承的情况下和private一样,外界无法访问,在有父子类的情况下才能有区别!
权限范围从大到小依次为: public > protected > private
访问限定符有三种,继承的方式则也有三种,继承方式与成员的访问限定符组合可以有多种情况:
访问/继承 权限 公有public 保护protected 私有private 父类public成员 仍然为public 变为protected 变为private 父类protected成员 仍然为protected 仍然为protected 权限为private 父类private成员 不可见 不可见 不可见 补充:
- 父类(基类)的私有成员在子类中始终不可见,但并不代表不在子类中,其在子类中仍然被继承,但是不可见!
- 父类(基类)的成员如果不想在类外被访问,但是可以被子类访问,则设置为protected,protected是专门为该场景设计的!
- 在成员的继承上,权限只能被缩小,不能被放大,即成员最终的权限是在成员本身的访问权限和继承权限中取最小权限即可!
- 在class继承时,如果我们不显示指定继承方式,则默认为private;在struct继承时,如果不显示指定继承方式,则默认为public;平时不建议省略继承方式!
- 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强!
重定义
重定义也叫隐藏(覆盖),是指子类继承父类后,子类重新声明的成员名字与父类相同,此时访问子类成员时默认访问的是子类的同名成员而隐藏父类成员(局部优先原则),同时也证明了父子类中的作用域是独立存在的!
class Base { public: void func() { cout << "Base num:" << num << endl; } //同名方法 void Bfunc() { cout << "Bfunc" << endl; } //非同名方法 int num = 1; //同名变量 char b = 'b'; //非同名变量 }; class Derived : public Base { public: void func() { cout << "Derived num:" << num << endl; } void Dfunc() { cout << "Dfunc" << endl; } int num = 2; char d = 'd'; }; int main() { Derived der; //声明子类对象 return 0; }
执行同名函数func:
此时我们发现,对于同名函数和同名变量,执行时访问子类的同名函数和变量!
如果我们删除子类的同名变量再执行:
此时执行的是子类函数访问的是父类的num变量!
访问各自的非同名成员:
对于非同名成员,访问是正常的!如果我们想要访问父类的隐藏成员,可以使用 作用域限定符 :: 指定父类成员进行显示访问!
int main() { Derived der; der.Base::func(); cout << der.Base::b << endl; return 0; }
总结:
- 在继承体系中基类和派生类都有独立的作用域。
- 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,
也叫重定义(在子类成员函数中,可以使用 基类::基类成员 显示访问)。- 如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
- 注意在实际中在继承体系里面最好不要定义同名的成员。
基类和派生类对象赋值转换
派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片
或者切割。寓意把派生类中父类那部分切来赋值过去。
基类对象不能赋值给派生类对象,子类是在基类的基础上进行拓展,将基类赋值给子类时需要编译器去推敲父类对象中需要增加什么,编译器不敢随便做这种事情(毕竟儿子不可能道反天罡)。
当基类的指针(引用)指向子类对象时,可以通过强制类型转换赋值给子类的指针(引用)。注意!这里必须是基类指针(引用)指向子类对象才是安全的!如果基类如果是多态类型,可以使用C++11中的安全的类型转换 dynamic_cast 进行赋值!
总结:
- 对于父类和子类之间的对象赋值会涉及类型转换,类似于double转int等。
- 子类可以赋给父类对象,其中会产生类型转换,所以会有临时变量。
- 但是父类不能赋值给子类(可以理解为子类的成员比父类多,父类无法判断),但是父类的指针可以指向子类。
- 子类赋值给父类需要公开继承,是天然支持的,不存在类型转换(子类对象是一个特殊的父类对象)。
class Person { protected: string _name; // 姓名 string _sex; // 性别 int _age; // 年龄 }; class Student : public Person { public: int _No; // 学号 }; int main() { Student sobj; // 1.子类对象可以赋值给父类对象/指针/引用 Person pobj = sobj; Person* pp = &sobj; Person& rp = sobj; //2.基类对象不能赋值给派生类对象 例如 sobj = pobj; // 3.基类的指针可以通过强制类型转换赋值给派生类的指针 pp = &sobj; Student * ps1 = (Student*)pp; // 这种情况转换时可以的。 ps1->_No = 10; pp = &pobj; Student* ps2 = (Student*)pp; // 这种情况转换时虽然可以,但是会存在越界访问的问题 ps2->_No = 10; return 0; }
派生类中的默认成员函数
前面我们介绍过,类的六大默认成员函数:
同样的,子类在继承父类后也会有这六个默认成员函数;但是子类不写类的六大默认函数则调用时使用父类的默认函数,如果实现了这六个默认成员函数则按照隐藏的规则,在调用时默认调用子类的这六个默认成员函数,对于父类的则需要显示调用,这里我们对这种情况进行分析!
隐式调用
子类在实例化调用构造函数时,会先调用父类的构造函数先初始化父类再初始化子类本身;同样的,子类在析构时也会先析构自己再调用父类的析构函数析构父类空间!
class Base { public: Base() { cout << "Base()" << endl; } ~Base() { cout << "~Base()" << endl; } }; class Derived : public Base { public: Derived() { cout << "Derived()" << endl; } ~Derived() { cout << "~Derived()" << endl; } }; int main() { Derived der; return 0; }
注意: 自动调用是由编译器完成的,如果不存在默认构造函数,那么需要我们手动在子类的构造函数中初始化父类对象,如果不处理则会报错!
显示调用
对于一些类,我们不妨会使用 赋值重载 和 拷贝构造 这里如果我们不对相关函数进行特殊处理,在发生拷贝时,子类无法调用父类的赋值重载和拷贝构造函数,父类部分极易容易发生浅拷贝!
class Base { public: Base() {} ~Base() {} Base(const Base& b) { cout << "Base(const Base& b)" << endl; } Base& operator=(const Base& b) { cout << "operator=(const Base& b)" << endl; return *this; } }; //子类中不对父类对象做特殊除了 class Derived : public Base { public: Derived() {} ~Derived() {} Derived(const Derived& d) { cout << "Derived(const Derived& d)" << endl; } Derived& operator=(const Derived& d) { cout << "operator=(const Derived& d)" << endl; return *this; } }; //子类中对父类进行特殊处理 //class Derived : public Base //{ //public: // Derived() {} // ~Derived() {} // Derived(const Derived& d):Base(d) //切片 构造父类对象 初始化父类 // { // cout << "Derived(const Derived& d)" << endl; // } // Derived& operator=(const Derived& d) // { // Base::operator=(d); //切片 构造父类对象 赋值给子类的父类部分 // cout << "operator=(const Derived& d)" << endl; // return *this; // } //}; int main() { Derived der1; Derived der2(der1); cout << "-----------------------------" << endl; der1 = der2; return 0; }
我们分别对特殊处理和不特殊处理的Derived类,在同一段main函数代码下测试,测试结果:
我们可以发现,在子类的成员函数中对父类隐藏的成员函数显示调用进行特殊处理,可以避免浅拷贝的问题,而且我们可以在子类中 通过 初始化列表 和 :: 显示调用父类 构造函数 和 默认成员函数父类的拷贝问题进行处理!
总结:
- 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认
的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。- 派生类的拷贝构造函数必须调用基类的拷贝构造在初始化列表完成基类的拷贝初始化。
- 派生类的operator=必须要调用基类的operator=完成基类的复制。
- 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能
保证派生类对象先清理派生类成员再清理基类成员的顺序。- 派生类对象初始化先调用基类构造再调派生类构造,析构清理先调用派生类析构再调基类的析构(父类总是先构造最后析构)。
- 对于析构函数,编译器会统一处理成 destructor 函数,对于父类的析构函数,我们不能显示调用,因为父类先构造,根据栈区规则必须先析构子类,此时因为类析构被统一处理为destructor,所以父类析构函数被隐藏,析构时只执行了子类析构造成父类内存空间不能被正常释放,此时我们需要对父类的析构函数进行重写(将父类析构使用virtual修饰成为虚函数)以满足析构要求,在多态中我们会重点介绍。
继承中的友元与静态成员
友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员!
静态成员
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例。
静态变量为于静态区,不同于普通的堆栈区,静态变量的声明周期很长,通常是程序运行结束后才会被销毁,因此 假设父类中存在一个静态变量,那么子类在继承后,可以共享此变量!class Base { public: void Bfunc() { cout << "Base a=" << a << endl; } static int a; }; class Derived : public Base { public: void Dfunc() { cout << "Derived a=" << a << endl; } }; int Base::a = 0; //int Derived::a = 1; //继承中的静态成员只能被指定一次初始化 int main() { Base b; Derived d; b.Bfunc(); Base::a = 1; d.Dfunc(); Derived::a = 2; b.Bfunc(); return 0; }
菱形继承
C++中,继承是可以单继承也可以多继承,单继承就是一个子类只继承一个父类,多继承就是一个子类继承多个父类!
关于多继承,即支持一个子类继承多个父类,使其基础信息更为丰富,但凡事都有双面性,多继承 在带来巨大便捷性的同时,也带来了个巨大的坑,即菱形继承问题!
因为多继承的弊端,其他大部分面向对象的语言都禁止多继承!
关于多继承,只需要使用 , 将继承的多个父类连接起来即可
class 子类 : 继承方式 父类1, 继承方式 父类2, .... { /*子类成员*/}
概念
菱形继承是多继承的一种特殊情况!
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。
在Assistant的对象中Person成员会有两份。
class Person { public: string _name; // 姓名 }; class Student : public Person { protected: int _num; //学号 }; class Teacher : public Person { protected: int _id; // 职工编号 }; class Assistant : public Student, public Teacher { protected: string _majorCourse; // 主修课程 }; int main() { // 这样会有二义性无法明确知道访问的是哪一个 Assistant a; a._name = "peter"; //此语句会报错 // 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决 a.Student::_name = "xxx"; a.Teacher::_name = "yyy"; return 0; }
我们注释掉这句错误访问代码,编译运行进行调试发现:
a对象中出现了两个 _name 变量,如果我们直接访问,编译器会提示不明确,因为编译器不知道我们要访问哪一个 _name 。
此时我们仍然要坚持访问 _name变量 只能通过 :: 指定访问域进行访问:
这种方法解决了访问的问题,但是数据冗余和二义性的问题仍然没有被解决!
我们在实际使用中不需要两个 _name ,我们想 _name 是唯一的,此时我们就需要借助虚继承(与虚函数没有任何关系)来解决这个问题,换句话说,虚继承就是专门为解决菱形继承而产生的!
虚继承方式:
class 子类 : virtual 继承方式 父类 { /*子类成员*/ }
这就是对父类的虚继承!
需要注意的是,虚拟继承不要在其他地方去使用。
我们只需要在多继承的腰部,也就是父类在继承父父类时添加virtual即可!class Person { public: string _name; // 姓名 }; class Student : virtual public Person { protected: int _num; //学号 }; class Teacher : virtual public Person { protected: int _id; // 职工编号 }; class Assistant : public Student, public Teacher { protected: string _majorCourse; // 主修课程 }; int main() { Assistant a; a._name = "peter"; //此时可以正常赋值 return 0; }
此时我们可以发现,我们可以通过a对象直接访问 _name ,且修改 _name 后 所有的_name成员都发生了变化,此时就解决了数据冗余和二义性的问题!
此时内存中的成员分布为:
为什么会是这样?接下来我们探究一下虚继承的原理!
虚继承原理
利用 虚基表 将冗余的数据存储起来,此时冗余的数据合并为一份,原来存储 冗余数据 的位置,现在用来存储 虚基表指针!
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口观察对象成
员的模型。class A { public: int _a; }; // class B : public A class B : virtual public A { public: int _b; }; // class C : public A class C : virtual public A { public: int _c; }; class D : public B, public C { public: int _d; }; int main() { D d; d.B::_a = 1; d.C::_a = 2; d._b = 3; d._c = 4; d._d = 5; return 0; }
下图是菱形继承的内存对象成员模型:这里可以看到数据冗余
B和C类中都有各自的A类成员!
下图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中将A放到的了对象组成的最下
面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指
向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量
可以找到下面的A。
此时无论这个 冗余 的数据存储在何处,都能通过 基地址 + 偏移量 的方式进行访问!
补充:
当我们使用父类指针指向子类时,会发生父子类赋值兼容问题,也就是切片,切出父类的那一部分,从首地址开始,但我们访问公共成员时,仍然是通过 基地址+偏移量 实现的!
当然我们也可以实例化B对象,让ptrb指向b对象:
我们发现,当不存在继承关系时,只要我们对父类进行了虚继承,其父类对象的存储发生就是在 原数据位存放虚表地址来获取偏移量,再通过偏移量找到父类的成员!
之所以这么做,就是因为对象指针ptrb可能指向子类类型的对象,也可能指向本类类型对象,此时如果我们要访问父类成员 _a ,一条语句 ptrb->_a 对于指向D对象和指向B对象的地址不同场景需要有不同的处理方式,为了统一操作,只要是虚继承,其父类对象的都放在对象后面,采用偏移量的方式去访问,只要简化了处理过程!
当编译器识别到需要通过 基地址+偏移量 去访问时,会在汇编指令中进行特殊处理,而不是按照原方式直接去访问对象中的成员!
如果虚继承的父类中其成员是另一个对象存在多个成员,在访问时编译器先通过偏移量找到该成员对象的首地址,然后通过成员对象的声明顺序去依次访问,该场景例如:class test { public: int a; int b; int c; }; //此时被虚继承的父类A中有一个对象t ! class Base { public: test t; }; class Derived : virtual public Base {}; //此时Derived实例的对象首地址仍然是存放Base对象中t对象所在偏移量 //访问时,通过int一个一个跳过即可(即使涉及内存对齐问题,编译器也会根据规则做出调整)
所以,无论最终位置在何处,最终汇编指令都一样(得益于偏移量的设计模式)!
总结:
- 虚继承底层是如何解决菱形继承问题的?
对于冗余的数据位,改存指针,该指针指向虚表,虚表中从首地址偏移4字节即为冗余的数据位偏移到数据地址的偏移量;对于冗余的成员,统一放置在后面,通过首地址和虚表中偏移量进行访问!- 为何在冗余处存指针?
方便统一访问同一个变量,解决二义性和数据冗余问题;同时,偏移量存放在虚表首地址偏移4字节处(一般为第二个条目),而首地址的四个字节是将来存放多态的虚表地址的!- 虚基表指针 和 虚基表 是否会造成空间浪费?
不会,指针大小固定为 4/8 字节;虚基表可以忽略不计,所有对象共享!为了解决 菱形继承 问题,想出了 虚继承 这种绝妙设计,但在实际使用中,要尽量避免出现 菱形继承 问题!
说明:在多继承中,关于多个父类谁先初始化与其声明顺序有关,例如在上面的D类:
class D : public B, public C { public: int _d; };
此时D类实例化会先初始化B再初始化C,最后初始化自己,所以初始化的顺序与继承时声明父类的顺序有关!
继承和组合
除了可以通过继承使用父类中的成员外,还可以通过 组合 的方式进行使用,前提是父类对象中的成员是public权限允许在类外访问或可以通过函数进行访问!
关于继承和组合:
- 公有继承:is-a —> 高耦合,可以直接使用父类成员
- 组合:has-a —> 低耦合,可以间接使用父类成员
实际项目中,更推荐使用 组合 的方式,这样可以做到 解耦,避免因父类的改动而直接影响到子类,不过,具体使用哪种方式还要取决于具体场景,具体问题具体分析!//父类 class A {}; //继承-直接使用 class B : public A {}; //组合 class C { private: A _oa; //创建 _oa 对象,使用成员及方法 }
继承的作用主要是为多态做准备,继承是多态不可或缺的一步!
以上就是关于C++继承的内容,本节我们介绍了面向对象三大特性之一的继承,介绍了什么是继承,怎么用,有那些问题等等;最后介绍了C++多继承中的问题菱形继承,使用虚继承解决了这个问题,虚继承的原理等,这些知识将为后面的多态进行铺垫,大家在学习后一定要动手实践,多多琢磨!
本次
如果文章中有瑕疵,还请各位大佬细心点评和留言,我将立即修补错误,谢谢!
其他文章阅读推荐
C++-CSDN博客
C++-CSDN博客
C++-CSDN博客
欢迎读者多多浏览多多支持!