本文是我系统整理 C++ 面向对象三大特性的第一篇继承笔记。继承是多态的基础,也是整个 OOP 体系里最容易写错、面试最爱考的部分。写这篇文章的目的一方面是把自己学到的东西梳理清楚,另一方面也希望对看到这篇文章的人有所帮助。内容分上下两篇,本篇覆盖继承基础、权限变化、切片原理、作用域与隐藏;下篇覆盖派生类默认成员函数、虚继承底层、菱形继承以及继承与组合的设计取舍。


为什么需要继承

在没有继承之前,假设我们要设计 StudentTeacher 两个类,会发现两个类里有大量重复的成员:姓名、地址、电话、年龄,以及身份认证函数 identity()。每个类都单独写一遍,这叫代码冗余,改一处就要改两处,维护起来很痛苦。

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() { /* 刷二维码认证 */ }  // 和 Student 完全重复
    void teaching() { /* ... */ }
protected:
    string _name = "张三";
    int    _age = 18;
    string _address;
    string _tel;
    string _title;   // 职称(Teacher 独有)
};

继承就是为了解决这个问题。把公共部分抽到基类 PersonStudentTeacher 各自继承 Person,只需要写自己独有的部分,公共成员自动复用。

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();   // 直接调用从 Person 继承来的函数
    t.identity();
    return 0;
}

继承的本质:函数层次的复用是把代码抽成函数,继承是类设计层次的复用,把公共接口和属性抽到基类。


继承的定义语法

class 派生类名 : 继承方式 基类名

Person 叫基类,也叫父类;Student 叫派生类,也叫子类。两种叫法都对,是翻译来源不同导致的。

继承方式有三种:publicprotectedprivate

class Student : public Person    { /* ... */ };  // public 继承(最常用)
class Student : protected Person { /* ... */ };  // protected 继承
class Student : private Person   { /* ... */ };  // private 继承

一个容易忽略的默认行为: class 关键字定义的类默认继承方式是 privatestruct 定义的类默认是 public。实际写代码时建议显式写出继承方式,不要依赖默认值,可读性更好也不容易出错。


继承后成员访问权限的变化

这是继承里最基础也最容易搞混的部分,先看完整的变化表:

基类成员 / 继承方式public 继承protected 继承private 继承
基类 public 成员派生类 public派生类 protected派生类 private
基类 protected 成员派生类 protected派生类 protected派生类 private
基类 private 成员派生类不可见派生类不可见派生类不可见

表格看起来复杂,其实记一个规律就够了:

派生类成员的访问权限 = Min(基类成员权限, 继承方式),其中 public > protected > private

举几个例子验证:

  • 基类 public 成员,用 protected 继承:Min(public, protected) = protected,所以在派生类里变成 protected。
  • 基类 protected 成员,用 public 继承:Min(protected, public) = protected,仍然是 protected。
  • 基类 private 成员,无论什么继承方式:结果永远是不可见。

关于 private 成员"不可见"的准确含义: 不可见不等于没继承过来。基类的 private 成员实际上已经存在于派生类对象的内存中,只是语法上不允许派生类在类内或类外访问它。这个区别在面试里经常被问到。

protected 存在的意义: 如果一个成员既不想被外部直接访问(不能是 public),又需要在派生类内部访问(不能是 private),就用 protected。可以说 protected 限定符是专门为继承而生的。

实际开发里几乎只用 public 继承。 protected 和 private 继承下来的成员只能在派生类内部使用,对外完全封闭,扩展性差,实际项目里极少见到。


继承类模板

基类是类模板时,派生类在调用基类成员函数时需要显式指定类域,否则编译器找不到。

namespace bit {
    template<class T>
    class stack : public std::vector<T> {
    public:
        void push(const T& x) {
            // 必须写 vector<T>::push_back(x)
            // 不能直接写 push_back(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();
        }
    };
}

原因在于模板是按需实例化的。stack<int> 实例化时虽然也实例化了 vector<int>push_back 等成员函数在没有显式调用的情况下未被实例化,编译器在当前作用域找不到,必须通过 vector<T>:: 指定类域告诉编译器去哪里找。


基类与派生类之间的转换

这部分有三条规则,方向不对称,很容易混淆。

规则一:派生类对象可以赋值给基类的指针或引用(切片)

class Person {
protected:
    string _name;
    string _sex;
    int    _age;
};

class Student : public Person {
public:
    int _No;  // 学号
};

int main() {
    Student sobj;

    Person* pp = &sobj;   // OK:基类指针指向派生类对象
    Person& rp = sobj;    // OK:基类引用绑定派生类对象
    Person  pobj = sobj;  // OK:派生类对象赋值给基类对象(调用基类拷贝构造)

    return 0;
}

这个过程有个形象的名字叫**切片(slice)**或切割。意思是把派生类对象中属于基类的那部分"切出来",让基类指针或引用指向这一部分。

切片只在 public 继承下成立。private 和 protected 继承不支持这种隐式转换,因为它们破坏了"派生类 is-a 基类"的语义。

规则二:基类对象不能赋值给派生类对象

sobj = pobj;  // 编译报错

