继承
继承是 C++ 面向对象设计的基石,它允许我们在已有类的基础上构建新类,实现代码复用和逻辑扩展。通过继承,我们可以建立层次分明的类体系,让代码结构更清晰、维护更方便。
一、继承的概念及定义
1.1 继承的概念
继承(Inheritance)机制的核心在于复用。在保持原有类特性的基础上,派生类可以扩展新的方法或属性。这种设计体现了由简单到复杂的认知过程,将函数层次的复用提升到了类设计层次。
假设我们有两个类 Student 和 Teacher,它们都有姓名、地址、电话等公共成员,也有各自的特有成员。如果分开写,公共部分就会重复定义。
class Student {
public:
void identity() { /* ... */ }
void study() { /* ... */ }
private:
string _name = "peter";
int _age = 18;
// ... 其他成员
};
class Teacher {
public:
void identity() { /* ... */ }
void teaching() { /* ... */ }
private:
string _name = "张三";
int _age = 18;
// ... 其他成员
};
引入继承后,我们将公共部分提取为基类 Person,Student 和 Teacher 继承自它:
class Person {
public:
void identity() {
cout << "void 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;
};
int main() {
Student s;
Teacher t;
s.identity();
t.identity();
return 0;
}
这样,Student 和 Teacher 就复用了 Person 的成员,避免了冗余。
1.2 继承的定义
1.2.1 定义格式
在上述例子中,Person 是基类(父类),Student 是派生类(子类)。
这里需要特别注意 private 和 protected 的区别。在同一个类内它们作用相同,但在继承时,它们决定了派生类对基类成员的访问权限。
1.2.2 继承基类成员访问方式的变化
| 类成员/继承方式 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
| 基类的 public 成员 | 派生类的 public 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
| 基类的 protected 成员 | 派生类的 protected 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
| 基类的 private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
表格虽繁琐,但核心逻辑其实很简单:
- 私有成员不可见:基类的
private成员无论以什么方式继承,在派生类中都是不可见的。虽然数据被继承了,但语法上限制了访问。 - 保护成员的作用:如果基类成员不想在类外直接访问,但需要在派生类中访问,应定义为
protected。 - 访问权限计算:基类其他成员在派生类的访问方式遵循
Min(成员在基类的访问限定符,继承方式),即public > protected > private。 - 默认继承方式:使用关键字
class时默认为private继承,使用struct时默认为public继承。实际开发中建议显式写出继承方式。 - 最佳实践:实际运用中一般使用
public继承。protected或private继承下来的成员只能在派生类内部使用,扩展性和维护性较弱,不提倡滥用。
1.3 继承类模板
之前学习 stack 时提到它是容器适配器,底层基于 vector。我们完全可以用 stack 继承 vector:
namespace sxn {
template<class T>
class stack : public std::vector<T> {
public:
void push(const T& x) { push_back(x); }
void pop() { pop_back(); }
const T& top() { return back(); }
bool empty() { return empty(); }
};
}
int main() {
sxn::stack<int> st;
st.push(1);
st.push(2);
st.push(3);
while (!st.empty()) {
cout << st.top() << " ";
st.pop();
}
return 0;
}
但运行时可能会遇到编译错误,这是因为类模版是按需实例化的。当定义类模板时,编译器不会立即生成具体类型的类代码,只有当程序中实际使用了特定成员时才会生成。
如果基类是类模板,调用其成员函数时需要指定类域,否则可能找不到标识符:
namespace sxn {
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();
}
};
}
二、基类和派生类间的转化
- 向上转换:
public继承的派生类对象可以赋值给基类的指针或引用。这被称为'切片'或'切割',寓意把派生类中基类那部分切出来。 - 向下转换限制:基类对象不能直接赋值给派生类对象。
- 安全转换:基类指针或引用可以通过强制类型转换赋值给派生类指针或引用,但必须确保基类指针确实指向派生类对象。如果是多态类型,建议使用 RTTI 中的
dynamic_cast进行安全识别。
class Person {
protected:
string _name;
string _sex;
int _age;
};
class Student : public Person {
public:
int _No;
};
int main() {
Student sobj;
// 1. 派生类对象可以赋值给基类的指针/引用
Person* pp = &sobj;
Person& rp = sobj;
// 派生类对象可以赋值给基类的对象,这是通过调用基类的拷贝构造完成的
Person pobj = sobj;
// 2. 基类对象不能赋值给派生类对象,这里会编译报错
// sobj = pobj;
return 0;
}
三、继承中的作用域
3.1 隐藏规则
- 在继承体系中,基类和派生类都有独立的作用域。
- 如果派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这叫隐藏。(在派生类成员函数中,可以使用
基类::成员名显式访问) - 对于成员函数的隐藏,只需要函数名相同就构成隐藏,这与重载不同(重载要求在同一作用域且参数不同)。
- 实际开发中,在继承体系里最好避免定义同名的成员,以免混淆。
// Student 的_num 和 Person 的_num 构成隐藏关系
class Person {
protected:
string _name = "小雷子";
int _num = 072;
};
class Student : public Person {
public:
void Print() {
cout << " 姓名:" << _name << endl;
cout << " 身份证号:" << Person::_num << endl; // 显式访问基类成员
cout << " 学号:" << _num << endl;
}
protected:
int _num = 520;
};
int main() {
Student s1;
s1.Print();
return 0;
}
3.2 考察继承作用域相关选择题
问题: 下面 A 和 B 类中的两个 func 构成什么关系? A. 重载 B. 隐藏 C. 没关系
解析: 很多人容易误选重载。关键在于重载是在同一个作用域内,而基类和派生类是两个不同的作用域。因为函数名相同,所以是隐藏。
问题: 下面程序的编译运行结果是什么?
class A {
public:
void fun() { cout << "func()" << endl; }
};
class B : public A {
public:
void fun(int i) { cout << "func(int i)" << i << endl; }
};
int main() {
B b;
b.fun(10); // 调用派生类中的 fun(int)
b.fun(); // 调用哪个?基类被隐藏了呀
return 0;
};
答案: 编译报错。
解释: b.fun() 试图调用基类的 fun(),但由于派生类定义了同名的 fun(int),导致基类函数被隐藏。编译器在语义分析时发现函数调用与类定义不匹配,直接报错。这属于编译阶段错误。
四、派生类的默认成员函数
4.1 4 个常见默认成员函数
C++ 有 6 个默认成员函数,如果不写,编译器会自动生成。在派生类中,它们的生成行为如下:
- 构造函数:派生类的构造函数必须调用基类的构造函数初始化基类部分。如果基类没有默认构造函数,必须在派生类构造函数的初始化列表中显式调用。
- 拷贝构造函数:派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- 赋值运算符:派生类的
operator=必须要调用基类的operator=完成基类的赋值。注意,派生类的operator=隐藏了基类的,所以显式调用时需要指定基类作用域。 - 析构函数:派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。顺序是先清理派生类成员,再清理基类成员。
- 执行顺序:派生类对象初始化先调用基类构造再调派生类构造;析构清理先调用派生类析构再调基类的析构。
- 虚析构:在多态场景中,析构函数通常需要重写。由于编译器对析构函数名有特殊处理(destructor),如果基类析构函数不加
virtual,派生类析构函数和基类析构函数构成隐藏关系。
简单来说,我们是将继承的基类成员变量当做一个整体,调用基类的构造、赋值等操作。
示例练习:
class Person {
public:
Person(const char* name = "joke") : _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;
};
int main() {
Student s1("jack", 18);
Student s2(s1);
Student s3("rose", 17);
s1 = s3;
return 0;
}
4.2 实现一个不能被继承的类
- 方法 1:将基类的构造函数设为私有。派生类必须调用基类构造函数,但私有化后看不见就无法调用,从而无法实例化。(当然,基类本身也无法实例化)。
- 方法 2:C++11 新增了
final关键字,修饰基类后,派生类就不能继承了。
class Base final {
public:
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
class Derive : public Base { // 编译报错:cannot derive from 'Base' which is marked 'final'
void func4() { cout << "Derive::func4" << endl; }
int b = 2;
};
五、继承与友元
友元关系不能继承。也就是说,基类的友元不能访问派生类的私有和保护成员。这一点很好理解,我们可以通过演示来验证:
#include <iostream>
#include <string>
using namespace std;
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;
cout << s._stuNum << endl;
}
int main() {
Person p;
Student s;
Display(p, s);
return 0;
}
六、继承与静态成员
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类,都只有一个 static 成员实例。
#include <iostream>
#include <string>
using namespace std;
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._name << endl;
cout << &s._name << endl;
cout << &p._count << endl;
cout << &s._count << endl;
cout << Person::_count << endl;
cout << Student::_count << endl;
return 0;
}
七、多继承及菱形继承问题
7.1 继承模型
- 单继承:一个派生类只有一个直接基类。
- 多继承:一个派生类有两个或以上直接基类。多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员放到最后面。
- 菱形继承:菱形继承是多继承的一种特殊情况。从对象成员模型可以看出,菱形继承有数据冗余和二义性的问题。例如在
Assistant的对象中,Person成员会有两份。
支持多继承就一定会有菱形继承。像 Java 就不支持多继承,规避掉了这个问题。实践中也不建议设计出菱形继承模型。
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"; // 编译报错:对'_name'的访问不明确
return 0;
}
解决二义性问题需要显式指定访问哪个基类的成员,但这无法解决数据冗余问题:
int main() {
Assistant a;
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
return 0;
}
7.2 虚继承
为了解决菱形继承的数据冗余和二义性问题,引入了 virtual 关键字。
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 = "bob"; // 正常访问
return 0;
}
虚继承的实现比想象中复杂,底层开销也会增加。虽然能解决问题,但依然不建议过度设计菱形虚拟继承。
7.3 多继承中指针偏移问题
在多继承中,不同基类的指针地址可能不同,存在偏移。
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
return 0;
}
答案是 p1 == p3 != p2。p1 和 p3 指向同一块内存起始位置,而 p2 指向 Base2 部分的起始位置,存在偏移。
八、继承和组合
- is-a 关系:
public继承是一种 is-a 的关系。每个派生类对象都是一个基类对象。 - has-a 关系:组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。
- 白箱复用 vs 黑箱复用:继承允许根据基类实现定义派生类实现,称为白箱复用。但这破坏了封装,耦合度高。对象组合则是黑箱复用,依赖关系弱,耦合度低。
- 优先使用组合:实际尽量多用组合,组合的耦合度低,代码维护性好。不过也不是绝对的,如果类之间适合继承(is-a)或者要实现多态,就用继承。如果既适合继承也适合组合,优先用组合。
就像前面用 stack 继承 vector 一样,标准库有时也用 has-a 的关系来实现,这也是值得参考的实践。
总结
继承为后续学习多态打下了坚实基础,是深入理解 C++ 面向对象编程的关键一步。合理运用继承机制,能够让我们设计出更加优雅、可扩展的代码结构。


