面向对象(Object-Oriented Programming,简写 OOP)编程有三大支柱:封装(Encapsulation)、继承(Inheritance)和多态(Polymorphism)。我们前面已经用类和对象讲透了封装——把数据和操作数据的方法捆在一起,用 private 把内部细节藏起来。今天就跨到第二根支柱:继承。

为什么继承地位这么高?因为它是面向对象程序设计中让代码可以复用(reuse)的最重要手段。但请注意,这个"复用"和我们以前在函数里做的复用不是一个层次:函数的复用是在"指令"这一层,而继承是在"类设计"这一层。换句话说,函数是一座小砖块,类是一面墙,而继承让你能在一面墙的基础上再搭出新的墙,不必从零开始。正因为有了继承,我们才能用"由简单到复杂"的方式去组织一个大型程序。

"由简单到复杂"这句话可能有点抽象,换个生活场景你就懂了:编程里的继承,特别像盖楼。先打下一套公共的"地基"(基类),然后在这套地基上盖出学生宿舍、教师公寓、办公楼(派生类)。每个建筑都共享同一套水电系统(基类的成员),又各自有自己的房间布局(派生类新增的成员)。你永远不需要给每一栋楼重新挖一次地基——把公共的东西沉淀到底层,把差异的东西留在各层,这正是继承要解决的问题。

在开始之前,我默认你已经理解了几个前置知识:什么是类、什么是 public/private 这样的访问限定符、类对象在内存里是怎么存放的。如果这些还有点模糊,我建议你先回去把类和封装那一篇过一遍,因为今天的内容几乎全部建立在它们之上——尤其"对象在内存里是一块连续空间、成员按声明顺序排列"这个认知,后面理解"切片"和"菱形继承"的时候会反复用到。

为什么需要继承:让代码可以复用

我们先从一个具体的困境出发。假设你要为一个大学管理系统写代码,需要设计两个类:一个是学生(Student),一个是老师(Teacher)。在没有继承之前,你可能只能这样硬写:

#include <iostream>
#include <string>
using namespace std;
 
// 学生类:录入校园信息
class Student
{
public:
    void identity()   // 刷二维码做身份认证
    {
        cout << "Student identity()" << endl;
    }
    void study()      // 学生的独有动作:学习
    {
        cout << "study()" << endl;
    }
protected:
    string _name = "peter";   // 姓名
    string _address;          // 地址
    string _tel;              // 电话
    int _age = 18;            // 年龄
    int _stuid;               // 独有:学号
};
 
// 老师类:同样录入校园信息
class Teacher
{
public:
    void identity()   // 刷二维码做身份认证,功能几乎一样
    {
        cout << "Teacher identity()" << endl;
    }
    void teaching()   // 老师的独有动作:授课
    {
        cout << "teaching()" << endl;
    }
protected:
    string _name = "张三";    // 姓名
    int _age = 18;            // 年龄
    string _address;          // 地址
    string _tel;              // 电话
    string _title;            // 独有:职称
};
 
int main()
{
    Student s;
    Teacher t;
    s.identity();
    t.identity();
    return 0;
}

