C++ 继承全解析(从三种继承方式到菱形继承与内存布局)

发布时间:2026/10/29 2:18:28
C++ 继承全解析(从三种继承方式到菱形继承与内存布局) 一、前言一份代码看懂 C 继承继承是 C 面向对象三大特性之一很多人初学继承时能写出代码却讲不清三种继承方式改了什么、对象在内存里怎么排、为什么会有二义性和数据冗余。本文拿一份课堂/练习代码从头到尾逐段拆解把继承的 13 个核心知识点串成一条线从“为什么要继承”到三种继承方式、is-a/has-a、赋值兼容、名字隐藏、默认成员函数最后到最难的多继承、菱形继承与虚继承。全篇文章的代码都来自同一条练习主线每一步都配有底层的内存布局示意帮助你建立“对象在内存里长什么样”的直觉而不是只记住语法。二、为什么需要继承消灭重复看这份代码的起点一开始我们写了 Student 和 Teacher 两个类它们几乎一模一样地重复声明了姓名、地址、电话、年龄class Student { public: void identity() { cout void student identity() endl; } void study() { cout void student study() endl; } protected: string _name; // 姓名 string _address; // 地址 string _tel; // 电话 int _age 18; // 年龄 }; class Teacher { public: void identity() { cout void teacher identity() endl; } void teaching() { cout void teacher teaching() endl; } protected: string _name; // 姓名 string _address; // 地址 string _tel; // 电话 string _title; // 职称 };两个人都有身份认证、都有姓名地址电话这些公共属性被复制粘贴了两遍。一旦要加一个字段比如手机号就得改两处漏改一处就出 bug。这就是代码坏味道。解决思路把这些公共部分抽出来单独成一个基类 Person再让 Student、Teacher 去“继承”它。继承的含义是派生类Student/Teacher自动拥有基类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() { cout void stude() endl; } protected: int _stuid; // 学号Student 自己才有的 }; class Teacher : public Person { public: void teaching() { cout void teaching() endl; } protected: string _title; // 职称Teacher 自己才有的 };◆ 核心继承让“公共部分只写一份”派生类只关心自己特有的那部分。这既减少了重复也让“Student 是一种 Person”的语义变得明确。三、三种继承方式public / protected / private继承方式决定“基类里的成员到了派生类里变成什么访问级别”。同一个基类 Person分别用 public、protected、private 三种方式继承效果完全不同。代码里用注释开关逐一验证class Student : public Person {}; // 公有继承 class Student : protected Person {}; // 保护继承 class Student : private Person {}; // 私有继承在 main 里分别调用 stu1.identity() 观察public 继承下可以正常调用protected 和 private 继承下由于 identity() 在派生类外变成了 protected/private外部无法访问会编译报错。完整的访问权限变化如下基类各成员的访问级别 × 继承方式 → 派生类中可访问级别代码里还有一段专门演示基类私有成员在不同继承下的情况class Person { public: void Print() { cout _name endl; } protected: string _name; // 姓名 private: int _age; // 年龄 ← 私有成员 }; class Student : public Person { protected: int _stunum; // 学号 };◆ 三句话记住public 继承基本“原样传递”protected 继承把基类的 public 降成 protectedprivate 继承把基类所有能见的成员都变成 private。基类的 private 成员无论怎样继承派生类都不可见——私有是“只属于基类自己”的。实际工程里几乎只用public 继承。protected/private 继承会让“is-a”语义被破坏见下一节而且很难维护初学阶段理解其作用即可不要滥用。四、is-a 与 has-a继承还是组合代码最后给出了一个很经典的对照Car 和 BMW/Benz 是 is-a 关系而 Car 和 Tire 是 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; // 组合4 个轮胎成员对象 }; class BMW : public Car { public: void Drive() { cout 好开-操控 endl; } }; // is-a class Benz : public Car { public: void Drive() { cout 好坐-舒适 endl; } }; // is-a判断口诀问一句“B 是 A 的一种吗”——BMW 是 Car 的一种是 is-a用继承Tire 不是 Car 的一种只是 Car 拥有轮胎是 has-a用成员对象组合。代码里还有一个非常有意思的细节stack 和 vector 的关系。// 写法一继承 —— 既符合 is-astack 是一种受限 vector templateclass T class stack : public vectorT { ... }; // 写法二组合 —— 也符合 has-astack 内部“有一个” vector templateclass T class stack { public: vectorT _v; // 组合成员 ... };◆ 同一个需求往往既能用继承也能用组合表达。现代 C 设计更推荐“组合优于继承”只有确实是“is-a”且派生类真正复用基类接口时才用继承否则优先用组合耦合更小。五、赋值兼容规则派生类 → 基类切片因为派生类对象“包含”了基类子对象所以 C 允许把派生类对象当作基类来用派生类对象可以赋值给基类的指针、引用和对象。class person { protected: string _name zs; string _sex man; int _age 18; }; class student : public person { public: int _no; }; int main() { student sobj; person* pp sobj; // 派生类对象 → 基类指针合法 person rp sobj; // 派生类对象 → 基类引用合法 person pobj sobj; // 派生类对象 → 基类对象合法切片 // sobj pobj; // 基类对象 → 派生类对象编译报错 return 0; }内存层面这就是“切片”slicing把派生类对象拷贝给基类对象时只拷贝基类子对象那一段派生类新增的部分被“切掉”。所以通过基类指针/引用只能看到基类部分看不到 student 的 _no。◆ 为什么基类不能反向赋给派生类因为派生类对象里还缺派生类新增的成员比如 _no编译器无法从基类对象里补出来所以直接报错。六、名字隐藏同名成员与同名函数继承里派生类和基类可能出现同名成员。这时派生类的名字会把基类的同名名字“藏住”隐藏/遮蔽访问时默认用的是派生类的那个。分两种情况情况 1同名数据成员class Person { protected: string _name 小李子; int _num 111; // 身份证号 }; class Student : public Person { public: void Print() { cout 姓名: _name endl; // 用的是基类 _name cout 身份证号: Person::_num endl; // 显式指定基类 cout 学号: _num endl; // 用的是派生类 _num } protected: int _num 999; // 学号 }; int main(){ Student s1; s1.Print(); return 0; }这里 Student 和 Person 各有一个 _num。在 Student 内部直接写 _num得到的是派生类自己的 999想访问基类的 111必须写 Person::_num。虽然能跑但极其容易混淆——这就是隐藏的坑。情况 2同名成员函数class A { public: void fun() { cout func() endl; } }; class B : public A { public: void fun(int i) { cout func(int i) i endl; } // 隐藏了 A::fun() }; int main() { B b; b.fun(10); // 调 B::fun(int) // b.fun(); // 编译报错B::fun 隐藏了 A::fun()无参版本找不到 // b.A::fun(); // 显式指定类域即可调用 return 0; }◆ 重要函数隐藏跟“重载”不是一回事。重载是同一类里同名不同参隐藏是派生类的函数把基类的同名函数全部遮住即使参数不同也不会参与重载必须用“基类名::函数名”才能调到被藏的版本。七、继承下的默认成员函数构造 / 拷贝 / 赋值 / 析构继承时派生类的构造函数、拷贝构造、赋值、析构都有自己的责任要先处理基类那一份。看这份代码它在每个函数里打印了调用痕迹class person { public: person(const string name) : _name(name) { cout person() endl; } ~person() { 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; } 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(const student s) endl; if (this ! s) { person::operator(s); // 必须显式调用基类赋值被隐藏了 _num s._num; } return *this; } ~student() { cout ~student() endl; } protected: int _num; }; int main() { student s1(jack, 18); student s2(s1); // 拷贝构造 student s3(rose, 17); s1 s3; // 赋值 return 0;四个要点构造派生类必须先调用基类构造函数在初始化列表里 person(name)再初始化自己的成员。如果基类没有默认构造必须显式传参否则编译报错。拷贝构造同样要先调基类拷贝构造 person(s)把 s 当作基类引用传过去完成基类部分的拷贝。赋值由于派生类的 operator 把基类的 operator 隐藏了必须显式写 person::operator(s) 才能把基类部分赋好否则基类部分不会被赋值。析构顺序与构造相反——先析构派生类部分再自动析构基类部分“~student()”打印后紧接着打印“~person()”。◆ 一句话总结派生类所有“复制/构造”类操作都要负责把自己“所包含的那份基类”也一并处理不能只处理自己的新成员。八、禁止继承final 与私有构造有时我们希望一个类不能被继承比如某种不可派生的工具类。C 提供了两种做法对应 C98 和 C11。// C11final 关键字最直接 class base final { public: void func() { cout void base::func() endl; } protected: int _num 5; }; // class derive : public base { ... }; // 编译报错不能从 final 类派生 // C98把构造函数设为 private class base98 { private: base98() {} // 私有构造 protected: int _num; };为什么私有构造能禁止继承因为派生类实例化时“必须先调用基类构造函数”而基类的构造是 private派生类访问不到于是派生类根本无法完成构造也就无法存在。同理基类自身也不能在类外直接 new 一个对象main 里 base b; 也会报错这就把一个类彻底“锁死”成不可继承、不可直接实例化。◆ final 更常用它只禁止派生不影响基类自己正常实例化而“私有构造 静态工厂方法”是 C98 时代用来实现“单例/不可实例化”类的手段。九、友元与继承友元不继承友元函数可以访问类的私有/保护成员。但有个重要规则友元关系不会被继承。基类的友元函数不能自动访问派生类新增的私有成员。class student; // 前置声明因为 person 的友元要引用 student class person { public: friend void display(const person p, const student s); // 友元能访问 person 私有 protected: string _name; }; class student : public person { public: friend void display(const person p, const student s); // 必须再声明一次 protected: int _stuNUM; }; void display(const person p, const student s) { cout p._name endl; // 访问 person 的 protected cout s._stuNUM endl; // 若没在 student 里声明友元这里报 C2248 }代码注释里已经指出关键坑一开始只在 person 里声明了 display 为友元display 想访问 s._stuNUMstudent 新增的 protected 成员时编译器报“error C2248: 无法访问 protected 成员”。原因就是友元不继承——person 的友元 ≠ student 的友元。解决办法在 student 里也把它声明为友元。还有一个工程细节person 的友元函数签名里要用到 student 类型但此时 student 还没定义所以必须先写 class student; 前置声明把顺序理清。◆ 两个易错点① 友元关系不随继承传递派生类新增的私有成员需要重新声明友元② 交叉引用类时要会用前置声明。十、静态成员与继承全局只有一份静态成员属于“类本身”不属于任何对象。继承后基类和派生类共享同一份静态成员——地址是同一个。class person { public: string _name; static int _count; // 静态成员 }; int person::_count 0; // 类外定义 class student : public person { protected: int _stuNum; }; int main() { student s; person p; cout p._name endl; // 不同对象的普通成员地址不同 cout s._name endl; cout p._count endl; // 静态成员地址一样 cout s._count endl; // 公有静态成员两个类域都能访问 cout person::_count endl; cout student::_count endl; return 0; }运行结果里p._count 和 s._count 是完全相同的地址——说明 student 并没有再复制一份 _count它和 person 共用同一份。这也是静态成员“跨所有对象全局唯一”的体现哪怕是通过派生类访问指的还是那一个静态变量。◆ 注意静态成员仍然遵循访问控制。若静态成员是 private派生类通过 student:: 也访问不到上面的 _count 是 public 才能这样用。十一、多继承与二义性一个类可以有多个直接基类这叫多继承。但多继承最常见的坑就是“二义性”。// person // student teacher // assistant 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; // 编译报错error C2385 对_name的访问不明确 a.student::_name xxx; // 显式指定能访问 a.teacher::_name yyy; // 显式指定能访问 return 0; }问题根源assistant 同时从 student 和 teacher 继承而 student 和 teacher 又都从 person 继承。于是 assistant 对象里出现了两份 person 子对象、两份 _name。直接写 a._name 时编译器不知道该用哪一份报“访问不明确”。用 a.student::_name 或 a.teacher::_name 显式指定类域可以消除二义但“数据冗余”问题仍在——对象里确实躺着两份 _name、两份 person。这正是下一节要解决的。十二、菱形继承与虚继承上一节的继承结构person → student/teacher → assistant画出来正好是一个菱形叫“菱形继承”。它有两个问题二义性 数据冗余同一份 person 被复制两份。// 普通菱形继承冗余 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; }; // 虚继承让 person 只保留一份 class student : virtual public person { protected: int _num; }; class teacher : virtual public person { protected: int _id; }; class assistant : public student, public teacher { protected: string _majorCourse; }; int main() { assistant a; a._name peter; // 虚继承后唯一一份不再二义可直接访问 return 0; }把 student、teacher 对 person 的继承改为 virtual publicperson 在整个 assistant 对象里就只有一份了。编译器通过“虚基类指针 vbptr”来定位这份唯一的 personstudent、teacher 子对象各自保存一个 vbptr它们都指向同一份 person 子对象。虚继承还有一个隐蔽细节——谁负责初始化这份共享的 personclass Student : virtual public Person { public: Student(const char* name, int num) : Person(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 , Student(name1, 1) , Teacher(name2, 2) {} protected: string _majorCourse; }; // 思考Assistant a(张三,李四,王五); 里 _name 到底是哪个 // 答案是最派生类显式传的 name3王五因为虚基类由最派生类(Assistant)初始化 // Student/Teacher 里对 Person(name) 的调用会被忽略。◆ 虚继承时虚基类person由“最派生类”assistant负责初始化。上面代码注释里的思考题答案就是_name 是“王五”name3。至于为什么两处 Student/Teacher 都写了 Person(name) 却没用——因为共享的虚基类只需初始化一次最派生类说了算这是虚继承和普通继承最大的行为差异。十三、总结一张表看完整条线把这份代码里的知识点收拢成一张速查表知识点关键结论一句话记忆为什么要继承公共部分上提基类消除重复is-a学生/老师都是人三种继承方式public 原样 / protected 降级 / private 私有化工程只用 public 继承is-a vs has-a“是 A 的一种”→继承“拥有 B”→组合BMW 继承 CarCar 组合 Tire赋值兼容派生→基类指针/引用/对象合法切片基类不能反向赋给派生类名字隐藏派生类同名成员/函数遮蔽基类要调基类用 基类名::成员默认成员函数构造/拷贝/赋值/析构都要先处理基类复制必须带好“基类那一份”禁止继承finalC11或私有构造C98锁死派生即锁死构造菱形继承普通版冗余二义virtual 虚继承去冗余虚基类由最派生类初始化继承这条线练到这里你基本就把它打通了从“为什么要继承”到三种继承方式到 is-a/has-a 的选择再到切片、隐藏、默认成员函数最后攻下最难的多继承/菱形/虚继承。每一步都能落到内存布局上——这也正是 C 底层能力的关键不仅知道怎么写更知道对象在内存里长什么样。