【c++】多态(多态的概念及实现、虚函数重写、纯虚函数和抽象类、虚函数表、多态的实现过程)

简介: 本文介绍了面向对象编程中的多态特性,涵盖其概念、实现条件及原理。多态指“一个接口,多种实现”,通过基类指针或引用来调用不同派生类的重写虚函数,实现运行时多态。文中详细解释了虚函数、虚函数表(vtable)、纯虚函数与抽象类的概念,并通过代码示例展示了多态的具体应用。此外,还讨论了动态绑定和静态绑定的区别,帮助读者深入理解多态机制。最后总结了多态在编程中的重要性和应用场景。文章结构清晰,从基础到深入,适合初学者和有一定基础的开发者学习。如果你觉得内容有帮助,请点赞支持。 ❤❤❤

前言

       本篇文章是继继承之后,博主跟大家介绍面向对象三大特性的最后一个——多态。


正文开始


一、多态的概念

       通俗地讲,多态就是“多种形态” 的意思,它的核心要义在于“一个接口,多种实现”,也就是说调用同一个接口,而产生不同的行为。多态可以分为编译时多态(静态多态)和运行时多态(动态多态)。在之前的学习当中,我们已经接触过编译时多态,例如函数重载和函数模板。本篇文章,博主主要和大家分享运行时多态的相关知识。


本文所提到的多态皆表示运行时多态。


       举个例子,当我们提到汽车、飞机、轮船等交通工具时,它们虽然都属于“交通工具”这一大类,但在实际使用时,却有着不同的出行方式。也就是说,当我们说“乘坐xx出行”时,这些交通工具会以各自独特的方式行驶。这正好体现了多态性:我们调用相同的接口(“乘坐xx出行”),对于不同的对象(如汽车、飞机、轮船等)会根据它们各自的实现来展现出不同的行为。这样,我们就可以在不关心具体实现细节的情况下,灵活地处理和使用这些交通工具了。



二、多态的实现

1. 多态的构成条件

       要实现多态,就要满足以下三点构成条件:


1. 多态发生在有继承关系的类之间。

2. 必须用基类的指针或引用调用该接口。

3. 被调用的接口是虚函数,并且虚函数完成了重写(覆盖)。


至于什么是虚函数,什么是重写(覆盖),接下来博主跟大家进行介绍。


2. 虚函数及其重写

2.1 概念

虚函数:指的是被virtual关键字修饰的成员函数。


举个例子:

class A
{
public:
    //虚函数
    virtual void fun()
    {
        //...
    }
};

注意:非成员函数不能加virtual关键字。


虚函数的重写:也叫做覆盖,当派生类中有一个与基类完全相同(返回值类型、参数列表、函数名都相同)的虚函数时(注意基类中的这个函数也是虚函数),称派生类的该虚函数重写(覆盖)了基类的相应虚函数。这样,派生类的虚函数就提供了一个基类虚函数的新实现。然后我们调用该虚函数时,编译器就会根据基类的指针/引用所表示的对象类型来调用相应的虚函数。


注意:当派生类的某个成员函数(没有virtual关键字)与基类的虚函数完全相同时,也会构成虚函数的重写(因为基类虚函数被继承到了派生类)。但是为了保持代码规范,一般还是会加上virtual。


2.2 实现多态

接下来根据刚才的例子实现一个简单的多态。代码如下:

#include <iostream>
using namespace std;
 
//定义交通工具类
class Transport
{
public:
    //出行
    virtual void going_out() const {}
private:
    //...
};
 
//定义飞机类
class Plane : public Transport
{
public:
    //出行
    virtual void going_out() const
    {
        cout << "飞机:飞行" << endl;
    }
private:
    //...
};
 
//定义轮船类
class Steamship: public Transport
{
public:
    //出行
    virtual void going_out() const
    {
        cout << "轮船:航行" << endl;
    }
private:
    //...
};
 
//定义汽车类
class Car : public Transport
{
public:
    //出行
    virtual void going_out() const
    {
        cout << "汽车:地面行驶" << endl;
    }
private:
    //...
};
 
//中间函数,将派生类对象转化为基类的引用
void func(const Transport& t)
{
    t.going_out();
}
 
int main()
{
    Plane p;
    Steamship s;
    Car c;
 
    func(p);
    func(s);
    func(c);
}


这里的going_out就是一个虚函数,各派生类的going_out都重写了基类的going_out。


运行结果:



不难发现,虽然我们传入的参数都是基类的引用,但程序却调用了不同的函数。这就说明编译器根据引用所表示的实际对象来执行相应代码。