你盯着这段代码,应该已经闻到一股"冗余"的味道了:_name、_age、_address、_tel 这个"姓名、年龄、地址、电话"的组合,在两个类里几乎原样被复制了一遍;identity() 这个身份认证函数也写了两份。如果以后要加一个"邮箱"字段,或者给身份认证逻辑加一种校验方式,你就得在两个类里各改一次——如果学校里有十种角色,你就得改十次。这种重复,就是代码里最想消灭的东西。软件工程里有个专门的词描述这种现象,叫 DRY 原则(Don't Repeat Yourself,不要重复你自己)——你现在看到的,就是违反 DRY 之后的典型后果。

这里的本质是:Student 和 Teacher 虽然角色不同,但有一个公共的身份——他们都是"人"。人这个"基类"拥有的共同属性(姓名、年龄、联系方式)和共同行为(身份认证),完全可以抽出来单独定义一个类,比如叫 Person(人)。然后让 Student 和 Teacher 去"继承"它。这样,那些公共的成员,Student 和 Teacher 就不用自己再定义一遍了。

这个"把公共部分抽出来、让差异部分各自保留"的操作,在面向对象里叫抽取公共基类,它其实是"自上而下分层"思想的第一步。你别小看这一步——很多大型框架(后面你会接触到的 Qt、MFC、标准库的流类体系)都是靠这种层层抽取堆起来的。

我们来改造一下,看看继承是怎么一劳永逸地消掉冗余的:

#include <iostream>
#include <string>
using namespace std;
 
// 公共部分抽到一个基类 Person 中
class Person
{
public:
    // 进校园/图书馆/实验室刷二维码等身份认证
    void identity()
    {
        cout << "void identity() " << _name << endl;
    }
protected:
    string _name = "张三";   // 姓名
    string _address;         // 地址
    string _tel;             // 电话
    int _age = 18;           // 年龄
};
 
// Student 用 public 继承 Person,只保留自己独有的内容
class Student : public Person
{
public:
    void study()             // 学生独有:学习
    {
        cout << "study()" << endl;
    }
protected:
    int _stuid;              // 学生独有:学号
};
 
// Teacher 同样继承 Person
class Teacher : public Person
{
public:
    void teaching()          // 老师独有:授课
    {
        cout << "teaching()" << endl;
    }
protected:
    string _title;           // 老师独有:职称
};
 
int main()
{
    Student s;
    Teacher t;
    s.identity();   // 虽然 Student 里没写 identity,但它继承了 Person 的方法
    t.identity();   // Teacher 也一样
    return 0;
}

你注意看 class Student : public Person 这里的 public Person,这就是"继承"的语法:冒号后面跟的是你想继承的类。这样一来,Student 从 Person 那里"继承"了 _name、_address、_tel、_age 四个成员变量和 identity() 成员函数,自己只需要声明独有的 _stuid 和 study()。以后要给所有人的邮箱字段,只管在 Person 里加一处,所有派生了 Person 的类全部自动拥有。这才是"设计层次"的复用——改一次,处处生效。

这里顺便把三个术语统一一下:Person 叫基类(base class),也叫父类;Student、Teacher 叫派生类(derived class),也叫子类。因为翻译的原因,这两组叫法在教材和网上都常见,见到谁都不要懵,它们完全是一回事。英文里还常出现 superclass/subclass(超类/子类)、parent/child(父/子),指的还是同样的东西。以后读英文文档时别被这几个词吓到,它们都是"基类/派生类"的意思。

再给一个生活类比帮你固化"继承=共享公共物"这个直觉:把继承想成基因遗传。孩子(派生类)会从父母(基类)那里遗传到眼睛颜色、身高这些"公共基因"(基类成员),然后又有自己独特的"后天特征"(派生类新增成员)。你永远不会单独再给每个孩子"发明"一遍眼睛颜色——那是从父母那里直接继承来的。继承在代码里干的事,和基因做的事一模一样:公共特质上溯到源头,独有特质留在每个个体身上。

基类与派生类:继承的定义格式

继承的完整定义格式是:

class 派生类名 : 继承方式 基类名
{
    // 派生类自己新增的成员
};

继承方式有 public、protected、private 三种,这是下一节的重点。现在你先记住一个结论:实际开发里几乎只用 public 继承,其余两种很少用、也不提倡用,原因后面会讲到。所以在绝大多数情况下,你看到的写法就是 class Student : public Person 这种。

在深入之前,我们先给"继承的长相"做个全面体检。继承在 C++ 里有三种"形状",你可以根据继承链的分叉情况来辨认:

单继承(single inheritance):一个派生类只有一个直接基类。这是我们最常见的形态,Student 只有一个直接基类 Person,就是单继承。以后你遇到的绝大多数类都属于这种。

多继承(multiple inheritance):一个派生类有两个或以上直接基类。例如"水陆两栖车"既有 Vehicle(交通工具)的属性也有 Boat(船)的属性,那它就可以同时继承这两个基类。多继承是 C++ 的特色,也是后面菱形继承问题的温床,今天我们整篇后面的大半篇幅都在围绕着它打转。

多层继承(multi-level inheritance):形如 A -> B -> C 的一条长链,也就是继承可以一直往下传递几层。比如 Person -> Student -> DoctorStudent(人→学生→博士生),博士生既是学生,也是人。多层继承的每一层都继承上一层,越往下对象越"具体",成员越"多"。

注意区分:"多继承"说的是一个儿子有几个爹(横向),"多层继承"说的是一个祖先传了几辈(纵向)。这两个词很多人搞混,记不住的话就记"多=多个爹,层=传几代"。

其中,B -> C 这种"中间层"的类有个专门的称号——当 B 既有自己的基类 A,又作为 C 的基类时,B 被称为 中间层类。它在整棵继承树里起着承上启下的作用,你可以把它想成一栋楼里"既属于上一层、又被下一层复用"的那个公共层。这个角色在讲虚继承时(菱形继承那一节)格外重要,先在这里把名字记下来。

派生类对象在内存里到底长什么样?

这是理解后续一切概念的关键,我在这里提前打一个底:一个派生类对象,在内存里是"基类子对象的一部分 + 派生类自己新增的一部分"依次排列的。也就是说,你 new 一个 Student,它对象内存的前半段是一个完整的 Person(基类部分),后半段才是 _stuid 等派生类新增的成员。

这句话里有三个字要圈出来——基类子对象(base subobject)。子对象不是什么神秘的东西,它就是指"嵌套在派生类对象内部的那个完整的基类对象"。因为它的存在,一个 Student 对象的内部其实"住"着一个完整的 Person 对象,这也是为什么后面派生类的构造函数必须先去构造这个基类子对象、派生类的对象可以赋值给基类指针——一切"同一性"的根源都在这里。

我再用一座楼打比方:派生类对象好比一栋"楼下是商店、楼上是住宅"的综合楼。你把这栋楼看成一个整体(派生类对象),它的"地下一层到一层"是一个完整的、独立自洽的商铺(基类子对象),"二层往上"才是住宅(派生类新增成员)。任何需要"商铺"的场合,直接把这栋楼的下面几层指过去就行——这就是后面"切片可以安全进行"的内存基础。

记住这张"拼图",下面讲"切片"和"菱形继承"时你会豁然开朗。为了让你亲眼看到这个布局,我们写一段代码把内存"丈量"一下:

#include <iostream>
using namespace std;
 
class Person
{
public:
    int _a = 1;   // 基类第一个成员
    int _b = 2;   // 基类第二个成员
};
 
class Student : public Person
{
public:
    int _c = 3;   // 派生类新增成员
};
 
int main()
{
    Student s;
    // 我们让基类指针/引用指向派生类对象的地址起点
    char* pObj = reinterpret_cast<char*>(&s);        // 整个对象的起始字节地址
    char* pA   = reinterpret_cast<char*>(&s._a);     // 基类成员 _a 的地址
    char* pC   = reinterpret_cast<char*>(&s._c);     // 派生类成员 _c 的地址
 
    cout << "对象起点:" << (void*)pObj << endl;
    cout << "_a 地址 :" << (void*)pA  << "  偏移=" << (long)(pA  - pObj) << endl;
    cout << "_c 地址 :" << (void*)pC  << "  偏移=" << (long)(pC  - pObj) << endl;
    return 0;
}

跑一下你会发现:_a 偏对象起点 0 字节(基类部分在最前面),_c 的地址则在 _a、_b 之后(派生类新增部分在最后面)。基类子对象在前、派生类新增成员在后,这个顺序就是 C++ 在内存里"排列一个继承出来的对象"的通用规则,请刻进脑子里。

顺带说一句:这里我用了 reinterpret_cast(一种底层强制类型转换)和你还没学过的 long(长整型),目的是拿地址做算术看偏移。你现在不需要深究这些语法,只需要看懂"基类在家里的确住在前面"这一点。等讲到指针、类型转换那几篇,这些工具你会全部学会。

继承方式与访问限定符:public / protected / private

这一节是整个继承章节第一个"藏坑"的地方,讲的是:继承方式会如何改变基类成员在派生类中的访问权限。我们先记住一张总表,然后逐条解释:

基类成员public 继承protected 继承private 继承
public 成员publicprotectedprivate
protected 成员protectedprotectedprivate
private 成员不可见不可见不可见

这张表其实可以浓缩成一句公式:

基类非私有成员在派生类中的访问方式 = min(基类中的访问限定符, 继承方式),其中 public > protected > private。

什么意思?就是说,基类里是 public 的成员经过 public 继承还是 public;经过 protected 继承就降级成 protected;经过 private 继承就降级成 private。基类里是 protected 的成员,经过 public 或 protected 继承都保持 protected,经过 private 继承降级为 private。取"更小的那个"权限,就是这张表的规律。"取 min"你也可以理解成"权限只降不升"——继承只会让基类成员在派生类里变得更私密,而绝不可能把它们"提升"成更公有的权限。

但更关键的是第三行,我用一个粗体提醒你:

基类的 private 成员无论用什么方式继承,在派生类中都是"不可见"的。

"不可见"是什么意思?不是说不存在。基类的私有成员仍然被继承进了派生类对象的内存中(前面说过对象里的基类部分包含它),但是语法上限制你:不管在派生类类内还是类外,都不能直接访问它。你想想,private 本来就是"只有类自己内部能访问"的意思,派生类再亲也不是它自己,自然碰不得。打个比方,家庭保险柜是父母私人的,密码只告诉父母自己,孩子(派生类)虽然住在同一个家(同一个对象)里,但也无权打开。

这就引出了一个非常重要的结论,也是今天一定要理解的:protected 这个访问限定符,几乎就是因继承而存在的。如果基类某个成员不想在类外被外人直接改(不想用它当 public),但又希望在派生类里能被你们这些"自家人"访问,怎么办?三类限定符里只剩 protected 可选了。所以你会发现 protected 是个折中:类外看不见,但派生的子类看得见。它就像家里的"公共阳台"——外人(类外代码)进不去,但自家人(派生类)随便出入。

另外还有几个零碎但常考的规则,一次性交代清楚:

  • class 和 struct 的默认继承方式不同:用 class 定义时默认继承方式是 private,用 struct 定义时默认继承方式是 public。为了避免踩坑,我强烈建议你永远把继承方式显式写出来,别依赖默认值。这一条表面上只差两个关键字,但如果你哪天用 class 声明一个继承但又忘了写继承方式,基类的 public 成员会悄悄变成 private,派生类外就全访问不到了——查起来相当隐蔽。
  • 前面说的 min 公式也可以用实例验证:Person 的 private _age 就算用 public 方式继承,Student 里访问 _age 依然报错,报错信息通常是"无法访问 private 成员"。这说明:无论继承方式多"宽容",基类的 private 都进不了派生类的视野。
  • 实际工作中为什么只推荐 public 继承?因为 protected/private 继承会让基类的成员在派生类外全都不可访问,扩展性、维护性都很差。你私下写自己玩玩无所谓,但团队工程里几乎见不到这两种继承,有人把它当"theoretically correct but practically useless"来调侃。

深挖:只有 public 继承才有"向上转型"的资格

这一小节必须单独拎出来讲,因为它是一个极其常见又极其隐蔽的坑:只有 public 继承,才具备"派生类对象可以隐式赋值给基类指针/引用"(也就是 is-a,意为"是……的一种")的资格。如果是 protected 或 private 继承,那么在类外,你连做个"把派生类当基类用"的转换都不被允许。

这背后的道理其实很朴素:public 继承表达的语义是"每个派生类对象,本质都是基类对象"(Student 是 Person),所以它被允许"伪装"成基类;而 private/protected 继承表达的却是"内部借用实现",语义上派生类不是基类,自然就不该被当成基类用。在类外的代码里想用 Person* 去接一个 Student,编译器会直接拒绝。看代码:

#include <iostream>
using namespace std;
 
class Person { public: int _a; };
 
class Pub  : public    Person {};   // public 继承
class Prot : protected Person {};   // protected 继承
class Priv : private   Person {};   // private 继承
 
int main()
{
    Pub   pu;
    Prot  pt;
    Priv  pv;
 
    Person* p1 = &pu;   // ok:public 继承下,派生类可以充当基类指针
    // Person* p2 = &pt; // 编译报错:protected 继承,类外不允许向上转型
    // Person* p3 = &pv; // 编译报错:private 继承,类外不允许向上转型
 
    (void)p1;
    return 0;
}

把注释切回来,你会收到两个编译错误。这也从侧面印证了为什么"实际只用 public 继承":一旦用了 protected/private 继承,就连最基础的"把子类当父类用"都做不到了,这个类是"继承了个寂寞"。真正的看点在于:public 继承 = 能向上转型 = is-a 语义成立,这三者是三位一体、密不可分的。

我们再用一张可完整运行的测试程序,把三种继承方式下"基类成员在派生类里到底能不能访问"各自验证一遍。你可以通过切换 main 里的注释开关,亲眼看到哪一行被允许、哪一行被禁止:

#include <iostream>
#include <string>
using namespace std;
 
class Person
{
public:
    void Print()          // public 成员:类内外都能访问
    {
        cout << _name << endl;
    }
protected:
    string _name = "张三";  // protected 成员:类外不能直接访问
private:
    int _age = 18;          // private 成员:派生类也不可见
};
 
// 演示三种继承方式,把注释的开关切换一下即可逐一观察
class Student : public Person
{
public:
    void Test()
    {
        // _age 是 Person 的 private 成员,在派生类内访问会编译报错(不可见)
        cout << _name << endl;   // _name 经 public 继承仍是 protected,派生类内可访问
    }
protected:
    int _stunum;   // 学号
};
 
int main()
{
    Student s;
    s.Test();   // ok:names 是 protected,派生类内可访问
    // s._name = "李四";   // 编译报错:_name 是 protected,类外不可访问
    return 0;
}

把 main 里的那行注释打开,你会看到 _name 在类外访问被拒。再想想:如果 _age 想被 Test() 访问能不能做到?做不到,它是 private,连派生类都看不见。到此,三张表 + 三条规则 + 两个"why",访问限定符这一节就算闭环了。

基类与派生类对象的赋值转换(切片)

在公共继承(public inheritance)下,C++ 给类型转换开了一个非常特殊的"后门",这个后门面试常考,就是切片(slicing)/ 切割 / 赋值转换。我们用代码把它讲透:

#include <iostream>
#include <string>
using namespace std;
 
class Person
{
protected:
    string _name;   // 姓名
    string _sex;    // 性别
    int    _age;    // 年龄
};
 
class Student : public Person
{
public:
    int _No;        // 学号
};
 
int main()
{
    Student sobj;
    sobj._No = 100;
 
    // 规则一:派生类对象可以赋值给基类的指针,绑定的是对象中的"基类部分"
    Person* pp = &sobj;
    // 规则一(引用的写法):派生类对象可以绑定到基类引用
    Person& rp = sobj;
 
    // 规则二:派生类对象可以"赋值"给基类对象
    // 这一步调用的是 Person 的拷贝构造/赋值重载,
    // 只把 sobj 中的 Person 部分拷贝给 pobj,
    // sobj 自己新增的 _No 被"切掉"了,所以叫切片
    Person pobj = sobj;
 
    // 规则三(重点,会编译报错):基类对象不能赋值给派生类对象
    // sobj = pobj;   // error: 没有合适的转换能把 Person 转成 Student
 
    return 0;
}

这里你应该被三个规则的对比震撼到了。常规的指针/引用转换,类型对不上是要产生临时对象、要加 const 的(比如 int a = 1; const double& d = a;)。但在 public 继承下,派生类对象赋值给基类指针或引用,不需要加 const、不需要任何强制转换,直接就能绑定。为什么?因为派生类对象在内存里本来就"包含"一个完整的基类部分,给基类指针赋值时,指针直接指向这个对象里的基类子对象即可——就像一栋楼含有一个标准户型,你给它一张"标准户型"的图纸,直接指过去就行,天经地义。

而拷贝给基类对象的那一步,Person pobj = sobj; 则真的切了一下:它只拷贝 sobj 中属于 Person 的那段字节,sobj 新加的 _No 被丢弃了。这个过程直观得像用刀切蛋糕,把多出来(新加)的那块切掉,只留下基类的部分,所以叫切片,也被称为"切割"。注意切片这个词,在严格的术语意义上只指规则二(对象与对象之间的拷贝);规则一的指针/引用转换在术语上通常叫"向上转型(upcasting)"或"赋值转换",课本经常把三者混在一起讲,你心里要清楚它们其实是有区别的。

重点深挖一下规则三,它是个大坑:基类对象不能赋值给派生类对象。站在内存的角度特别好理解——基类对象只有一块"蛋糕",而你要求它填满派生类对象那么大的一整块(派生类对象还有 _No 这些额外空间),数据根本不够,编译器无从下手,只能报错。反过来,从一个派生类对象"拿出"基类部分(向下少给),是安全的;但从基类对象"补出"派生类的额外部分(向上多要),是不可行的。你可以把这想象成"大杯的咖啡可以倒进小杯(多去的被倒掉)"但"小杯的咖啡倒不进大杯(杯子还有空位,但编译器不愿意帮你猜空位里该装什么),C++ 的立场是:转换方向被允许与否,取决于目标类型是否"够装"源类型。

深挖:切片是单向的,而且按值传参会悄悄切片

"切片只能发生在派生类→基类这个方向上",这句话值得再强调三遍。而且它还有一个特别擅长的"暗算"方式,很多初学者都栽过:函数按值传参,也会悄悄发生切片。假设写一个接收 Person 的函数,你传一个 Student 进去,编译器不会报错——它直接把 Student 切片成 Person 传进去,Student 特有的成员全部丢失。

看下面这段完整的演示,你就能理解"切片会在哪些不起眼的角落偷偷发生":

#include <iostream>
#include <string>
#include <vector>
using namespace std;
 
class Person
{
public:
    string _name = "人";
    Person(const string& n) : _name(n) {}
};
 
class Student : public Person
{
public:
    int _No = 0;
    Student(const string& n, int no) : Person(n), _No(no) {}
};
 
// 按值接收 Person:传进来的 Student 会被切片
void Announce(Person someone)
{
    // 注意:someone 已经是 Person,学生特有的一切都不存在了
    cout << "宣布:" << someone._name << endl;
}
 
int main()
{
    Student s("小明", 888);
    Announce(s);   // 发生切片:只把 _name 传进去,_No 丢失
 
    // 更隐蔽的场景:把派生类对象塞进"存放基类对象"的容器
    vector<Person> v;
    v.push_back(s);        // 切片:v 里存的是一份"被砍掉 _No 的 Person"
    // 取出来也永远是 Person,永远无法再变回 Student
    cout << "容器里对象的 name:" << v[0]._name << endl;
 
    return 0;
}

Announce(s) 这行运行后,函数内部拿到的 someone 已经是一份 Person 的拷贝了——_No 在这路上被永远地切掉了。容器 vector<Person> 同理,你把学生塞进去,存下来的只是他的"人"的身份。要彻底避开这种切片,办法只有一个:用指针或引用去传,而不是按值传对象。引用和指针不会产生拷贝,也就不存在"切"的动作。

你可能要追问:那基类指针能不能转成派生类指针用?可以,但要强制类型转换,而且有个前提——只有这个基类指针原本确实指向一个派生类对象时才是安全的。如果基类指针指向的只是一个基类对象,你强行把它转成派生类指针然后用,就会越界访问到不存在的派生类成员,这是未定义行为。为了安全,C++ 提供了一种叫 RTTI(Run-Time Type Information,运行时类型识别)的机制,配合 dynamic_cast 这个操作符可以先做安全检查再转换。不过 dynamic_cast 和类型转换的完整细节通常单独开一篇专门讲,这里你只需要知道有这回事,记下"别拿基类指针瞎转派生类"这个红线即可。

继承中的作用域与同名成员隐藏

类和类之间、基类和派生类之间,各自拥有独立的作用域(scope)。这句话听起来平淡,但它衍生出继承体系中最容易让人困惑的一个行为——同名成员隐藏(也叫名字遮蔽)。

规则是这样的:基类和派生类中如果有同名成员,那么在派生类内部,派生类的同名成员会"屏蔽"对基类同名成员的直接访问。举个例子:

#include <iostream>
#include <string>
using namespace std;
 
class Person
{
protected:
    string _name = "小李子";   // 姓名
    int _num = 111;            // 身份证号
};
 
class Student : public Person
{
public:
    void Print()
    {
        cout << " 姓名:" << _name << endl;
        // 下面这行:_num 在自己的类里先被找到,是"学号"
        cout << " 学号:" << _num << endl;
        // 想访问父类的身份证号 _num,用 基类::成员 显式指定作用域
        cout << " 身份证号:" << Person::_num << endl;
    }
protected:
    int _num = 999;   // 学号:和 Person 里的 _num 重名,构成隐藏
};
 
int main()
{
    Student s1;
    s1.Print();
    return 0;
}

这里 Student::_num 和 Person::_num 重名了。当你在 Print() 里写 _num 时,编译器从 Student 的作用域开始找,第一个就找到学号 999,所以直接用了它;而父类的身份证号 111,躲在 Person 作用域里被屏蔽了。想访问被隐藏的父类成员,就显式写 基类名::成员名,也就是 Person::_num。这是必须掌握的标准做法。这个 :: 叫作用域限定符,前面写 cout、std:: 你其实已经见过它了——它的通用含义就是"去哪个作用域里找这个名字"。

你可能会好奇:为什么编译器不先在派生类里找,找不到再去基类里找?它确实就是这么做的。C++ 的重名查找遵循一个朴素的规则:从名字出现的那一层作用域开始,一层层往外(往上)找,谁先被找到就算谁。派生类的作用域嵌套在基类作用域之上,所以派生类的同名成员永远"捷足先登"。这个查找顺序叫做"名字查找(name lookup)先于一切决议",下面讲函数隐藏时还会再用到它。

关于隐藏,再强调两个容易踩的细节:

第一,成员函数的隐藏,只看"函数名是否相同",不看参数列表。 也就是说,只要函数名一样,哪怕参数不同,也算隐藏,而不是构成"重载"。看这段经典选择题的代码:

#include <iostream>
using namespace std;
 
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(int),正常调用,输出 func(int i) 10
    // b.fun();   // 若去掉注释会编译报错!B 的 fun(int) 把 A 的 fun() 整个藏起来了
    return 0;
}