道理很简单:基类对象里没有派生类独有的成员(比如 _No),强行赋值过去,那部分数据从哪来?所以编译器直接拒绝。

规则三:基类指针或引用可以强转为派生类指针或引用,但有条件

Person* pp = &sobj;
Student* ps = (Student*)pp;  // 安全,因为 pp 本来就指向 Student 对象

只有当基类指针本来就指向的是派生类对象时,这种强转才安全。如果基类指针指向的是一个纯基类对象,强转后访问派生类成员会产生未定义行为。如果基类是多态类型,可以用 dynamic_cast 做运行时安全识别(这个放到类型转换章节再讲)。


继承中的作用域与隐藏

继承体系中,基类和派生类各自有独立的作用域。当两个类里出现同名成员时,就会触发隐藏规则。

什么是隐藏

派生类中的同名成员会屏蔽基类同名成员的直接访问,这种情况叫隐藏(也叫重定义)。

class Person {
protected:
    string _name = "小李子";
    int    _num  = 111;   // 身份证号
};

class Student : public Person {
public:
    void Print() {
        cout << "姓名:" << _name << endl;
        cout << "身份证号:" << Person::_num << endl;  // 加类域才能访问基类的 _num
        cout << "学号:" << _num << endl;              // 直接访问的是 Student 自己的 _num
    }
protected:
    int _num = 999;   // 学号,和 Person::_num 同名,构成隐藏
};

Student::_numPerson::_num 隐藏了。在 Student 内部直接写 _num 访问到的是学号 999,如果要访问身份证号 111,必须显式加上 Person::_num

隐藏和重载的区别: 重载要求在同一作用域,且函数名相同但参数列表不同。隐藏发生在不同作用域(父子类),只要名字相同就构成隐藏,参数列表是否相同不影响结论。

成员函数的隐藏只看函数名,不看参数:

class A {
public:
    void fun() { cout << "func()" << endl; }
};

class B : public A {
public:
    void fun(int i) { cout << "func(int i) " << i << endl; }
    // B::fun(int) 隐藏了 A::fun(),两者构成隐藏而非重载
};

int main() {
    B b;
    b.fun(10);  // OK,调用 B::fun(int)
    b.fun();    // 编译报错!A::fun() 被隐藏,直接调用找不到
    return 0;
}

这道题的答案是编译报错,原因是 B::fun(int) 隐藏了基类的 A::fun()b.fun() 在 B 的作用域里找到的是 fun(int),但没有传参,参数不匹配,编译失败。如果想调用基类版本,需要写 b.A::fun()

很多人看到 B 里有 fun(int i)、A 里有 fun(),参数不同,就以为构成重载。这是典型的误区——重载必须在同一作用域,父子类是两个独立作用域,不存在重载,只有隐藏。


考察题详解

题目一:A 和 B 类中的两个 func 构成什么关系?

答案是 B(隐藏)。理由上面已经分析过,不同作用域,名字相同即构成隐藏,参数不同不影响。

题目二:下面程序的编译运行结果是什么?

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);
    b.fun();    // 这里是关键
    return 0;
}

答案是 A(编译报错)。编译器在 B 的作用域里找到了 fun(int),确定了候选函数,但 b.fun() 没有传参,不匹配,报错。编译器不会再去父类里找,因为 B::fun 已经把 A::fun 隐藏掉了。

这两道题放在一起是因为它们考的是同一个知识点的两面:第一道考你认不认识隐藏,第二道考你理不理解隐藏之后名字查找的行为。


易错点总结

1. private 成员"不可见"不等于"没有继承"

派生类对象里实际存在基类 private 成员的内存,只是语法上无法访问。sizeof 一个派生类对象可以验证这一点。

2. 切片只在 public 继承下有效

Person* pp = &sobj 这种隐式转换只对 public 继承成立。写代码时如果用了 private 继承又尝试切片,编译器会直接报错,但报错信息可能比较难懂,不容易定位原因。

3. 隐藏和重载的混淆

不同作用域永远不构成重载,只要函数名相同就是隐藏。实际开发里要尽量避免在继承体系中定义同名成员,非常容易引发混淆。

4. class 继承默认是 private

class Student : Person { /* ... */ };  // 等价于 private 继承,不是 public!

这个默认行为很容易踩坑,明明想用 public 继承,漏写了关键字,切片就失效了,但编译器不会直接告诉你是继承方式的问题。

5. 基类指针强转派生类指针的安全前提

强转本身不会报错,但如果基类指针指向的不是派生类对象,访问派生类成员就是未定义行为,运行时可能崩溃也可能静默出错,非常难调试。


小结

本篇覆盖了继承的基础部分:为什么需要继承、三种继承方式及权限变化规则(Min 公式)、切片的原理与单向性、继承类模板的类域指定、以及隐藏规则和两道经典考察题的深度分析。

下篇将深入派生类的六个默认成员函数(构造/析构顺序、拷贝构造、赋值运算符在继承中的特殊处理)、友元与静态成员的继承行为、菱形继承的数据冗余与二义性问题,以及虚继承在底层是如何通过 vbptr 和虚基表解决这些问题的。

更多推荐