
在面向对象编程(OOP)中,继承 是代码复用的核心机制。通过继承,我们可以从已有类(基类 ,也称父类)派生出新类(派生类,也称子类),派生类自动获得基类的成员(除构造函数、析构函数外),并可以添加新的成员或修改继承来的行为。
继承的概念与派生类的定义
为什么需要继承?
想象你要开发一个角色扮演游戏,需要设计"战士""法师""弓箭手"等角色。它们都有共同属性 (如姓名、生命值、攻击力)和共同行为(如移动、攻击)。如果为每个角色独立编写类,代码将大量重复,且维护困难。
继承 应运而生:先定义一个通用的 Character 类(基类),然后让 Warrior、Mage、Archer 继承它。派生类自动拥有基类的全部成员,只需专注于自己特有的部分。这种"is-a "关系是继承的经典应用:战士是一个 角色,法师是一个角色。
派生类的定义语法
cpp
class 基类名 { ... };
class 派生类名 : 继承方式 基类名 {
// 派生类新增成员
};
继承方式有三种:
public公有继承:基类的public成员在派生类中仍为public,protected成员仍为protected,private成员不可直接访问。protected保护继承:基类的public和protected成员在派生类中都变为protected。private私有继承:基类的public和protected成员在派生类中都变为private。
实践中 99% 使用公有继承,因为它保持了"is-a"语义。下文若无特殊说明,均指公有继承。
派生类的成员构成
派生类对象由两部分组成:
- 基类子对象:存储从基类继承来的数据成员。
- 派生类新增成员:存储派生类独有的数据成员。
cpp
class Character {
public:
std::string name;
int health;
void move() { std::cout << name << " moves.\n"; }
};
class Warrior : public Character {
public:
int rage; // 战士独有的怒气值
void smash() { // 战士独有的技能
std::cout << name << " smashes with rage " << rage << "!\n";
}
};
此时,Warrior 对象拥有 name、health、rage 三个数据成员,以及 move()、smash() 两个成员函数。
派生类对象的内存布局(单继承)
理解底层内存布局能帮你彻底掌握继承的行为。对于上述 Warrior 类,在典型实现下,其对象内存结构如下:
高地址 ┌──────────────┐
│ rage │ ← Warrior 新增成员
├──────────────┤
│ health │
├──────────────┤ ← Character 子对象
│ name │
低地址 └──────────────┘
基类子对象位于派生类对象的起始位置 。这意味着一个 Warrior 对象的地址同时也是一个合法的 Character 对象的地址------这就是类型兼容规则的底层基础(见3.2.5节)。
思考:为什么这样布局?
因为派生类对象"是一个"基类对象,所以它必须能在任何需要基类对象的地方使用。把基类子对象放在开头,使得指向派生类的指针/引用无需任何偏移就能直接当作基类指针/引用使用。这是C++实现多态的基础。
派生类的构造函数
构造函数负责对象的初始化。派生类不能继承基类的构造函数(C++11 的 using 声明可"继承"构造函数,但本质仍是编译器生成转发版本),因此派生类必须定义自己的构造函数。
构造函数的调用顺序
原则:先基类,后派生类。
创建派生类对象时,执行顺序如下:
- 调用基类的构造函数,构造基类子对象。
- 调用派生类新增成员(类类型成员)的构造函数,按它们在类中声明的顺序。
- 执行派生类构造函数的函数体。
这个顺序保证了派生类的初始化可以建立在已经完整初始化的基类子对象之上。
cpp
#include <iostream>
using namespace std;
class Base {
public:
Base() { cout << "Base constructor\n"; }
~Base() { cout << "Base destructor\n"; }
};
class Member {
public:
Member() { cout << "Member constructor\n"; }
~Member() { cout << "Member destructor\n"; }
};
class Derived : public Base {
Member m; // 类类型成员
public:
Derived() { cout << "Derived constructor\n"; }
~Derived() { cout << "Derived destructor\n"; }
};
int main() {
Derived d;
return 0;
}
输出:
Base constructor
Member constructor
Derived constructor
Derived destructor
Member destructor
Base destructor
注意析构顺序与构造完全相反(见3.2.3节)。
如何显式调用基类构造函数
基类子对象的初始化必须通过构造函数初始化列表完成。派生类构造函数可以在初始化列表中显式调用基类的某个构造函数。
cpp
class Base {
public:
Base(int x) { cout << "Base with " << x << "\n"; }
};
class Derived : public Base {
public:
// 必须显式调用 Base(int)
Derived(int a, int b) : Base(a) { // 初始化列表调用基类构造函数
// 其他初始化
}
};
重要规则:
- 如果基类没有定义构造函数 ,或定义了无参构造函数,派生类构造函数可以省略对基类构造函数的显式调用(编译器会自动调用默认构造函数)。
- 如果基类只定义了带参构造函数(即没有默认构造函数),则派生类必须在初始化列表中显式调用基类的某个带参构造函数,否则编译错误。
cpp
class Base {
public:
Base(int) {} // 带参构造函数,编译器不会生成默认构造函数
};
class Derived : public Base {
public:
// Derived() {} // 错误:基类缺少默认构造函数
Derived() : Base(0) {} // 正确
};
初始化列表的深层含义
构造函数初始化列表是初始化所有直接基类 和非静态数据成员 的唯一场所。在进入构造函数函数体之前,这些成员已经构造完成。因此,在函数体内对成员赋值其实是赋值 而非初始化 。对于 const 成员、引用成员以及没有默认构造函数的类类型成员,必须在初始化列表中初始化。
派生类构造函数初始化列表的完整语法:
cpp
Derived(参数列表) : 基类构造函数(实参列表), 成员1(初值), 成员2(初值), ... {
// 函数体
}
派生类的拷贝控制
- 拷贝构造函数:默认情况下,派生类的拷贝构造函数会自动调用基类的拷贝构造函数,并执行派生类成员的拷贝。如果需要自定义,必须显式调用基类拷贝构造。
- 拷贝赋值运算符:默认会调用基类的拷贝赋值运算符。自定义时需注意显式调用基类赋值。
- 移动构造函数/移动赋值:类似规则。
cpp
class Derived : public Base {
public:
Derived(const Derived& other)
: Base(other) // 调用基类拷贝构造
, m_data(other.m_data)
{}
Derived& operator=(const Derived& other) {
if (this != &other) {
Base::operator=(other); // 调用基类赋值
m_data = other.m_data;
}
return *this;
}
};
派生类的析构函数
析构函数负责对象销毁时的资源清理。与构造函数相反,析构顺序为:先派生类,后基类。
- 执行派生类析构函数体。
- 调用派生类成员的析构函数(按声明顺序逆序)。
- 调用基类的析构函数。
这个顺序保证了派生类可以安全地访问基类成员来完成自己的清理,然后才释放基类部分。
虚析构函数的重要性
当通过基类指针删除派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,派生类的析构函数不会执行,导致资源泄漏。
cpp
Base* p = new Derived();
delete p; // 若 Base 析构非虚,仅调用 ~Base()
解决方案 :将基类的析构函数声明为 virtual。
cpp
class Base {
public:
virtual ~Base() {} // 虚析构函数
};
底层原理 :虚析构函数使对象的析构能够动态绑定,通过虚函数表找到正确的派生类析构函数。这也是为什么凡是设计为基类的类,几乎都应该提供虚析构函数。
派生类的成员函数:隐藏与重载
函数隐藏的定义
如果派生类定义了与基类同名 的成员函数(无论参数列表、返回值是否相同),则基类的所有同名函数都被隐藏(hide)。此时,通过派生类对象直接调用该函数名,只会调用派生类的版本。
cpp
class Base {
public:
void func() { cout << "Base::func()\n"; }
void func(int) { cout << "Base::func(int)\n"; }
};
class Derived : public Base {
public:
void func() { cout << "Derived::func()\n"; } // 隐藏了 Base::func 和 Base::func(int)
};
int main() {
Derived d;
d.func(); // 输出 Derived::func()
// d.func(10); // 错误!Base::func(int) 被隐藏
}
隐藏与重载、覆盖的区别
| 特性 | 作用域 | 函数名 | 参数列表 | virtual | 效果 |
|---|---|---|---|---|---|
| 重载(overload) | 同一作用域 | 相同 | 不同 | 无关 | 编译时根据参数选择 |
| 覆盖(override) | 基类与派生类 | 相同 | 相同 | 必须有 | 动态绑定,通过基类指针调用派生类版本 |
| 隐藏(hide) | 基类与派生类 | 相同 | 任意 | 无关 | 派生类作用域屏蔽基类同名函数 |
本质原因:名称查找规则。编译器在派生类作用域找到函数名后,就不会再向基类作用域查找。这与参数匹配与否无关。
如何调用被隐藏的基类函数
两种方法:
-
使用作用域限定符
::
d.Base::func(10);明确指定调用基类版本。 -
使用类型兼容规则,通过基类指针/引用调用 (仅当函数未被覆盖时有效)
Base* p = &d; p->func(10);此时静态类型为Base*,调用基类版本。
最佳实践:避免意外隐藏
- 尽量不要在派生类定义与基类同名的非虚函数。
- 如果确实需要修改基类行为,应使用虚函数实现覆盖(override),而非隐藏。
- 若派生类需要提供基类某个重载版本的新实现,应使用
using声明将基类版本引入派生类作用域。
cpp
class Derived : public Base {
public:
using Base::func; // 将基类所有 func 重载引入
void func() override; // 覆盖其中一个
};
类型兼容规则与多态基础
什么是类型兼容规则?
公有继承下,派生类对象可以当作基类对象使用。具体表现为:
- 基类指针可以指向派生类对象。
- 基类引用可以绑定派生类对象。
- 可以将派生类对象赋值给基类对象(会发生切片)。
这是因为派生类对象包含基类子对象,且该子对象位于对象的起始位置。
cpp
Derived d;
Base* p = &d; // 合法
Base& r = d; // 合法
Base b = d; // 合法,但只复制了基类部分,派生类部分被切掉
静态类型与动态类型
- 静态类型:变量声明时的类型,编译期确定。
- 动态类型:指针/引用实际指向的对象类型,运行期确定。
cpp
Base* p = new Derived(); // 静态类型 Base*, 动态类型 Derived*
通过静态类型为 Base* 的指针调用成员函数时,编译器根据静态类型 决定调用哪个版本。这就是静态绑定。例如:
cpp
p->func(); // 若 func 非虚,调用 Base::func();若 func 是虚函数,动态绑定到 Derived::func()
对象切片
将派生类对象直接赋值给基类对象时,派生类特有的成员被"切掉",只保留基类部分。
cpp
Derived d;
Base b = d; // 切片:b 是一个纯粹的 Base 对象,没有派生类信息
b.func(); // 调用 Base::func()
切片会导致派生类的行为丢失,通常应避免这种赋值,尽量使用指针或引用。
虚函数与动态多态
隐藏是编译期行为,而覆盖 (override)配合虚函数才能实现运行期多态。当基类函数声明为 virtual,派生类提供相同签名的函数(且不加 override 也会覆盖,但建议显式标注),通过基类指针/引用调用时,将根据动态类型决定调用哪个版本。
cpp
class Base {
public:
virtual void speak() { cout << "Base\n"; }
};
class Derived : public Base {
public:
void speak() override { cout << "Derived\n"; }
};
void talk(Base& b) { b.speak(); } // 动态绑定
int main() {
Derived d;
talk(d); // 输出 Derived
}
这是面向对象最强大的特性之一,而继承正是实现多态的基石。
编程建议
优先使用组合而非继承
"is-a"关系是继承的典型场景,但很多情况下"has-a"关系更合适。组合(类中包含另一个类的对象)比继承耦合度更低,更灵活。
错误示例 :为了复用 Engine 的 start() 方法,让 Car 继承 Engine。
正确示例 :Car 类包含一个 Engine 成员。
基类应定义虚析构函数
只要一个类被设计为基类(即使目前只有一个派生类),就应该给它定义虚析构函数。这是为了防止通过基类指针删除派生类对象时资源泄漏。
避免在构造函数/析构函数中调用虚函数
在基类构造期间,派生类部分尚未初始化,此时调用虚函数不会下降到派生类版本(静态绑定)。这常导致难以发现的逻辑错误。
cpp
class Base {
public:
Base() { print(); } // 调用的 Base::print(),而非派生类版本
virtual void print() { cout << "Base\n"; }
};
class Derived : public Base {
int* p;
public:
Derived() : p(new int(5)) {}
void print() override { cout << "Derived " << *p << "\n"; }
};
// 构造 Derived 时输出 "Base",此时 p 尚未初始化,但并未使用,安全;但逻辑易误解
显式使用 override 和 final
C++11 引入 override 关键字,显式标注派生类意图覆盖基类虚函数。若签名不匹配,编译器报错。final 关键字可禁止类被继承或虚函数被覆盖。
cpp
class Derived : public Base {
void print() override; // 明确覆盖
};
class NoInherit final {}; // 不可被继承
##理解函数隐藏并规避
- 不要在派生类定义与基类非虚函数同名的函数。
- 若需要扩展基类重载集,用
using声明引入基类名字。 - 若意图修改基类行为,应使用虚函数 + 覆盖,而非隐藏。