你可能会想:B::fun(int) 和 A::fun() 参数不同,应该叫"重载"吧?其实不是。重载(overload)必须发生在同一个作用域内;而基类和派生类是两个不同作用域。跨作用域、仅多参数的同名函数,构成的是隐藏,不是重载。所以 b.fun()(无参)在 B 作用域里找不到匹配,向上又因为被 B::fun 屏蔽而找到不到父类的 fun(),直接报错。要在派生类里调用父类的这个函数,得写成 b.A::fun() 才是被隐藏的名字遮蔽给绕过去的显式访问。

这个例子在很多面试题里会包装成"下面哪个选项构成重载/隐藏/重写",所以我把三者的区别整理成一张对比表,务必记住:

概念英文作用域函数名/参数典型场景
重载 overloadoverload必须在同一作用域名字相同,参数必须不同同一个类里两个同名函参的函数
隐藏 hidehiding基类与派生类两个作用域只看名字相同,与参数无关基类派生类出现同名函数/成员
重写 overrideoverride基类与派生类名字、参数、返回类型都要一致,且基类函数必须 virtual多态章节的主角

记住一句话区分:重载发生在同一个类内部,隐藏跨越了继承的两层,而重写是隐藏里带 virtual 的那种特殊情况。重写我们放到多态章节再讲,今天就先把"隐藏只看名字"钉死。

