[知识点] 1.5.2 继承与多态
总目录 > 1 语言基础 > 1.5 C++ 进阶 > 1.5.2 继承与多态
前言
继承是面向对象程序设计语言的第三大特性,重要性次于之前介绍了的抽象与封装,其作用主要是软件复用,降低代码冗余量,大幅缩短软件开发周期。
子目录列表
1、继承概念
2、protected 属性
3、继承方式
4、派生类与基类
5、多继承
6、虚拟继承
7、多态
8、虚函数
9、纯虚函数和抽象类
1.5.2 继承与多态
1、继承概念
面向对象程序设计的目的是尽可能复现现实生活中的各种事物与之间的关系,而与继承最相对应的,可能是 —— 遗传。
生物界的遗传学说提出后代能够传承前代的特征和行为,面向对象程序设计语言提出继承一说,表示可以基于一个已有的类创建新类,且新类拥有已有类的所有功能,已有类称为基类(父类),新类称为派生类(子类)。
遗传不一定是代代完全照搬,继承亦非,派生类可以在基类的基础上对其功能进行扩充、修改或重定义。
然而,继承的适用范围绝不仅仅是家族图谱,任何存在上下级且下级为上级的真子集的关系,均可以视作继承层次结构,比如:
如果将这些类均附加上继承关系,那么 People 就是 Teacher, Student 的(直接)基类,Teacher 就是 Professor, Assistant 的(直接)基类,等等。
如果是非直接继承关系,则基类称为间接基类。
抽象和封装是维护每个类个体的属性,而继承则是维护类与类之间的关系,总而言之最终目的都是提高程序编写和软件复用的效率。
2、protected 属性
在 1.5.1 类与对象 中,已经大致介绍了三种访问说明符中的 public 和 private,而 protected 和继承有着密切关系,所以在这里单独介绍。
简单地说,protected 是一种允许派生类直接访问的权限,不同于 public 的地方在于不能被外部函数访问,不同于 private 的地方在于允许派生类访问。也就是说,如果一个类不被任何派生类继承,权限设置为 protected 或者 private 是等价的。
3、继承方式
继承本身也由访问说明符来确定权限,分为公有继承(public)、保护继承(protected)和私有继承(private)。
继承的一般形式为(以 class 为例):
class 派生类名: [继承方式] 基类名 { ... };
继承方式可以为上述三种访问说明符,可以缺省,class 默认为 private(struct 默认为 public)。
如果是公有继承,基类成员的访问权限在派生类全部保持不变;
如果是保护继承,基类中的 public 成员访问权限在派生类变成 protected;
如果是私有继承,基类成员的访问权限全部变成 private。
举个例子:
class Student { string name; public: int id; protected: int grade; }; class Graduate: private Student { ... };
这种情况下,基类 Student 的 name, id, grade 被 Graduate 继承后全部变成 private 属性。
可以在统一访问权限后对个别成员的权限进行修改,在派生类再次声明基类的成员,格式为:
using 基类名 :: 基类成员;
允许在 private 继承下将成员权限更改为 protected 或 public,或者 protected 继承下更改为 public。举例:
class Graduate: private Student { public: using Student :: grade; using Student :: id; };
原本都被更改为 private 属性的 grade, id 被单独声明后被再次更改为 public 属性。注意,在基类是 private 属性的并不能被更改权限。
4、派生类对基类的扩展
派生类继承基类的绝大多数功能,除开:
> 构造函数(C++ 11 后被允许)
> 析构函数
> 友元函数
> 静态成员
继承的基础上,派生类可以增加属于自己的成员,可以重载从基类继承而来的成员函数,改变基类成员的访问权限(上面已有介绍),统称为派生类对基类的扩展。
① 访问方式
先给出一段代码:
1 class A() { 2 int x; 3 public: 4 void f() { 5 cout << "A.f"; 6 } 7 void g() { 8 cout << "A.g"; 9 } 10 protected: 11 int y; 12 }; 13 14 class B(): public A { 15 public: 16 void f() { 17 cout << "B"; 18 } 19 void y() { 20 cout << y; 21 } 22 }; 23 24 B b; 25 b.g(); 26 b.A :: f();
派生类可以直接访问基类的 public 和 protected 成员,访问方式一般分为:
> 通过派生类对象直接访问(代码 L25)
> 在派生类成员函数中直接访问(代码 L19 ~ L21)
> 通过基类名访问被覆盖成员(代码 L26,关于覆盖下面会有介绍)
② 函数重定义(覆盖)
从基类继承而来的成员函数可以被重定义(或者称作覆盖),指派生类可以定义与基类具有相同函数原型(返回类型、函数名和参数表)而内容不同的成员函数。比如上面有提到的部分:
class A() { void f() { cout << "A"; } }; class B: public A { void f() { cout << "B"; } };
派生类 B 重定义了成员函数 f(),那么在调用 B 中的 f() 时,A.f() 就会被覆盖而不执行,即只输出 “B”。
③ 构造函数与析构函数
因为派生类可以自己定义新的数据成员,它们同样可以通过构造函数进行初始化。与无继承关系的类不同,派生类必须使用初始化列表为基类或对象成员进行初始化,一般形式为:
派生类名(参数表): 基类名(基类参数初值), 成员1(初值1), ... {
...
}
举个例子:
class A() { int x, y; public: A(int _x, int _y): x(_x), y(_y) {} }; class B(): public A { int z; public: B(int _x, int _y, int _z): A(_x, _y), z(_z) {} };
如果基类本身存在构造函数,则编译器不会为其合成默认构造函数,同样也不会为派生类合成,所以即使派生类没有新增数据成员,也需要显式定义构造函数;反之,如果基类有默认构造函数(无参构造函数+全部参数都有默认值的构造函数),则可以不定义。
如果存在多层次继承,即假设类 C 具有直接基类 B 和间接基类 A,那么每个派生类都只需要负责其直接基类的构造。
构造函数的调用顺序为:基类构造函数 -> 对象成员构造函数 -> 派生类构造函数
5、多继承
① 多继承概念
前面我们介绍的继承,全部都是单继承。但其实,现实生活中的继承关系不仅仅是单继承,而可能是多继承。
多继承,即允许一个类从多个基类派生而来。并不是所有面向对象程序设计语言都支持多继承,比如 Java;但 C++ 是支持的。其一般格式为:
class 派生类名: [继承方式1] 基类名1, [继承方式2] 基类名2, ... { ... };
举个例子:
class A { public: void f() { cout << "A" << endl; } }; class B { public: void f() { cout << "B" << endl; } }; class C: public A, public B { ... };
类 C 继承于类 A 和类 B。
既然是多继承,那么类比于单继承,这个派生类也就能够继承多个基类的成员,并可以进行扩展,但其实不难想到这个过程中,会出现许多单继承不会出现的问题,比如上述代码中,我们发现 C 的直接基类 A, B 均有一个名为 f() 的成员函数,假设我们有如下访问:
C c;
c.f();
因为 C 本身没有该成员函数,表示并未对其进行扩展,根据继承关系,我们找到的是直接基类的 A :: f() 和 B :: f(),但两者并无优先级高低之分,那么这个 c.f() 到底是调用哪一个呢?你也许知道,但编译器不知道,这就产生了多继承下成员的二义性,那么解决这个问题的方法只有在成员前加上基类名,比如 c. A :: f() 或者 c. B :: f()。
② 虚拟继承
再来看一段代码:
class A { public: void f() { cout << "A" << endl; } }; class B: public A { public: ... }; class C: public A { public: ... }; class D: public B, public C { ... }; D d; d.f();
代码中,同时出现多重派生和多继承。这个时候,d.f() 看起来似乎不会出现二义性 —— 因为不论是继承于 B 还是 C,它们均继承于 A,且均未重定义 f(),即只可能执行间接基类 A 的 f(),但实际上编译器并不会这样处理。
首先,尽管我们在思考或者编写时,很清楚 A, B, C, D 之间的关系,如下左图,但实际上对于编译器而言,其关系如下右图:
也就是说,每一次继承都是基类的一次拷贝,尽管它们是继承于同一个基类。这显然是不够符合逻辑的,d.f() 出现了二义性,就像问他的同属于一个上司的两个主管 “上司是谁?”,两个主管却不能给出明确的答案一样。这时候,虚拟继承就派上了用场。
虚拟继承在普通继承的基础上加上限定词 virtual 即可,以上面代码举例:
class B: virtual public A { public: ... }; class C: virtual public A { public: ... };
这时,我们称 A 为 B, C 的虚(拟)基类。和非虚基类相比较,虚基类的使用需要注意:
> 虚基类构造函数调用优先于非虚基类;
> 虚基类由最终派生类初始化,而非虚基类的构造函数只负责其直接基类初始化。
③ 多继承的缺点
前面已经提到,多继承在 C++ 中可以实现,但诸多语言是不允许的,而且可能会在它们的语言特性中引以为傲地提到这一点,那么多继承招谁惹谁了?为什么不支持反而成了它们口中的优点呢?
以 Java 为例,它不支持多继承,一种合理的解释是,Java 认为:一个事物的本质只能有一个。苹果梨必须是有苹果属性的梨子,或者是有梨子属性的苹果;学生职工必须理解为正在任职的学生,或者有学生身份的职工。对于非本质的属性,Java 提出了 Interface(接口)这个概念来实现,以避免上述的二义性出现,也就不存在虚基类一说了。
7、多态
多态(Polymorphism),面向对象程序设计语言第四大特征,是指不同对象收到相同消息会执行不同的操作,通俗地讲,就是用一个相同的名称定义多个不同的函数,并针对不同数据类型实现相同或相似的功能,即所谓的 “一个接口,多种实现”。之前我们已经在 1.2 C 语言数据类型 介绍过函数重载,它就是属于广义上多态的一类。
广义上来讲,多态分别如下三类:
① 重载多态
包括函数重载和运算符重载(关于运算符重载请参见 1.5.3 运算符重载),很好理解,这里也不做过多介绍;
② 模板多态
关于模板请参见 1.5.4 模板与 STL;
③ 继承多态
通过基类对象的指针或引用,调用不同派生类对象的重定义同名成员函数。
如下提到的多态,或者说在以面向对象为主题的语境下的多态,通常指第 ③ 类。
多态的意义是什么?通过多态,基类表达 “做什么”,而派生类体现 “怎么做”,从另一个角度将接口与实现分离开来,这种特征对于软件开发和维护而言意义重大,开发者可以在没确定某些具体功能如何实现的情况下完成系统性的开发,总的而言,多态使程序变得灵活,可扩充性和可替换性更强。
8、虚函数
bebe 家养了一只狗狗、一只猫猫和一只猪猪(?,它们在家里吵死了,bebe 想给它们的叫声进行管理。三者同属于动物,那么我们先创建一个 Animal 类,然后创建三个继承于 Animal 的 Dog, Cat, Pig 类,三个类均有成员函数 sound() 表示叫。现在问题来了:作为三个类的基类 Animal,当然也是有声音的,如果也定义了 sound(),它们的声音并不是确定的,并不能清楚地表达;如果不定义,而只是三个派生类自己定义的独立函数,结果上没问题,但并不符合面向对象程序设计的思想。
虚函数,用来解决存在而难以表达的抽象概念。虚函数和虚拟继承是同一个虚,均使用的是 virtual 限定词,即:
class X { virtual 返回类型 函数名(参数表) { ... } ... };
我们将上述内容以代码形式体现出来:
1 class Animal { 2 public: 3 virtual void sound() { 4 cout << "N/A" << endl; 5 } 6 }; 7 8 class Cat: public Animal { 9 public: 10 void sound() { 11 cout << "Miao~" << endl; 12 } 13 }; 14 15 class Dog: public Animal { 16 public: 17 void sound() { 18 cout << "Wang!" << endl; 19 } 20 }; 21 22 class Pig: public Animal { 23 public: 24 void sound() { 25 cout << "Oink." << endl; 26 } 27 };
加上 virtual 后,基类的 sound() 其实就只是起一个形式化的作用了,“N / A” 一般不会被输出。
多态是指当基类指针(或引用)绑定到派生类对象时,通过此指针(引用)调用基类的成员函数时,实际上调用的是派生类覆盖的版本。我们试着开始调用:
1 int main() { 2 Cat cat; 3 Dog dog; 4 Pig pig; 5 Animal *p = &cat; 6 p->sound(); 7 p = &dog, p->sound(); 8 p = &pig, p->sound(); 9 /* 10 Animal *p = new Cat; 11 p->sound(); 12 p = new Dog; 13 p->sound(); 14 p = new Pig; 15 p->sound(); 16 */ 17 return 0; 18 }
p 是 Animal 的指针,当其指向派生类对象时,直接调用对应派生类的成员函数,即多态。最终狗狗、猫猫和猪猪将依次叫一声(先后输出 Miao~ Wang! Oink.);
注释内的 p 是 Animal 的引用,效果是一样的;
把基类绑定在派生类对象时,也可以实现多态,这也是体现得更多的一种形式,将指针或引用作为函数参数来调用,比如:
void Sound(Animal &a) { a.sound(); } Sound(dog); Sound(cat); Sound(pig);
输出同上。
与普通成员函数不同的是:
① 派生类必须覆盖基类的虚函数,这一点是显而易见的;
② 虚函数必须被定义;
③ 虚函数一旦被声明,其所在类的所有派生类内的该函数全部为虚函数(也就是说在派生类内是否加上 virtual 限定词都没有区别)。
还有,构造函数不能被定义为虚函数,但析构函数可以;并且如果类中存在虚函数,析构函数也需要定义为虚析构函数,以防止内存未全部被回收。
9、纯虚函数和抽象类
类与对象往往密不可分,但也非必然。有些类被定义出来的目的,并不是为了建立它的对象,而是为了表达某种概念,这样的类我们称之为抽象类。
比如上述的 Animal 类,我们并没有建立该类的对象,它只是为了将狗狗、猫猫和猪猪都归为 Animal 这一类。但如果以上述代码定义该类,它仍然不是抽象类,因为抽象类的充要条件是 —— 至少有一个纯虚函数。
纯虚函数是虚函数的一种,它在声明时被初始化为 0,一般格式如下:
class X { virtual 返回类型 函数名(参数表) = 0; ... };
抽象类必须为基类,故又被称为抽象基类;如果其派生类只是简单继承了纯虚函数,则派生类也是抽象类。
那么,已经有虚函数这个概念的前提下,纯虚函数存在的意义是什么?上面我们也提到,虚函数必须被定义,而不难发现其实对于 Animal 基类来说,sound() 根本没有必要被定义,而使用纯虚函数则显然更符合规范,而这个时候,Animal 也确切地成为了一个抽象类。