当然,这里的中间函数也可以设置为将对象的地址转化为基类的指针,使用时传入对象的地址。

void func(const Transport* t)
{
    t->going_out();
}

2.3 一道选择题

以下程序的输出结果是什么?

A. A->0 B. A->1 C. B->0 D. B->1 E. 编译报错 F. 以上都不正确



先看结果:



解释:


首先要知道的是:两个func函数是构成重写的。

p是一个B*类型的指针,它指向的对象是B类型的对象。p调用test函数,本质是将p传给了test函数的this指针。

注意:该test函数是由A继承到B中的,但是其参数类型仍然是A*。

test调用func函数,本质是将this指针传过去,该this指针的类型是A*,指向的对象是B类型。

由于实际对象是B类型,所以程序调用B的func函数。

但是注意:在重写过程当中,派生类重写的只是基类虚函数的函数体。

所以说对于参数列表而言,由于this指针是A*类型,所以仍然使用A类的。

所以运行结果是:B->1


2.4 虚函数重写的特例

2.4.1 协变

       所谓协变,指的是派生类重写基类虚函数时,与基类虚函数返回值类型不同,此时需要满足:基类虚函数的返回值是基类对象的指针或引用,派生类虚函数的返回值是派生类对象的指针或引用。


举个例子:

class A
{
public:
    virtual A* fun() {/*...*/ }
};
 
class B : public A
{
public:
    virtual B* fun() {/*...*/ }
};

协变的意义并不大,作为了解即可。


2.4.2 析构函数的重写(重点)

       如果基类的析构函数为虚函数,那么派生类的析构函数只要定义,无论是否加virtual关键字,都会重写基类析构函数,这是因为编译之后析构函数的名称被统一处理成destructor()。


来看一段代码:

#include <iostream>
using namespace std;
 
class A 
{
public:
     ~A() { cout << "~A()" << endl; }
//...
};
class B : public A 
{
public:
     ~B() { cout << "~B()" << endl; }
//...
};
 
int main()
{
    A* p1 = new A;
    A* p2 = new B;
 
    delete p1;
    cout << endl;
    delete p2;
    return 0;
};


运行结果:



       上述代码当中,我们创建了分别为A、B类型的两个对象,将它们的地址赋值给两个A*指针p1和p2。但从运行结果中可以看出,这段代码出问题了:对于p1指针,它所指向的对象是A类型,对象销毁时直接调用A类型的析构函数,没毛病;但是对于p2指针,它所指向的对象是B类型,B是A的派生类,其在销毁时首先要调用派生类析构,然后调用基类析构,但是程序并没有调用派生类析构,导致内存泄漏。这是由于指向它的指针是A*类型,所以只会调用A的析构函数。


解决方法:将A类的析构函数设置为虚函数。

class A 
{
public:
     virtual ~A() { cout << "~A()" << endl; }
//...
};

此时,两析构函数构成重写,这样程序在调用析构函数时,就会根据指向对象的类型进行调用(而不是根据指针),那么当p2指向的对象在销毁时,就会调用B的析构函数,进而在函数内部调用A的析构函数,完成派生类部分和基类部分的数据销毁。


运行结果:



结论:基类当中的析构函数建议设计为虚函数。


2.5 c++11关键字:override和final

       不难发现,c++对虚函数重写的要求十分严格。但是有时我们会因为疏忽(例如函数名/参数名写错)导致无法构成重写,并且这种错误在编译阶段并不会被发现。为了尽量避免这种疏忽,c++11提供了关键字override,用于检查派生类虚函数是否重写了基类某个函数,如果没有重写,则编译报错。

class B : public A
{
public:
    virtual void fun() override {/*...*/ }
};

而final关键字可以用于修饰虚函数,表示该虚函数不能被重写。

class A
{
public:
    virtual void fun() final {/*...*/ }
};

2.6 重载、重写、隐藏的对比


三、纯虚函数和抽象类

       当我们在虚函数的参数列表之后写一个“=0”,那么该函数就成为了“纯虚函数”。包含纯虚函数的类叫做抽象类。


抽象类有以下特点:


1. 抽象类不能实例化。

2. 抽象类的派生类如果不重写纯虚函数,那么派生类也是抽象类。

//抽象类
class A
{
public:
    //纯虚函数
    virtual void fun() = 0;
};
 
int main()
{
    A a; //报错:无法实例化抽象类
    return 0;
}

抽象类从某种程度上强制了派生类重写虚函数,因为虚函数无法实例化。


四、多态的实现原理