第二,实际工程里,尽量不要在继承体系中定义同名成员。 同名成员虽然能编译、能跑,但代码可读性差,极易把人绕晕——你可能以为在操作学号,实际被切到身份证号去了。好的设计是让每个成员名字在整棵继承树上尽量唯一。

派生类的默认成员函数

类有 6 个默认成员函数:构造函数、析构函数、拷贝构造函数、赋值运算符重载(这只是常见的 4 个),以及 C++11 加的移动构造、移动赋值。"默认"的意思是:你不写,编译器自动帮你生成一个。那么问题来了——**在派生类里,这些函数是怎么自动生成的?**这是本节的真正考点。我们记住四句口诀:

  1. 派生类的构造,必须"先构造基类部分"。派生类构造函数会先调用基类的构造函数来初始化对象里的基类子对象部分。如果基类恰好没有默认构造函数(比如它只有一个带参构造函数),那么派生类构造函数必须在"初始化列表"阶段显式调用基类的某个构造。
  2. 派生类的拷贝构造,必须先调用基类的拷贝构造来完成基类部分的拷贝初始化。
  3. **派生类的 operator= 要先调用基类的 operator=**来完成基类部分的复制。注意:派生类的 operator= 会把基类的 operator= 隐藏掉(这就是上一节的同名隐藏),所以调用时必须显式写成 基类名::operator=。
  4. 派生类的析构函数,会在它执行完毕后自动再调用基类的析构函数,用来清理基类部分。这样保证构造是先基类后派生,析构是先派生后基类——层层对称,互不越界。

这里要特别留意口诀第 1 条里的一个关键词——初始化列表(member initializer list),也就是构造函数头后面冒号加 : _num(num) 那一整段。理解它是理解整个派生类构造机制的钥匙:C++ 规定,对象每一个成员的构造,都在进入构造函数函数体之前就已经完成了。你说的 Person(name)、_num(num) 写在初始化列表里,就是在"进入函数体之前"先把这个基类子对象和成员初始化好。等函数体 {...} 开始执行时,对象其实已经"被砌好了",你在函数体里只是站在上面做装饰。这条先决知识,正是"必须先构造基类"这条铁律得以成立的基础。

下面用一段完整的、带打印的代码,把这套"谁先谁后、怎么显式调用"全都演给你看:

#include <iostream>
#include <string>
using namespace std;
 
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:
    // 派生类构造:在初始化列表里显式调用基类构造 Person(name)
    Student(const char* name, int num)
        : Person(name)   // 先构造基类部分
        , _num(num)      // 再初始化自己新增的学号
    {
        cout << "Student()" << endl;
    }
 
    // 派生类拷贝构造:要先调用基类拷贝构造完成基类的拷贝
    Student(const Student& s)
        : Person(s)       // 把 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)
        {
            // 基类的 operator= 被隐藏了,必须显式指定作用域来调用
            Person::operator=(s);   // 先完成基类部分的赋值
            _num = s._num;          // 再完成自己新增部分的赋值
        }
        return *this;
    }
 
    // 派生类析构:函数体执行完后,编译器自动调用 Person 的析构
    ~Student()
    {
        cout << "~Student()" << endl;
    }
 
protected:
    int _num;   // 学号
};
 
int main()
{
    Student s1("jack", 18);   // 构造:先 Person() 后 Student()
    Student s2(s1);           // 拷贝构造:先 Person 拷贝后 Student 拷贝
    Student s3("rose", 17);
    s1 = s3;                  // 赋值:先 Person 赋值后 Student 赋值
    return 0;                 // 析构顺序:先 s3 后 s1(先派生后基类)
}

