C++ 多态性

背景介绍

  虚函数重写:子类重新定义父类中有相同返回值、名称参数的虚函数;

   非虚函重写:子类重新定义父类中有相同名称参数的非虚函数;

   父子间的赋值兼容:子类对象可以当作父类对象使用(兼容性);具体表现为:

   1. 子类对象可以直接赋值给父类对象;

   2. 子类对象可以直接初始化父类对象;

   3. 父类指针可以直接指向子类对象;

   4. 父类引用可以直接引用子类对象;

   当发生赋值兼容时,子类对象退化为父类对象,只能访问父类中定义的成员,可以直接访问被子类覆盖的同名成员;

// 在赋值兼容原则中,子类对象退化为父类对象,子类是特殊的父类;
#include<iostream>
#include<string>
using namespace std;

class Parent{
public:
    int mi;

    void add(int i){
        mi += i;
    }

    void add(int a, int b){
        mi += (a + b);
    }
};

class Child : public Parent{
public:
    int mi;

    void add(int x, int y, int z){
        mi += (x + y + z);
    }    
};

int main(){
    Parent p;
    Child c;

    c.mi = 100;
    p = c;  //p.mi = 0; 子类对象退化为父类对象    
    Parent p1(c); //pl.mi = 0; 同上
    Parent& rp = c;
    Parent* pp = &c;

    rp.add(5);
    pp->add(10, 20);

    cout<<"p.mi: "<<p.mi << endl;
    cout<<"p1.mi: "<<p1.mi<<endl;
    cout<<"c.Parent::mi: "<<c.Parent::mi<<endl;
    cout<<"rp.mi: "<<rp.mi<<endl;
    cout<<"pp=>mi: "<<pp->mi<<endl;

    return 0;
}
p.mi: 0
p1.mi: 0        
c.Parent::mi: 35
rp.mi: 35       
pp=>mi: 35  

  

  在面向对象的继承关系中,我们了解到子类可以拥有父类中的所有属性与行为;但是,有时父类中提供的方法并不能满足现有的需求,所以,我们必须在子类中重写父类中已有的方法,来满足当前的需求。此时尽管我们已经实现了函数重写(这里是非虚函数重写),但是在类型兼容性原则中也不能出现我们期待的结果(不能根据指针/引用所指向的实际对象类型去调到对应的重写函数)。接下来我们用代码来复现这个情景:

#include<iostream>
#include<string>
using namespace std;

class Parent{
public:
    void print(){
        cout<<"I'm Parent."<<endl;
    }
};

class Child : public Parent{
public:
    void print(){
        cout<<"I'm Child."<<endl;
    }
};

void how_to_print(Parent* p){
    p->print();
}

int main(){
    Parent p;
    Child c;

    how_to_print(&p);
    how_to_print(&c);

    return 0;
}
I'm Parent.  //期望为 I'm Parent.
I'm Parent.  //期望为 I'm Child

为什么会出现上述现象呢?(在赋值兼容中,父类指针/引用指向子类对象时为何不能调用子类重写函数?)

  问题分析:在编译期间,编译器只能根据指针的类型判断所指向的对象;根据赋值兼容,编译器认为父类指针指向的是父类对象;因此,编译结果只可能是调用父类中定义的同名函数。

  在编译这个函数的时候,编译器不可能知道指针p究竟指向了什么。但是编译器没有理由报错,于是,编译器认为最安全的做法是调用父类的print函数。因为父类和子类肯定都有相同的print函数。

  要想解决这个问题,就需要使用c++中的多态。那么如何实现c++中的多态呢?请看下面详解:

多态

1、 什么是多态

  在现实生活中,多态是同一个事物在不同场景下的多种形态。

  在面向对象中,多态是指通过基类的指针或者引用,在运行时动态调用实际绑定对象函数的行为。与之相对应的编译时绑定函数称为静态绑定。

  多态是设计模式的基础,多态是框架的基础。

2、 多态的分类

  

(1)静态多态(重载,模板):编译器在编译期间完成的,编译器会根据实参类型来选择调用合适的函数,如果有合适的函数就调用,没有的话就会发出警告或者报错。

(2)动态多态(覆盖,虚函数实现):在程序运行时根据基类的引用(指针)指向的对象来确定自己具体该调用哪一个类的虚函数(动态绑定)。运行基类指针指向派生类的对象,并调用派生类的函数。

  虚函数实现原理:虚函数表vtable和虚函数指针vptr。

  纯虚函数:virtual int fun() = 0;

