继承
在面向对象的三大特性中,封装(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 |
通过这个例子可以看到,使用作用域解析符可以明确地访问基类被隐藏的成员。不过在实际开发中,我们应该尽量避免在派生类中定义与基类同名的成员,除非是有意进行覆盖,比如虚函数的重写,否则容易造成代码的混淆和可读性的下降。
我们可以使用 using Base::show; 把基类所有 show 函数导入派生类来解除隐藏。
1 | class Derived : public Base { |
也可以给基类函数加 virtual:
1 | class Base { |
派生类重写同名函数,这样也不再是隐藏,而是多态覆盖,基类函数不会被隐藏。
那什么是同名覆盖呢?覆盖(override)是指派生类中的函数重写了基类中的同名虚函数,使得在使用基类指针或引用调用该函数时,会根据运行时的实际对象来选择调用基类函数还是派生类函数。覆盖只能发生在虚函数之间,而且必须使用virtual关键字进行声明。如果在派生类中重新定义了虚函数而没有使用virtual关键字,则该函数不会被视为虚函数,也不会被认为是基类中同名函数的覆盖。
构造和析构函数调用顺序
除了访问权限的控制,继承中另一个需要重点关注的问题是构造函数和析构函数的调用顺序。当创建一个派生类对象时,C++会先调用基类的构造函数来初始化基类部分,然后再调用派生类的构造函数来初始化派生类新增的部分。这种顺序是由C++的对象模型决定的,确保基类部分在派生类部分初始化之前就已经准备完毕。而在销毁对象时,顺序则完全相反,先调用派生类的析构函数清理派生类部分,再调用基类的析构函数清理基类部分,这样可以避免出现资源泄漏或访问无效内存的问题。
我们可以通过一个简单的代码示例来验证这一顺序:
1 | class Base { |
运行这段代码,输出结果会清晰地展示构造和析构的顺序:
1 | Base 构造函数 |
需要特别注意的是,如果基类的构造函数需要参数,派生类必须在其构造函数的初始化列表中显式调用基类的构造函数,否则编译器会尝试调用基类的默认构造函数。如果基类没有提供默认构造函数,就会导致编译错误。这一点在实际开发中很容易被忽略,因此在设计带有继承关系的类时,需要格外注意构造函数的设计。
赋值兼容规则
C++ 面向对象编程中有一条重要的规则:公有继承意味着 “是一个”。在任何需要基类对象的地方都可以用公有派生类的对象来代替,这条规则称赋值兼容规则。包括以下情况:
- 派生类的对象可以赋值给基类的对象,这时是把派生类对象中从对应基类中继承来的隐藏对象赋值给基类对象。反过来不行,因为派生类的新成员无值可赋。
- 可以将一个派生类的对象的地址赋给其基类的指针变量,但只能通过这个指针访问派生类中由基类继承来的隐藏对象,不能访问派生类中的新成员。同样也不能反过来做。
- 派生类对象可以初始化基类的引用。引用是别名,但这个别名只能包含派生类对象中的由基类继承来的隐藏对象。
1 | //赋值兼容规则 |
1 | 原始派生类对象 |
继承与静态成员
在继承体系中,静态成员(static member) 的行为与普通成员截然不同:它不属于任何对象,而是属于类本身。这一特性在普通类继承和模板类继承中表现出显著差异。
1 | class Object { |
1 | Singleton_Hungry_Global 构造函数被调用 |
Object::num 在内存中只有一份,所有派生类(Base、Test)的对象共享这个变量。程序两次调用 PrintNum() 输出的 &num 完全相同,证明所有子类访问的是同一个静态变量。静态成员不会被重写,只会被共享,不存在 “子类覆盖父类静态成员” 的多态行为。protected 修饰的静态成员可被所有子类直接访问,符合继承的访问权限规则。
但当 Object 变为模板类时,静态成员的行为发生本质变化:每个模板实例(不同类型参数)拥有独立的静态变量。
1 | template<class T> |
相比于普通类:
- 实例隔离:
Object<Base>::num和Object<Test>::num是两个完全独立的静态变量,互不干扰。 - 初始化方式:模板类的静态成员必须在类外通过
template<class T>前缀初始化,编译器会为每个实例化类型生成独立的静态变量。 - 继承关系:
Base继承Object<Base>,Test继承Object<Test>,两者的num属于不同的基类实例,因此不再共享。
如果创建 2 个 Base 对象和 3 个 Test 对象,预期运行结果为:
1 | Singleton_Hungry_Global 构造函数被调用 |
可以看到,Base 和 Test 的 num 各自独立计数,不再像普通类那样全局累加。
如果希望模板类的所有实例共享同一个静态变量,应该如何设计?可以将静态成员提取到一个非模板的基类中,让模板类继承该基类:
1 | class ObjectBase { |
这样所有 Object<T> 实例都会共享 ObjectBase::num,回到普通类继承的共享特性。
菱形继承
如果我们有如下继承的方式:

- 最顶层基类:
Person - 中间派生类:
Student、Employee(均继承自Person) - 最底层类:
EStudent(同时继承自Student和Employee)
这种结构会带来两个核心问题:数据冗余和二义性。
1 | class Person { /* 公共属性:姓名、年龄等 */ }; |
数据冗余:EStudent 对象中会包含两份 Person 子对象,一份来自 Student 继承的 Person,一份来自 Employee 继承的 Person。这将导致内存浪费,且数据不一致(比如修改姓名时,两份数据可能不同步)。
访问二义性:当你直接访问 EStudent 继承的 Person 成员时,编译器不知道该用哪一份:
1 | EStudent es; |
必须显式指定路径:
1 | es.Student::name = "Alice"; |
我们可以用虚继承(Virtual Inheritance)的方式来解决这一问题,在中间层 Student 和 Employee 继承 Person 时,使用 virtual 关键字:
1 | class Person { |
- 虚基类:
Person成为虚基类,EStudent中只保留一份Person子对象。 - 初始化责任:最终类
EStudent必须在构造函数初始化列表中直接调用虚基类Person的构造函数,中间层Student/Employee对虚基类的构造调用会被忽略。 - 访问无歧义:直接访问
Person成员不再报错,所有路径共享同一份数据。
这样相比于普通菱形继承内存布局具有两份 Person 数据,冗余且二义性。
1 | EStudent |
虚继承内存布局中间层类会额外存储虚基类指针(vbptr),指向共享的 Person 子对象,所有虚派生类共享同一份虚基类实例,解决了冗余和二义性。
1 | EStudent |
有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,不要设计出菱形继承。否则在复杂度及性能上都有问题。
继承的主要目的是实现代码复用和层次结构的设计,但过度使用继承也可能导致代码的耦合度增加,使得类之间的关系变得复杂。因此,在设计类的层次结构时,我们应该遵循“组合优于继承”的原则,优先考虑使用组合来实现代码复用,只有在确实存在明确的“is-a”关系时才使用继承。