对照口诀看这段代码,把运行顺序在心里过一遍:构造 s1 时,先打印 Person() 再打印 Student()(先基类后派生);拷贝构造 s2 时,先调用基类拷贝构造;s1 = s3 时,进入派生类 operator=,先 Person::operator= 处理基类部分,再处理 _num;程序结束时,三个对象分别先跑自己的 ~Student() 再自动跑 ~Person()(先派生后基类,叫"清理顺序")。

记住这个铁律,很多问题都能迎刃而解:构造永远是"先建地基再搭房间",析构永远是"先搬走家具再拆地基"。一栋楼你总不会先装修再打地基,推楼时也总不会先拆地基再搬家具——对象生命周期的管理就是这么朴实。

这里有一个关于析构函数名的冷知识,提前埋个引子:C++ 的析构函数名字带 ~,其实编译器在底层统一把析构函数处理成 destructor() 这个名字,为的是后面讲多态时,基类析构和派生类析构能构成"重写"关系。正因为如此,当基类析构不加 virtual 时,派生类析构和基类析构就构成隐藏关系——这也是为什么教科书总强调基类析构最好写成 virtual(多态章节会细讲,这里先留个印象)。

深挖:移动构造/移动赋值,以及约定俗成的"先基类"纪律

前面主讲的是 4 个"老"默认成员函数。C++11 引入了移动语义之后,默认成员函数扩成了 6 个,派生类里这俩移动函数同样要遵守"先处理基类"的规矩。看这段 C++11 起才可用的示例:

#include <iostream>
#include <string>
#include <utility>
using namespace std;
 
class Person
{
public:
    string _name;
    // C++11:移动构造
    Person(Person&& other) noexcept
        : _name(std::move(other._name))
    {
        cout << "Person move" << endl;
    }
    Person() {}
};
 
class Student : public Person
{
public:
    int _no = 0;
    // C++11:派生类移动构造,必须先移动基类部分
    Student(Student&& other) noexcept
        : Person(std::move(other))   // 关键:用 std::move 把 other 作为右值传出去
        , _no(other._no)
    {}
    Student() {}
};
 
int main()
{
    Student tmp;
    Student s = std::move(tmp);   // 触发移动构造,先 Person move 再派生部分
    return 0;
}

注意这里一个反直觉的细节:Person(std::move(other)) 中 other 的类型是 Student&&,它可以直接绑定到 Person&&(派生类右值引用可以绑定到基类右值引用,原理就是我们切片节讲的"派生类可以作为基类用"),所以基类移动构造能正确地把 other._name 这个字符串"偷"走。这就是移动语义在继承体系里的正确打开方式。

还有几个与派生类默认成员函数伴生的现代 C++ 工具,一并交代:

  • =default:C++11 起,你可以显式写 Student() = default; 让编译器给你生成缺省的实现。在某些特殊情况下(比如你手动声明了别的构造,编译器就不会再替你生成默认构造),=default 能帮你"补回"被隐式抑制的能力。
  • =delete:C++11 起,你可以写 Student(const Student&) = delete; 明确禁止某种操作——声明了但删除,一旦有人尝试拷贝就会编译报错。它是实现"禁用拷贝的类"的标准手法。
  • 多态类习惯把析构写成 virtual:虽然这是多态章节的主题,但既然现在讲到析构了可以先定个基调——只要你的类打算被继承、而且以后要透过基类指针去释放派生类对象,基类析构就应写成 virtual。原因留到多态篇用一整节讲,现在就当你抱了个"待解之谜"。

实现一个"不能被继承"的类

掌握了构造必须先调用基类构造这条铁律,你就可以玩出一个小技巧:实现一个不能被继承的类。思路很简单——如果派生类构造时必须调用基类构造,而基类的构造我把它设为私有,派生类看不见它、调不了,那派生类就无法实例化出对象,等于被"断了后路"。

#include <iostream>
using namespace std;
 
// C++98 时代的思路:
// 把基类构造函数私有化,派生类就无法构造,从而不能被继承
class NonInherit
{
private:
    NonInherit() {}   // 私有构造函数,派生类调用不到
};
 
// C++11 更简单的思路:
// 用 final 修饰基类,编译器直接禁止派生
class Base final
{
public:
    void func5() { cout << "Base::func5" << endl; }
protected:
    int a = 1;
private:
    int hidden = 0;
};
 
// class Derive : public Base {};   // 报错:final 类不能被继承
 
int main()
{
    Base b;            // Base 本身可以正常使用
    b.func5();
    // NonInherit n;    // 连 NonInherit 自己的对象也构造不了(私有构造)
    return 0;
}

注意两种手法。C++98 的做法是"釜底抽薪":用访问限定符把构造藏起来,让派生类在语法上注定失败——正如当前面说的,派生类构造必须能调到基类构造,基类构造私有后它自然无从构造。代价是这种手法"杀敌一千自损八百":连 NonInherit 自己的对象都没法在外部构造了(所以上面我把那行注释掉了)。C++11 则一步到位地引入了 final 关键字——直接在类名后加 final,告诉编译器"这个类到此为止,谁都不许继承",一旦有人尝试 class X : public Base,编译器立刻报错,而 Base 自己照常使用,一点不影响。第二种现代、直接、不误伤,是现在推荐的做法。

顺带一提,final 在 C++11 里不仅能修饰类,还能修饰成员函数,用来告诉编译器"这个虚函数不许再被派生类重写",那是多态章节的内容,这里先知道"final 管两件事:禁止继承类 + 禁止重写虚函数"即可,下次遇到就不陌生了。另外别忘了:final 和 override 一起出现时很常见,override 是"C++11 给重写打标记"的关键字,也都留到多态篇展开。

继承与友元、静态成员的关系

继承到这儿,有两个"各管各的"的特性容易被忽略,虽然简单但面试常问,我合并成一节讲清楚。

友元关系不能继承。 基类的一个友元函数,可以访问基类的私有/保护成员,但它不能顺带访问派生类的私有/保护成员。你可能会觉得"基类的朋友对子类也应该算半个熟人吧"——并不会。友元是"点对点"建立的信任,不沿继承关系传递。打个比方:你的朋友(基类的友元)知道你家保险柜的密码,但你家孩子的房间日记(派生类的私有成员),他照样没权限碰——信任是面向你个人建立的,不自动传递给下一代。

#include <iostream>
#include <string>
using namespace std;
 
class Student;   // 前置声明,供下面的 Person 声明友元用
 
class Person
{
public:
    friend void Display(const Person& p, const Student& s);
protected:
    string _name = "张三";   // 姓名
};
 
class Student : public Person
{
protected:
    int _stuNum = 9527;   // 学号
};
 
// Display 是 Person 的友元,能访问 p._name
// 但它不能访问 s._stuNum,因为 Student 没有把它当朋友
void Display(const Person& p, const Student& s)
{
    cout << p._name << endl;   // 合法:Person 的友元可访问 Person 的保护成员
    // cout << s._stuNum << endl;   // 编译报错:无法访问 protected 成员
    // 解决办法:让 Display 同时成为 Student 的友元
}
 
int main()
{
    Person p;
    Student s;
    Display(p, s);   // 因为报错的那行被注释掉,这里可以正常运行
    return 0;
}

这里报错信息通常是 error C2248: "Student::_stuNum": 无法访问 protected 成员。解决方式也直白:想要访问派生类的私有成员,就让这个友元函数也同时在派生类里声明为友元。做法不难,只要把 Display 在 Student 里再声明一次 friend 即可。记住"友元不继承"就够了。

静态成员是整个继承体系共享一份的。 基类里定义一个 static 静态成员,那么无论派生出多少层、多少个子类,整个继承体系里只有一个这样的成员实例,所有基类和派生类对象都共用这一份。这背后的原因其实很简单:静态成员压根不属于任何一个对象,它是"挂在类身上"的,所以继承一百层它也存在一份,不会因为对象数量的增加而复制。

