☰
【C++】继承|从单继承到菱形虚拟继承,小白入门必看
2026/10/26 1:56:38 网站建设 项目流程

文章目录

  • 前言
  • 一、为什么要有继承?
  • 二、继承怎么写?三种继承方式有什么区别?
    • 1、基本语法
    • 2、子类继承父类之后,成员的访问权限怎么改变?
  • 三、切片(切割):子类对象赋值给父类
  • 四、隐藏(重定义):父子类有同名成员怎么办?
    • 1、成员变量的隐藏
    • 2、成员函数的隐藏
  • 五、派生类的默认成员函数(重点)
    • 1、构造函数
    • 2、拷贝构造
    • 3、赋值运算符重载
    • 4、析构函数
    • 5、完整代码流程
    • 6、怎么写一个不能被继承的类?
  • 六、继承里的两个小知识点
    • 1、友元关系不能继承
    • 2、静态成员全体系共享一份
  • 七、多继承和菱形继承(难点)
    • 1、什么是多继承?
    • 2、菱形继承是什么?有什么问题?
    • 3、虚拟继承:解决菱形继承
  • 八、继承和组合
    • 1、两种关系
    • 2、优先推荐组合的原因
  • 总结

前言

我们敲代码经常遇到这样一种情况:写一个教学管理系统,我们已经写了教师 Teacher 类,里面有姓名、年龄、学院、工号这些,后面又要写学生 Student 类,发现里面也需要有姓名、年龄……等。两个类内容大差不大,难道要复制粘贴改一改?那也太蠢了,以后改个东西还得改好几处。

继承就是来解决这个问题的 —— 把公共的东西抽出来放父类里,子类直接拿来用,自己只写与父类不同的部分。

所以继承的本质是:子承父业,代码复用。


一、为什么要有继承?

先看没有继承的时候有多难受。
假设要写 Student 和 Teacher 两个类:

classStudent{public:voididentity(){/* 刷脸进校园 */}voidstudy(){/* 学习 */}protected:string _name;// 姓名string _address;// 地址string _tel;// 电话int_age;// 年龄int_stuid;// 学号};classTeacher{public:voididentity(){/* 刷脸进校园 */}voidteaching(){/* 授课 */}protected:string _name;// 姓名——跟Student重复了string _address;// 地址——重复string _tel;// 电话——重复int_age;// 年龄——重复string _title;// 职称};

姓名、地址、电话、年龄,还有identity()函数,两个类里写了两遍。以后要是identity()逻辑改了,我们还得改两个地方,漏一个就会出bug。

先了解术语:
基类(又叫:父类):被继承的类,比如下面的 Person
派生类(又叫:子类):继承别人的类,比如下面的 Student、Teacher

用继承就方便多了。把公共的抽到 Person 父类里:

classPerson{public:voididentity(){cout<<"身份认证:"<<_name<<endl;}protected:string _name="张三";string _address;string _tel;int_age=18;};// 学生继承人类classStudent:publicPerson// 继承的语法格式:public也可以换成 protected、private{public:voidstudy(){/* 学习 */}protected:int_stuid;// 学号,学生独有};// 老师也继承人类classTeacher:publicPerson// 继承的语法格式:public也可以换成 protected、private{public:voidteaching(){/* 授课 */}protected:string _title;// 职称,老师独有};intmain(){Student s;Teacher t;s.identity();// 直接用父类的,不用自己写t.identity();return0;}

公共的写一遍就行,子类直接继承过来用,也就是子类里面其实也有 Person 父类中的成员变量和成员函数。

二、继承怎么写?三种继承方式有什么区别?

1、基本语法

class子类名:继承方式 父类名{// 子类自己的成员};

继承方式有三种:public、protected、private。

基类本身成员有三种权限:

  • public:公开,任意地方都能访问
  • protected:保护,本类 + 子类可以访问,外部不能访问
  • private:私有,只有本类内部可以访问,子类、外部都不能直接访问

重点!!!:基类成员本身的访问属性 + 子类继承方式 (public/protected/private),两个一起决定派生类里面、以及外部对象能怎么访问。

2、子类继承父类之后,成员的访问权限怎么改变?

子类里成员的最终权限 = 父类中权限和继承方式,取更严格的那个。
权限宽松程度:public > protected > private

举个例子,父类里是 public 的成员,你用 protected 继承,取更严格的 protected,所以到子类里成员就变成 protected 了。

因此,我们就可以做如下表格:

父类成员 / 继承方式public 继承protected 继承private 继承
publicpublicprotectedprivate
protectedprotectedprotectedprivate
private不可见不可见不可见

这里有几个重点:

第一,父类的 private 成员,子类是 “不可见” 的。
注意不可见不是说没继承下来,而是继承了但你访问不到。就像你爸把存折锁保险柜里了,钱是家里的,但你拿不到。

第二,protected 这个关键字就是为继承而生的。
如果你想让成员在类外面不能访问,但子类里能访问,那就用 protected。private 子类访问不到,public 又太暴露了,protected 刚好卡在中间。

这里的 public/protected/private 指的是访问限定符,也就是父类内部的 public/protected/private,不要和继承方式搞混了。

第三,实际开发中基本都用 public 继承方式。
protected 和 private 继承很少见,继承下来的东西只能在子类里用,扩展性很差。除非有特殊需求,否则一律 public 就完事了。

#include<iostream>usingnamespacestd;classPerson{public:voidPrint(){cout<<_name<<endl;}protected:string _name="继承";private:int_age;// 子类访问不到};classStudent:publicPerson// public 继承{public:// Print()函数是public的voidtest(){cout<<_name<<endl;// 继承过来的 protected// cout << _age << endl; // 报错,_age 不可见}protected:int_stunum;};intmain(){Student s;s.Print();// Print是 publics.test();// test是 public// s._name; // _name是 protected,外部不能访问// s._stunum; // _stunum是 Student自己的 protected,外部不能访问return0;}

三、切片(切割):子类对象赋值给父类

public 继承下,子类对象可以直接赋值给父类的指针、引用、对象。这个过程叫切片或者切割。

为啥叫切片?可以这样理解:
子类对象里面包含了两部分:从父类继承来的那部分,加上自己独有的那部分。赋值给父类的时候,相当于把子类对象里属于父类的那一块 “切” 出来给父类,子类独有的部分直接丢掉了。

#include<iostream>usingnamespacestd;classPerson{protected:string _name;int_age;};classStudent:publicPerson{public:int_No;// 学号};intmain(){Student sobj;// 下面三种都可以,都是切片Person*pp=&sobj;// 子类对象地址给父类指针Person&rp=sobj;// 子类对象给父类引用Person pobj=sobj;// 子类对象给父类对象(调用父类拷贝构造)// 反过来不行!// sobj = pobj; // 编译报错,父类对象没有子类的学号等成员return0;}

子类可以给父类,父类不能给子类。因为子类是 “大” 的,包含了父类所有东西;父类是 “小” 的,缺了子类独有的部分,赋值过去也填不满。

四、隐藏(重定义):父子类有同名成员怎么办?

父类和子类是两个独立的作用域。如果子类里定义了跟父类同名的成员,子类的成员会把父类的同名成员 “藏” 起来,直接访问默认访问子类的,这就叫隐藏,也叫重定义。

总结就是:只要子类作用域里出现和父类同名的名字(变量名 / 函数名),就会构成隐藏,和返回值类型、参数列表无关。

1、成员变量的隐藏

想访问父类被隐藏的成员,加个父类名::就行。

#include<iostream>usingnamespacestd;classPerson{protected:string _name="柿子酱";int_num=111;// 身份证号};classStudent:publicPerson{public:voidPrint(){cout<<"姓名:"<<_name<<endl;cout<<"身份证号:"<<Person::_num<<endl;// 想访问父类的,需要加类域cout<<"学号:"<<_num<<endl;// 默认访问子类的}protected:int_num=999;// 学号,跟父类_num同名};intmain(){Student s1;s1.Print();return0;}

2、成员函数的隐藏

#include<iostream>usingnamespacestd;classA{public:voidfun(){cout<<"func()"<<endl;}};classB:publicA{public:voidfun(inti){cout<<"func(int i):"<<i<<endl;}};intmain(){B b;b.fun(10);// 调用B的fun(int)// b.fun(); // 编译报错!A的无参fun被隐藏了b.A::fun();// 加类域就能调用了return0;}

很多人会搞混成重载,注意:重载必须在同一个作用域里。父子类是两个作用域,所以这不是重载,是隐藏。

实际写代码的时候,继承体系里最好别搞同名成员,真的容易把人绕晕,也容易出 bug。

五、派生类的默认成员函数(重点)

类的 6 个默认成员函数,在继承里,派生类的这几个函数有特殊的规矩。但核心其实就一条:子类处理自己的部分,父类的部分交给父类的函数去处理。

1、构造函数

子类构造的时候,必须先调用父类的构造函数,把从父类继承来的成员初始化好,再初始化自己的成员。
如果父类有默认构造(无参或全缺省),子类可以不写,编译器自动调。但如果父类没有默认构造,子类必须在初始化列表里显式调用。

#include<iostream>usingnamespacestd;classPerson{public:Person(constchar*name="peter"):_name(name){cout<<"Person()"<<endl;}protected:string _name;};classStudent:publicPerson{public:Student(constchar*name,intnum):_num(num),Person(name)// 初始化列表里调用父类构造{cout<<"Student()"<<endl;}protected:int_num;};intmain(){Students("Louis",1);return0;}


看运行结果,确实也是先打印 Person() 后打印 Student()。当然,由于父类构造函数是全缺省,子类构造函数也可以不传 name 参数的。

2、拷贝构造

子类的拷贝构造,要先调父类的拷贝构造把父类那部分拷过去。这里有个巧妙的地方:子类对象传给父类拷贝构造,自动切片。

Student(constStudent&s):Person(s)// s是Student,传给Person的拷贝构造,自动切片,_num(s._num){cout<<"Student(const Student& s)"<<endl;}

3、赋值运算符重载

赋值重载也要先调父类的赋值重载。但注意,因为父子类的 operator = 函数名相同,构成隐藏,所以必须显式加 父类名::。

Student&operator=(constStudent&s){cout<<"Student& operator=(const Student& s)"<<endl;if(this!=&s){Person::operator=(s);// 必须加类域,否则递归调用自己_num=s._num;}return*this;}

这里要是忘了加Person::,写成 operator=(s),会无限递归调用自己,直接栈溢出。

4、析构函数

析构比较特殊,子类析构函数执行完之后,编译器会自动调用父类的析构函数,不用你手动调。

所以析构顺序跟构造是反的:

  • 构造:先父类构造 → 再子类构造
  • 析构:先子类析构 → 再父类析构
~Student(){cout<<"~Student()"<<endl;// 不用写 ~Person(),编译器自动调}

5、完整代码流程

把上面四个放一起,大家可以跑一下看看输出顺序:

#include<iostream>usingnamespacestd;classPerson{public:// 构造Person(constchar*name="peter"):_name(name){cout<<"Person()"<<endl;}// 拷贝构造Person(constPerson&p):_name(p._name){cout<<"Person(const Person& p)"<<endl;}// 赋值运算符重载Person&operator=(constPerson&p){cout<<"Person operator=(const Person& p)"<<endl;if(this!=&p)_name=p._name;return*this;}// 析构~Person(){cout<<"~Person()"<<endl;}protected:string _name;};classStudent:publicPerson{public:// 构造Student(constchar*name,intnum):Person(name),_num(num){cout<<"Student()"<<endl;}// 拷贝构造Student(constStudent&s):Person(s),_num(s._num){cout<<"Student(const Student& s)"<<endl;}// 赋值运算符重载Student&operator=(constStudent&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;};intmain(){Students1("Louis",18);Students2(s1);Students3("dumpling",2);s1=s3;return0;}

6、怎么写一个不能被继承的类?

两种办法:

  • C++98 老办法:把父类构造函数设成 private,子类调不到构造,自然没法实例化
  • C++11 新办法:加个final关键字,简单直接
classBasefinal// 加了final(C++11新增),表示这个类不能被继承{public:voidfunc(){cout<<"Base::func"<<endl;}};// class Derive : public Base {}; // 编译报错

六、继承里的两个小知识点

1、友元关系不能继承

基类的友元函数,不能访问派生类的私有成员。
举例:你爸的朋友,不一定是你的朋友。

#include<iostream>usingnamespacestd;classStudent;// 前置声明classPerson{public:friendvoidDisplay(constPerson&p,constStudent&s);protected:string _name;};classStudent:publicPerson{protected:int_stuNum;};voidDisplay(constPerson&p,constStudent&s){cout<<p._name<<endl;// 可以,是Person的友元//cout << s._stuNum << endl; // 不行,不是Student的友元}

想访问子类的,就得在子类里也声明成友元。

2、静态成员全体系共享一份

父类里定义了 static 静态成员,那不管派生多少个子类,整个继承体系里就这一份,大家共用。

#include<iostream>usingnamespacestd;classPerson{public:string _name;staticint_count;// 普通静态成员变量,类里面只能写声明,除非是整型的 const static 才能类内赋值(C++11)};intPerson::_count=1;// 类外全局域定义+初始化classStudent:publicPerson{protected:int_stuNum;};intmain(){Person p;Student s;cout<<&p._count<<endl;// 地址一样cout<<&s._count<<endl;// 地址一样,说明是同一份cout<<Person::_count<<endl;cout<<Student::_count<<endl;return0;}


父类子类的静态成员地址都是一样的,说明其实都是同一个变量。
这个也容易理解,静态成员本来就不属于某个对象,是整个类共享的,继承下来自然还是那一份。

七、多继承和菱形继承(难点)

1、什么是多继承?

一个子类只有一个父类,叫单继承。一个子类同时继承好几个父类,就叫多继承。

C++ 是支持多继承的,像 Java 就不支持,只能单继承 + 接口。
多继承虽然灵活,但也带来了一个头疼的问题 ——菱形继承。

2、菱形继承是什么?有什么问题?

菱形继承是多继承的一种特殊情况,长这样:

学生和老师都继承了人,然后助教同时继承学生和老师。
这就出问题了:Assistant 对象里会有两份 Person 成员,一份从 Student 来,一份从 Teacher 来。

#include<iostream>usingnamespacestd;classPerson{public:string _name;};classStudent:publicPerson{protected:int_num;};classTeacher:publicPerson{protected:int_id;};classAssistant:publicStudent,publicTeacher// 多继承用逗号隔开{protected:string _majorCourse;};intmain(){Assistant a;// 1. 二义性!编译报错,不知道访问 Student::Person::_name 还是 Teacher::Person::_name// a._name = "peter"; // 编译报错:对_name的访问不明确// 必须显式指定类域,才能访问a.Student::_name="xxx";// 加类域可以指定用哪份a.Teacher::_name="yyy";// 2. 数据冗余:内存里存在两份Person对象,两份_namecout<<&(a.Student::_name)<<endl;cout<<&(a.Teacher::_name)<<endl;// 地址不一样!两份数据,冗余return0;}


菱形继承有两个问题:

  1. 二义性:直接访问_name,编译器不知道你要 Student 那份还是 Teacher 那份
  2. 数据冗余:_name 存了两份,浪费空间

加类域能解决二义性,但冗余的问题解决不了。这时候就得用虚拟继承了。

3、虚拟继承:解决菱形继承

在菱形继承中间的子类(Student 和 Teacher)继承父类 Person 的时候,加上virtual关键字,就是虚拟继承。

#include<iostream>usingnamespacestd;classPerson{public:string _name;};classStudent:virtualpublicPerson// 虚继承{protected:int_num;};classTeacher:virtualpublicPerson// 虚继承{protected:int_id;};classAssistant:publicStudent,publicTeacher// 多继承用逗号隔开{protected:string _majorCourse;};intmain(){Assistant a;a._name="peter";// 解决了二义性// 解决了数据冗余cout<<&(a.Student::_name)<<endl;cout<<&(a.Teacher::_name)<<endl;return0;}


虚拟继承的底层原理比较复杂,原理是通过虚基表指针来保证最终派生类里只有一份虚基类成员。平时知道虚拟继承是用来解决菱形继承的、知道怎么写,基本就够了。底层细节有兴趣可以自己再深挖。

说句实在话,实际开发中尽量别设计出菱形继承这种结构。多继承本身就够复杂了,再加上菱形继承和虚拟继承,维护起来很痛苦。

八、继承和组合

1、两种关系

  • 继承:is-a 关系,“是一个”。比如宝马是一辆车,学生是一个人。
  • 组合:has-a 关系,“有一个”。比如车有四个轮胎,电脑有 CPU。
// 组合:车有轮胎classTire{protected:string _brand="Michelin";size_t _size=17;};// 车classCar{protected:string _colour="白色";Tire _t1,_t2,_t3,_t4;// 车有四个轮胎};// 继承:宝马是车classBMW:publicCar{public:voidDrive(){cout<<"好开-操控"<<endl;}};

实际设计代码时,优先推荐组合。

2、优先推荐组合的原因

继承是 “白箱复用”—— 父类的内部细节对子类是可见的,一定程度上破坏了封装。父类一改,子类可能全受影响,耦合度很高。

组合是 “黑箱复用”—— 被组合的对象内部细节不可见,只通过接口交互,耦合度低,维护起来更灵活。

所以有个设计原则:优先使用组合,而不是继承。

当然也不是绝对的:
明确是 is-a 关系 → 用继承
明确是 has-a 关系 → 用组合
两种都说得通 → 优先组合
要实现多态 → 必须用继承


总结

继承这东西,概念多但不难,核心就是代码复用。如果这篇文章对你有帮助,点个赞收藏一下,后续持续更新 C++ 系列内容,有问题评论区交流~

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询