C++ 继承:代码复用的层次之道
复用不该靠复制粘贴。继承是 C++ 在类层面给出的答案。
为什么需要继承
写过 C 语言的人一定熟悉这种场景:两个结构体有大量重复字段,处理函数写了几乎一模一样的逻辑。你复制了一份,改了改——然后某天发现一个 Bug,得改两处。
这就是没有继承时的困境。假设要写一个学生管理系统:
// 冗余的写法:Student 和 Teacher 各自维护一份重复信息
class Student {
public:
void identity() { /* 身份认证 */ }
void study() { /* 学习 */ }
protected:
string _name = "peter";
string _address;
string _tel;
int _age = 18;
int _stuid; // 学号——Student 独有
};
class Teacher {
public:
void identity() { /* 身份认证 */ }
void teaching() { /* 授课 */ }
protected:
string _name = "张三";
int _age = 18;
string _address;
string _tel;
string _title; // 职称——Teacher 独有
};
_name、_address、_tel、_age 和 identity() 在两个类里一字不差地重复了。C 语言的套路是把公共部分抽成 struct Person 嵌套进去,但每次访问都要多写一层 p.info.name——这本质上是组合,不是继承。
继承(Inheritance) 做的事更直接:把公共成员放进基类,派生类自动拥有它们,同时还可以扩展自己的独有成员。它呈现的是面向对象程序设计的层次结构——由简单到复杂的认知过程,现在可以直接表达在代码里。
继承的定义与访问控制
定义格式
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 继承
public:
void study() { /* ... */ }
protected:
int _stuid; // 学号
};
class Teacher : public Person {
public:
void teaching() { /* ... */ }
protected:
string _title; // 职称
};
Student 和 Teacher 不再需要重复定义姓名、地址、电话——继承自 Person 的部分直接可用。
💡 背景补充:C 语言中类似的复用手段是在
struct B中嵌入struct A,然后通过b.a.field访问。这其实是组合(has-a),不是继承(is-a)。继承让派生类就是一个基类,而不只是包含一个基类。
基类成员在派生类中的访问权限
这不是简单的"继承过来还是原来的权限"。实际情况由两个因素共同决定:成员在基类中的访问限定符和继承方式。规则可以用一句话概括:
派生类中成员的访问权限 = min(基类访问限定符, 继承方式)
其中 public > protected > private。
| 基类成员访问限定符 | public 继承 | protected 继承 | private 继承 |
|---|---|---|---|
| public | public | protected | private |
| protected | protected | protected | private |
| private | 不可见 | 不可见 | 不可见 |
几个要点:
- private 成员在任何继承方式下都不可见。它们确实被继承到了派生类对象中(占据内存),但语法上派生类无论在类内还是类外都无法访问。
- protected 限定符因继承而生。如果基类成员不想被外部直接访问,但又需要派生类能访问——就用
protected。 - 实际工程中几乎只用 public 继承。protected/private 继承让派生类拿到的成员只能在类内部使用,扩展维护性差。
class关键字默认继承方式是private,struct默认是public。显式写出继承方式,不要依赖默认。
继承中的作用域:隐藏规则
基类和派生类各自拥有独立的作用域。当派生类中定义了与基类同名的成员(变量或函数),派生类的成员会隐藏(Hide) 基类的同名成员——即使函数签名不同也构成隐藏。
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; // 访问的是 Student::_num
}
protected:
int _num = 999; // 学号——隐藏了 Person::_num
};
调用 s1.Print() 输出:
姓名:小李子
身份证号:111
学号:999
对于成员函数,只需要函数名相同就构成隐藏——参数列表不同也不行。这和重载(同一作用域内函数名相同但参数不同)是完全不同的概念。所以:
⚠️ 在继承体系中,不要再定义同名成员。隐藏不是功能——是技术债。
破解隐藏:using 声明
如果确实需要在派生类中为基类的重载函数集添加新版本(而非全部隐藏),C++ 提供了 using 声明:
class Base {
public:
virtual void mf1() = 0;
virtual void mf1(int); // mf1 的重载版本
virtual void mf2();
void mf3();
void mf3(double); // mf3 的重载版本
};
class Derived : public Base {
public:
using Base::mf1; // 让基类所有名为 mf1 的函数在派生类中可见
using Base::mf3; // 让基类所有名为 mf3 的函数在派生类中可见
virtual void mf1() override; // 只重写无参版本,mf1(int) 也可见
void mf3(); // 只重写无参版本,mf3(double) 也可见
};
Derived d;
d.mf1(); // OK: Derived::mf1
d.mf1(5); // OK: Base::mf1(int) —— 没有被隐藏
d.mf3(); // OK: Derived::mf3
d.mf3(1.0); // OK: Base::mf3(double) —— 没有被隐藏
📖 参考:《Effective C++》条款33;《C++ Primer》第15章
这个技巧在实际工程中很实用——比如派生类想让基类的 print(ostream&) 和 print(FILE*) 两个重载都保持可见,同时又想追加一个 print(const string&)。
派生类的默认成员函数
这可能是初学者最困惑的部分。编译器为派生类自动生成的 6 个默认成员函数,每个都跟基类有耦合关系:
| 派生类默认函数 | 必须调用的基类部分 | 关键点 |
|---|---|---|
| 构造函数 | 基类构造函数(初始化列表) | 基类无默认构造时必须显式调用 |
| 拷贝构造函数 | 基类拷贝构造(初始化列表) | Student(const Student& s) : Person(s), _num(s._num) {} |
| operator= | 基类 operator= | 构成隐藏,需 Person::operator=(s); 显式调用 |
| 析构函数 | 基类析构函数(自动调用) | 先析构派生类,再析构基类——严格逆序 |
完整示例:
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=" << 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=" << endl;
if (this != &s) {
Person::operator=(s); // 显式调用基类赋值——构成隐藏,必须指定作用域
_num = s._num;
}
return *this;
}
~Student() { cout << "~Student()" << endl; } // 执行完后自动调用 ~Person()
protected:
int _num;
};
初始化顺序:基类构造 → 派生类成员(按声明顺序)→ 派生类构造函数体。析构顺序:严格反过来。
还有一个容易被忽略的事实:编译器会将所有析构函数名统一处理成 destructor。所以在不加 virtual 的情况下,基类和派生类的析构函数构成隐藏关系——而非重写。这对后面的多态至关重要。
基类与派生类的转换
public 继承下,派生类对象可以赋值给基类的指针或引用——这个过程常被称为切片(Slicing):只把派生类中属于基类的那部分"切"出来。
class Person {
protected:
string _name;
string _sex;
int _age;
};
class Student : public Person {
public:
int _No; // 学号
};
Student sobj;
Person* pp = &sobj; // ✅ 派生类指针 → 基类指针
Person& rp = sobj; // ✅ 派生类引用 → 基类引用
Person pobj = sobj; // ✅ 通过基类拷贝构造完成(切片)
// sobj = pobj; // ❌ 编译报错:基类对象不能赋值给派生类对象
基类指针/引用要转回派生类指针,需要强制类型转换——而且只有在基类指针本来就指向派生类对象时才安全。
这里引出一个重要的概念区分:静态类型(Static Type) 和动态类型(Dynamic Type)。
- 静态类型:变量声明时的类型,编译期确定,永远不变
- 动态类型:变量实际指向/引用的对象类型,运行期确定,可以变化
只有指针和引用有动态类型的区分。 普通对象没有:
Student st;
Person* ptr = &st; // ptr 的静态类型 = Person*,动态类型 = Student*
Person& ref = st; // ref 的静态类型 = Person&,动态类型 = Student&
Person obj = st; // obj 的静态类型和动态类型都是 Person——派生类部分已被切掉!
obj.BuyTicket(); // 调用 Person::BuyTicket,不是 Student::BuyTicket
C++ 提供了 dynamic_cast 来做安全的向下转型(Downcasting)——运行时检查基类指针是否真的指向某个派生类对象:
void f(Person* ptr) {
if (Student* sp = dynamic_cast<Student*>(ptr)) {
// ptr 确实指向 Student,sp 非空
cout << "学号: " << sp->_No << endl;
} else {
// ptr 不是 Student
cout << "不是学生" << endl;
}
}
dynamic_cast 对指针失败时返回 nullptr,对引用失败时抛出 std::bad_cast。不过要注意——频繁使用 dynamic_cast 通常暗示设计有问题,应当优先考虑用虚函数替代类型分支。
📖 参考:《C++ Primer》第15章
多继承与菱形继承
多继承
C++ 允许一个派生类同时继承多个基类:
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;
};
内存模型:先继承的基类在前,后继承的基类在后,派生类自己的成员在最后。
菱形继承问题
上面 Assistant 的继承关系形成了一个菱形:Person 被 Student 和 Teacher 分别继承,Assistant 又同时继承两者。这带来两个问题:
- 二义性:
a._name = "peter";编译报错——编译器不知道你要访问从Student来的_name还是从Teacher来的。 - 数据冗余:
Assistant对象中有两份Person的成员。
Assistant a;
// a._name = "peter"; // ❌ 编译报错:对"_name"的访问不明确
a.Student::_name = "xxx"; // 只能显式指定路径——但数据冗余无法解决
a.Teacher::_name = "yyy";
虚继承:解决菱形问题
用 virtual 关键字修饰继承,可以让共同基类在最终派生类中只保留一份:
class Student : virtual public Person { /* ... */ };
class Teacher : virtual public Person { /* ... */ };
class Assistant : public Student, public Teacher { /* ... */ };
Assistant a;
a._name = "peter"; // ✅ 现在只有一份 _name,没有二义性
但虚继承有代价。底层实现更复杂,性能有损耗。C++ 支持多继承,就必然可能出现菱形继承——Java 通过直接禁止多继承绕过了这个问题。
⚠️ 不要主动设计菱形继承。如果已经陷入,虚继承是解药——但它不是让你随意设计菱形结构的通行证。
虚继承还有一个值得注意的细节:最终派生类负责初始化虚基类。
Assistant(const char* name1, const char* name2, const char* name3)
: Person(name3) // Assistant 直接初始化虚基类 Person
, Student(name1, 1) // Student 的 Person(name1) 被忽略
, Teacher(name2, 2) // Teacher 的 Person(name2) 被忽略
{}
// a 对象中的 _name 是 "王五"(来自 Person(name3))
继承 vs 组合:is-a 还是 has-a
这是面向对象设计中最需要厘清的一对概念。
| 继承(public) | 组合 | |
|---|---|---|
| 关系 | is-a(是一种) | has-a(有一个) |
| 复用方式 | 白箱复用——基类内部细节对派生类可见 | 黑箱复用——对象内部细节不可见 |
| 耦合度 | 高。基类改变,派生类受影响 | 低。只依赖公开接口 |
| 封装性 | 一定程度上破坏了基类封装 | 保持良好封装 |
// 组合:Car has-a Tire
class Tire {
protected:
string _brand = "Michelin";
size_t _size = 17;
};
class Car {
protected:
string _colour = "白色";
string _num = "陕ABIT00";
Tire _t1, _t2, _t3, _t4; // Car 由 4 个 Tire 组合而成——has-a
};
// 继承:BMW is-a Car
class BMW : public Car {
public:
void Drive() { cout << "好开-操控" << endl; }
};
📖 参考:《高质量C++/C编程指南》第10章;《Effective C++》条款38
设计时遵循优先级:能用组合就用组合。只有当类之间的关系明确符合 is-a(“B 是 A 的一种”),且基类的所有功能和属性对派生类都有意义时,才使用继承。要实现多态,继承是必须的——但那是下一篇文章的话题。
组合的另一种语义:is-implemented-in-terms-of
组合除了表达"有一个"(has-a),还有一层更底层的意思——“根据某物实现出”(is-implemented-in-terms-of)。这种场景下,新类并不"拥有"旧类的实例作为语义组件,而是利用旧类的内部机制来实现自身功能。
// is-implemented-in-terms-of:用 std::list 实现 Set
template<class T>
class Set {
public:
bool contains(const T& item) const {
return find(_data.begin(), _data.end(), item) != _data.end();
}
void insert(const T& item) {
if (!contains(item)) _data.push_back(item);
}
private:
std::list<T> _data; // Set 不是 List,但可以用 List 实现
};
Set<T> 和 std::list<T> 之间显然不是 is-a 关系——Set 不允许重复元素、不关心顺序。用组合而非继承,既利用了 list 的存储能力,又没有把 list 的接口暴露给 Set 的用户。
private 继承:当组合不够用时
C++ 还有 private 继承——这同样是 is-implemented-in-terms-of,但比组合更"重"。基类的 public 和 protected 成员在派生类中全部变为 private,对外部完全不可见。
能用组合就不要用 private 继承,除非:
- 需要访问基类的 protected 成员
- 需要重写基类的虚函数
- 需要利用空白基类最优化(EBO)——空类通过 private 继承不占额外空间
class Timer {
public:
virtual void onTick() const { /* ... */ }
};
class Widget : private Timer { // private 继承
private:
virtual void onTick() const override { /* Widget 的定时处理 */ }
// Timer 的 public 接口对外部完全隐藏——这是实现细节,不是接口
};
📖 参考:《Effective C++》条款39
其他细节速览
不能被继承的类:C++11 用 final 关键字直接禁止继承。C++98 则需要把构造函数私有化——派生类无法调用基类构造,自然无法实例化。
class Base final { /* ... */ }; // C++11:简洁明了
// class Derived : public Base {}; // ❌ 编译报错
友元关系不继承:基类的友元不能访问派生类的私有和保护成员。如果确实需要,必须把友元也声明为派生类的友元。
静态成员全局唯一:基类中定义的 static 成员在整个继承体系中只有一份。无论派生多少个子类,都共享同一个静态成员实例。
类模板继承:派生类继承模板基类时,访问基类成员需要指定类域(如 vector<T>::push_back(x)),因为模板按需实例化——编译器在第一次扫描时看不到基类模板的成员。
继承的构造函数(C++11):派生类可以用 using Base::Base; 直接"继承"基类的所有构造函数(除默认构造、拷贝构造、移动构造外),免去手动写转发构造函数的重复劳动:
class Base {
public:
Base(int i);
Base(int i, double d);
};
class Derived : public Base {
public:
using Base::Base; // 继承 Base 的所有构造函数
// 等价于编译器自动生成:
// Derived(int i) : Base(i) {}
// Derived(int i, double d) : Base(i, d) {}
};
📖 参考:《C++ Primer》第15章
本节要点
- 继承解决类层次的代码复用,不是取代组合的万能工具
- 访问权限 = min(基类限定符, 继承方式);private 成员在任何继承方式下都不可见
- 同名即隐藏,不管函数签名是否相同——不要在继承体系中定义同名成员
- 派生类默认成员函数必须调用基类对应函数;构造/析构顺序严格遵循"先基类后派生、析构反过来"
- 菱形继承用虚继承解决,但最好从一开始就不要设计菱形结构
- 优先组合,其次继承——is-a 用继承,has-a 用组合,模棱两可用组合
📖 参考:《高质量C++/C编程指南》第9章(类的构造/析构/赋值)、第10章(继承与组合);《Effective C++》条款32-40(继承与面向对象设计);《C++ Primer》第15章(面向对象程序设计);《C++ Primer Plus》第13章(类继承)
📎 下一篇:C++ 多态:同一调用,不同行为 —— 继承为多态铺路,虚函数让同一行代码产生不同行为。
更多推荐


所有评论(0)