【第五节】C++的多态性与虚函数

2024-05-29 15:04
文章标签 c++ 函数 多态性 第五节

本文主要是介绍【第五节】C++的多态性与虚函数,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

目录

前言

一、子类型

二、静态联编和动态联编

三、虚函数

四、纯虚函数和抽象类

五、虚析构函数

六、重载,重定义与重写的异同

 


前言

        面向对象程序设计语言的三大核心特性是封装性、继承性和多态性。封装性奠定了基础,继承性是实现代码重用和扩展的关键,而多态性则是功能的扩充。多态性体现在对不同类的对象发送相同的消息时,会产生不同的行为。这里所说的消息主要是指对类成员函数的调用,而不同的行为则对应着不同的实现方式。在C++中,实现多态性的方法包括:

  • 函数重载

  • 运算符重载

  • 模板

  • 虚函数

        函数重载是多态性的一种基本形式,它允许在同一作用域内,相同的函数名对应不同的实现。函数重载的实现条件是函数参数的类型或个数必须有所区别。

        除了函数重载这种简单的多态形式,C++还提供了更为灵活的特性——虚函数。虚函数使得函数调用与函数体的绑定可以在运行时动态确定,这对于实现同一接口、多种实现的场景尤为重要。在深入探讨虚函数的概念之前,我们需要先了解子类型、静态联编和动态联编等相关概念。

一、子类型

        在继承的框架下,如果类B以公有继承的方式从类A派生而来,那么类B不仅继承了类A的行为,还可能拥有自身独特的行为。在这种情况下,我们称类B为类A的一个子类型。具体来说,如果存在一个类型S,它至少提供了类型T的行为,那么我们称类型S是类型T的子类型。

        当类B是类A的子类型时,类A对象能够调用的函数,类B的对象同样能够调用。这种情况下,我们称类B与类A兼容,或者说类B适应于类A。子类型的一个重要作用是实现类型兼容性,即在公有继承的模式下,派生类的对象、指向对象的指针以及对象的引用,都能够无缝地适应于基类的对象、指向对象的指针和对象引用所适用的场合。

        需要注意的是,子类型关系是单向且不可逆的。如果已知类B是类A的子类型,那么认为类A也是类B的子类型是不正确的。子类型的概念强调的是派生类对基类的兼容性和扩展性,而不是基类对派生类的依赖。

二、静态联编和动态联编

        联编是程序中各个部分相互关联的过程。根据联编发生的时机,它可以分为静态联编和动态联编两种类型。静态联编,又称为早期联编,发生在程序的编译和链接阶段。在这种联编方式中,函数调用与执行该函数的代码之间的对应关系在程序运行之前就已经确定,这意味着所有关联工作都在程序执行前完成。

示例代码:

class CBase {
public:void fun() {cout << "CBase:fun" << endl;}
};class CMyClass :public CBase {
public:void fun() { cout << "CMyClass:fun" << endl; }
};int main() {CBase* p;CBase objA;CMyClass objB;p = &objA;p->fun();p = &objB;p->fun();return 0;
}

        在静态联编的情况下,如果存在一个指向基类的指针p,那么在程序运行之前,p->fun()就已经被确定为调用基类的成员函数fun()。因此,无论指针p指向的是基类对象还是派生类对象,p->fun()都将调用基类的成员函数,并且结果保持一致。这是静态联编的特性。

        相比之下,动态联编,又称为晚期联编,是在程序运行时进行的联编过程。动态联编要求在运行时确定函数调用与执行该函数代码之间的对应关系。以之前的例子为例,如果采用动态联编,那么随着指针pobjA指向的对象不同,pobjA->fun_a()将能够调用不同类中fun_a()的不同版本。这意味着,通过一个统一的接口pobjA->fun_a(),可以访问多个不同的实现版本,即函数调用取决于运行时pobjA所指向的对象,从而展现出多态性。使用虚函数可以实现动态联编,允许在不同的联编情况下选择不同的实现,这正是多态性的体现。

        继承是实现动态联编的基础,而虚函数则是动态联编的关键所在。通过虚函数,可以在运行时根据对象的实际类型来调用相应的函数版本,从而实现多态行为。

