首页 > 基础资料 博客日记

【C++】面向对象编程的三大特性:深入解析多态机制

2024-10-18 05:00:07基础资料围观96

这篇文章介绍了【C++】面向对象编程的三大特性:深入解析多态机制,分享给大家做个参考,收藏Java资料网收获更多编程知识

C++语法相关知识点可以通过点击以下链接进行学习一起加油!
命名空间缺省参数与函数重载C++相关特性类和对象-上篇类和对象-中篇
类和对象-下篇日期类C/C++内存管理模板初阶String使用
String模拟实现Vector使用及其模拟实现List使用及其模拟实现容器适配器Stack与QueuePriority Queue与仿函数
模板进阶-模板特化面向对象三大特性-继承机制

本文将深入解析面向对象编程的三大核心特性,特别是针对多态机制的使用和实现原理进行详细探讨。通过对这些概念的深入理解,帮助读者更好地掌握面向对象编程的精髓。



🌈个人主页:是店小二呀
🌈C语言专栏:C语言
🌈C++专栏: C++
🌈初阶数据结构专栏: 初阶数据结构
🌈高阶数据结构专栏: 高阶数据结构
🌈Linux专栏: Linux

🌈喜欢的诗句:无人扶我青云志 我自踏雪至山巅

文章目录

声明:

以下操作在x86程序中,涉及的指针都是4bytes。如果要其他平台下,部分代码选需要改动,如果是x64程序,则需要考虑指针是8bytes问题等等

一、多态概念

多态是指多种形态,完成某个行为,当不同的对象去完成时会产生出不同的状态

具体样例:买票-对于不同对象完成一件事,不同的状态

  • 成人票:30$
  • 学生票:15$
  • 军人票:优先买票

二、多态使用及实现

2.1 多态构造条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person,当Person对象买票全价,Student买票半价。

构成多态需要三个条件:

  1. 多态是发生在继承关系中
  2. 必须通过基类的指针或者引用调用虚函数
  3. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

2.2 虚函数

虚函数:即使被virtual修饰的类成员函数被称为虚函数

class Person
{
    public:
    virtual void BuyTicket(){cout << "买票-全价" << endl;}
}

当前使用vertual与菱形虚拟继承使用virtua属于一词多义的关系

2.3 虚函数重写

虚函数的重写(覆盖):派生类中有一个跟基类完全相同的虚函数(返回值类型、函数名字、参数列表完全相同)、称子类的虚函数重写了基类的虚函数。

多态对于虚函数重写态度:指向谁调用谁的虚函数,在讲述析构函数常见题将会有更深了解。

2.4 虚函数使用场景

使用事项:

  1. 父子类完成虚函数重写(三同:函数名、参数、返回值类型)

  2. 父类的指针或者引用去调用虚函数

class Person
{
    public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
    public:
    virtual void BuyTicket() { cout << "买票-半价" << endl; }
};

void Func(Person& p)
{
    p.BuyTicket();
}

int main()
{
    Person ps;
    Student st;

    Func(ps);
    Func(st);

    return 0;
}

2.5 虚函数重写特殊场景

2.5.1 协变

派生类重写基类虚函数时,与基类虚函数返回值类型不同。属于父子类继承关系即可。

即基类虚函数返回值基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

class A{};
class B : public A {};

class Person 
{
    public:
   // virtual A* f() {return new A;}
    virtual Person* f(){return new Person;}
};
class Student : public Person
{
    public:
    //virtual B* f() {return new B;}
    virtual Student* f(){return new Student;}
}

2.5.2 析构函数重写

如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字还是析构函数名不同,都与基类的析构函数构成重写。

虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor函数名

多态及其析构函数考察(常考)

class Person
{
    public:
     ~Person() {cout << "~Person()" << endl;}
};

class Student : public Person 
{
    public:
     ~Student() { cout << "~Student()" << endl; }
};

int main()
{
    Person* p1 = new Person;
    Person* p2 = new Student;
    delete p1;
    delete p2;
    return 0;
}

分析问题

如果是单纯定义Person p,Student s话,不管析构函数是不是析构函数不重要,也不会出现什么问题。但是对于上面这种父类指针类型指向开辟父类或者子类的空间,如果Person和Student析构函数没有完成虚函数的重写就会出现问题