#include <iostream>
#include <string>
using namespace std;
 
class Person
{
public:
    string _name;        // 普通(非静态)成员:每个对象一份
    static int _count;   // 静态成员:全体系只有一份
};
int Person::_count = 0;  // 静态成员必须在类外定义并初始化
 
class Student : public Person
{
protected:
    int _stuNum;
};
 
int main()
{
    Person p;
    Student s;
 
    // 非静态成员:派生类继承过来了,但每个对象各有一份,地址不同
    cout << "p._name 的地址:" << &p._name << endl;
    cout << "s._name 的地址:" << &s._name << endl;
 
    // 静态成员:基类和派生类共用同一份,地址相同
    cout << "Person::_count 地址:" << &p._count << endl;
    cout << "Student 里的 _count 地址:" << &s._count << endl;
 
    // 公有时,用基类或派生类的类域都能访问同一个静态成员
    cout << Person::_count << " " << Student::_count << endl;
    return 0;
}

对比很直观:_name 是普通成员,p 和 s 各有各的一份,打印出的地址不一样;_count 是静态成员,p 和 s 通过 &p._count、&s._count 拿到的地址完全一样——证实了"整个继承树共享一份静态成员"。用基类 Person::_count 或用派生类 Student::_count 去访问,指向的都是同一个东西。

这部分最后留个实用提示:既然静态成员在整个继承体系里只有一份,它天然适合当"计数器"(比如记录一共创建了多少个 Person 系对象)或者"全局配置"。只要你是通过 Student 去访问 Person 里公有的静态成员,访问到的和 Person 自己用的是同一份。这一点常被用来做"家族级别的全局状态",面试里喜欢拿它出判断题。

多继承与菱形继承:数据冗余与二义性

刚刚讲的都是"单继承"——一个派生类只有一个直接基类。C++ 还允许一个派生类从两个或以上的基类继承,这叫多继承。多继承对象的内存布局规则是:先继承的基类放前面,后继承的基类放后面,派生类自己新增的成员放最后。你可以把它理解为"把两个基类子对象像文件夹一样顺序排列,再在末尾贴上一个新增区"。

多继承本身不可怕,真正让人头疼的是多继承的一种"特殊形状"——菱形继承。什么叫菱形?就是你有一个最顶部的基类 Person,它被两个中间类 Student 和 Teacher 各自继承,然后你再来一个 Assistant 同时继承 Student 和 Teacher。这四条边连起来正好像一个菱形(也有叫钻石继承的,英文是 diamond inheritance)。我们直接构造出来看它的问题:

#include <iostream>
#include <string>
using namespace std;
 
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";      // 编译报错:对 "_name" 的访问不明确
    a.Student::_name = "xxx";  // 显式指定走 Student 这条路,可以
    a.Teacher::_name = "yyy";  // 显式指定走 Teacher 这条路,也可以
    return 0;
}

这个菱形暴露了两个连锁的问题:

问题一:二义性(ambiguity)。 Assistant 里有两个 _name——一个来自 Student 继承的 Person,一个来自 Teacher 继承的 Person。你不说清楚想要哪个,编译器就不知道 a._name 到底指哪一个,于是报错"访问不明确"。你固然可以用 a.Student::_name 或 a.Teacher::_name 显式指定是哪一条继承路径来消除二义性,但这种写法极其别扭。记住一句口诀:二义性,可以用 :: 强行指定路径来绕开。

问题二(更致命):数据冗余。 前面我讲对象内存时说过"派生类对象包含基类子对象"。现在 Assistant 通过两条路径各继承了一份 Person,所以在 Assistant 对象的内存里,Person 被你存了两遍——两份 _name、两份 _age……浪费内存不说,更可怕的是这两份数据可能被分别改得不一样,造成逻辑混乱。二义性你能用 :: 绕过,但数据冗余这个问题,用 :: 根本解决不了——因为那两份 Person 是实实在在同时存在的。你可以用一句很形象的话记住它::: 能帮你"指明看哪一面",却变不出"只有一面"。

数据冗余有多"实"?为了让你有体感,我们写代码对比一下 Person 和 Assistant 的大小(sizeof,即对象在内存中占的字节数),你会发现前者明显小于后者——多出来的那些字节,正是被存了两遍的 Person 部分:

#include <iostream>
#include <string>
using namespace std;
 
class Person
{
public:
    string _name;   // 8 字节(指针 + 栈上短串优化,平台相关)
    int _age;       // 4 字节
};
 
class Student : public Person
{
public:
    int _num;
};
 
class Teacher : public Person
{
public:
    int _id;
};
 
class Assistant : public Student, public Teacher
{
public:
    int _major;
};
 
int main()
{
    // 具体的字节数随编译器和平台浮动,重点观察相对大小:
    // Student 只含一份 Person,而 Assistant 里顺着两条路径各带了一份 Person
    cout << "Person 大小   :" << sizeof(Person)   << endl;
    cout << "Student 大小 :" << sizeof(Student)  << endl;
    cout << "Assistant大小:" << sizeof(Assistant) << endl;
    return 0;
}

在常见的 64 位平台上,Person 里 string(8 字节)加 int(4 字节)再对齐到 8 字节,通常是 16 字节;Assistant 里带着两份 Person(两个 16)+ 两个中间层的 int + 自己的 int,会明显大于 Student。你只需要记住:菱形继承让顶部的公共基类排列了两遍,这是纯浪费。

思考一下立场:正因为多继承天然招来菱形继承,而菱形继承又带来冗余和二义性,后来的很多语言干脆不提供多继承,从根上规避,比如 Java 只支持类的单继承(它的"多重行为"另用接口 interface 来实现)。这也常被视为 C++ 的一个"缺陷"——语法能力很强大,但强大到容易让自己掉坑。所以行业里有一条不成文的建议:你可以用多继承,但千万别故意设计出菱形继承。真要绕不开(比如标准库的 iostream 家族),就用下面这节讲的虚继承来化解。

虚继承:如何解决菱形继承

解决菱形继承两大顽疾(二义性 + 数据冗余)的手段叫 虚继承(virtual inheritance)。它做了一件"釜底抽薪"的事:让中间的两个基类 Student、Teacher 在继承最顶部的 Person 时,前面加上 virtual 关键字,告诉编译器"这个共同的 Person 我们只保留一份"。声明如下:

#include <iostream>
#include <string>
using namespace std;
 
class Person
{
public:
    string _name = "默认";   // 姓名
};
 
// 中间层继承 Person 时加 virtual,变成虚继承
class Student : virtual public Person
{
protected:
    int _num;   // 学号
};
 
// Teacher 也一样虚继承 Person
class Teacher : virtual public Person
{
protected:
    int _id;   // 职工编号
};
 
// 最下层的 Assistant 继承 Student 和 Teacher
class Assistant : public Student, public Teacher
{
protected:
    string _majorCourse;   // 主修课程
};
 
int main()
{
    Assistant a;
    a._name = "peter";   // 虚继承后只有一份 Person,不再报"不明确"
    return 0;
}

加了 virtual public 继承之后,Assistant 里 Person 子对象只剩一份,_name 不再有二义性,a._name = "peter" 畅通无阻,同时内存里那份冗余的 Person 也被消除。虚继承把"公共基类抽出来、大家共享同一份"这件事在底层用额外指针和偏移量做了处理——这也就意味着虚继承在底层实现变复杂了,运行时会有一点点性能代价。所以还是那句老话:最好别设计菱形继承;真要躲不开,虚继承是标准答案。

深挖:虚继承的底层为什么要"绕远路"?

你可能好奇:虚继承到底是怎么做到"只存一份 Person 的"?简单说说它的底层思路(具体数值因编译器而异,这里讲原理):

普通继承下,Student 里的 Person 部分和 _num 是紧紧挨在一起、按偏移量直接寻址的,简单直接。但虚继承下,中间的 Student、Teacher 都要共享同一份 Person,可"这一份"到底放哪、谁先遇到,不到最终实例化 Assistant 时是不知道的——所以编译器不能再把 Person 硬塞在 Student 开头那个固定的位置,而是改用"间接寻址"。