三、虚函数

虚函数的概念:在基类中冠以关键字 virtual 的成员函数
虚函数的定义:
virtual<类型说明符>函数名>(<参数表>)
{
//<函数体>
}

virtual void fun a()
{
//<函数体>
}

虚函数的定义与特性如下:

  1. 若在基类中将某一成员函数声明为虚函数,则该函数在所有派生类中均保持其虚函数的属性,即使派生类中未显式使用virtual关键字。

  2. 动态绑定(或动态联编)仅在通过基类指针或引用调用虚函数时发生,这是实现多态性的关键机制。

  3. 虚函数不能被声明为静态函数,也不能是友元函数,因为这些类型的函数不支持动态绑定。

  4. 在基类中声明为虚函数的成员函数,在派生类中即便没有使用virtual关键字,仍然保持其虚函数的特性。

  5. 当一个成员函数在基类中被声明为虚函数时,它允许在派生类中拥有不同的实现版本,这为多态性的实现提供了可能。

  6. 由于虚函数的存在,编译器会在运行时进行动态联编,确保调用虚函数的对象在运行时根据实际对象类型来确定,从而实现动态联编的多态性。

说了半天虚函数,它到底有什么特性??特性如下:

当一个父类指针指向子类对象的时候,调用一个虚函数,将调用子类的虚函数。

示例代码:

#include <iostream>
using namespace std;class Base {
public:virtual void Fun1() {cout << "Base::Funl ..."<<endl ;}virtual void Fun2() {cout << "Base::Fun2 ..." << endl;}void Fun3() {cout << "Base::Fun3 ..." << endl;}
};class Derived : public Base {
public:/*virtual*/void Fun1()//不加virtual Fun1 也是虚函数。{cout << "Derived::Fun1 ..."<< endl; }/*virtual*/void Fun2(){cout << "Derived::Fun2 ..." << endl;}void Fun3(){cout << "Derived::Fun3 ..." << endl;}};
int main() {Base* p;Derived d;p = &d;p->Fun1(); //Fun1是虚函数,基类之指针指向派生类对象,调用的是派生类对象的虚函数p->Fun2();p->Fun3(); //Fun3非虚函数,根据p指针实际类型来调用相应类的成员函数return 0;
}

运行结果:

        这是一个很厉害的特性,我们知道调用什么函数,一般是和类型绑定的,类A的指针调用fun这个函数,本身调用的应该是类A的函数。但是有了虚函数之后,看这个指针指向哪一个子类对象了。
        这给我们提供了很多想象力,比如一个函数的参数是父类指针,我们往里面传递不同的子类对象,就可以在函数中调用到不同的虚函数。
        再比如,我们在一个数组中存储不同的子类对象,统一的去调用虚函数,大家的行为都是不相同的。

四、纯虚函数和抽象类

        虚函数机制赋予了基类指针指向派生类对象的能力,并确保调用的是派生类中相应的虚函数,这一特性使得我们能够以统一的方式处理不同派生类的对象。这种动态绑定确保了函数入口在运行时才得以确定。然而,当面临基类接口无法实现的情况时,我们该如何应对?以形状类为例,它定义了一个求面积的函数,而圆形和矩形作为其派生类,各自拥有计算面积的方法。但形状本身作为一个抽象概念,并不具备计算面积的具体方法。在这种情况下,纯虚函数便派上了用场。包含纯虚函数的类被称为抽象类,它们不能被实例化。纯虚函数是一种特殊的虚函数,它没有具体的实现,仅作为接口存在,强制派生类提供必要的实现细节。

其定义格式如下:

class <类名>
{
    virtual <函数类型> <函数名>(<参数表>)=0;
    //...
}

class CClassA
{
    virtual <函数类型> <函数名>(<参数表>)=0;
    //...
}

        在众多场景中,基类可能无法为虚函数提供实质性的实现,此时将其声明为纯虚函数,将其实现的责任转交给派生类,这正是纯虚函数的核心作用。当一个类中包含了纯虚函数,它便成为了抽象类。根据C++的规范,抽象类无法直接创建对象。

        由于纯虚函数缺乏具体的实现,包含此类函数的类自然无法直接实例化,这一点显而易见——因为无法调用未实现的纯虚函数。因此,这类类被形象地称为抽象类。抽象类若要摆脱其抽象的本质,唯有依赖派生类来充实这些虚函数的具体实现。

示例代码:

#include <iostream>
using namespace std;class Shape {
public:virtual void Draw() = 0;virtual ~Shape() {}
};class Circle :public Shape{
public:void Draw() {cout << "Circle::Draw()..." << endl;}~Circle() {cout << "~Circle ..." << endl;}};class Square : public Shape {
public:void Draw() {cout << "Square::Draw()" << endl;}~Square() {cout << "~Square ..." << endl;}
};
int main() {//Shape obj; //错误的,抽象类不能定义对象Shape* pobj = NULL;Circle objcirele;pobj = &objcirele;pobj->Draw();return 0;
}

        抽象类仅能作为基类被继承,而不能直接声明抽象类的实例。在类的构造与析构过程中,构造函数不可设为虚函数,而析构函数则允许为虚函数。

        抽象类本身不具备直接创建对象实例的能力,但可以声明抽象类的指针或引用。通过指向抽象类的指针,我们能够实现运行时的多态性。派生类有义务实现基类中的纯虚函数,若未能履行这一职责,该派生类仍将被视为抽象类。

五、虚析构函数

        构造函数不可声明为虚函数,而析构函数则具备这一特性,通过在析构函数前添加关键字virtual来实现。一旦基类的析构函数被声明为虚函数,其派生类的析构函数默认也成为虚析构函数,此时可省略virtual关键字。

        将析构函数声明为虚函数的原因在于,当基类指针指向派生类对象时,这是多态性的常见应用场景。在释放内存时,若通过delete操作符删除基类指针,通常只会触发基类的析构函数,而派生类的析构函数则不会被调用,这可能导致内存泄漏。

        然而,若基类的析构函数是虚函数,且派生类提供了自定义的析构函数实现,那么在delete基类指针时,将同时调用派生类的析构函数。在派生类执行析构过程时,会自动调用基类的析构函数,确保所有相关资源得到妥善清理。

示例代码:

#include <iostream>
using namespace std;class CClassA{
public :CClassA(){cout << "CClassA" << endl; }virtual ~CClassA() {cout << "~CClassA" << endl; }
};class CClassB : public CClassA {
public:CClassB() { cout << "CClassB" << endl; }virtual ~CClassB() { cout << "~CClassB" << endl; }
};int main() {CClassA* pobjA = new CClassB;delete pobjA;return 0;
}

六、重载,重定义与重写的异同

        在面向对象编程中,"重载"(overload)、"重写"(override)和"重定义"(redefine)是三个重要的概念,它们在处理成员函数时有着不同的应用和特征。

重载(Overload)

  • 发生在同一个类中。

  • 函数名称相同。

  • 参数列表必须不同。

  • 是否使用virtual关键字是可选的。

重写(Override)

  • 发生在派生类与基类之间。

  • 函数名称相同。

  • 参数列表相同。

  • 基类中的函数必须声明为virtual

重定义(Redefine)

  • 发生在派生类与基类之间。

  • 当函数名和参数都相同时,基类函数不需要virtual关键字。

  • 当函数名相同但参数不同时,是否使用virtual关键字是可选的。

这些概念的理解和正确应用对于掌握面向对象编程至关重要,它们帮助开发者以更加灵活和高效的方式设计和实现类和对象。

这篇关于【第五节】C++的多态性与虚函数的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1013867

相关文章

【C++ Primer Plus习题】13.4

大家好,这里是国中之林! ❥前些天发现了一个巨牛的人工智能学习网站,通俗易懂,风趣幽默,忍不住分享一下给大家。点击跳转到网站。有兴趣的可以点点进去看看← 问题: 解答: main.cpp #include <iostream>#include "port.h"int main() {Port p1;Port p2("Abc", "Bcc", 30);std::cout <<

C++包装器

包装器 在 C++ 中,“包装器”通常指的是一种设计模式或编程技巧,用于封装其他代码或对象,使其更易于使用、管理或扩展。包装器的概念在编程中非常普遍,可以用于函数、类、库等多个方面。下面是几个常见的 “包装器” 类型: 1. 函数包装器 函数包装器用于封装一个或多个函数,使其接口更统一或更便于调用。例如,std::function 是一个通用的函数包装器,它可以存储任意可调用对象(函数、函数

hdu1171(母函数或多重背包)

题意:把物品分成两份,使得价值最接近 可以用背包,或者是母函数来解,母函数(1 + x^v+x^2v+.....+x^num*v)(1 + x^v+x^2v+.....+x^num*v)(1 + x^v+x^2v+.....+x^num*v) 其中指数为价值,每一项的数目为(该物品数+1)个 代码如下: #include<iostream>#include<algorithm>

C++11第三弹:lambda表达式 | 新的类功能 | 模板的可变参数

🌈个人主页: 南桥几晴秋 🌈C++专栏: 南桥谈C++ 🌈C语言专栏: C语言学习系列 🌈Linux学习专栏: 南桥谈Linux 🌈数据结构学习专栏: 数据结构杂谈 🌈数据库学习专栏: 南桥谈MySQL 🌈Qt学习专栏: 南桥谈Qt 🌈菜鸡代码练习: 练习随想记录 🌈git学习: 南桥谈Git 🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈🌈�

【C++】_list常用方法解析及模拟实现

相信自己的力量,只要对自己始终保持信心,尽自己最大努力去完成任何事,就算事情最终结果是失败了,努力了也不留遗憾。💓💓💓 目录   ✨说在前面 🍋知识点一:什么是list? •🌰1.list的定义 •🌰2.list的基本特性 •🌰3.常用接口介绍 🍋知识点二:list常用接口 •🌰1.默认成员函数 🔥构造函数(⭐) 🔥析构函数 •🌰2.list对象

06 C++Lambda表达式

lambda表达式的定义 没有显式模版形参的lambda表达式 [捕获] 前属性 (形参列表) 说明符 异常 后属性 尾随类型 约束 {函数体} 有显式模版形参的lambda表达式 [捕获] <模版形参> 模版约束 前属性 (形参列表) 说明符 异常 后属性 尾随类型 约束 {函数体} 含义 捕获:包含零个或者多个捕获符的逗号分隔列表 模板形参:用于泛型lambda提供个模板形参的名

6.1.数据结构-c/c++堆详解下篇(堆排序,TopK问题)

上篇:6.1.数据结构-c/c++模拟实现堆上篇(向下,上调整算法,建堆,增删数据)-CSDN博客 本章重点 1.使用堆来完成堆排序 2.使用堆解决TopK问题 目录 一.堆排序 1.1 思路 1.2 代码 1.3 简单测试 二.TopK问题 2.1 思路(求最小): 2.2 C语言代码(手写堆) 2.3 C++代码(使用优先级队列 priority_queue)

【C++高阶】C++类型转换全攻略:深入理解并高效应用

📝个人主页🌹:Eternity._ ⏩收录专栏⏪:C++ “ 登神长阶 ” 🤡往期回顾🤡:C++ 智能指针 🌹🌹期待您的关注 🌹🌹 ❀C++的类型转换 📒1. C语言中的类型转换📚2. C++强制类型转换⛰️static_cast🌞reinterpret_cast⭐const_cast🍁dynamic_cast 📜3. C++强制类型转换的原因📝

C++——stack、queue的实现及deque的介绍

目录 1.stack与queue的实现 1.1stack的实现  1.2 queue的实现 2.重温vector、list、stack、queue的介绍 2.1 STL标准库中stack和queue的底层结构  3.deque的简单介绍 3.1为什么选择deque作为stack和queue的底层默认容器  3.2 STL中对stack与queue的模拟实现 ①stack模拟实现

c++的初始化列表与const成员

初始化列表与const成员 const成员 使用const修饰的类、结构、联合的成员变量,在类对象创建完成前一定要初始化。 不能在构造函数中初始化const成员,因为执行构造函数时,类对象已经创建完成,只有类对象创建完成才能调用成员函数,构造函数虽然特殊但也是成员函数。 在定义const成员时进行初始化,该语法只有在C11语法标准下才支持。 初始化列表 在构造函数小括号后面,主要用于给