引言
继承是面向对象程序设计中实现代码复用的核心手段。它允许在保持原有类特性的基础上进行扩展,增加新的成员函数和成员变量,从而形成新的类(称为派生类或子类)。继承呈现了面向对象程序的层次结构,体现了从简单到复杂的认知过程。与函数级别的复用不同,继承是类设计层面的复用。
本文基于C++继承的完整知识点,从基本概念、语法定义、访问控制、作用域规则、默认成员函数行为,到多继承、菱形继承问题及虚继承解决方案,再到继承与组合的设计选择,进行系统性的客观阐述。
目录
[1.1 继承的概念](#1.1 继承的概念)
[1.2 继承的定义格式](#1.2 继承的定义格式)
[1.3 继承方式与成员访问变化](#1.3 继承方式与成员访问变化)
[1.4 继承类模板](#1.4 继承类模板)
[3.1 隐藏规则](#3.1 隐藏规则)
[3.2 典型例题分析](#3.2 典型例题分析)
[4.1 构造函数](#4.1 构造函数)
[4.2 拷贝构造函数](#4.2 拷贝构造函数)
[4.3 赋值运算符重载](#4.3 赋值运算符重载)
[4.4 析构函数](#4.4 析构函数)
[4.5 完整示例](#4.5 完整示例)
[4.6 实现一个不能被继承的类](#4.6 实现一个不能被继承的类)
[7.1 继承模型分类](#7.1 继承模型分类)
[7.2 菱形继承的问题](#7.2 菱形继承的问题)
[7.3 虚继承解决方案](#7.3 虚继承解决方案)
[7.4 多继承中的指针偏移问题](#7.4 多继承中的指针偏移问题)
[7.5 IO 库中的菱形虚拟继承](#7.5 IO 库中的菱形虚拟继承)
[8.1 两种关系](#8.1 两种关系)
[8.2 白箱复用与黑箱复用](#8.2 白箱复用与黑箱复用)
[8.3 设计原则](#8.3 设计原则)
一、继承的概念及定义
1.1 继承的概念
在没有继承的情况下,若存在Student和Teacher两个类,它们都包含姓名、地址、电话、年龄等成员变量,以及身份认证identity()成员函数。这些公共成员需要在两个类中重复定义,造成代码冗余。通过继承,可以将公共成员提取到一个基类(如Person)中,然后让Student和Teacher分别继承该基类,从而复用这些成员。
示例:
cpp
class Person {
public:
void identity() {
cout << "identity()" << name << endl;
}
protected:
string _name = "张三";
string _address;
string _tel;
int _age = 18;
};
class Student : public Person {
public:
void study() { /* ... */ }
protected:
int _stuid; // 学号
};
class Teacher : public Person {
public:
void teaching() { /* ... */ }
protected:
string _title; // 职称
};
1.2 继承的定义格式
cpp
class 派生类 : 继承方式 基类 {
// 派生类成员
};
-
基类:被继承的类,也称父类。
-
派生类:继承而来的类,也称子类。
1.3 继承方式与成员访问变化
继承方式分为三种:public、protected、private。基类成员在派生类中的访问方式遵循以下规则:
| 基类成员 \ 继承方式 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
| public 成员 | 派生类中为 public | 派生类中为 protected | 派生类中为 private |
| protected 成员 | 派生类中为 protected | 派生类中为 protected | 派生类中为 private |
| private 成员 | 派生类中不可见 | 派生类中不可见 | 派生类中不可见 |
要点说明:
-
基类的
private成员在派生类中不可见。这里的"不可见"是指语法上禁止访问,但这些成员实际上仍被继承到了派生类对象中。 -
如果基类成员不想在类外被直接访问,但又希望在派生类中能够访问,应定义为
protected。protected限定符正是因继承而引入的。 -
访问方式的简化规则:
Min(成员在基类的访问限定符, 继承方式),其中public > protected > private。 -
使用
class定义类时,默认继承方式为private;使用struct时,默认继承方式为public。建议显式写出继承方式。 -
实际开发中,绝大多数场景使用
public继承。protected和private继承很少使用,因为它们会限制派生类成员的访问性,不利于扩展和维护。
1.4 继承类模板
当派生类继承的基类是类模板时,在派生类成员函数中调用基类成员函数需要显式指定基类作用域,否则可能因模板按需实例化而导致编译错误。
cpp
template<class T>
class stack : public std::vector<T> {
public:
void push(const T& x) {
vector<T>::push_back(x); // 需要指定类域
}
void pop() {
vector<T>::pop_back();
}
const T& top() {
return vector<T>::back();
}
bool empty() {
return vector<T>::empty();
}
};
二、基类和派生类间的转换(切片)
-
派生类对象可以赋值给基类的指针或引用 。编译器会将派生类对象中属于基类的部分"切出来",基类指针/引用指向这部分。这种操作称为切片(slicing)。
-
派生类对象可以赋值给基类对象(通过调用基类的拷贝构造函数完成)。
-
基类对象不能赋值给派生类对象,因为基类缺少派生类新增的成员。
-
基类的指针或引用可以通过强制类型转换转换为派生类的指针或引用。但只有当基类指针实际指向派生类对象时,这种转换才是安全的。在多态场景下,可使用
dynamic_cast进行安全的向下转换。
cpp
class Person { /* ... */ };
class Student : public Person { /* ... */ };
int main() {
Student s;
Person* pp = &s; // 派生类指针 → 基类指针
Person& rp = s; // 派生类对象 → 基类引用
Person pobj = s; // 派生类对象 → 基类对象(拷贝构造)
// s = pobj; // 错误:基类对象不能赋值给派生类对象
return 0;
}
三、继承中的作用域与隐藏规则
3.1 隐藏规则
-
基类和派生类各自拥有独立的作用域。
-
如果派生类和基类中存在同名成员 (包括成员变量和成员函数),派生类的成员会屏蔽 基类同名成员的直接访问。这种情况称为隐藏(hidden)。
-
在派生类成员函数中,可以通过
基类::成员名显式访问被隐藏的基类成员。 -
对于成员函数,只需函数名相同即构成隐藏,与参数列表无关。
-
实际开发中,应尽量避免在继承体系中定义同名成员,以免造成混淆。
cpp
class Person {
protected:
string _name = "小李子";
int _num = 111; // 身份证号
};
class Student : public Person {
public:
void Print() {
cout << "姓名:" << _name << endl;
cout << "身份证号:" << Person::_num << endl; // 显式访问基类成员
cout << "学号:" << _num << endl; // 访问派生类成员
}
protected:
int _num = 999; // 学号,与基类 _num 构成隐藏
};
3.2 典型例题分析
题目 :A 和 B 类中的两个 func 构成什么关系?
cpp
class A {
public:
void fun() { cout << "func()" << endl; }
};
class B : public A {
public:
void fun(int i) { cout << "func(int i)" << i << endl; }
};
答案 :构成隐藏 。因为函数名相同(均为 fun),派生类的 fun(int) 隐藏了基类的 fun(),即使参数列表不同,也不构成重载(重载要求在同一作用域内)。
调用 b.fun() 会编译报错,因为派生类中 fun(int) 隐藏了基类的无参版本,编译器在派生类作用域中找不到匹配的 fun()。
四、派生类的默认成员函数
派生类中,6个默认成员函数(构造、拷贝构造、赋值重载、析构、取地址重载、const取地址重载)的生成规则有其特殊性。以下重点说明前四个。
4.1 构造函数
-
派生类的构造函数必须调用基类的构造函数来初始化从基类继承的那部分成员。
-
如果基类没有默认构造函数(即无参构造函数),则必须在派生类构造函数的初始化列表中显式调用基类的某个构造函数。
-
初始化顺序:先调用基类构造函数,再调用派生类构造函数。
4.2 拷贝构造函数
-
派生类的拷贝构造函数必须调用基类的拷贝构造函数来完成基类部分的拷贝初始化。
-
在初始化列表中调用基类拷贝构造时,可以直接传入派生类对象(会切片)。
4.3 赋值运算符重载
-
派生类的
operator=必须调用基类的operator=来完成基类部分的复制。 -
由于派生类的
operator=会隐藏基类的operator=(同名构成隐藏),因此需要显式指定基类作用域来调用。
4.4 析构函数
-
派生类的析构函数在执行完自身的清理工作后,会自动调用基类的析构函数。
-
清理顺序:先调用派生类析构函数,再调用基类析构函数。
-
由于多态场景下析构函数需要构成重写(函数名相同),编译器会对析构函数名进行特殊处理,统一处理为
destructor()。因此,如果基类析构函数不加virtual,派生类析构函数与基类析构函数将构成隐藏关系,而非重写。
4.5 完整示例
cpp
class Person {
public:
Person(const char* name = "peter") : _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;
}
~Person() {
cout << "~Person()" << endl;
}
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;
}
~Student() {
cout << "~Student()" << endl;
}
protected:
int _num;
};
4.6 实现一个不能被继承的类
-
C++98 方法 :将基类的构造函数设为
private。由于派生类的构造函数必须调用基类构造函数,而私有成员不可见,因此派生类无法实例化对象。 -
C++11 方法 :使用
final关键字修饰基类,阻止被继承。
cpp
// C++11
class Base final {
// ...
};
// C++98
class Base {
private:
Base() {}
};
五、继承与友元
友元关系不能继承。即基类的友元函数不能访问派生类的私有或保护成员。
cpp
class Student; // 前向声明
class Person {
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name;
};
class Student : public Person {
protected:
int _stuNum;
};
void Display(const Person& p, const Student& s) {
cout << p._name << endl; // OK
// cout << s._stuNum << endl; // 错误:无法访问派生类的 protected 成员
}
解决方法是也将 Display 声明为 Student 的友元函数。
六、继承与静态成员
如果基类定义了一个 static 静态成员,则在整个继承体系中只有一个该成员的实例。无论派生多少个派生类,所有基类和派生类共享同一个静态成员。
cpp
class Person {
public:
string _name;
static int _count;
};
int Person::_count = 0;
class Student : public Person {
protected:
int _stuNum;
};
int main() {
Person p;
Student s;
cout << &p._count << endl; // 地址相同
cout << &s._count << endl; // 地址相同
cout << Person::_count << endl;
cout << Student::_count << endl; // 可以通过派生类访问
return 0;
}
七、多继承及其菱形继承问题
7.1 继承模型分类
-
单继承:一个派生类只有一个直接基类。
-
多继承:一个派生类有两个或以上直接基类。多继承对象在内存中的布局为:先继承的基类成员在前,后继承的基类成员在后,派生类自身成员放在最后。
-
菱形继承 :多继承的一种特殊情况,形成菱形结构。例如:
Person被Student和Teacher继承,而Assistant同时继承Student和Teacher。
7.2 菱形继承的问题
菱形继承带来两个主要问题:
-
数据冗余 :
Assistant对象中包含两份Person的成员。 -
二义性 :访问
Person的成员时,编译器无法确定访问的是Student中的Person还是Teacher中的Person。
cpp
class Person { public: string _name; };
class Student : public Person { protected: int _num; };
class Teacher : public Person { protected: int _id; };
class Assistant : public Student, public Teacher { protected: string _majorCourse; };
int main() {
Assistant a;
// a._name = "peter"; // 错误:二义性
a.Student::_name = "xxx"; // 显式指定,可解决二义性,但数据冗余仍存在
a.Teacher::_name = "yyy";
return 0;
}
7.3 虚继承解决方案
C++ 通过虚继承 解决菱形继承的数据冗余和二义性问题。在继承时使用 virtual 关键字。
cpp
class Person { public: string _name; };
class Student : virtual public Person { protected: int _num; };
class Teacher : virtual public Person { protected: int _id; };
class Assistant : public Student, public Teacher { protected: string _majorCourse; };
int main() {
Assistant a;
a._name = "peter"; // 唯一,无二义性
return 0;
}
虚继承的底层实现较为复杂,通常通过虚基类指针和偏移量来保证只有一份虚基类成员。实践中应尽量避免设计菱形继承。C++ 的多继承被认为是语言的一个复杂点,后来的许多编程语言(如 Java)直接不支持多继承,从而规避了菱形继承问题。
7.4 多继承中的指针偏移问题
例题:以下说法正确的是?
cpp
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main() {
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
// 比较 p1, p2, p3
}
答案 :p1 == p3 != p2。原因:p1 和 p3 都指向对象的起始地址(因为 Base1 是最先继承的基类),而 p2 指向 Base2 子对象的起始地址,位于 Base1 之后,因此地址值不同。
7.5 IO 库中的菱形虚拟继承
C++ 标准库中的 IO 流体系使用了菱形虚拟继承。例如 basic_ostream 和 basic_istream 均虚继承自 basic_ios,而 basic_iostream 同时继承前两者,通过虚继承保证了 basic_ios 部分只有一份。
cpp
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_ostream : virtual public std::basic_ios<CharT, Traits> {};
template<class CharT, class Traits = std::char_traits<CharT>>
class basic_istream : virtual public std::basic_ios<CharT, Traits> {};
八、继承与组合
8.1 两种关系
-
public 继承 表示 is-a 关系:每个派生类对象都是一个基类对象。例如,
BMW是一种Car。 -
组合 表示 has-a 关系:每个对象中包含另一个对象作为成员。例如,
Car包含四个Tire对象。
8.2 白箱复用与黑箱复用
-
继承(白箱复用):基类的内部细节对派生类可见。这在一定程度上破坏了基类的封装性。基类的任何改变都可能影响派生类,导致类之间的依赖关系强、耦合度高。
-
组合(黑箱复用):被组合对象的内部细节不可见,仅通过良好定义的接口进行交互。组合类之间依赖关系弱、耦合度低,有利于代码维护。
8.3 设计原则
优先使用组合,而不是继承。组合带来的耦合度更低,代码维护性更好。但以下情况适合使用继承:
-
类之间确实存在 is-a 关系。
-
需要实现多态(虚函数机制必须基于继承)。
如果类之间的关系既可以用 is-a 描述也可以用 has-a 描述(例如 stack 和 vector),则优先选择组合。
cpp
// 组合示例:Car has-a Tire
class Tire { /* ... */ };
class Car {
Tire _t1, _t2, _t3, _t4;
};
// 继承示例:BMW is-a Car
class BMW : public Car {
public:
void Drive() { cout << "好开-操控" << endl; }
};
// stack 与 vector:既可以用继承,也可以用组合,优先选择组合
template<class T>
class stack {
vector<T> _v; // 组合
};
总结
本文系统梳理了 C++ 继承机制的核心知识点:
-
继承概念:通过提取公共成员到基类,实现类层面的代码复用。
-
访问控制 :
private成员在派生类中不可见;protected是为继承而设计的访问级别;实际开发以public继承为主。 -
切片与转换:派生类对象可赋值给基类指针/引用/对象,反之不行。
-
作用域与隐藏 :基类和派生类有独立作用域,同名成员构成隐藏,可通过
基类::显式访问。 -
默认成员函数:派生类的构造、拷贝构造、赋值、析构函数需正确调用基类对应函数,注意初始化顺序和析构顺序。
-
友元与静态成员:友元不继承;静态成员在整个继承体系中共享一份实例。
-
多继承与菱形继承:多继承带来菱形继承问题(数据冗余、二义性),通过虚继承解决,但应尽量避免设计菱形继承。
-
继承与组合:继承是 is-a 关系(白箱复用,耦合度高),组合是 has-a 关系(黑箱复用,耦合度低)。优先使用组合,仅在明确存在 is-a 关系或需要多态时使用继承。
理解继承的这些细节,是掌握 C++ 面向对象编程的基础,也是写出健壮、可维护代码的关键。