复用不该靠复制粘贴。继承是 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_ageidentity() 在两个类里一字不差地重复了。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;  // 职称
};

StudentTeacher 不再需要重复定义姓名、地址、电话——继承自 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 不可见 不可见 不可见

几个要点:

  1. private 成员在任何继承方式下都不可见。它们确实被继承到了派生类对象中(占据内存),但语法上派生类无论在类内还是类外都无法访问。
  2. protected 限定符因继承而生。如果基类成员不想被外部直接访问,但又需要派生类能访问——就用 protected
  3. 实际工程中几乎只用 public 继承。protected/private 继承让派生类拿到的成员只能在类内部使用,扩展维护性差。
  4. class 关键字默认继承方式是 privatestruct 默认是 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 的继承关系形成了一个菱形:PersonStudentTeacher 分别继承,Assistant 又同时继承两者。这带来两个问题:

  1. 二义性a._name = "peter"; 编译报错——编译器不知道你要访问从 Student 来的 _name 还是从 Teacher 来的。
  2. 数据冗余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 继承,除非:

  1. 需要访问基类的 protected 成员
  2. 需要重写基类的虚函数
  3. 需要利用空白基类最优化(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++ 多态:同一调用,不同行为 —— 继承为多态铺路,虚函数让同一行代码产生不同行为。

更多推荐