当前位置: 首页 > news >正文

【C++】面向对象三大特性之多态的详细解析(一)

前言:面向对象的三大特性分别是封装、继承和多态。多态并不是孤立存在的,它需要建立在继承关系之上,因此在继续认识多态之前,需要先把基类与派生类之间的关系弄清楚。本篇先介绍继承的基本使用、基类与派生类之间的转换、继承中的作用域与隐藏,以及派生类的默认成员函数,为后面真正认识多态做好准备。



1.继承的概念与基本使用

1.1继承的概念

假设要分别设计学生类和教师类,它们都会包含姓名、年龄等成员,也都会使用一些相同的成员函数。如果把这些内容分别写进两个类中,不仅会产生重复代码,后续修改时也需要同时维护多个地方。

继承可以把这些公共内容抽取到一个类中,再让其他类在它的基础上继续扩展。被继承的类叫做基类,也叫父类;通过继承得到的新类叫做派生类,也叫子类。

下面的代码先定义了Person,再让Student公有继承Person

voidtest1(){// 父类:人classPerson{public:voidPrintInfo(){cout<<"姓名:"<<_name<<endl;cout<<"年龄:"<<_age<<endl;}protected:string _name;int_age=0;private:size_t _Weight=0;};// 子类 学生// 如果不写访问限定符对于class来说默认是private// 是struct默认就是 publicclassStudent:publicPerson{public:Student(conststring&name,intage,intstudentId){// 虽然不可见但是可以通过父类间接的使用//cout << _Weight << endl;// _name 和 _age 是从 Person 中继承下来的_name=name;_age=age;// _studentId 是 Student 自己新增的成员_studentId=studentId;}voidPrintStudent(){// 调用从父类继承下来的成员函数PrintInfo();cout<<"学号:"<<_studentId<<endl;}private:int_studentId;};Students("张三",19,20260001);s.PrintStudent();}

定义继承关系的基本格式如下:

class 派生类 : 继承方式 基类

代码中的class Student : public Person表示Student公有继承Person。一个Student对象不仅拥有自己新增的_studentId,还包含一份从Person继承而来的基类部分,所以可以在派生类中使用_name_age以及PrintInfo()

运行得到:

姓名:张三 年龄:19 学号:20260001

这里的PrintStudent()先调用继承下来的PrintInfo()输出姓名和年龄,再输出Student自己新增的学号。继承并不是简单地把两份代码拼在一起,它表达的是一种is-a关系,也就是“学生是一种人”。

Person中的_Weight虽然属于基类的private成员,派生类对象中依然存在对应的基类部分,但是Student不能直接访问它。private限制的是访问权限,并不代表这个成员没有进入派生类对象。

1.2继承方式与访问权限

C++提供了publicprotectedprivate三种继承方式。基类成员原来的访问限定符与继承方式会共同决定它们在派生类中的访问属性:

基类成员/继承方式public继承protected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员不可直接访问不可直接访问不可直接访问

可以把前两行简单理解成:继承以后得到的访问权限不会比继承方式更开放。public继承尽量保留基类原来的访问属性;protected继承会把原来的public成员收紧为protectedprivate继承则会把基类的publicprotected成员都收紧为private

protected成员在类外不能直接访问,但是派生类的成员函数可以访问,所以它经常用来保存需要交给派生类使用、又不希望完全暴露到类外的数据。实际设计中最常见的还是public继承,因为它能够比较自然地保持is-a关系。

如果使用class定义派生类但省略继承方式,默认是private继承;如果使用struct,默认则是public继承。为了让代码含义更加清楚,通常还是把继承方式显式写出来。

继承的基类也可以是一个类模板,代码中使用std::vector<T>作为stack<T>的基类:

namespaceqen{template<classT>classstack:publicstd::vector<T>{public:voidpush(constT&x){// 父类是类模板时,需要指定一下类域,// 因为stack<int>实例化时,也实例化vector<int>了// 但是模版是按需实例化,push_back等成员函数未实例化,所以找不到vector<T>::push_back(x);//push_back(x);}voidpop(){vector<T>::pop_back();}constT&top(){returnvector<T>::back();}boolempty(){returnvector<T>::empty();}};}

当基类类型依赖模板参数T时,编译器在模板定义阶段不会通过普通的未限定名称查找直接进入这个依赖基类,因此代码通过vector<T>::明确指出成员来自哪个类域。代码注释保留了比较直观的理解方式,这里再补充得准确一些:真正起作用的是依赖基类中的名称查找规则,与push_back是否已经完成实例化并不是一回事。

这样写以后,pushpoptopempty就可以复用vector<T>提供的对应接口。这里主要是为了认识“基类也可以是类模板”,不继续展开容器适配器本身。

2.基类与派生类之间的关系

2.1赋值兼容转换

public继承下,派生类对象可以交给基类对象、基类指针或者基类引用使用,这种规则也叫做赋值兼容转换:

voidtest2(){classPerson{protected:string _name;// 姓名string _sex;// 性别int_age;// 年龄};classStudent:publicPerson{public:int_No;// 学号};Student sobj;Person*pp=&sobj;Person&rp=sobj;Person pobj=sobj;//int a = 1;//double b = 2.1;//double& rb = a + b;//基类对象不能赋值给派生类对象,这里会编译报错//sobj = pobj;}

代码中出现了三种不同形式:

代码实际含义
Person* pp = &sobj;基类指针指向派生类对象中的基类部分
Person& rp = sobj;基类引用绑定派生类对象中的基类部分
Person pobj = sobj;使用派生类对象的基类部分创建一个新的基类对象

前两种写法没有创建新的Person对象,pprp仍然关联原来的Student对象,只是通过基类的角度访问其中的基类部分。这种从派生类到基类的转换通常叫做向上转型,在公有继承下可以隐式完成。

还需要注意的一点是:

第三种写法会创建一个真正的Person对象,只复制sobj中的基类部分,Student自己新增的_No不会进入pobj,这种现象叫做对象切片

上图只是对基类部分和派生类独有部分的逻辑示意,不代表标准规定了对象成员必须采用图中的实际内存布局。它想说明的是:派生类对象包含基类部分,而复制成一个独立的基类对象时,只会留下这部分内容。

反过来,基类对象不能直接赋给派生类对象,因为一个普通Person对象并没有Student新增的_No,无法凭空补出派生类独有的数据,所以代码中的sobj = pobj会导致编译报错

从派生类到基类的方向叫向上转型,通常是安全的;从基类回到派生类的方向叫向下转型,它不能仅凭继承关系就保证安全,本篇先不继续展开。这里最重要的是分清楚:**基类指针和引用接收派生类对象时不会发生对象切片,创建独立的基类对象时才会切片。**这也是后面继续认识多态时非常重要的一点。

2.2继承中的作用域与隐藏

基类和派生类各自拥有独立的类作用域。如果派生类重新定义了一个与基类同名的成员,派生类成员会屏蔽基类同名成员的直接访问,这种关系叫做隐藏

分别演示了成员变量与成员函数的隐藏:

voidtest3(){classBase{public:int_a=10;voidFunc(){cout<<"Base::Func()"<<endl;}};classDerive:publicBase{public:int_a=20;// 隐藏父类的_avoidFunc(intx)// 隐藏父类的Func(){cout<<"Derive::Func(int)"<<endl;}voidTest(){// 访问子类自己的_acout<<"Derive::_a = "<<_a<<endl;// 访问父类被隐藏的_acout<<"Base::_a = "<<Base::_a<<endl;// 调用子类函数Func(10);// 调用父类被隐藏的函数Base::Func();}};Derive d;d.Test();// 下面这两个func函数并不构成重载,因为父类与子类都用各自的类域// 所以不构成重载而是隐藏classA{public:voidFunc(){}};classB:publicA{public:voidFunc(int){}};}

Derive自己定义了_a,所以在Derive::Test()中直接写_a时访问的是派生类成员。想访问基类中被隐藏的_a,需要显式指定Base::_a

Derive::_a = 20 Base::_a = 10

成员函数也是一样。Derive::Func(int)会隐藏Base::Func(),即使两个函数的参数不同,它们也不会构成重载,因为重载要求函数位于同一个作用域中。代码通过Base::Func()明确调用基类版本,完整输出为:

Derive::_a = 20 Base::_a = 10 Derive::Func(int) Base::Func()

所以在继承体系中遇到同名成员时,首先应该想到的是隐藏,而不是重载。虽然可以通过指定基类作用域访问被隐藏的成员,但同名设计很容易增加阅读和维护成本,实际使用时应该尽量避免。

3.派生类的默认成员函数

一个派生类对象可以看成“基类部分+派生类自己新增的部分”,因此构造、拷贝、赋值和析构时,这两部分都必须得到正确处理。本篇主要关注下面四个常见成员函数:

成员函数基类部分的处理方式
构造函数先调用基类构造函数,再构造派生类部分
拷贝构造函数调用基类拷贝构造函数复制基类部分
赋值运算符调用基类operator=复制基类部分
析构函数先清理派生类部分,再自动调用基类析构函数

这张图表示的是普通对象在继承体系中的构造与析构顺序:构造时先基类后派生类,析构时顺序相反。下面再结合代码分别来看。

3.1构造函数

派生类构造函数不仅要初始化自己新增的成员,还要先把继承得到的基类部分构造好。如果基类没有可以直接调用的默认构造函数,就必须在派生类构造函数的初始化列表中显式调用合适的基类构造函数。

test4中的Person只提供了一个带参数的构造函数,因此Student通过Person(name)完成基类部分的初始化:

voidtest4(){classPerson{protected:string _name;public:// 这是一个带参构造函数。// 因为我们手动提供了这个函数,编译器将不会再自动生成无参的默认构造函数Person(constchar*name):_name(name){cout<<"调用了基类 Person 的带参构造函数, 初始化 _name: "<<_name<<endl;}};//子类classStudent:publicPerson{protected:int_num;string _addrss;public:// 派生类的构造函数Student(constchar*name,intnum,constchar*addrss):Person(name)//在初始化列表中显式调用基类的带参构造,_num(num),_addrss(addrss){cout<<"调用了派生类 Student 的构造函数"<<endl;}};// 创建子类对象时,会先调用父类构造,再调用子类构造Students("李白",1314,"王者峡谷");}

创建Student对象时,虽然代码看起来只调用了Student的构造函数,但是执行顺序一定是先构造基类部分,再执行派生类构造函数体:

调用了基类 Person 的带参构造函数, 初始化 _name: 李白 调用了派生类 Student 的构造函数

这里还需要注意,基类部分的构造顺序由继承关系决定,并不会因为初始化列表中的书写位置改变。只有基类部分构造完成以后,派生类新增的成员和构造函数体才能继续处理。

3.2拷贝构造函数

派生类进行拷贝构造时,同样需要先调用基类的拷贝构造函数完成基类部分的复制,再复制派生类自己新增的成员。

Student(const Student& s)通过Person(s)调用Person的拷贝构造,再用_num(s._num)复制自己的学号:

voidtest5(){classPerson{protected:string _name;public:// 基类的普通带参构造Person(conststring&name):_name(name){cout<<"调用了 Person 的普通构造函数"<<endl;}// 基类的拷贝构造Person(constPerson&p):_name(p._name){cout<<"调用了 Person 的拷贝构造函数, 拷贝了 _name: "<<_name<<endl;}};classStudent:publicPerson{public:int_num;// 子类的普通带参构造Student(conststring&name,intnum):Person(name),_num(num){cout<<"调用了 Student 的普通构造函数"<<endl;}Student(constStudent&s):Person(s)//显式调用父类拷贝构造并将子类对象 s 传进去(触发切片/向上转型)//如果不写 Person(s),编译器会偷偷这样调用无参构造导致_name为空// : Person(),_num(s._num){cout<<"调用了 Student 的拷贝构造函数, 拷贝了 _num: "<<_num<<endl;}voidprintInfo(){cout<<"当前学生信息 -> 姓名: "<<_name<<", 学号: "<<_num<<endl;}};Students1("李白",1314);s1.printInfo();// 此时会触发 s2 的拷贝构造函数Student s2=s1;s2.printInfo();}

Student对象可以绑定到const Person&,所以Person(s)会让基类拷贝构造函数取得s中的基类部分,并复制其中的_name。这里不会复制Student独有的_num,它仍然由派生类自己的初始化列表负责。

分别创建s1s2以后,输出为:

调用了 Person 的普通构造函数 调用了 Student 的普通构造函数 当前学生信息 -> 姓名: 李白, 学号: 1314 调用了 Person 的拷贝构造函数, 拷贝了 _name: 李白 调用了 Student 的拷贝构造函数, 拷贝了 _num: 1314 当前学生信息 -> 姓名: 李白, 学号: 1314

派生类自己实现拷贝构造时,如果初始化列表中省略基类,编译器会尝试默认初始化基类部分。当前Person只提供了带参构造和拷贝构造,并没有无参的默认构造函数,因此这里必须写出Person(s),否则代码无法通过编译。

3.3赋值运算符与析构函数

派生类的赋值运算符也要分别处理基类部分和派生类部分。test6先通过Person::operator=(s)复制基类中的_name,再复制Student自己的_num

voidtest6(){classPerson{protected:string _name;public:Person(conststring&name):_name(name){}Person&operator=(constPerson&p){if(this!=&p){_name=p._name;cout<<"调用了 Person 的 operator="<<endl;}return*this;}~Person(){cout<<"调用了 Person 的析构函数"<<endl;}};classStudent:publicPerson{public:int_num;Student(conststring&name,intnum):Person(name),_num(num){}Student&operator=(constStudent&s){if(this!=&s){// operator=(s);// 会发生无限递归死循环!因为派生类的 operator= 隐藏了基类的//指定基类作用域,显式调用基类的赋值运算符完成基类成员的复制Person::operator=(s);_num=s._num;cout<<"调用了 Student 的 operator="<<endl;}return*this;}~Student(){// 这里不需要显示调用父类的析构函数,子类析构会// 自动调用父类的析构函数cout<<"调用了 Student 的析构函数"<<endl;}};Students1("张三",1001);Students2("李四",1002);s2=s1;}

派生类的operator=会隐藏基类中的同名operator=,所以这里必须指定Person::作用域。如果在Student::operator=内部直接再次调用未限定的operator=(s),就会重新进入当前函数,形成无限递归。

析构函数不需要手动调用基类析构。Student析构函数体执行完成以后,编译器会自动继续调用Person析构函数,保证派生类自己新增的部分先清理,基类部分后清理。

运行结果如下:

调用了 Person 的 operator= 调用了 Student 的 operator= 调用了 Student 的析构函数 调用了 Person 的析构函数 调用了 Student 的析构函数 调用了 Person 的析构函数

前两行说明赋值时先处理基类部分,再处理派生类自己的成员。后面两组析构输出来自s2s1,局部对象会按照创建顺序的逆序离开作用域,而每一个Student对象内部又会先执行派生类析构,再执行基类析构。

到这里,继承体系中最基础的对象关系就比较清楚了。继承让派生类能够复用并扩展基类,向上转型让基类指针和引用可以接收派生类对象,而构造、拷贝、赋值和析构则负责正确处理对象中的基类部分与派生类部分。这些内容正是后面继续认识多态的重要基础,剩下的内容再放到后续文章中展开。


http://www.cnnetsun.cn/news/3818638.html

相关文章:

  • 奥维昔巴特Odevixibat获FDA批准:进行性家族性肝内胆汁淤积症和Alagille综合征胆汁淤积性瘙痒的非全身性治疗
  • 从零到月入2W+:一位程序员用AI做知识付费的完整闭环(含选题库、SOP模板、转化漏斗)
  • 如何免费解锁Wand专业版功能:3种补丁方案与远程控制完整指南
  • 《React Native 精解与实战》书籍连载「React 与 React Native 简介」
  • Path of Building:5分钟学会《流放之路》离线构筑,告别浪费资源的试错
  • Unity URP自定义渲染管线:RenderFeature与Volume协同实现动态特效
  • Docker 入门与完整实战视频教程
  • Unity UGUI自适应布局避坑指南:Content Size Fitter嵌套问题解析与实战解决方案
  • 别再手动盘库存了!电商库存预警系统3步搭建指南(附真实案例)
  • 相关系数全解析:从皮尔逊到斯皮尔曼,量化变量关联的实战指南
  • HarmonyOS应用实战-启示散页-72-多窗口编辑别互相覆盖草稿:给每个窗口分配 draftSessionId
  • 收藏!小白程序员也能掌握大模型,高薪就业机会等你来!
  • 大模型高效部署实战:从Inkling-Small看参数减半性能持平的实现与落地
  • 国产长芯微LDMX345完全pin-pin替代ADXL345,是一款小而薄的低功耗三轴加速度计
  • WebPShop:让Photoshop完美支持WebP格式的终极解决方案
  • Shader Weaver图形化着色器编辑:降低Unity视觉开发门槛
  • 构建文本解析与状态机引擎:从复杂字符串到结构化业务逻辑
  • jQuery低版本高危漏洞CVE-2020-11022/11023深度解析与修复指南
  • Cocos Creator虚拟摇杆开发指南:从基础实现到高级手感优化
  • Agentic SRE 落地实战:告别救火式运维,解锁人机协同可靠性新范式
  • Termux中使用Ngrok实现内网穿透:从原理到实战
  • Unity SLG项目启动:基于GameFramework的加载界面与初始化流程实践
  • Unity UI动态渐变Shader实现:从原理到实战,突破内置限制
  • SingleFile:一站式网页归档解决方案,打造个人数字图书馆
  • 鸿蒙云购物系统与阿里云部署优化指南
  • 为什么PySide6桌面宠物框架是桌面应用创新的突破口?
  • Python游戏辅助工具开发:基于状态机与事件模拟的王昭君技能练习器
  • 紧急通知:微信即将上线AI内容溯源标签!现在不掌握这6类合规生成法,下月起流量腰斩
  • Linux多进程聊天室实战:从管道通信到并发模型设计
  • Unity XR开发交互示例完整教程:从入门到实战应用