C++:继承与多态详解
文章目录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 继承的概念核心定义继承是一个类派生类 / 子类复用、扩展另一个类基类 / 父类的成员变量和成员函数的机制。也就是一个类可以使用另一个类的成员变量和函数。举个例子#includeiostream#includestringusing 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班);couta.wid()endl;couta.wname()endl;couta.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;// 派生类对象完整的学生ptrs;// ✅ 合法指针指向s的Person部分ptr-show();//假设父类子类都有个void show(),这里只能调用Person的成员函数访问不到Student的内容这里就是把子类的show()给切出来了。基类对象不能赋值给派生类对象。Person p;Student sp;// ❌ 编译报错Student s;// 真正的派生类对象Person*ps;// 基类指针指向派生类对象// 安全强转回 Student*Student*s1(Student*)p;s1-id2026001;// ✅ 能正常访问派生类成员如果基类指针不指向派生类对象就不行Person p;// 纯基类对象Person*ptrp;// 基类指针指向基类对象// 危险强转成 Student*Student*s2(Student*)ptr;s2-id2026001;// ❌ 访问非法内存程序崩溃1.4 继承中的作用域在继承体系中基类和派生类都有独立的作用域。派生类和基类中有同名成员派生类成员将屏蔽基类对同名成员的直接访问这种情况叫隐藏。在派生类成员函数中可以使用 基类::基类成员 显示访问需要注意的是如果是成员函数的隐藏只需要函数名相同就构成隐藏。注意在实际中在继承体系里面最好不要定义同名的成员。class Person{protected:string _name赛罗;// 姓名int_num111;// ⾝份证号};class Student:public Person{public:voidPrint(){cout 姓名:_nameendl;cout 身份证号:Person::_numendl;cout 学号:_numendl;//这里两个_num就构成隐藏}protected:int_num999;// 学号};intmain(){Student s1;s1.Print();return0;};这里特别声明隐藏后的函数是找不到的一旦调用编译器找不到就会报错。1.5 类可以不被继承吗法1派生类的构造函数必须调用基类的构造函数来初始化基类部分。如果基类的构造函数是 private派生类就无法访问它也就无法实例化派生类对象从而实现 “不能被继承”。class NoInherit{private:// 私有化构造函数NoInherit(){}friend NoInheritCreateNoInherit();};// 友元函数可以调用私有构造用来创建对象NoInheritCreateNoInherit(){returnNoInherit();}// 尝试继承会编译报错class Derived:public NoInherit{// ❌ 无法访问基类的私有构造函数};intmain(){// 可以通过友元创建基类对象NoInherit objCreateNoInherit();// Derived d; // ❌ 编译失败return0;}法2final 关键字是 C11 新增的特性专门用来禁止类被继承语法更简洁是现代 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 继承公共基类时编译器会在派生类对象中通过虚基类指针指向一个共享的基类成员副本而不是复制多份。这样一来整个继承体系中公共基类的成员就只有一份实例彻底解决了数据冗余和二义性。#includeiostream#includestringusing namespace std;class Person{public:string _name;};// 虚继承class Student:virtual public Person{public:Student(){_name1;// 先赋值 1}};// 虚继承class Teacher:virtual public Person{public:Teacher(){_name2;// 后赋值 2}};// 多继承class Assistant:public Student,public Teacher{public:voidPrint(){cout_nameendl;}};intmain(){Assistant a;a.Print();// 输出多少// 你甚至可以从 Student 路径访问cout从Student访问a.Student::_nameendl;// 从 Teacher 路径访问cout从Teacher访问a.Teacher::_nameendl;return0;}结果2. 多态多态是什么简单说同一个行为不同对象做会有不同的表现。买票普通人全价、学生打折、军人优先动物叫猫 “喵”、狗 “汪汪”在C里多态分两种类型别名例子特点编译时多态静态多态函数重载、函数模板编译器在编译阶段就确定调用哪个函数运行时多态动态多态虚函数重写程序运行时根据对象的实际类型决定调用哪个函数2.1 多态的构成条件实现多态还有两个必须重要条件必须是基类的指针或者引用调用虚函数被调用的函数必须是虚函数并且完成了虚函数重写/覆盖。说明要实现多态效果第一必须是基类的指针或引用因为只有基类的指针或引用才能既指向基类对象又指向派生类对象第二派生类必须对基类的虚函数完成重写/覆盖重写或者覆盖了基类和派生类之间才能有不同的函数多态的不同形态效果才能达到。2.2 虚函数类成员函数前面加virtual修饰那么这个成员函数被称为虚函数。注意非成员函数不能加virtual修饰。class Person{public:virtualvoidBuyTicket(){cout买票-全价endl;}};2.2.1 虚函数的重写/覆盖虚函数的重写/覆盖派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同)称派生类的虚函数重写了基类的虚函数。注意在重写基类虚函数时派生类的虚函数在不加virtual关键字时虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性)但是该种写法不是很规范不建议这样使用不过在考试选择题中经常会故意买这个坑让你判断是否构成多态。#includeiostream#includestringusing namespace std;// 基类Personclass Person{public:// 加了 virtual这就是【虚函数】virtualvoidBuyTicket(){coutPerson全价买票endl;}};// 派生类Studentclass Student:public Person{public:// 函数名、参数、返回值 和 基类完全相同构成【虚函数重写】virtualvoidBuyTicket()override{// override 是C11的规范写法可选但强烈推荐coutStudent半价买票endl;}};// 派生类Soldierclass Soldier:public Person{public:// 同样重写了基类的虚函数virtualvoidBuyTicket()override{coutSoldier优先买票endl;}};// 多态的核心基类引用/指针接收不同对象voidDoBuy(Personp){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触发多态会先调用派生类的析构函数再调用基类的析构函数 → 资源完全释放不会泄漏。#includeiostreamusing namespace std;class A{public:A(){pnewint;}// 关键加virtual析构函数成为虚函数virtual~A(){delete p;coutA::~A()endl;}private:int*p;};class B:public A{public:B(){qnewint;}// 派生类析构函数自动构成重写可加override规范写法~B()override{delete q;coutB::~B()endl;}private:int*q;};intmain(){A*pnewB();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 或直接定义创建对象。如果派生类继承抽象类后没有重写所有纯虚函数那么派生类依然是抽象类同样不能实例化。#includeiostreamusing 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*catnew Cat;Animal*dognew Dog;cat-Speak();// 输出喵~dog-Speak();// 输出汪汪delete cat;delete dog;return0;}2.7 虚函数表定义每个包含虚函数的类的对象都会额外包含一个隐藏的指针成员就是虚函数表指针。它指向这个对象所属类的虚函数表。32 位程序中占 4 字节64 位程序中占 8 字节。它通常在对象内存布局的最开头。那么虚函数表有什么用呢每个有虚函数的类会自动生成一张虚函数表 vtable表里存的是虚函数的地址每个对象内部隐藏一个 vfptr 虚表指针指向自己类的虚表继承 重写时子类会复制父类虚表只要子类重写了虚函数✅ 虚表中对应位置的函数地址直接替换成子类函数地址调用时通过对象的 vfptr 找到自己真实的虚表查表运行时拿到子类函数地址调用子类方法。#includeiostreamusing namespace std;// 父类class Person{public:// 加 virtual → 产生虚函数表virtualvoidbuy(){cout全价买票endl;}};// 子类class Student:public Person{public:// 重写虚函数子类虚表会替换该函数地址voidbuy()override{cout半价买票endl;}};intmain(){// 父类指针 指向 子类对象Person*pnew 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再根据函数在表中的索引取出对应的函数地址并调用从而实现“同一个调用语句根据对象真实类型自动执行不同版本函数”的多态效果。#includeiostreamusing namespace std;class Person{public:virtualvoidBuyTicket(){coutPerson买全价票endl;}};class Student:public Person{public:voidBuyTicket()override{coutStudent买半价票endl;}};// 动态绑定的调用场景voidFunc(Person*ptr){// 这句就是典型的动态绑定运行时查表调用ptr-BuyTicket();}intmain(){Person p;Student s;Func(p);// ptr指向Person对象Func(s);// ptr指向Student对象return0;}