函数的运行版本由实参决定,在运行时选择函数的版本,所以动态绑定又称为运行时绑定。当编译器遇到一个模板定义时,它并不生成代码。只有当实例化出模板的一个特定版本时,编译器才会生成代码。 

 

3、动态多态成立的条件

    由之前出现的问题可知,编译器的做法并不符合我们的期望(因为编译器是根据父类指针的类型去父类中调用被重写的函数);但是,在面向对象的多态中,我们期望的行为是 根据实际的对象类型来判断如何调用重写函数(虚函数)

  1. 即当父类指针(引用)指向 父类对象时,就调用父类中定义的虚函数;

  2. 即当父类指针(引用)指向 子类对象时,就调用子类中定义的虚函数;

       

  这种多态行为的表现效果为:同样的调用语句在实际运行时有多种不同的表现形态。

  那么在c++中,如何实现这种表现效果呢?(实现多态的条件)

  1.  要有继承

  2.  要有虚函数重写(被 virtual 声明的函数叫虚函数)

  3.  要有父类指针(父类引用)指向子类对象

4、静态联编和动态联编

  静态联编:在程序的编译期间就能确定具体的函数调用;如函数重载,非虚函数重写;

  动态联编:在程序实际运行后才能确定具体的函数调用;如虚函数重写,switch 语句和 if 语句;

#include<iostream>
#include<string>
using namespace std;

class Parent{
public:
    virtual void func(){
        cout<<"Parent::void func()"<<endl;
    }

    virtual void func(int i){
        cout<<"Parent::void func(int i): "<<i<<endl;
    }

    virtual void func(int i,int j){
        cout<<"Parent::void func(int i, int j): "<<"("<<i<<","<<j<<")"<<endl;
    }
};

class Child : public Parent{
public:
    void func(int i, int j){
        cout<<"Child::void func(int i, int j): "<<i+j<<endl;
    }

    void func(int i, int j, int k){
        cout<<"Child::void func(int i, int j, int k): "<<i+j+k<<endl;
    }
};

void run(Parent* p){
    p->func(1, 2);  //展现多态的特性 动态联编
}

int main(){
    Parent p;

    p.func(); //静态联编
    p.func(1);  //静态联编
    p.func(1, 2);  //静态联编

    cout<<endl;

    Child c;

    c.func(1, 2);  //静态联编

    cout<<endl;
    
    run(&p);
    run(&c);

    return 0;
}
Parent::void func()
Parent::void func(int i): 1
Parent::void func(int i, int j): (1,2)

Child::void func(int i, int j): 3

Parent::void func(int i, int j): (1,2)
Child::void func(int i, int j): 3

  

5、动态多态的实现原理

  虚函数表与vptr指针

  1. 当类中声明虚函数时,编译器会在类中生成一个虚函数表;

  2. 虚函数表是一个存储类成员函数指针的数据结构;

  3. 虚函数表是由编译器自动生成与维护的;

  4. virtual成员函数会被编译器放入虚函数表中;

  5. 存在虚函数时,每个对象中都有一个指向虚函数表的指针(vptr指针)。

  多态执行过程:

  1.  在类中,用 virtual 声明一个函数时,就会在这个类中对应产生一张 虚函数表,将虚函数存放到该表中;

       2.  用这个类创建对象时,就会产生一个 vptr指针,这个vptr指针会指向对应的虚函数表;

       3.  在多态调用时, vptr指针 就会根据这个对象 在对应类的虚函数表中 查找被调用的函数,从而找到函数的入口地址;

            》 如果这个对象是 子类的对象,那么vptr指针就会在 子类的 虚函数表中查找被调用的函数

            》 如果这个对象是 父类的对象,那么vptr指针就会在 父类的 虚函数表中查找被调用的函数

  

  

 

 

 

  注:出于效率考虑,没有必要将所有成员函数都声明为虚函数。

  如何证明vptr指针的存在?

#include<iostream>
#include<string>
using namespace std;

class Demo1{
private:
    int mi;
    int mj;
public:
    virtual void print(){} //由于虚函数的存在,在实例化类对象时,就会产生一个vptr指针
};

class Demo2{
private:
    int mi;
    int mj;
public:
    void print(){}
};

