继承
在面向对象的三大特性中,封装(Encapsulation)是面向对象程序设计最基本的特性,把数据(属性)和函数(方法,操作)合成一个整体,这在计算机世界中是用类与对象实现的。继承和派生一体两面,**继承(inheritance)**是类型层次结构设计中实现代码复用的重要手段。派生是保持原有类特性的基础上进行扩展,增加新属性和新方法,从而产生新的类型。在面向对象程序设计中,继承和派生是构造出新类型的过程。呈现类型设计的层次结构,体现了程序设计人员对现实世界由简单到复杂的认识过程。
多态(Polymorphism)则是同一个接口(方法)在不同子类对象上会表现出不同的行为。分为编译时多态(如函数重载、运算符重载)和运行时多态(如通过虚函数实现的动态绑定),让程序更灵活、易于扩展。
继承的概念与定义
C++ 通过类派生(class derivation)的机制来支持继承。被继承的类称为基类(base class)或超类(superclass),新产生的类为派生类(derived class)或子类(subclass)。基类和派生类的集合称作类继承层次结构(hierarchy)。
层次概念是计算机的重要概念
由基类派生出,派生类的使用如下:
1 | class 派生类名 : 访问限定符 基类名 |
在C++的继承机制中,访问限定符扮演着至关重要的角色,它直接决定了基类成员在派生类中的访问权限。常见的访问限定符有public、protected和private三种,不同的选择会塑造出完全不同的继承关系。
- public继承:基类的public成员和protected成员在派生类中会保持原有的访问权限,这意味着基类的public成员在派生类中仍然可以被外部代码直接访问,而protected成员则只能在派生类内部及其子类中被访问。这种方式是最常用的继承模式,因为它既保留了基类的接口特性,又允许派生类在其基础上自由扩展功能,很好地体现了“is-a”的类属关系:如学生类继承自人类,则表示学生是一个人,但不能反过来说人是学生。
- protected继承:基类的public成员和protected成员在派生类中都会被调整为protected成员。此时,这些成员只能在派生类内部及其子类中访问,外部代码无法通过派生类的对象直接接触到它们。这种继承方式适用于希望将基类的接口隐藏起来,仅让派生类家族内部使用的场景,它在一定程度上限制了基类的开放性,但也为后续的扩展提供了更灵活的控制。
- private继承:是最为严格的一种方式,基类的public成员和protected成员在派生类中都会变为private成员。这意味着这些成员只能在当前派生类的内部被访问,就连派生类的子类也无法触及它们。private继承通常用于实现“基于基类实现派生类”的场景,而非为了复用基类的接口,它更像是一种实现细节的复用,而非接口的继承。
为了更直观地理解这三种继承方式的差异,我们可以通过一个简单的代码示例来展示。假设我们有一个基类Base,其中包含public、protected和private三种不同访问权限的成员:
1 | class Base { |
当我们以public方式继承Base时,派生类Derived_Public的成员访问权限会保持原样:
1 | class Derived_Public : public Base { |
此时,在Derived_Public的外部可以直接访问public_var,protected_var则只能在Derived_Public内部访问,private_var则对派生类完全不可见。如果我们将继承方式改为protected,情况就会发生变化:
1 | class Derived_Protected : protected Base { |
这时,Derived_Protected的外部无法再访问public_var,只有Derived_Protected及其子类才能访问public_var和protected_var。而如果使用private继承,限制会更加严格:
1 | class Derived_Private : private Base { |
此时,只有Derived_Private内部可以访问public_var和protected_var,就连Derived_Private的子类也无法访问这些成员,基类的实现细节被完全封装在当前派生类中。
派生反映了事物之间的联系,事物的共性与个性之间的联系。派生类与设计独立的若干相关类,后者工作量要明显大于前者。
派生类的编写可以分为四个步骤:
- 吸收基类的成员:不论是数据成员还是函数成员,除了构造与析构函数外全盘接收。
- 改造基类成员:声明一个与基类成员同名的新成员,派生类中的新成员就屏蔽了基类同名的成员,也叫同名隐藏或同名覆盖。
- 发展新成员:派生类新成员必须与基类成员不同名,它的加入保证派生类在功能上有所发展,独有的新成员才是继承与派生的核心特征。
- 重写派生类的构造函数与析构函数。
同名隐藏
在继承的过程中,还可能遇到同名隐藏(hidden)的问题。如果派生类中定义了一个与基类同名的成员,无论是变量还是函数,基类的这个成员都会在派生类中被隐藏,使得基类中的函数在派生类中无法被访问。当派生类中有一个成员函数和基类中的同名函数时,派生类中的函数会隐藏基类中的函数,除非使用了作用域运算符 :: 或using声明,或者在派生类中使用virtual关键字使其成为虚函数。
例如,我们可以定义这样一组类:
1 | class Base { |
在使用派生类对象时,默认会访问派生类的成员:
1 | int main() { |
运行这段代码,输出结果为:
1 | Derived 的 show 函数,var = 20 |
通过这个例子可以看到,使用作用域解析符可以明确地访问基类被隐藏的成员。不过在实际开发中,我们应该尽量避免在派生类中定义与基类同名的成员,除非是有意进行覆盖,比如虚函数的重写,否则容易造成代码的混淆和可读性的下降。
那什么是同名覆盖呢?覆盖(override)是指派生类中的函数重写了基类中的同名虚函数,使得在使用基类指针或引用调用该函数时,会根据运行时的实际对象来选择调用基类函数还是派生类函数。覆盖只能发生在虚函数之间,而且必须使用virtual关键字进行声明。如果在派生类中重新定义了虚函数而没有使用virtual关键字,则该函数不会被视为虚函数,也不会被认为是基类中同名函数的覆盖。
构造和析构函数调用顺序
除了访问权限的控制,继承中另一个需要重点关注的问题是构造函数和析构函数的调用顺序。当创建一个派生类对象时,C++会先调用基类的构造函数来初始化基类部分,然后再调用派生类的构造函数来初始化派生类新增的部分。这种顺序是由C++的对象模型决定的,确保基类部分在派生类部分初始化之前就已经准备完毕。而在销毁对象时,顺序则完全相反,先调用派生类的析构函数清理派生类部分,再调用基类的析构函数清理基类部分,这样可以避免出现资源泄漏或访问无效内存的问题。
我们可以通过一个简单的代码示例来验证这一顺序:
1 | class Base { |
运行这段代码,输出结果会清晰地展示构造和析构的顺序:
1 | Base 构造函数 |
需要特别注意的是,如果基类的构造函数需要参数,派生类必须在其构造函数的初始化列表中显式调用基类的构造函数,否则编译器会尝试调用基类的默认构造函数。如果基类没有提供默认构造函数,就会导致编译错误。这一点在实际开发中很容易被忽略,因此在设计带有继承关系的类时,需要格外注意构造函数的设计。
继承的主要目的是实现代码复用和层次结构的设计,但过度使用继承也可能导致代码的耦合度增加,使得类之间的关系变得复杂。因此,在设计类的层次结构时,我们应该遵循“组合优于继承”的原则,优先考虑使用组合来实现代码复用,只有在确实存在明确的“is-a”关系时才使用继承。