Person 的析构函数不是虚函数,因此,如果你使用 delete p2; 释放 p2 指向的 Student 对象,只会调用 Person 类的析构函数,而不会调用 Student 类的析构函数。这样会导致Student 对象中的资源没有得到正确释放,重复析构是一种未定义的行为,可能导致内存泄漏或者程序崩退。

具体说明:

如果析构函数没有完成虚函数的重写,会根据对象的类型,去调用析构函数,因为编译器只知道这个是一个指向基类的指针。就会调用基类的析构。

这里从底层来看,编译器只能通过指针类型去推断调用对应的析构函数

  • p1->destructor + operator delete(p1)
  • p2->destructor + operator delete(p2)

2.5.3 派生类可以不加virtual

重写基类虚函数,派生类虚函数不加virtual关键字修饰,可以构成重写。由于继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性,但是该种写法不是很规范,不建议这样使用。

2.6 大坑题(深度理解)

具体解析:

  • 这里p->test调用B类对象的test函数,但是test 函数属于类 A,它被继承到了类 B 中。由于没有在类 B 中被重写,test 函数的实现仍然是类 A 中的版本,this是A*类型,很好解释了这一块。
  • 在类 A 中,func 是一个虚函数,这意味着在运行时,调用哪个版本的 func 取决于指向对象的实际类型。p 是指向 B 类对象的指针,所以 p->func() 会调用类 Bfunc 函数。
  • 这里需要注意的是,默认参数在编译时已经被绑定到函数调用上。test 函数在类 A 中定义,而 A::func(int val = 1) 使用默认参数 1,因此在 test 中调用 func() 时,将使用默认参数 1。
  • 虚函数重写,重写的是函数体的实现

三、C++ 11 override 和 final

从上面可以看出,C++对函数重写的要求比较严格,但事实有些情况下**由于疏忽,可能会导致无法构成重写,而着这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结构才来debug会得不偿失。**对此C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

引入场景:让父类不能被子类继承

C++98方法:父类构造函数设置为私有,子类的构造无法生成和实现,导致子类对象无法实例化。

class Car
{
    public:
    
    private:
   	Car(){}
};
class Benz :public Car
{
    public:
};
int main()
{
 	Benz b;
    return 0;
}

C++11方法:采用final关键字表示最终类

3.1 final与override使用

final:修饰虚函数,表示该虚函数不能再被重写。通俗一点比喻:老爹不给你留家底了,想子类体会下人生。

class Car
{
    public:
    virtual void Drive() final {}
};
class Benz :public Car
{
    public:
    virtual void Drive() {cout << "Benz-舒适" << endl;}
};

override:检查派生类虚函数是否重写基类某个虚函数,进行语法检查,如果没有发生重写将会编译报错。注意这个只是检查。

class Car
{
    public:
    virtual void Drive(){}
};
class Benz :public Car 
{
    public:
    virtual void Drive() override {cout << "Benz-舒适" << endl;}
}

四、重载、覆盖(重写)、隐藏(重定义)区分

关于隐藏与重写语言存在重叠,可以看作重写属于隐藏的自己。因为构成重写需求比隐藏多。

五、抽象类

5.1 抽象类概念

在虚函数的后面写上 = 0,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(接口类),抽象类不能实例化对象。

派生类继承后也不能实例化出对象。只当重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外虚函数更体现出了接口继承。

class Car
{
    public:
    virtual void Drive() = 0;
};

class Benz : public Car
{
};

int main()
{
    Benz z;
    z.Drive();
    return 0;
}

5.2 抽象类与override区别

  • 抽象类:强制要求派生类完成虚函数的重写,否则都不能实例化对象
  • override:检查语句,帮助检查语法是否有问题,没有重写将编译器报错

六、实现继承与接口继承

实现继承:

  • 普通函数的继承属于实现继承,由派生类继承了基类函数,可调用函数,继承函数实现

接口继承:

  • 虚函数的继承属于接口继承,由派生类继承了基类虚函数的接口,目的是为了重写,其中重写是指函数体的实现,达成多态,继承的是接口

对此,如果不实现多态,不要把函数定义成虚函数。

