C++ 继承机制详解
1. 继承的概念和定义
继承(inheritance)是面向对象程序设计实现代码复用的核心手段。它允许我们在保持原有类特性的基础上进行扩展,增加方法(成员函数)和属性(成员变量),从而产生新的类,称为派生类。
概念理解
以前我们接触的函数层次复用,继承则是类设计层次的复用。举个例子,学生和教师都有公共的属性(如姓名、电话、地址),如果分别定义在两个类中会造成冗余。我们可以将这些公共成员封装到一个基类 Person 中,让 Student 和 Teacher 通过继承来复用这些成员。
class Person {
public:
// 身份识别,比如进入图书馆打卡等
void Identify() {
cout << "void Identify()" << _name << endl;
}
protected:
string _name; // 名字
string _tel; // 电话
string _address;// 地址
int _age = 18; // 年龄
};
class Student : public Person {
public:
void study() { /* ... */ }
protected:
int _stuid; // 学号 <- 学生特有
};
class Teacher : public Person {
public:
void teaching() { /* ... */ }
protected:
string _title; // 职称 <- 教师特有
};
继承方式定义
C++ 中的继承方式有三种:public, protected, private。基类的私有成员在派生类中无论以什么方式继承都是不可见的(虽然物理上被继承了,但语法限制无法访问)。保护成员限定符是因继承才出现的,用于控制派生类内部可访问但类外不可访问。
总结访问权限变化规律:基类的其他成员在派生类的访问方式 == Min(成员在基类的访问限定符,继承方式)。使用关键字 class 时默认的继承方式是 private,使用 struct 时默认是 public,建议显式写出继承方式。
实例演示
public 继承:
class Person {
public:
void Print() { cout << _name << endl; }
protected:
string _name = "1";
private:
int _age;
};
class Student : public Person {
protected:
int _stunum;
};
int main() {
Student s;
s.Print(); // 可以访问,因为 Public -> Public
return 0;
}
protected 继承:
此时对于 Print 成员函数,最终的继承方式为 protected,在子类中该成员函数的访问权限为 protected,类外不可访问。
private 继承:
子类中关于父类的成员的全部访问权限变为 private,无论是子类内还是子类外全不可访问。实际运用中一般使用 public 继承,很少使用 protected/private 继承,因为继承下来的成员都只能在派生类的类里面使用,扩展维护性不强。
继承类模板
当基类是模板类时,子类访问父类的成员需要指定访问限定符,因为模板类是按需实例化,不指定的话可能找不到成员。
template<class T>
class Stack : public vector<T> {
public:
void push(const T& x) {
// 需要 typename 或作用域解析,确保编译器知道这是类型
typename vector<T>::push_back(x);
}
void pop() {
vector<T>::pop_back();
}
};
2. 基类和派生类间的转换
public 继承的派生类对象可以赋值给基类的指针或引用。这里有个形象的说法叫'切片'或者'切割',寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。
- 派生类对象可以赋值给基类的对象是通过调用基类的拷贝构造函数完成的。
- 基类对象不能赋值给派生类对象,编译会报错。
- 基类的指针或引用可以通过强制类型转换赋值给派生类的指针或引用,但必须是基类指针是指向派生类对象时才是安全的。如果是多态类型,可以使用 RTTI (
dynamic_cast) 来进行识别后进行安全转换。
class Person {
protected:
string _name;
string _sex;
int _age;
};
class Student : public Person {
public:
int _No; // 学号
};
int main() {
Student s;
// 子类切片给父类的指针或者引用
Person* ptr = &s;
Person& a = s;
Person per = s; // 调用拷贝构造
// s = per; // 编译报错:父类对象不可以赋值给子类对象
return 0;
}
3. 继承中的作用域
在继承体系中,基类和派生类都有独立的作用域。派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。在派生类成员函数中,可以使用 基类::基类成员 显式访问。
需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。在实际中在继承体系里面最好不要定义同名的成员,否则容易混淆。
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; // 学号
};
int main() {
Student s1;
s1.Print();
return 0;
}
4. 派生类的默认成员函数
派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
- 拷贝构造函数:派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
- operator=:派生类的
operator=必须要调用基类的operator=完成基类的复制。需要注意派生类的operator=隐藏了基类的operator=,所以显示调用基类的operator=需要指定基类作用域。 - 析构函数:派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。顺序是先清理派生类成员再清理基类成员,满足先定义的类后析构的设计要求。
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;
};
int main() {
Student s1("jack", 18);
Student s2(s1);
Student s3("rose", 17);
s1 = s3;
return 0;
}
实现一个不能被继承的类
方法一:基类的构造函数私有化,派生类看不见就不能调用了。
方法二:C++11 新增了 final 关键字,final 修饰基类,派生类就不能继承了。
class Base final {
public:
void func5() { cout << "Base::func5" << endl; }
protected:
int a = 1;
};
// class Derive : public Base { ... }; // 编译错误
5. 继承与友元
友元关系不能继承。也就是说基类友元不能访问派生类私有和保护成员,但该友元依旧可以访问基类的私有和保护成员。
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; // 编译报错:无法访问 protected 成员
}
int main() {
Person p; Student s;
// 解决方案:Display 也变成 Student 的友元即可
return 0;
}
6. 继承与静态成员
基类定义了 static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个派生类,都只有一个 static 成员实例。即基类实例化的对象和派生类实例化的对象调用 static 成员时,调用的是同一个。
7. 多继承及其菱形继承问题
单继承是一个派生类只有一个直接基类。多继承是一个派生类有两个或以上直接基类。多继承对象在内存中的模型是,先继承的基类在前面,后面继承的基类在后面,派生类成员放到最后面。
菱形继承问题
菱形继承是多继承的一种特殊情况。从对象成员模型构造可以看出,菱形继承有数据冗余和二义性的问题。在 Assistant 的对象中 Person 成员会有两份。
- 数据冗余:Assistant 会有两份 Person 造成数据的浪费。
- 二义性:Assistant 实例化的对象访问 Person 类中的成员时,不知道访问的是由 Student 类继承下来的 Person 还是 Teacher 继承下来的 Person。
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;
}
虚继承
使用虚继承可以解决菱形继承的数据冗余和访问时的二义性。在继承声明前加上 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 = "peter"; // 现在可以正常访问
return 0;
}
菱形继承足够复杂时会出现各种问题,所以在实际开发中,我们可以实现多继承(符合语法设计),但是尽量避免菱形继承,或者说就不要设计菱形继承。查询一下菱形继承的设计场景,结果是很少。
IO 库中的菱形继承
C++ 标准库中也有类似的设计,例如 basic_ostream 和 basic_istream 都虚继承自 basic_ios。
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. 继承和组合
public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象。组合是一种 has-a 的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。
- 白箱复用:继承允许你根据基类的实现来定义派生类的实现。基类的内部细节对派生类可见。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。
- 黑箱复用:对象组合是类继承之外的另一种复用选择。对象只以'黑箱'的形式出现。组合类之间没有很强的依赖关系,耦合度低。
程序的设计要求高内聚,低耦合,这样可以保证每个模块的稳定与高效。因此优先使用组合,而不是继承。实际尽量多去用组合,组合的耦合度低,代码维护性好。不过也不太那么绝对,类之间的关系就适合继承(is-a)那就用继承,另外要实现多态,也必须要继承。类之间的关系既适合用继承(is-a)也适合组合(has-a),就用组合。
吃透 C++ 继承的这些知识点 —— 从访问权限的 'Min 规则' 到菱形继承的 '虚继承解决方案',再到继承与组合的 '耦合度权衡',你已经打通了面向对象进阶的关键一关。继承的故事还没结束,它埋下的 '析构函数隐藏''多态适配' 等伏笔,都要在多态专题里揭晓答案。