int main(){
    cout<<"sizeof(Demo1) = "<<sizeof(Demo1)<<" bytes"<<endl;
    cout<<"sizeof(Demo2) = "<<sizeof(Demo2)<<" bytes"<<endl;

    return 0;
}
//64位OS,指针大小为8
sizeof(Demo1) = 16 bytes sizeof(Demo2) = 8 bytes

  显然,在普通的类中,类的大小 == 成员变量的大小;在有虚函数的类中,类的大小 == 成员变量的大小 + vptr指针大小。

6、 虚析构函数

  定义:用 virtual 关键字修饰析构函数,称为虚析构函数;

  格式:virtual ~ClassName(){ ... }

  意义:虚析构函数用于指引 delete 运算符正确析构动态对象;(当父类指针指向子类对象时,通过父类指针去释放所有子类的内存空间)

  应用场景:在赋值兼容性原则中(父类指针指向子类对象),通过  delete 父类指针   去释放所有子类的内存空间。(动态多态调用:通过父类指针所指向的实际对象去判断如何调用 delete 运算符)

  !!:建议在设计基类时将析构函数声明为虚函数,为的是避免内存泄漏,否则有可能会造成派生类内存泄漏问题。
  案列分析
#include<iostream>
#include<cstring>
using namespace std;

class Base{
protected:
    char *name;
public:
    Base(){
        name = new char[20];
        strcpy(name, "Base()");
        cout<<this<<" "<<name<<endl;
    }

    ~Base(){
        cout<<this<<" ~Base()"<<endl;
        delete[] name;
    }
};

class Derived : public Base{
private:
    int *value;
public:
    Derived(){
        strcpy(name, "Derived()");
        value = new int(strlen(name));
        cout<<this<<" "<<name<<" "<<*value<<endl;
    }

    ~Derived(){
        cout<<this<<" ~Derived()"<<endl;
        delete value;
    }
};

int main(){
    cout<<"在赋值兼容中,关于子类对象存在内存泄漏的测试"<<endl;

    Base* bp = new Derived();
    cout<<bp<<endl;
    // ...
    delete bp;  //虽然是父类指针,但析构的是子类资源

    return 0;
}
在赋值兼容中,关于子类对象存在内存泄漏的测试
0x751710 Base()
0x751710 Derived() 9
0x751710
0x751710 ~Base()
//虚析构函数解决子类内存泄漏案列
#include<iostream> #include<cstring> using namespace std; class Base{ protected: char *name; public: Base(){ name = new char[20]; strcpy(name, "Base()"); cout<<this<<" "<<name<<endl; } virtual ~Base(){ cout<<this<<" ~Base()"<<endl; delete[] name; } }; class Derived : public Base{ private: int *value; public: Derived(){ strcpy(name, "Derived()"); value = new int(strlen(name)); cout<<this<<" "<<name<<" "<<*value<<endl; } virtual ~Derived(){ cout<<this<<" ~Derived()"<<endl; delete value; } }; int main(){ cout<<"在赋值兼容中,关于子类对象存在内存泄漏的测试"<<endl; Base* bp = new Derived(); cout<<bp<<endl; // ... delete bp; //虽然是父类指针,但析构的是子类资源 return 0; }
在赋值兼容中,关于子类对象存在内存泄漏的测试
0x6b1710 Base() 0x6b1710 Derived() 9 0x6b1710 0x6b1710 ~Derived() 0x6b1710 ~Base()

  两个案列的区别:第1个案列只是普通的析构函数;第2个案列是虚析构函数。

7、 关于虚函数的思考题

  1. 构造函数可以成为虚函数吗?--- 不可以

      不可以。因为在构造函数执行结束后,虚函数表指针才会被正确的初始化。

   在c++的多态中,虚函数表是由编译器自动生成与维护的,虚函数表指针是由构造函数初始化完成的,即构造函数相当于是虚函数的入口点,负责调用虚函数的前期工作;在构造函数执行的过程中,虚函数表指针有可能未被正确的初始化;由于在不同的c++编译器中,虚函数表 与 虚函数表指针的实现有所不同,所以禁止将构造函数声明为虚函数。

          

  2. 析造函数可以成为虚函数吗?--- 虚函数,且发生多态

      可以,并且产生动态多态。因为析构函数是在对象销毁之前被调用,即在对象销毁前  虚函数表指针是正确指向对应的虚函数表。

  3. 构造函数中可以调用虚函数发生多态吗?--- 不能发生多态

   构造函数中可以调用虚函数,但是不可能发生多态行为,因为在构造函数执行时,虚函数表指针未被正确初始化。

  4. 析构函数中可以调用虚函数发生多态吗?--- 不能发生多态

      析构函数中可以调用虚函数,但是不可能发生多态行为,因为在析构函数执行时,虚函数表指针已经被销毁。   