七、多态的原理(重点)

7.1 虚函数表

场景引入:计算sizeof(Base)大小

class Base
{
    public:
    virtual void Func1()
    {
        cout << "Func1()" << endl;
    }
    private:
    int _b = 1;
};

结果:sizeof(Base) == 8

具体解析:

  • 通过调式窗口,我们发现除了_b成员,还多了一个 _vfptr放在对象的前面。该指针称为虚函数表指针(v代表virtual,f代表function)。
  • 一个含有虚函数的类都至少都有一个虚函数表指针,因为虚函数地址要被放到虚函数表中的虚函数表中,虚函数表也简称虚表。
  • 在调试窗口中_vfptr位置跟平台有关系,有些平台可能会放置到对象的最后面。

7.2 派生类继承基类成员

class Base
{
    public:

    virtual void Func1()
    {
        cout << "Base::Func1()" << endl;
    }

    virtual void Func2()
    {
        cout << "Base::Func2()" << endl;
    }
    //非虚函数
    void Func3()
    {
        cout << "Base::Func3()" << endl;
    }
    private:
    int _b = 1;
};
class Derive : public Base
{
    public:
    //虚函数func1的重写
    virtual void Func1()
    {
        cout << "Derive::Func1()" << endl;
    }
    private:
    int _d = 2;
};
int main()
{
    Base b;
    Derive d;
    return 0;
}

通过观察和调式:

  • 派生类对象d也存在虚表指针,d对象由两部分构成:基类继承下来的成员和虚表指针,另外一部分是自己的成员。
  • 基类b对象和派生类d对象虚表是不一样的,这里Func1完成了重写,所以d的虚表中存的是重写的Derive :: Func1
  • 所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。(重写是语法的叫法,覆盖是原理层的叫法)
  • 只有虚函数才会被放到虚表里面。Func2继承下来后是虚函数,而Func3也继承下来了,但是不是虚函数,所以不会放进虚表
  • 派生类没有自己的虚表指针,直接继承基类的虚表指针,如果无法继承,那么派生类自己建立虚表
  • 虚函数表本质是一个虚函数指针的指针数组,一般情况这个数组最后后面放一个nullptr

总结派生类的虚表生成:

  • 先将基类中的虚表内容拷贝一份到派生类虚表中
  • 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖表中基类的虚函数
  • 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后

7.3 多态的原理

class Person
{
    public:
    virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person
{
    public:
    virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
    p.BuyTicket();
}
int main()
{
    Person Mike;
    Func(Mike);
    Student Johnson;
    Func(Johnson);
    return 0;
}

7.3.1 基类指针或引用进行调用虚函数理由

对于多态来说,Func函数传Person调用的Person::BuyTicket,传Student调用的是Student::BuyTicket。

具体说明:

  • 构成多态的条件其中一条:通过基类的指针或引用进行调用虚函数
  • 根据图中信息可得,基类对象被基类类型指针指向,通过基类中虚表指针找到虚表中的虚函数;派生类对象被基类类型指针指向,指向派生类中基类切片那部分。
  • 指向派生类基类切片那部分,导致编译器无法区分基类是指向基类本身,还是指向派生类中切片中包含基类部分。但是编译器不会主动去区分它所指向的是一个实际的基类对象还是派生类对象中的基类部分。
  • 编译器通过基类类型来限制访问的范围,而虚函数的动态绑定通过虚表指针和虚表来确保正确的函数调用。
  • 体现了切片的作用及其为什么需要通过基类的指针或者引用调用
  • 相同类型的类,共享同一块虚表。运行时去指向对象虚函数表中找BuyTicket的地址。

7.3.2 不满足多态情况

如果出现不满足多态的情况,编译链接根据调用对象类型,确定调用函数及其函数地址

小结:

  • 多态调用:运行时,到指向对象的虚表中找虚函数调用,做到指向父类调用父类的虚函数,指向子类调用子类的虚函数

  • 普通调用:编译时,调用对象是哪个类型,就调用他的函数

  • 虚表:虚函数表,存的虚函数,目标实现多态