1. 虚函数表

       首先看一段代码(x64平台):

#include <iostream>
using namespace std;
 
class A
{
public:
    virtual void fun()
    {
        //...
    }
private:
    int _m;
    int _n;
};
 
int main()
{
    A a;
    cout << sizeof(a) << endl;
    return 0;
}


运行结果:



根据类成员的内存对其规则,原本类A的内存大小应该是8字节。但为什么运行结果是16呢?


我们看看调试窗口:



可以看到,对象的成员当中,除了两个变量_m、_n,还有一个变量叫做__vfptr。 __vfptr是一个指针类型,在x64环境下,它的大小是8个字节,这就能够说明为什么输出结果是16了。


那么__vfptr到底是什么呢?


实际上,__vfptr叫做虚函数表指针。对于一个含有虚函数的类,它的所有虚函数的地址都被存放在一个虚函数表当中,而这个虚函数表指针存放的是虚函数表的地址。所以一个含有虚函数的类中都至少拥有一个虚函数表指针。


那么,这里的 “ [0] ” 中,存放的就是fun()的地址了。


看看更复杂的情况:

class A
{
public:
    virtual void fun1()
    {
        //...
    }
 
    virtual void fun2()
    {
        //...
    }
 
    void fun3()
    {
        //...
    }
private:
    int _m;
    int _n;
};
 
class B : public A
{
public:
    virtual void fun1() override
    {
        //...
    }
private:
    int _q;
};
 
int main()
{
    A a;
    B b;
    return 0;
}

在类A当中,我们定义了两个成员变量已经三个成员函数,其中fun1和fun2是虚函数,fun3是普通函数;类B继承了类A,定义了一个成员变量,并且重写了fun1函数。


调试窗口:



a对象的虚函数表当中,我们可以看到有两个变量,它们分别是fun1和fun2的地址。由于fun3并非虚函数,所以它的地址并不在虚函数表当中。


b对象继承了a对象的虚函数表指针。值得注意的是,其中的“ [0] ”的值与a对象中“ [0] ”的值不同,但是“ [1] ”的值相同。这是由于b对象重写了fun1,而没有重写fun2,所以“ [1] ”还是同一个函数fun2的地址。


总结一下虚函数表的相关知识:

1. 虚函数表的本质是一个函数指针数组,在编译阶段生成,基类的虚函数表存放的是基类所有虚函数的地址。同类型的对象共用同一张虚函数表,不同类型对象有各自的虚函数表,例如基类和派生类。


2. 一般情况下,派生类当中有继承得到的__vfptr,自己就不会再生成__vfptr。但要注意派生类中的__vfptr与基类的不是同一个。


3. 如果是多继承的情况,那么派生类继承了多少个带有虚函数的基类,该派生类就有多少张虚函数表。


3. 如果派生类重写了基类的虚函数,那么派生类的虚函数表中对应的虚函数地址就会被覆盖成重写的新虚函数地址。


4. 派生类的虚函数表包含三个部分:基类的虚函数地址、由于派生类重写而覆盖的虚函数地址、派生类自己的虚函数地址。


5. c++标准并没有规定虚函数表位于内存哪一个区域,vs下默认位于代码段。


2. 多态的实现过程

       总的来说,多态的实现过程是:首先根据规则创建虚函数表,在虚函数表中存储相应的虚函数地址,然后在基类和派生类中添加虚函数表指针,最后通过指向的虚函数表来调用相应的虚函数或重写函数。



       我们通过基类的指针或引用调用虚函数时,若该指针或引用指向的是父类,运行时就到指向父类对象的虚函数表中找到对应的虚函数进行调用;若指向的是子类,运行时就到指向子类对象的虚函数表中找到对应的虚函数进行调用。


       如此,即便都是通过基类的指针或引用进行调用,但只要访问所表示对象的虚函数表,就能确定调用的重写函数是哪一个,也就做到了根据基类的指针/引用所表示的对象类型来调用相应的虚函数。


       由于访问虚函数表、通过虚函数表访问函数等一系列行为都是在程序运行时完成的,所以该过程也被称为运行时多态。


3. 动态绑定和静态绑定

       最后,我们根据多态的实现原理,总结编译时多态和运行时多态的区别,由此引申出静态绑定和动态绑定的概念:


静态绑定,指的是在程序编译期间确定了程序的行为,也就是编译时多态,比如:函数重载,函数模板。


动态绑定,是在程序运行期间,根据具体类型来确定程序的具体行为,调用具体的函数,也就是运行时多态。