#include<iostream>
#include<string>
using namespace std;

class Base{
protected:
    char *name;
public:
    Base(){
        cout<<"Base()"<<endl;

        func();
    }

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

    virtual ~Base(){
        func();

        cout<<"~Base()"<<endl;
    }
};

class Derived : public Base{
private:
    int *value;
public:
    Derived(){
        cout<<"Derived()"<<endl;

        func();
    }

    virtual void func(){
        cout<<"Derived::func()"<<endl;
    }

    virtual ~Derived(){
        func();

        cout<<"~Derived()"<<endl;
    }
};

void test(){
    Derived d;
}

int main(){
    //栈空间
    test();

    //堆空间
    // Base *p = new Derived();
    // delete p; // 多态发生(指针p指向子类对象,并且又有虚函数重写)

    return 0;
}
Base()
Base::func()
Derived()
Derived::func()
Derived::func()
~Derived()
Base::func()
~Base()

  结论:在构造函数与析构函数中调用虚函数不能发生多态行为,只调用当前类中定义的函数版本! !

8、纯虚函数、抽象类、接口

  1.  定义 --- 以案例的方式说明

  想必大家很熟悉,对于任何一个普通类来说都可以实例化出多个对象,也就是每个对象都可以用对应的类来描述,并且这些对象在现实生活中都能找到各自的原型;比如现在有一个“狗类🐶”,我们就可以用这个“狗类🐶”实例化出很多只“狗🐶”。但是,在面向对象分析时,还会发现一些抽象的概念,它描述的是一类事物,并不能反映一个具体的实物,我们把这种包含抽象概念的现象称为 抽象类。关于抽象类的例子有很多,比如:如何计算一个“图形”的面积;什么“宠物”最可爱 等等。了解了抽象类之后,那么什么是纯虚函数呢?我们现在就以 如何计算一个“图形”的面积 这个抽象类案列说明问题;在这个例子中有2个抽象概念,分别是 “图形” 与 “面积”,即什么样“图形” --- 不知道,如何”求面积“或者“面积公式”是什么 --- 也不知道;在这里,我们可以把”图形“看成是抽象类的类名,”面积“看成是抽象类的成员函数,因为这个成员函数无法实现,只是让外界知道有这么一回事,此处的成员函数就可以看成 纯虚函数,同时,此处的抽象类也可以看成是 接口

  2.  特点

  纯虚函数:

  (1)只在基类中声明虚函数,并不需要在基类中定义函数体,语法格式:virtual void funtion1()=0;

  (2)“=0”是告诉编译器当前是声明纯虚函数,因此并不需要定义函数体。

  (3)纯虚函数被实现后成为虚函数;

  (4)基类中的纯虚函数就是个接口,纯虚函数不能被调用,它的存在只是为了在派生类中重新实现该方法

  (5)c++ 规定虚析构函数必须包含声明与实现(在对象销毁前,基类中的析构函数最后一个被调用,若此时没有对应的函数实现,显然是不行的);

  抽象类:

  (1)用于表示现实世界中的抽象概念

  (2)是一种只能定义类型,而不能创建对象的类;但是,可以有抽象类指针 或 接口类指针,当它指向子类对象时就会发生多态;

  (3)抽象类只能用作父类被继承,子类必须实现纯虚函数的具体功能;

  (4)c++语言中没有抽象类的概念,但是可以通过纯虚函数实现抽象类;

  (5)一个c++类中存在纯虚函数就成为了抽象类;(判断条件)

  (6)如果子类没有实现纯虚函数,则子类成为抽象类。

  接口:

  (1)类中没有定义任何的成员变量;

  (2)所有的成员函数都是公有的纯虚函数;(判断条件 1 + 2)

  (3)接口是一种特殊的抽象类;

      一个类全是纯虚函数就是接口;

      一个类部分是纯虚函数就是抽象类;

  3. 引入原因

  (1)为了方便使用多态特性,我们常常需要在基类中声明纯虚函数。

  (2)在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。

     所以,为了解决上述问题,引入了纯虚函数的概念;将基类的成员函数声明为纯虚函数,则编译器要求必须在派生类中重写该成员函数以实现多态性。

 

参考c++中的多态机制 - PRO_Z - 博客园 (cnblogs.com)

posted @ 2021-09-01 15:11  默行于世  阅读(65)  评论(0编辑  收藏  举报