  • 虚基表:存的当前位置记录虚基类部分的偏移量,解决菱形继承导致的数据冗余和二义性

7.3.3 反汇编中情况

void Func(Person* p)
{
    ...
        p->BuyTicket();
    // p中存的是mike对象的指针,将p移动到eax中
    001940DE mov eax,dword ptr [p]
        // [eax]就是取eax值指向的内容,这里相当于把mike对象头4个字节(虚表指针)移动到了edx
        001940E1 mov edx,dword ptr [eax]
        // [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
        00B823EE mov eax,dword ptr [edx]
        // call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的。
        001940EA call eax
        001940EC cmp esi,esp
}
int main()
{
    ...
        // 首先BuyTicket虽然是虚函数,但是mike是对象,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call 地址
        mike.BuyTicket();
    00195182 lea ecx,[mike]
        00195185 call Person::BuyTicket (01914F6h)
        ...
}

八、虚函数与虚表存储内存区域

问题:

  • 虚函数存储在哪的?
  • 虚表存储在哪的?

错误答案:虚函数存在虚表,虚表存在对象中,这里答案是错误的。

接下来我们可以通过打印地址来观察,这样是一种小技巧

int main()
{
    int i = 0;
    static int j = 1;
    int* p1 = new int;
    const char* p2 = "xxxxxxxx";
    printf("栈:%p\n", &i);
    printf("静态区:%p\n", &j);
    printf("堆:%p\n", p1);
    printf("常量区:%p\n", p2);

    Person p;
    Student s;
    Person* p3 = &p;
    Student* p4 = &s;

    printf("Person虚表地址:%p\n", *(int*)p3);
    printf("Student虚表地址:%p\n", *(int*)p4);

    return 0;
}

从打印结构来看,关于上面两个问题,我们可以得到答案

答案:

  • 虚函数存储在代码段,同普通函数一样。
  • 虚表存储在常量区,虚表存储是虚函数指针,而不是虚函数
  • 虚表属于类,不归属函数局部中,因此不应该存储在栈上

九、动态绑定与静态绑定

  • 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载

  • 动态绑定又称为后期绑定(晚绑定),在程序运行期间,根据具体拿到的类型确定程序的具体行,调用具体的函数,也成为动态多态。

十、单继承与多继承的虚函数表

10.1 单继承的虚函数表

class Base
{
    public :
    virtual void func1() { cout<<"Base::func1" <<endl;}
    virtual void func2() {cout<<"Base::func2" <<endl;}
    private :
    int a;
};
class Derive :public Base
{
    public :
    virtual void func1() {cout<<"Derive::func1" <<endl;}
    virtual void func3() {cout<<"Derive::func3" <<endl;}
    virtual void func4() {cout<<"Derive::func4" <<endl;}
    private :
    int b;
};

调试窗口进行观察(不够准确)

子类继承了父类虚表,得到了Func2虚函数及其Func1完成了虚函数的重写;问题在于监视窗口观察不到Func3和Func4,这里是编译器的监视窗口故意隐藏。

内存窗口进行观察

如果通过内存窗口来观察的话,虽然我们可以大致确定就是Func3和Func4虚函数的地址,但是如何证明呢?这里就需要使用到了打印虚表中函数了

10.2 打印虚表中函数

通过调式窗口来看,虚表指针是存储在头4字节上的,虚函数表本质是一个存虚函数指针的指针数组,这个数组最后面放了一个nullptr。

如果是函数指针数组话,类型是难以书写,可以使用typedef对于类型重定义typedef void(*VFPTR) (); (这里数组指针和函数指针重定义写法是比较特殊的)

如果需要取头4个字节,能不能直接强转为int类型就行。这里强转是没有用的,只有相同类型才能进行强制类型转化,那么怎么办?

10.2.1 指针高级用法

打印虚表中虚函数地址实现步骤

步骤:

