在C++中关于对象的三大特性,封装,继承和多态,本篇将要开始对继承进行详细的讲解与解析其中的难点。不过我们首先也将对封装进行总结。然后介绍了继承的概念定义、赋值转换、派生类中的默认成员函数还介绍了菱形继承的底层解决方式,最后分析了组合和继承这两种复用关系的选择,如下:
目录
1. 封装特性总结
封装简单来说就是将数据和方法结合起来放入一个类里面,然后使用访问限定符做限定(把想访问的定义为公有,把不想访问的定义为私有/保护)。这只是简单的一层封装含义,封装还可以将一个类放入到另一个类中,通过 typedef 成员函数,封装另一个全新的类(比如在STL在实现queue和stack的时,封装的容器为deque)。
2. 继承
2.1 继承的概念
继承机制是面向对象程序设计使用代码可以复用的最重要的手段,允许程序猿在保证持原有类特性的基础上进行扩展,增加功能,这样产生新的类,叫做派生类(子类),原类叫做基类(父类)。继承呈现了面向对象程序设计的层次结构,是类设计层次的复用。如下的代码:
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; };
如上所示,我们将 Person 类继承给 Student 和 Teacher,将 Person 继承的原因是,不管我们写一个 Student 类还是一个 Teacher 类,都会在类中写出关于 Person 类中会有的信息,我们不如直接将其抽离出来,作为一个单独的类,然后在写其他类的时候,将其继承,就可以少些很多冗余的代码。
继承之后,父类的成员(成员函数 + 成员变量)都会变成子类的一部分。
2.2 继承的定义
继承定义的格式:派生类 : 继承方式(public、protected、private)基类。如下:
关于继承方式与基类中访问限定符的关系:
对于如上的继承方式和基类中访问限定符的关系,总结来说,继承方式决定基类成员的访问限定符上限(public继承,基类成员是什么限定符就是什么限定符,private继承,基类无论是什么限定符,都是private权限),绝大多数我们都是使用 public 继承方式。对于基类中的private成员,不管是什么样的继承,在派生类中都是不可见的。以下是对这张表的总结:
1. 基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2. 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
3. 基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。4. 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
5. 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。
2.3 基类和派生类的赋值转换——切片
派生类对象也可以赋值给基类对象/基类指针/基类的引用,主要依靠切片的方式,也就是将派生类中有关基类对象的部分切割出去,然后赋值给基类(对象/指针/引用)。(注:这种方式是单向的,只能单向赋值,基类不能赋值给派生类)。
只有public继承才可以赋值。
如下:
对于切片还存在赋值兼容,对于平时的不同类型之间的转换,都会产生一个临时变量,然后在赋值给新变量,但是对于派生类给基类赋值,则不会产生临时变量,而是直接的切片转换过去。
2.4 继承中的作用域
在继承体系中的基类和派生类都有独立的作用域。基类和派生类中的同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏(重定义,在子类成员函数中,若想访问,可以使用访问限定符进行访问:基类 :: 基类成员 访问时间)。
只需要成员名相同就可以构成隐藏(在实际的继承体系中最好不要定义同名的成员)。
如下:
class A { public: void fun() { cout << "func()" << endl; } }; class B : public A { public: void fun(int i) { A::fun(); cout << "func(int i)->" << i << endl; } };
如上所示的 fun 函数,构成隐藏,不是构成函数重载,函数重载只有在相同的作用域中才构成函数重载。
2.5 派生类中的默认成员函数
关于派生类中的默认成员函数,我们主要讨论构造函数和析构函数、拷贝构造和赋值运算符重载。
对于构造函数而言,我们在构造的时候需要考虑是自定义类型还是内置类型,但是我们同时还需要考虑继承过来的基类怎么构造。对于基类的构造,我们将其按照自定义类型处理即可,当做派生类中的成员变量中有一个基类的成员对象。当开始对派生类对象进行构造的时候,会在构造的过程中默认调用基类的构造函数,当基类中没有默认构造函数的时候,我们需要在构造派生类的构造函数中调用非默认构造函数,构造的形式有所区别,如下:
class Person { public: // 非默认构造函数 Person(const char* name) : _name(name) { cout << "Person()" << endl; } // 拷贝构造 Person(const Person& p) : _name(p._name) { cout<<"Person(const Person& p)" <<endl; } // 赋值运算符重载 Person& operator=(const Person& p) { cout << "Person operator=(const Person& p)" << endl; if (this != &p) _name = p._name; return *this; } protected: string _name; // 姓名 }; class Student : public Person { public: Student(const char* name, int num) : Person(name) // 使用这种方式进行调用构造函数 , _num(num) { cout << "Student()" << endl; } Student(const Student& s) : Person(s) // 调用拷贝构造,切片的方式 , _num(s._num) { cout << "Student(const Student& s)" << endl; } Student& operator = (const Student& s) { cout << "Student& operator= (const Student& s)" << endl; if (this != &s) { Person::operator = (s); _num = s._num; } return *this; } protected: int _num; //学号 };
关于拷贝构造函数的调用方式,如上,和构造函数的调用方式差不多,不过其主要实现的逻辑为赋值切片。对于运算符重载函数的调用,我们调用的时候需要使用作用域限定符限定基类的运算符重载函数,因为基类和派生类的赋值运算符的函数名一样。
对于析构函数而言,和前三个默认成员函数不同,前三个成员函数的调用为显示调用,而析构函数为自动调用,不需要在派生类中直接显示调用。因为对于派生类的析构函数调用顺序为,先调用派生类的析构函数,当析构函数调用结束的时候,自动调用基类的析构函数(若想要直接调用基类的析构函数,我们需要像调用赋值运算符重载函数一样,需要使用作用域限定符进行修饰调用,这是因为析构函数的函数名在底层处理的时候都会被处理为destructor,这个时候基类和派生类的函数名就相同了)。
调用构造函数的顺序是先调用基类的构造函数,然后在调用派生类的构造函数,调用析构函数的时候正好相反。
2.6 友元与继承
友元关系不能被继承,也就是说基类友元不能访问子类私有和保护成员,子类的友元也不能访问基类的私有和保护成员。如下:
如上所示,基类中的友元并不能访问派生类中的保护和私有成员,想要让其访问,只能是在派生类中也声明友元。
2.7 继承与静态成员
基类定义了 static 静态成员,则整个继承体系里面都会有一个这样的静态成员(这个静态成员都是同一个),无论派生出多少个子类,都只有一个 static 成员实例。如下:
如上所示,在不同类之中的静态成员的值和地址都是一样的。
2.8 菱形继承和菱形虚拟继承
在继承中存在着单继承和多继承。如下为单继承和多继承的关系:
如上所示的两种方式就是单继承和多继承,对于如上的继承方式,多继承会导致一个菱形继承的问题。菱形继承的问题:存在数据冗余和二义性的问题,如上的 Assistant 的对象中 Person 会成员会有两份。如下:
如上所示,当我们直接通过 Assistant 直接访问 Person 中成员时,会报错,出现二义性,不知道调用哪一个成员中的 Person 变量,若想要解决访问 Person 中的成员,需要显示指定访问哪个父类的成员。
关于菱形继承的解决方法为:虚继承。虚继承就是需要在继承的菱形中间位置的前加一个 virtual 修饰,如下:
class A { public: int _a; }; class B : virtual public A { public: int _b; }; class C : virtual public A { public: int _c; }; class D : public C, public B { public: int _d; };
如上所示的虚继承就可以解决菱形问题,现在我们通过调试来查看内存关于 D 对象中值的变化,如下:
如上所示,当我们使用虚继承,调用内存进行监视的时候,我们会发现 A 中的变量不管是由 d 调用还是 B 或者 C 调用,变换的都始终只是一个 _a 的值,并且该值还被放在了 D 对象中的最低处,在 B 和 C 中不仅仅存在 _b _c 的值,各自还存在一个地址,让我们来看看这两个地址:
如上图所示,其中对于 D 对象中 A 的值只有一块区域,当我们将 B C 中的地址打开的时候,我们发现其中只有两个数据,第一个数据代表 A 中 _a 的数据,第二个数据代表偏移量,也就是从当前位置到 D 中 A 变量的偏移量。为什么需要使用一个偏移量来解决这样的一个问题呢?这是因为当 A 中含有很多的变量的时候,在 B C 出如果展开那么会占很多的空间,当我们使用偏移量的时候,就可以很好的解决该问题,并且还可以解决切片的问题,当我们使用子类对基类赋值的时候(如将D赋值给B),会产生切片,那么我们这个时候需要将B的部分切出来,同时还需要将A拿出来,这时候就可以通过偏移量来计算找到A。
关于菱形继承并不是真的就是一个菱形,而是继承下来的类中使用了公共基类。关于虚继承下来的非菱形继承的类,其内部结构也会和菱形继承的解决方法一直,在存储空间中也存在一个一个地址,如下:
如上所示,只要存在虚继承,那么就会在内存中给出一个指针,用于记录偏移量。
2.9 继承和组合的选择
组合和继承都是一种复用,组合是将一个类在另一个类中实例化出一个对象,在该类中使用这个对象,就可以达到调用其他类中的方法。那么关于组合和继承这两种复用方式,我们在实际运用中如何选择呢?
先给出这两个复用的区别:
继承:允许根据基类的实现来定义派生类的实现,通过生成派生类的复用通常被称为白箱复用,也就是基类的内部细节对子类可见。一定程度上的破坏了封装,基类的改变,对派生类有很大的影响,派生类和基类之间的依赖关系很强,耦合度高。
组合:对象组合要求被组合的对象具有良好定义的接口,这种复用方式被称为黑箱复用,因为对象的内部细节是不可兼得。组合类之间没有很强的关联性,耦合度低,优先使用对象组合有助于保持每个类被封装。
关于以上两种复用方式的选择,看两个对象之间的关系,若关系是 is - a 的关系(学生 — 人 狗 — 动物)就选择使用继承关系,若关系是 has - a 关系(汽车 — 轮胎)关系,选择使用组合关系,若两种类型都存在,那么尽量选择组合的方式,遵循软件开发的低耦合、高内聚原则。