具体做法是:给 Student、Teacher 子对象各加一个指针(叫虚基类表指针,英文 virtual table pointer,常缩写为 vbptr),这个指针指向一张偏移表,表里记录着"真正的 Person 虚基类子对象离我有多远"。当代码要访问 _name 时,先通过 vbptr 取到偏移,再跳过去——多走一步间接寻址,所以会有那么一点性能开销。代价是换来了"整个 Assistant 里 Person 真的只剩一份"。

顺带提一句版本差异:vbptr 插在对象布局的哪个位置,MSVC 和 GCC/Clang 并不完全一致(有的放最前面,有的放基类之间),所以用 sizeof 去测量虚继承对象的大小,不同编译器给出的数字可能不同。你要是做题碰到这种"大小等于几"的题,别死记硬背具体数字,记住"比普通继承略大一些、且 Person 只存一份"这两条原则就够用了。

值得深挖一个点:虚继承时,最底层的派生类构造,到底是谁在真正初始化那个共享的 Person? 规矩是这样的——一旦中间层做了虚继承,那么最终的虚基类 Person 由最底层的派生类(也就是实际实例化的那个类 Assistant)来负责直接调用其构造函数,而中间层 Student、Teacher 初始化列表里的 Person(...) 调用会被忽略掉。看下面这个经典例子:

#include <iostream>
#include <string>
using namespace std;
 
class Person
{
public:
    Person(const char* name) : _name(name) {}
    string _name;
};
 
class Student : virtual public Person
{
public:
    Student(const char* name, int num)
        : Person(name)      // 虚继承下这行对最终 _name 的赋值会被覆盖
        , _num(num)
    {}
protected:
    int _num;
};
 
class Teacher : virtual public Person
{
public:
    Teacher(const char* name, int id)
        : Person(name)      // 虚继承下同理
        , _id(id)
    {}
protected:
    int _id;
};
 
class Assistant : public Student, public Teacher
{
public:
    Assistant(const char* name1, const char* name2, const char* name3)
        : Person(name3)               // 最终由它真正初始化共享的 Person,name3="王五"
        , Student(name1, 1)
        , Teacher(name2, 2)
    {}
protected:
    string _majorCourse;
};
 
int main()
{
    // 思考题:a 对象里的 _name 到底是 "张三"、"李四" 还是 "王五"?
    Assistant a("张三", "李四", "王五");
    cout << a._name << endl;   // 输出:王五
    return 0;
}

答案是 "王五",而且这次我们直接把答案打印出来验证。原因是虚继承确立了一条纪律:共享的虚基类成员,谁实例化最底层的类,就由谁来构造。这里实例化的最底层类是 Assistant,所以真正调用 Person(name3) 的是 Assistant 的构造,传进去的 name3 是 "王五"。Student 和 Teacher 初始化列表里的 Person(...) 在虚继承体系里都是"摆设",被统一忽略。这个结论在虚继承的题目里是必考的高频点,务必亲手在编译器里跑一遍确认。

你可以把这个规则用一句话钉死:"虚基类由最底层派生类初始化"——Assistant 是整棵继承树最下面的那层,所以它说了算;中间的 Student、Teacher 再怎么在初始化列表里写 Person(...),都改变不了最终结果。这有点像家族里"户口本上的户主":真正说了算的是户主(最底层的实例子对象),家里其他人(中间层)提的建议(Person(name1))都会被户主的一锤定音覆盖。

顺带一提,多继承还有一个"取地址偏移"的经典选择题,帮你把内存布局钉死:如果 Derive 同时继承 Base1 和 Base2,那么 Base1* p1 = &d; 和 Derive* p3 = &d; 指向同一地址(派生类开头就是第一个基类),而 Base2* p2 = &d; 因为要指向二者中居后的那个基类部分,地址会比 p1 靠后,所以 p1 == p3 != p2。这也再次印证了"先继承的基类在前、后继承的基类在后"。原文课件里那个选择题(A:p1==p2==p3 / B:p1<p2<p3 / C:p1==p3!=p2 / D:p1!=p2!=p3)的正确答案就是 C。

我们再自己造一个类似的情形来验证这个结论,让所见即所得:

#include <iostream>
using namespace std;
 
class Base1 { public: int _b1; };
class Base2 { public: int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
 
int main()
{
    Derive d;
    Base1* p1 = &d;
    Base2* p2 = &d;
    Derive* p3 = &d;
    cout << "p1:" << p1 << endl;
    cout << "p2:" << p2 << endl;
    cout << "p3:" << p3 << endl;
    cout << "p1==p3?" << (p1 == p3) << "   p1!=p2?" << (p1 != p2) << endl;
    return 0;
}

运行后你会看到 p1 和 p3 指向同一个地址(p1==p3 输出 1),而 p2 的地址比它们大(p1!=p2 输出 1)——因为 Derive 内部 Base1 的子对象在最前面、Base2 的子对象在 Base1 之后。这再次验证了多继承"先继承的基类在前"的内存规律。

IO 库里的菱形虚继承

说虚继承是"纯粹应付考试的东西"那可就误会它了——C++ 标准库本身就是菱形虚继承的最大活教材。你从入门第一天就在用的 cout、cin,它们的底层就站着一个菱形虚继承体系。

标准库里 basic_istream(输入流,cin 的底层)和 basic_ostream(输出流,cout 的底层)都虚继承自 basic_ios(流公共基类,管理流状态),而 basic_iostream(可读可写的双向流)又同时继承这两者。简化示意如下:

// 简化示意(真实标准库有复杂的模板与字符类型参数,这里只画继承骨架)
// struct basic_ostream : virtual public basic_ios   { ... };
// struct basic_istream : virtual public basic_ios   { ... };
// struct basic_iostream : public basic_ostream,
//                         public basic_istream      { ... };

可以看到,basic_iostream 的两条直系祖先 basic_istream 和 basic_ostream 都虚继承了共同的 basic_ios,于是 basic_iostream 里 basic_ios 只有一份流状态,不会因为"既能读又能写"就多出两份状态来打架。这正是虚继承解决的"共享公共基类"问题在真实工业代码里的落地。所以下次再有人问你"虚继承到底用来干嘛",你可以底气十足地答:标准库的 iostream 就靠它把自己拧成一股绳,避免流状态被复制两份。

继承 vs 组合:is-a 与 has-a

继承讲到这里,你可能会产生一种冲动:"既然继承这么能复用,那我是不是能继承的都继承?"不要急,面向对象设计里有个比"怎么写继承"更重要的问题——什么时候该用继承,什么时候该用组合。这里的关键是一对判断关系:

  • public 继承,本质上描述一种 "is-a"(是…的一种)的关系。 每个派生类对象,从逻辑上看都是一个基类对象。Student 是 Person,Teacher 也是 Person——用继承,顺理成章。
  • 组合(composition),本质上描述一种 "has-a"(拥有…)的关系。 一个对象"包含"另一个对象。比如车由轮胎组成,那么 Car 里有四个 Tire 成员对象,这是组合。

这两种复用风格,在复用对象的"透明度"上截然不同,术语上一对很有意思的名字:

继承是"白箱复用"(white-box reuse)。 为什么叫"白箱"?因为基类的内部细节对派生类是可见的(派生类可以通过 protected/public 看到父类的东西)。可见意味着透明,也意味着继承会破坏基类的封装——基类里哪怕是 protected 的成员,也算把自己的器官展示给子类了。更进一步,基类一旦改动,派生类会受到很大牵连,基类和派生类之间依赖强、耦合度高。耦合度高,是面向对象设计里最想避免的东西。一个类比:继承就像你继承了父亲的一整套旧房子——房子内部结构你全都看得见(白箱),但也正因为你依赖这些内部结构,父亲哪天一敲承重墙(基类内部改动),你的生活马上受影响。

组合是"黑箱复用"(black-box reuse)。 组合的对象对使用者只暴露对外接口,内部实现是一团"黑箱",看不见也摸不着。一个 Car 用 Tire 时,只知道调用轮胎的公开接口,不知道它内部怎么造。所以组合类之间没有很强的依赖,耦合度低,每个类都能保持独立封装。面向对象设计有一条著名的原则叫"组合优于继承"(composition over inheritance),指的基本就是:能用组合解决就别急着继承。

来看看一个教科书里讲 is-a 和 has-a 的经典例子——汽车:

#include <iostream>
#include <string>
using namespace std;
 
// Tire(轮胎)和 Car(车)是 has-a 关系:车"拥有"四个轮胎
class Tire
{
protected:
    string _brand = "Michelin";   // 品牌
    size_t _size = 17;            // 尺寸
};
 
class Car
{
protected:
    string _colour = "白色";       // 颜色
    string _num = "陕ABIT00";      // 车牌号
    Tire _t1;                      // 轮胎一
    Tire _t2;                      // 轮胎二
    Tire _t3;                      // 轮胎三
    Tire _t4;                      // 轮胎四
};
 
// BMW 和 Car 是 is-a 关系:宝马车就是一辆车,用 public 继承
class BMW : public Car
{
public:
    void Drive() { cout << "好开-操控" << endl; }
};
 
// 奔驰同样 is-a 于 Car:奔驰车也是一辆车
class Benz : public Car
{
public:
    void Drive() { cout << "好坐-舒适" << endl; }
};
 
int main()
{
    BMW b;
    Benz z;
    b.Drive();
    z.Drive();
    return 0;
}

判断逻辑一目了然:轮胎是"车的一部分"(has-a),用组合;宝马车"是一辆车"(is-a),用继承。如果一种关系既能靠 is-a 描述也能靠 has-a 描述——最典型的例子是栈(stack)和动态数组(vector):栈既可以说"是一个线性表",也可以说"内部用了一个 vector"——那设计指南就说了:用组合,优先组合。因为组合耦合更低,更好维护。

继承类模板的一个坑:模板按需实例化的连带问题

课件里还留了一个特别经典、特别容易踩的"继承类模板"示例,值得单独拿出来讲,因为它同时牵扯到"继承"和"模板"两大语言特性。设想你想用继承为一个 vector 包一层封装,做成一个栈 stack。思路是让 stack 继承 std::vector:

#include <iostream>
#include <vector>
using namespace std;
 
// 用继承的方式封装一个"栈":stack 是 vector 的一种"受限的包装"
template<class T>
class Stack : public std::vector<T>
{
public:
    void push(const T& x)
    {
        // 关键:这里必须写 std::vector<T>::push_back
        // 如果直接写 push_back(x),在部分编译器上会报:
        // "找不到标识符 push_back"(error C3861 之类)
        std::vector<T>::push_back(x);
    }
    void pop()
    {
        std::vector<T>::pop_back();
    }
    const T& top()
    {
        return std::vector<T>::back();
    }
    bool empty()
    {
        return std::vector<T>::empty();
    }
};
 
int main()
{
    Stack<int> st;
    st.push(1);
    st.push(2);
    st.push(3);
    while (!st.empty())
    {
        cout << st.top() << " ";
        st.pop();
    }
    cout << endl;
    return 0;
}

为什么会需要 std::vector<T>:: 这个显式限定?这就要提到模板一个著名机制——按需实例化(lazy instantiation)。std::vector<T> 是类模板,它不会在"定义 Stack"的时候就整块实例化出来,而是按需、一点一点地实例化;当 Stack<int> 被实例化时,编译它的成员函数,而它依赖的 std::vector<int> 里那些成员函数(push_back、back 等)此刻可能还没有被实例化出来。此时在 template<class T> class Stack 这个类模板内部去查找"裸的 push_back"这个名字,编译器发现:非限定的名字查找不会去依赖的基类(dependent base,即 std::vector<T>)里找,于是说"找不到"。而一旦你写出 std::vector<T>::push_back,就明确告诉编译器"去 std::vector<T> 里找",问题迎刃而解。

这种场景更王道、风格上也被更推荐的写法是加 this->,比如 this->push_back(x)——因为 this-> 也会触发在依赖基类中的查找,而且写法更简洁。两种都能解,我按课件用 std::vector<T>:: 的写法,同时告诉你还有 this-> 这条路。这条坑本质上讲的是 C++ 的"模板里不肯为依赖基类的名字做隐式查找",属于模板与继承交汇的地带,遇到报错"C3861 找不到标识符"时,第一反应就该想到它。

不过要泼一盆冷水:在真实工程里,用继承去封装 std::vector 做成栈并不是个好主意。原因恰恰就是本节的议题——"栈是 vector 吗?"它可能是(has-a)。更稳妥的做法是用组合:class Stack { std::vector<T> _v; ... },让 Stack 内部持有一个 vector 成员。这样既不会把 vector 的庞大公有接口全暴露出去(继承会把基类的所有公有接口也"传下来"),耦合也更低。教科书把这个继承版的例子摆出来,主要是为了锤炼你对"模板 + 继承"语法的理解,而不是鼓励你在产品代码里这么写。

再回到 is-a / has-a 的判断。其实前面这句话已经暗含了一个决策规则:如果一种关系既可以说 is-a 又可以说 has-a,优先选 has-a(组合)。栈对 vector 就是这样——你说"栈是 vector",勉强能说通(毕竟它真的是继承来的 vector);但若改成"栈内部包含了一个 vector",毫不损失功能,还更干净。这也是"组合优于继承"在心智模型上的价值:继承是硬绑定(改基类伤及派生类),组合是软绑定(换实现不影响依赖方)。能选软的,就别上硬的。

当然组合也不是万能的,这句话"优先使用组合,而不是继承"不要理解成"继承是坏的"。实际项目里记住这条决策链就够用了:**类之间关系确实适合 is-a(比如"学生是人"),那就用继承;而且如果你后面还要实现多态(同样名字的函数在不同对象上调出不同行为,这个我们下一篇专门讲),也必须用继承。除此之外,能用组合就用组合。**继承负责表达"层次这件事",组合负责表达"拼装这件事",二者各司其职,谁也别滥用谁。

你一路读到这里,其实已经走完了继承这条主线的完整路径:从"为什么要继承"的复用动机,到基类/派生类的定义,再到三种继承方式如何改造访问权限;接着见识了"派生类可以赋给基类、基类不能赋给派生"的切片规则和内存原理;然后处理了同名成员隐藏的坑、弄清了派生类默认成员函数"先基类后派生"的构造与析构顺序;最后把继承放到更大的设计图景里,看到了多继承带来的菱形问题与虚继承的解法,以及在 is-a / has-a 之间做权衡的组合哲学。

到这里,你应该对"继承到底在 C++ 对象模型里干了一件什么事"有了立体的认识:它本质上是在描述"抽象上下层之间的关系,同时严密管理对象内存中公共子对象的霸道归属权"。你手里应该也攒下了一张张"内存地图":派生类对象里基类子对象在前、新增成员在后;菱形里 Person 存了两份;虚继承里 Person 归最底层管……这些地图,正是 C++ 程序员和"只懂语法、不懂模型"的程序员之间的分水岭。

掌握了今天的内容,下一站我们将进入 OOP 的第三根支柱——多态。当你看到基类指针能优雅地调用出"看似来自同一个函数、行为却各不相同"的版本时,就会明白今天埋下的"基类析构为何要 virtual""析构函数名的底层处理"这些伏笔,全都有了归处。所以,这个周末不妨就着这套继承的知识,把前几篇的类和封装、还有今天的基类/派生类,通通在编译器里亲手敲一遍——内存布局用 sizeof 量一量,构造析构顺序用打印盯一盯,你会发现,这些"抽象的道理"最后都落成了触手可及的字节。