  • 先取b的地址,强制成一个int*的指针,指针可以随便转,指针本质是地址编号是整型,虽然不能直接转化为int类型,但是可以通过int *类型的指针间接的转化,是一种指针高级用法。
  • 再解引用取值,就得到了b对象头4个字节的值,这个值就是指向虚表的指针
  • 再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组
  • 虚表指针传递给printVTTable进行打印虚表
  • 需要声明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题,我们只需要清理解决方案,在次编译就行了
//得到数据,重新定义个函数指针数组
VFPTR* vTableb = (VFPTR*)(*(int*)&b);
PrintVTable(vTableb);

VFPTR* vTabled = (VFPTR*)(*(int*)&d);
PrintVTable(vTabled);

打印虚表中虚函数地址函数逻辑:

void PrintVTable(VFPTR vTable[])
{
    // 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
    cout << " 虚表地址>" << vTable << endl;
    for (int i = 0; vTable[i] != nullptr; ++i)
    {
        printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
        VFPTR f = vTable[i];
        f();
    }
    cout << endl;
}

10.3 多继承中虚函数表

class Base1 
{
    public:
    virtual void func1() {cout << "Base1::func1" << endl;}
    virtual void func2() {cout << "Base1::func2" << endl;}
    private:
    int b1;
};
class Base2 
{
    public:
    virtual void func1() {cout << "Base2::func1" << endl;}
    virtual void func2() {cout << "Base2::func2" << endl;}
    private:
    int b2;
};
class Derive : public Base1, public Base2
{
    public:
    virtual void func1() {cout << "Derive::func1" << endl;}
    virtual void func3() {cout << "Derive::func3" << endl;}
    private:
    int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
    cout << " 虚表地址>" << vTable << endl;
    for (int i = 0; vTable[i] != nullptr; ++i)
    {
        printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
        VFPTR f = vTable[i];
        f();
    }
    cout << endl;
}
int main()
{
    Derive d;
    VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
    PrintVTable(vTableb1);
    VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1)));
    PrintVTable(vTableb2);
    return 0;
}

从上面的可以观察出来,多继承体制中派生类是继承了两张虚表,同时继承下来的虚函数是不同的,至于为什么不放在一张虚表,可以想一下切片,如果只有一个切片,如何实现多态的指向谁调用谁的逻辑呢?

10.3.1 打印多继承中第二张虚表中虚函数的地址

第一种办法

VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1))
                           PrintVTable(vTableb2);

第一种办法使用指针运算法则进行移动指针指向位置,但是只适应不考虑内存对齐等因素情况下。由于内存对齐等因素,可能会导致会导致指向错误。更加推荐下面通过取地址直接访问的办法

第二种方法:

十一、菱形继承、菱形虚拟继承

实践种我们不建议设计出菱形继承、菱形虚拟继承,一方面太复杂容易出现问题,另一方面这样的模型,访问基类成员有一定性能损耗。所以继承、菱形虚拟继承继承虚表情况,我们不就不需要看了,一般我们也不需要研究清楚,实践中也很少用,如果需要了解通过下面两篇链接文章。

C++ 虚函数表解析 | 酷 壳 - CoolShell

C++ 对象的内存布局 | 酷 壳 - CoolShell

11.1 菱形虚拟继承(简单了解)

菱形虚拟继承,每个类都有一个虚函数,除了虚表指针也有我们的虚基表指针。这里虚基表有存储两个偏移量一个是距离虚表的偏移量和距离共享虚基类A的偏移量。

这里由于虚基类A是共享的,B C类的虚函数不能放进去,所以只能单独建立虚表。没有继承父类的虚表,这里是不能利用父类的虚表,不能放放我自己的虚函数,A是共享,派生类单独建立虚表

十二、相关面试题

  1. inline函数可以是虚函数吗?
  • 答:可以,不过编译器就忽略inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去
  1. 静态成员可以是虚函数吗?
  • 答:不能,因为静态成员函数没有this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
  1. 构造函数可以是虚函数吗?
  • 答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
  1. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
  • 答:可以,并且最好把基类的析构函数定义成虚函数。
  1. 对象访问普通函数快还是虚函数更快?
  • 答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
  1. 虚函数表是在什么阶段生成的,存在哪的?
  • 答:虚函数表是在编译阶段就生成的,一般情况下存在代码段(常量区)的


以上就是本篇文章的所有内容,在此感谢大家的观看!这里是店小二呀C++笔记,希望对你在学习C++语言旅途中有所帮助!


文章来源:https://blog.csdn.net/2302_79177254/article/details/141816848
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:jacktools123@163.com进行投诉反馈,一经查实,立即删除!

标签:

相关文章

本站推荐

标签云