C++基础的三大特性之二:继承
继承解决的核心问题是:如何在已有类的基础上扩展新功能,而不重复写代码。它表达的是「is-a(是一个)」关系:Dog 是一个 Animal。以下是我在职坐标的学习记录:
一、什么是继承
继承允许一个新类(派生类 / 子类)基于已有类(基类 / 父类)来构建:
- 派生类自动获得基类的成员(数据 + 函数)
- 派生类可以新增自己的成员
- 派生类可以改写基类的行为(这一点引出了多态,见下篇)
Animal (基类:通用属性 name,通用行为 eat/sleep)
|
+-----+-----+
| |
Dog Cat (派生类:各自新增 bark / catchMouse)
二、基本语法
#include <iostream>
#include <string>
#include <utility> // std::move
// ========== 基类 ==========
class Animal {
protected: // protected:派生类可访问,外部不可访问
std::string name_;
public:
Animal(std::string name) : name_(std::move(name)) {}
void eat() const {
std::cout << name_ << " 正在吃东西\n";
}
void sleep() const {
std::cout << name_ << " 正在睡觉\n";
}
};
// ========== 派生类 ==========
class Dog : public Animal { // public 继承:Dog 是一个 Animal
private:
std::string breed_; // 派生类新增的成员
public:
Dog(std::string name, std::string breed)
: Animal(std::move(name)) // 必须先构造基类部分
, breed_(std::move(breed))
{}
void bark() const { // 派生类新增的行为
std::cout << name_ << "(" << breed_ << ")汪汪叫\n";
}
};
int main() {
Dog d("小黑", "拉布拉多");
d.eat(); // ✅ 继承自 Animal
d.sleep(); // ✅ 继承自 Animal
d.bark(); // ✅ Dog 自己的
// std::cout << d.name_; // ❌ protected,外部不可访问
}
三、三种继承方式:public / protected / private
继承方式决定了基类成员在派生类中的最高可见级别:
| 基类中的成员 | public 继承 |
protected 继承 |
private 继承 |
|---|---|---|---|
public |
public | protected | private |
protected |
protected | protected | private |
private |
不可访问 | 不可访问 | 不可访问 |
实践中 99% 的情况用 public 继承,因为它保留了「is-a」语义。
private继承的语义是「根据……实现出」(implemented-in-terms-of),等价于组合。现代 C++ 更推荐直接用成员对象(组合)来表达,可读性更好:
// ❌ 晦涩:private 继承
class Stack : private std::vector<int> { /* ... */ };
// ✅ 清晰:组合
class Stack {
private:
std::vector<int> data_; // Stack 有一个 vector,而不是"是一个" vector
};
设计准则:《Effective C++》条款 32 —— 确保 public 继承建模出 is-a 关系。
经典反例:class Square : public Rectangle。数学上正方形是矩形,但Rectangle有独立的setWidth/setHeight,正方形却要求长宽必须相等——派生类削弱了基类的契约,这不是真正的 is-a。
四、构造与析构的顺序
4.1 构造顺序:基类 → 派生类
class Base {
public:
Base() { std::cout << "Base 构造\n"; }
~Base() { std::cout << "Base 析构\n"; }
};
class Derived : public Base {
public:
Derived() { std::cout << "Derived 构造\n"; }
~Derived() { std::cout << "Derived 析构\n"; }
};
int main() {
Derived d;
}
// 输出:
// Base 构造
// Derived 构造
// Derived 析构 ← 析构顺序相反
// Base 析构
规则:
- 先构造基类部分,再构造成员对象,最后执行派生类构造函数体
- 派生类构造函数必须在初始化列表中调用基类构造函数;不写则调用基类默认构造函数
- 析构顺序严格相反
4.2 必须显式调用基类构造函数的情况
class Animal {
public:
Animal(std::string name) : name_(std::move(name)) {} // 没有默认构造函数
private:
std::string name_;
};
class Dog : public Animal {
public:
Dog() {} // ❌ 编译错误:Animal 没有无参构造函数
Dog() : Animal("无名") {} // ✅ 正确
};
五、名字隐藏(Name Hiding)
派生类中声明的同名函数会隐藏基类中所有同名重载版本——这是 C++ 最容易踩的坑之一:
class Base {
public:
void foo(int) { std::cout << "Base::foo(int)\n"; }
void foo(double) { std::cout << "Base::foo(double)\n"; }
};
class Derived : public Base {
public:
void foo(std::string) { std::cout << "Derived::foo(string)\n"; }
};
int main() {
Derived d;
d.foo("hi"); // ✅ Derived::foo(string)
d.foo(42); // ❌ 编译错误!Base::foo(int) 被隐藏了,重载集不跨类
}
注意:这与虚函数覆盖不同。名字隐藏只看名字,不看参数签名。
解决方法:用 using 声明把基类版本引入派生类作用域:
class Derived : public Base {
public:
using Base::foo; // 引入 Base 的所有 foo 重载
void foo(std::string) { /*...*/ }
};
d.foo(42); // ✅ 现在可以调用 Base::foo(int)
六、多重继承与菱形问题
C++ 支持一个派生类继承多个基类:
class Flying { public: void fly() { /*...*/ } };
class Swimming { public: void swim() { /*...*/ } };
class Duck : public Flying, public Swimming {}; // 鸭子既能飞又能游
菱形继承(Diamond Problem)
Animal
/ \
Dog Cat
\ /
DogCat
DogCat 会通过两条路径各继承一份 Animal 的成员,导致数据冗余 + 访问歧义:
class Animal { public: int age; };
class Dog : public Animal {};
class Cat : public Animal {};
class DogCat : public Dog, public Cat {};
DogCat dc;
dc.age = 3; // ❌ 编译错误:age 有歧义(Dog::Animal::age 还是 Cat::Animal::age?)
dc.Dog::age = 3; // ✅ 勉强可用,但存在两份独立数据
解决方案:虚继承(virtual inheritance)
class Animal { public: int age = 0; };
class Dog : virtual public Animal {}; // 虚继承
class Cat : virtual public Animal {}; // 虚继承
class DogCat : public Dog, public Cat {};
DogCat dc;
dc.age = 3; // ✅ 只有一份 Animal 子对象,无歧义
虚继承保证了最顶层基类在最终派生类中只存在一份共享实例。代价是有额外的运行时开销(虚基类表指针)和更复杂的构造顺序(虚基类由最底层派生类负责初始化)。
实践建议:多重继承容易引入复杂性,优先使用单继承 + 多接口(只含纯虚函数的类),或者干脆用组合。虚继承是最后的手段。
七、组合优先于继承
继承是最强的耦合关系之一——派生类与基类紧密绑定,基类的任何改动都会波及派生类。
判断准则:
| 关系 | 语义 | 推荐做法 |
|---|---|---|
| is-a | Dog 是一个 Animal |
用 public 继承 |
| has-a | Car 有一个 Engine |
用组合(成员对象) |
| is-implemented-in-terms-of | Stack 用 vector 实现 |
用组合(成员对象) |
// ✅ 组合:灵活、低耦合,Engine 可替换、可复用
class Car {
private:
Engine engine_; // Car 有一个引擎
Wheel wheels_[4];
public:
void start() { engine_.ignite(); }
};
// ❌ 继承:Car 不是一个 Engine,"汽车是一种引擎"在语义上就是错的
class Car : public Engine {};
组合的优势:
- 编译期依赖更小(可用前向声明 + Pimpl)
- 可在运行时替换实现(配合多态)
- 不会破坏封装(不像
protected成员那样暴露给派生类)
八、常见误区
8.1 基类的析构函数必须考虑继承场景
如果一个类可能被继承并通过基类指针删除,析构函数可以是非虚的——但一旦涉及多态删除,就必须是虚函数:
class Base {
public:
~Base() { /* ... */ } // ⚠️ 非虚析构:通过 Base* 删除 Derived 对象是未定义行为
};
正确做法见 多态篇——基类析构函数要么 virtual,要么 protected。
8.2 不要为了复用而继承
// ❌ 为了用 List 的排序功能而继承 List,但 Student 显然不是一个 List
class Student : public List { /* ... */ };
8.3 protected 数据成员同样是耦合
protected 数据成员等于把实现细节暴露给所有派生类,改一个字段会波及所有子类。更稳妥的是提供 protected 的接口函数 + private 的数据。
九、小结
| 要点 | 说明 |
|---|---|
| 本质 | 在已有类基础上扩展,表达 is-a 关系 |
| 语法 | class Derived : public Base { ... }; |
| 构造顺序 | 基类 → 成员 → 派生类自身;析构严格相反 |
| 可见性 | 优先 public 继承;private 继承应改用组合 |
| 陷阱 | 名字隐藏(用 using 解决)、菱形继承(用 virtual 继承解决) |
| 设计准则 | 组合优先于继承;public 继承必须满足真正的 is-a |
下一步:继承让代码可以复用,但真正的威力来自 多态——让基类指针在运行时调用到派生类的实现。
浙公网安备 33010602011771号