总结

       本篇文章,我们学习了面向对象编程的最后一点特性——多态。从多态的概念、多态的构成条件到多态的实现、多态的原理,以及多态在编程中的应用,我们进行了全面而深入的学习。如果你觉得博主讲的还不错,就请留下一个小小的赞在走哦,感谢大家的支持❤❤❤

目录
打赏
0
0
0
0
138
分享
相关文章
【C++】多态
多态是面向对象编程中的重要特性,允许通过基类引用调用派生类的具体方法,实现代码的灵活性和扩展性。其核心机制包括虚函数、动态绑定及继承。通过声明虚函数并让派生类重写这些函数,可以在运行时决定具体调用哪个版本的方法。此外,多态还涉及虚函数表(vtable)的使用,其中存储了虚函数的指针,确保调用正确的实现。为了防止资源泄露,基类的析构函数应声明为虚函数。多态的底层实现涉及对象内部的虚函数表指针,指向特定于类的虚函数表,支持动态方法解析。
62 1
|
6月前
|
C++
C++入门13——详解多态2
C++入门13——详解多态2
116 1
【C++篇】深度解析类与对象(下)
在上一篇博客中,我们学习了C++的基础类与对象概念,包括类的定义、对象的使用和构造函数的作用。在这一篇,我们将深入探讨C++类的一些重要特性,如构造函数的高级用法、类型转换、static成员、友元、内部类、匿名对象,以及对象拷贝优化等。这些内容可以帮助你更好地理解和应用面向对象编程的核心理念,提升代码的健壮性、灵活性和可维护性。
【c++11】c++11新特性(上)(列表初始化、右值引用和移动语义、类的新默认成员函数、lambda表达式)
C++11为C++带来了革命性变化,引入了列表初始化、右值引用、移动语义、类的新默认成员函数和lambda表达式等特性。列表初始化统一了对象初始化方式,initializer_list简化了容器多元素初始化;右值引用和移动语义优化了资源管理,减少拷贝开销;类新增移动构造和移动赋值函数提升性能;lambda表达式提供匿名函数对象,增强代码简洁性和灵活性。这些特性共同推动了现代C++编程的发展,提升了开发效率与程序性能。
34 12
【C++进阶】特殊类设计 && 单例模式
通过对特殊类设计和单例模式的深入探讨,我们可以更好地设计和实现复杂的C++程序。特殊类设计提高了代码的安全性和可维护性,而单例模式则确保类的唯一实例性和全局访问性。理解并掌握这些高级设计技巧,对于提升C++编程水平至关重要。
48 16
类和对象(中 )C++
本文详细讲解了C++中的默认成员函数,包括构造函数、析构函数、拷贝构造函数、赋值运算符重载和取地址运算符重载等内容。重点分析了各函数的特点、使用场景及相互关系,如构造函数的主要任务是初始化对象,而非创建空间;析构函数用于清理资源;拷贝构造与赋值运算符的区别在于前者用于创建新对象,后者用于已存在的对象赋值。同时,文章还探讨了运算符重载的规则及其应用场景,并通过实例加深理解。最后强调,若类中存在资源管理,需显式定义拷贝构造和赋值运算符以避免浅拷贝问题。
类和对象(上)(C++)
本篇内容主要讲解了C++中类的相关知识,包括类的定义、实例化及this指针的作用。详细说明了类的定义格式、成员函数默认为inline、访问限定符(public、protected、private)的使用规则,以及class与struct的区别。同时分析了类实例化的概念,对象大小的计算规则和内存对齐原则。最后介绍了this指针的工作机制,解释了成员函数如何通过隐含的this指针区分不同对象的数据。这些知识点帮助我们更好地理解C++中类的封装性和对象的实现原理。
|
1月前
|
【c++】继承(继承的定义格式、赋值兼容转换、多继承、派生类默认成员函数规则、继承与友元、继承与静态成员)
本文深入探讨了C++中的继承机制,作为面向对象编程(OOP)的核心特性之一。继承通过允许派生类扩展基类的属性和方法,极大促进了代码复用,增强了代码的可维护性和可扩展性。文章详细介绍了继承的基本概念、定义格式、继承方式(public、protected、private)、赋值兼容转换、作用域问题、默认成员函数规则、继承与友元、静态成员、多继承及菱形继承问题,并对比了继承与组合的优缺点。最后总结指出,虽然继承提高了代码灵活性和复用率,但也带来了耦合度高的问题,建议在“has-a”和“is-a”关系同时存在时优先使用组合。
111 6

热门文章

最新文章

AI助理

你好,我是AI助理

可以解答问题、推荐解决方案等