文章目录
- 1. 继承
- 1.1 继承的概念
- 1.2 继承方式
- 1.3 基类和派生类的转换
- 1.4 继承中的作用域
- 1.5 类可以不被继承吗
- 1.6 基类包含static函数
- 1.7 多继承与菱形继承问题
- 1.7 虚继承
- 2. 多态
- 2.1 多态的构成条件
- 2.2 虚函数
- 2.2.1 虚函数的重写/覆盖
- 2.3 析构函数的重写
- 2.4 override 和 final 关键字
- 2.5 重载/重写/隐藏
- 2.6 纯虚函数和抽象类
- 2.7 虚函数表
- 2.8 多态原理
- 2.9 动态绑定与静态绑定
1. 继承
1.1 继承的概念
核心定义:继承是一个类(派生类 / 子类)复用、扩展另一个类(基类 / 父类)的成员变量和成员函数的机制。
也就是一个类可以使用另一个类的成员变量和函数。
举个例子:
#include<iostream>#include<string>using namespace std;class person{protected:string name;intage;public:person(string name,intage):name(name),age(age){}stringwname(){returnthis->name;}intwage(){returnage;}};class student:public person//这里表示继承{protected:string id;public:student(string name,intage,string id):person(name,age),//调用父类构造id(id){}stringwid(){returnid;}};intmain(){studenta("赛罗",20000,"16班");cout<<a.wid()<<endl;cout<<a.wname()<<endl;cout<<a.wage()<<"岁"<<endl;}在这个代码中,student就是子类(派生类),:public就是继承方式,person就是父类(基类)。
1.2 继承方式
| 类成员/继承方式 | public继承 | protected继承 | private继承 |
|---|---|---|---|
| 基类的 public 成员 | 派生类的 public 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
| 基类的 protected 成员 | 派生类的 protected 成员 | 派生类的 protected 成员 | 派生类的 private 成员 |
| 基类的 private 成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可见 |
| 简而言之: |
- private成员在子类中不可见,无法访问,但确实是被继承了
- 所以我们一般用protected,protected 就是为了继承而设计的访问权限,外部不可见但子类可见
- public与protected唯一的区别就是继承后的protected成员对外依旧是隐藏的
- 工程实践中,public 继承是主流选择;
protected/private继承因会隐藏基类接口、丧失is-a语义与多态性,扩展性差,故极少使用且不推荐。
1.3 基类和派生类的转换
public继承的派生类对象 可以赋值给 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中基类那部分切出来,基类指针或引用指向的是派生类中切出来的基类那部分。
基类对象不能赋值给派生类对象。
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。
三种情况:
Person*ptr;// 基类指针(只能“看见”Person部分)Student s;// 派生类对象(完整的学生)ptr=&s;// ✅ 合法:指针指向s的Person部分ptr->show();//假设父类子类都有个void show(),这里只能调用Person的成员函数,访问不到Student的内容这里就是把子类的show()给切出来了。
- 基类对象不能赋值给派生类对象。
Person p;Student s=p;// ❌ 编译报错Student s;// 真正的派生类对象Person*p=&s;// 基类指针指向派生类对象// 安全:强转回 Student*Student*s1=(Student*)p;s1->id="2026001";// ✅ 能正常访问派生类成员如果基类指针不指向派生类对象就不行
Person p;// 纯基类对象Person*ptr=&p;// 基类指针指向基类对象// 危险:强转成 Student*Student*s2=(Student*)ptr;s2->id="2026001";// ❌ 访问非法内存,程序崩溃1.4 继承中的作用域
在继承体系中基类和派生类都有独立的作用域。
派生类和基类中有同名成员,派生类成员将屏蔽基类对同名成员的直接访问,这种情况叫隐藏。
(在派生类成员函数中,可以使用 基类::基类成员 显示访问)需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
注意在实际中在继承体系里面最好不要定义同名的成员。
class Person{protected:string _name="赛罗";// 姓名int_num=111;// ⾝份证号};class Student:public Person{public:voidPrint(){cout<<" 姓名:"<<_name<<endl;cout<<" 身份证号:"<<Person::_num<<endl;cout<<" 学号:"<<_num<<endl;//这里两个_num就构成隐藏}protected:int_num=999;// 学号};intmain(){Student s1;s1.Print();return0;};这里特别声明:隐藏后的函数是找不到的,一旦调用编译器找不到就会报错。
1.5 类可以不被继承吗
法1:
派生类的构造函数必须调用基类的构造函数来初始化基类部分。如果基类的构造函数是 private,派生类就无法访问它,也就无法实例化派生类对象,从而实现 “不能被继承”。
class NoInherit{private:// 私有化构造函数NoInherit(){}friend NoInheritCreateNoInherit();};// 友元函数可以调用私有构造,用来创建对象NoInheritCreateNoInherit(){returnNoInherit();}// 尝试继承会编译报错class Derived:public NoInherit{// ❌ 无法访问基类的私有构造函数};intmain(){// 可以通过友元创建基类对象NoInherit obj=CreateNoInherit();// Derived d; // ❌ 编译失败return0;}法2:
final 关键字是 C++11 新增的特性,专门用来禁止类被继承,语法更简洁,是现代 C++ 中推荐的写法。
// 用 final 修饰类,禁止继承class NoInherit final{public:NoInherit()=default;};// 尝试继承会直接编译报错// class Derived : public NoInherit { // ❌ 编译失败// };intmain(){NoInherit obj;// ✅ 可以正常创建对象return0;}外加:友元关系不能被继承,基类友元不能访问派生类私有和保护成员。
1.6 基类包含static函数
静态成员的本质是属于类本身,而不是某个对象。
基类定义了 static 成员后,它就独立存在,不随任何对象创建 / 销毁而改变
派生类继承后,只是获得了访问这个静态成员的权限,并不会复制一份新的静态成员
所以不管派生出多少个派生类,整个体系里始终只有这一个静态成员实例
1.7 多继承与菱形继承问题
多继承顾名思义就是继承多个父类
class Person{...};class Teacher{...};class Assistant:public Person,public Teacher{...};// 多继承内存模型特点:继承顺序决定内存布局,先继承的基类成员排在前面,后继承的基类排在后面,派生类自己的成员放在最后。
菱形继承:
当两个派生类继承自同一个基类,又有一个新类同时继承这两个派生类时,就会形成菱形结构:
菱形继承的两大问题
- 数据冗余:Assistant 对象中,会包含两份 Person 基类成员(一份来自 Student,一份来自 Teacher),浪费内存。
- 二义性:访问 Person 的成员时,编译器不知道该用哪一份副本,直接访问会编译报错。
建议:强烈不建议设计菱形继承模型,代码维护成本高,容易出错。
当然,有解决方案————虚继承。
1.7 虚继承
本质:虚继承的本质,是让多个派生类共享同一份公共基类的成员,而不是各自复制一份。
- 当派生类声明为 virtual 继承公共基类时,编译器会在派生类对象中,通过虚基类指针指向一个共享的基类成员副本,而不是复制多份。
- 这样一来,整个继承体系中,公共基类的成员就只有一份实例,彻底解决了数据冗余和二义性。
#include<iostream>#include<string>using namespace std;class Person{public:string _name;};// 虚继承class Student:virtual public Person{public:Student(){_name="1";// 先赋值 1}};// 虚继承class Teacher:virtual public Person{public:Teacher(){_name="2";// 后赋值 2}};// 多继承class Assistant:public Student,public Teacher{public:voidPrint(){cout<<_name<<endl;}};intmain(){Assistant a;a.Print();// 输出多少?// 你甚至可以从 Student 路径访问cout<<"从Student访问:"<<a.Student::_name<<endl;// 从 Teacher 路径访问cout<<"从Teacher访问:"<<a.Teacher::_name<<endl;return0;}结果:
2. 多态
多态是什么?
简单说,同一个行为,不同对象做,会有不同的表现。
- 买票:普通人全价、学生打折、军人优先
- 动物叫:猫 “喵”、狗 “汪汪”
在C++里,多态分两种:
| 类型 | 别名 | 例子 | 特点 |
|---|---|---|---|
| 编译时多态 | 静态多态 | 函数重载、函数模板 | 编译器在编译阶段就确定调用哪个函数 |
| 运行时多态 | 动态多态 | 虚函数重写 | 程序运行时,根据对象的实际类型,决定调用哪个函数 |
2.1 多态的构成条件
实现多态还有两个必须重要条件:
- 必须是基类的指针或者引用调用虚函数
- 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。
说明:要实现多态效果,第一必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象;第二派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。
2.2 虚函数
类成员函数前面加virtual修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。
class Person{public:virtualvoidBuyTicket(){cout<<"买票-全价"<<endl;}};2.2.1 虚函数的重写/覆盖
虚函数的重写/覆盖:派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。
注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。
#include<iostream>#include<string>using namespace std;// 基类:Personclass Person{public:// 加了 virtual,这就是【虚函数】virtualvoidBuyTicket(){cout<<"Person:全价买票"<<endl;}};// 派生类:Studentclass Student:public Person{public:// 函数名、参数、返回值 和 基类完全相同,构成【虚函数重写】virtualvoidBuyTicket()override{// override 是C++11的规范写法,可选但强烈推荐cout<<"Student:半价买票"<<endl;}};// 派生类:Soldierclass Soldier:public Person{public:// 同样重写了基类的虚函数virtualvoidBuyTicket()override{cout<<"Soldier:优先买票"<<endl;}};// 多态的核心:基类引用/指针接收不同对象voidDoBuy(Person&p){p.BuyTicket();}intmain(){Person normal;Student stu;Soldier sol;DoBuy(normal);// 调用 Person::BuyTicket()DoBuy(stu);// 调用 Student::BuyTicket()DoBuy(sol);// 调用 Soldier::BuyTicket()return0;}2.3 析构函数的重写
- 析构函数的特殊 “重写” 规则
普通函数重写,要求函数名、参数、返回值都必须完全一致。
但析构函数不一样:基类析构函数加了 virtual 后,派生类的析构函数哪怕名字不同(~A() vs ~B()),也会被编译器特殊处理,自动构成重写。
原理:编译器在编译时,会把所有类的析构函数统一命名为 destructor,所以它们能匹配上,构成多态。 - 为什么基类析构函数必须是虚函数?(关键考点)
举个例子,当你用基类指针指向派生类对象,然后 delete 它时:
不加 virtual:只会调用基类的析构函数,派生类的析构函数不会被执行 → 派生类中申请的资源(比如动态内存)无法释放 → 内存泄漏。
加了 virtual:触发多态,会先调用派生类的析构函数,再调用基类的析构函数 → 资源完全释放,不会泄漏。
#include<iostream>using namespace std;class A{public:A(){p=newint;}// 关键:加virtual,析构函数成为虚函数virtual~A(){delete p;cout<<"A::~A()"<<endl;}private:int*p;};class B:public A{public:B(){q=newint;}// 派生类析构函数自动构成重写,可加override规范写法~B()override{delete q;cout<<"B::~B()"<<endl;}private:int*q;};intmain(){A*p=newB();delete p;// 会先调用B::~B(),再调用A::~A(),资源完全释放return0;}2.4 override 和 final 关键字
一、override 关键字:给虚函数重写 “加个保险”
作用:
明确标记派生类的函数是 “重写基类虚函数”,让编译器帮你检查是否符合重写规则。
核心价值:
解决手误导致的重写失败问题(比如参数写错、返回值不匹配)
代码可读性更强,一眼就能看出 “这是重写,不是新函数”。
class Base{public:virtualvoidfunc(intx){}};class Derive:public Base{public:// ✅ 正确写法:用override标记重写virtualvoidfunc(intx)override{// 重写逻辑}// ❌ 错误示例:参数写错,编译器会直接报错// virtual void func(double x) override {}};二 final 关键字
final在继承有禁止继承的功能,在修饰虚函数有禁止重写的功能。
基类的虚函数加了 final 后,派生类不能再重写它。
class Base{public:virtualvoidfunc()final{}// 禁止派生类重写};class Derive:public Base{public:// ❌ 编译报错:无法重写final虚函数// virtual void func() override {}};2.5 重载/重写/隐藏
2.6 纯虚函数和抽象类
在虚函数的后面写上 =0,则这个函数为纯虚函数,纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可。包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。
virtualvoidFunc()=0;// 纯虚函数核心作用:
- 强制派生类必须重写该函数,否则派生类也会变成抽象类,无法实例化。
- 为多态提供一个统一的接口规范,让不同派生类实现各自的逻辑。
抽象类的规则
定义:包含至少一个纯虚函数的类,就是抽象类。
关键限制:
- 抽象类不能实例化对象(无法用 new 或直接定义创建对象)。
- 如果派生类继承抽象类后,没有重写所有纯虚函数,那么派生类依然是抽象类,同样不能实例化。
#include<iostream>using namespace std;// 抽象类(包含纯虚函数)class Animal{public:// 纯虚函数:强制派生类实现“叫”这个行为virtualvoidSpeak()=0;virtual~Animal()=default;};// 派生类:猫,必须重写 Speakclass Cat:public Animal{public:voidSpeak()override{cout<<"喵~"<<endl;}};// 派生类:狗,必须重写 Speakclass Dog:public Animal{public:voidSpeak()override{cout<<"汪汪!"<<endl;}};intmain(){// Animal a; // ❌ 抽象类不能实例化Animal*cat=new Cat;Animal*dog=new Dog;cat->Speak();// 输出:喵~dog->Speak();// 输出:汪汪!delete cat;delete dog;return0;}2.7 虚函数表
定义
每个包含虚函数的类的对象,都会额外包含一个隐藏的指针成员,就是虚函数表指针。
它指向这个对象所属类的虚函数表。
32 位程序中占 4 字节,64 位程序中占 8 字节。
它通常在对象内存布局的最开头。
那么,虚函数表有什么用呢?
每个有虚函数的类,会自动生成一张虚函数表 vtable
表里存的是:虚函数的地址
每个对象内部,隐藏一个 vfptr 虚表指针,指向自己类的虚表
继承 + 重写时:
- 子类会复制父类虚表
- 只要子类重写了虚函数:
- ✅ 虚表中对应位置的函数地址,直接替换成子类函数地址
调用时:通过对象的 vfptr 找到自己真实的虚表查表,运行时拿到子类函数地址
调用子类方法。
#include<iostream>using namespace std;// 父类class Person{public:// 加 virtual → 产生虚函数表virtualvoidbuy(){cout<<"全价买票"<<endl;}};// 子类class Student:public Person{public:// 重写虚函数,子类虚表会替换该函数地址voidbuy()override{cout<<"半价买票"<<endl;}};intmain(){// 父类指针 指向 子类对象Person*p=new Student;// 靠【虚函数表】运行时找子类的函数p->buy();delete p;return0;}2.8 多态原理
编译时:生成虚函数表
只要类中定义了虚函数,编译器就会为该类生成一张虚函数表(vtable),表中存储了所有虚函数的地址。
Person 类的虚表:存储 Person::BuyTicket 的地址
Student 类的虚表:存储 Student::BuyTicket 的地址(重写后会覆盖基类的地址)运行时:动态查表调用
当通过基类指针 / 引用调用虚函数(如 ptr->BuyTicket())时,程序不会在编译阶段就绑定固定的函数地址,而是:
通过对象内部的虚函数表指针(vfptr),找到该对象所属类的虚函数表。
从虚函数表中取出对应虚函数的地址。
调用该地址对应的函数。最终效果
当 ptr 指向 Person 对象时,查表调用 Person::BuyTicket
当 ptr 指向 Student 对象时,查表调用 Student::BuyTicket
从而实现了同一个调用语句,根据对象的实际类型,自动执行不同的函数逻辑,这就是多态的本质。
子类的虚函数表 = 复制父类的虚表内容
但是 = 一张全新的、独立的表
子类对象的 vfptr = 指向这张新表,和父类指针不一样
2.9 动态绑定与静态绑定
- 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
- 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。
动态绑定的核心是:当通过基类指针/引用调用虚函数时,程序不会在编译期写死函数地址,而是在运行时通过对象内部的虚表指针(vptr)找到其所属类的虚函数表(vtable),再根据函数在表中的索引取出对应的函数地址并调用,从而实现“同一个调用语句,根据对象真实类型自动执行不同版本函数”的多态效果。
#include<iostream>using namespace std;class Person{public:virtualvoidBuyTicket(){cout<<"Person:买全价票"<<endl;}};class Student:public Person{public:voidBuyTicket()override{cout<<"Student:买半价票"<<endl;}};// 动态绑定的调用场景voidFunc(Person*ptr){// 这句就是典型的动态绑定:运行时查表调用ptr->BuyTicket();}intmain(){Person p;Student s;Func(&p);// ptr指向Person对象Func(&s);// ptr指向Student对象return0;}