六、多态与虚函数

多态的基本概念

多态

  • 多态分为编译时多态和运行时多态
  • 编译时多态主要是指函数的重载(包括运算符的重载)。对重载函数的调用,在编译时就可以根据实参确定应该调用哪个函数,因此称为编译时多态。
  • 运行时多态则和继承、虚函数等概念有关。本章中提及的多态主要是指运行时多态。
  • 程序编译阶段都早于程序运行阶段,所以静态绑定称为早绑定,动态绑定称为晚绑定。静态多态和动态多态的区别,只在于在什么时候将函数实现和函数调用关联起来,是在编译阶段还是在运行阶段,即函数地址是早绑定的还是晚绑定的。
  • 在类之间满足赋值兼容的前提下,实现动态绑定必须满足以下两个条件:
    1. 必须声明虚函数
    2. 通过基类类型的引用或者指针调用虚函数

虚函数

  • 所谓“虚函数”,就是在函数声明时前面加了virtual关键字的成员函数。virtual关键字只在类定义中的成员函数声明处使用,不能在类外部写成员函数体时使用。静态成员函数不能是虚函数。包含虚函数的类称为“多态类”。

  • 声明虚函数成员的一般格式如下:

    virtual 函数返回值类型 函数名(行参表);
    
  • 在类的定义中使用virtual关键字来限定的成员函数即称为虚函数。再次强调一下,虚函数的声明只能出现在类定义中的函数原型声明时,不能在类外成员函数实现的时候。

  • 派生类可以继承基类的同名函数,并且可以在派生类中重写这个函数。如果不使用虚函数,当使用派生类对象调用这个函数,且派生类中重写了这个函数时,则调用派生类中的同名函数,即“隐藏”了基类中的函数。

  • 当然,如果还想调用基类的函数,只需在调用函数时,在前面加上基类名及作用域限定符即可。

关于虚函数,有以下几点需要注意

  1. 虽然将虚函数声明为内联函数不会引起错误,但因为内联函数是在编译阶段进行静态处理的,而对虚函数的调用是动态绑定的,所以虚函数一般不声明为内联函数。
  2. 派生类重写基类的虚函数实现多态,要求函数名。参数列表及返回值类型要完全相同。
  3. 基类中定义了虚函数,在派生类中该函数始终保持虚函数的特性。
  4. 只有类的非静态成员函数才能定义为虚函数,静态成员函数和友元函数不能定义为虚函数。
  5. 如果虚函数的定义是在类体外,则只需在声明函数时添加virtual关键字,定义时不加virtual关键字。
  6. 构造函数不能定义为虚函数。最好也不要将operator=定义为虚函数,因为使用时容易混淆。
  7. 不要在构造函数和析构函数中调用虚函数。在构造函数和析构函数中,对象是不完整的,可能会出现未定义的行为。
  8. 最好将基类的析构函数声明为虚函数。

通过基类指针实现多态

声明虚函数后,派生类对象的地址可以赋值给基类指针,也就是基类指针可以指向派生类对象。
对于通过基类指针调用基类和派生类中都有的同名、同参数表的虚函数的语句,编译时系统并不确定要执行的是基类还是派生类的虚函数;
而当程序运行到该语句时,
如果基类指针指向的是一个基类对象,则调用基类的虚函数;
如果基类指针指向的是一个派生类对象,则调用派生类的虚函数。

#include 
using namespace std;

class A {
public:
    virtual void Print() {
        cout << "A::Print" << endl;
    }
};

class B : public A {
public:
    virtual void Print() {
        cout << "B::Print" << endl;
    }
};

class D : public A {
public:
    virtual void Print() {
        cout << "D::Print" << endl;
    }
};

class E : public B {
public:
    virtual void Print() {
        cout << "E::Print" << endl;
    }
};

int main() {
    A a;
    B b;
    D d;
    E e;
    
    A *pa = &a;//基类pa指针指向基类对象a
    B *pb = &b;//派生类pb指针指向基类对象b
    
    pa->Print();//多态,目前指向基类对象a,调用a.Print()
    
    pa = pb;//派生类指针赋值给基类指针,pa指向派生类对象b
    pa->Print();//多态,目前指向派生类对象b,调用b.Print()
    
    pa = &d;//基类指针pa指向派生类对象d
    pa->Print();//多态,目前指向派生类对象d,调用d.Print()
    
    pa = &e;//基类指针pa指向派生类对象e
    pa->Print();//多态,目前指向派生类对象e,调用e.Print()

    return 0;
};

通过基类引用实现多态

通过基类指针调用虚函数时可以实现多态,通过基类的引用调用虚函数的语句也是多态的。
即通过基类的引用调用基类和派生类中同名、同参数表的虚函数时,
若其引用的是一个基类的对象,则调用的是基类的虚函数;
若其引用的是一个派生类的对象,则调用的是派生类的虚函数。

#include 
using namespace std;

class A {
public:
    virtual void Print() {
        cout << "A::Print" << endl;
    }
};

class B : public A {
public:
    virtual void Print() {
        cout << "B:Print" << endl;
    }
};

void PrintInfo(A &r) {
    //多态,使用基类引用调用哪个Print(),取决于r引用了哪个类的对象
    r.Print();
}

int main() {
    A a;
    B b;
    
    PrintInfo(a);//使用基类对象,调用基类中的函数
    PrintInfo(b);//使用派生类对象,调用派生类中的函数
    
    return 0;
}

多态的实现原理

多态的关键在于通过基类指针或引用调用一个虚函数时,编译阶段不能确定到底调用的是基类还是派生类的函数,运行时才能确定。

派生类对象占用的存储空间大小,等于基类成员变量占用的存储空间大小加上派生类对象自身成员变量占用的存储空间大小。

多态的使用

在普通成员函数(静态成员函数、构造函数和析构函数除外)中调用其他虚成员函数也是允许的,并且是多态的。

#include 
using namespace std;

class CBase {
public:
    void func1() {
        cout << "CBase::func1()" << endl;
        func2();//在成员函数中调用虚函数
        func3();
    };
    virtual void func2() {
        cout << "CBase::func2()" << endl;
    };
    void func3() {
        cout << "CBase::func3()" << endl;
    };
};

class CDerived : public CBase {
public:
    virtual void func2() {
        cout << "CDerived::func2()" << endl;
    };
    void func3() {
        cout << "CDerived::func3()" << endl;
    };
};

int main() {
    CDerived d;
    d.func1();
    //CBase::func1()
    //CDerived::func2()
    //CBase::func3()

    return 0;
};

不仅能在成员函数中调用虚函数,还可以在构造函数和析构函数中调用虚函数,但这样调用的虚函数不是多态的。

#include 
using namespace std;

class A {
public:
    virtual void hello() {
        cout << "A::hello" << endl;
    };
    virtual void bye() {
        cout << "A::bye" << endl;
    };
};

class B : public A {
public:
    virtual void hello() {
        cout << "B::hello" << endl;
    };
    B() {
        hello();//调用虚函数,但不是多态
    };
    ~B() {
        bye();//调用虚函数,但不是多态
    };
};

class C : public B {
public:
    virtual void hello() {
        cout << "C::hello" << endl;
    };
};

int main() {
    C c;
    //B::hello
    //A::bye

    return 0;
};
  • 在构造函数中调用的,编译系统可以据此决定调用哪个类中的版本,所以它不是多态的;
  • 在析构函数中调用的,所以也不是多态;
  • 实现多态时,必须满足的条件是:使用基类指针或引用来调用基类中声明的虚函数。
  • 派生类中继承自基类的虚函数,可以写virtual关键字,也可以省略这个关键字,这不影响派生类中的函数也是虚函数。
#include 
using namespace std;

class A {
public:
    void func1() {
        cout << "A::func1" << endl;
    };
    virtual void func2() {//虚函数
        cout << "A::func2" << endl;
    };
};

class B : public A {
public:
    virtual void func1() {
        cout << "B::func1" << endl;
    };
    void func2() {//自动成为虚函数
        cout << "B::func2" << endl;
    };
};

class C : public B {
public:
    void func1() {//自动成为虚函数
        cout << "C::func1" << endl;
    };
    void func2() {//自动成为虚函数
        cout << "C::func2" << endl;
    };
};

int main() {
    C c;
    A *pa = &c;
    B *pb = &c;
    
    pa->func2();//多态 C::func2
    pa->func1();//因为基类的func1不是虚函数,这也的调用也不是多态 A::func1
    pb->func1();//多态 C::func1

    return 0;
};

虚析构函数

  • 如果一个基类指针指向的对象是用new运算符动态生成的派生类对象,那么释放该对象所占用的空间时,如果仅调用基类的析构函数,则只会完成该析构函数内的空间释放,不会涉及派生类析构函数内的空间释放,容易造成内存泄露。

  • 声明虚析构函数的一般格式如下:

    virtual ~类名();
    
  • 虚析构函数没有返回值类型,没有参数,所以它的格式非常简单。

  • 如果一个类的虚构函数是虚函数,则由他派生的所有子类的析构函数也是虚析构函数。使用虚析构函数的目的是为了在对象消亡时实现多态。

#include 
using namespace std;

class ABase {
public:
    ABase() {
        cout << "ABase构造函数" << endl;
    };
    virtual ~ABase() {
        cout << "ABase::析构函数" << endl;
    };
};

class Derived : public ABase {
public:
    Derived() {
        cout << "Derived构造函数" << endl;
    };
    ~Derived() {
        cout << "Derived::析构函数" << endl;
    };
};

int main() {
    ABase *a = new Derived();
    delete a;
    //ABase构造函数
    //Derived构造函数
    //Derived::析构函数
    //ABase::析构函数

    return 0;
};
  • 可以看出,不仅调用了基类的析构函数,也调用了派生类的析构函数
  • 只要基类的析构函数是虚函数,那么派生类的析构函数不论是否用virtual关键字声明,都自动成为虚析构函数
  • 一般来说,一个类如果定了虚函数,则最好将析构函数也定义成虚函数。不过切记,构造函数不能是虚函数

纯虚函数和抽象类

纯虚函数

  • 纯虚函数的作用相当于一个统一的接口形式,表明在基类的各派生类中应该有这样的一个操作,然后在各派生类中具体实现与本派生类相关的操作。

  • 纯虚函数是声明在基类中的虚函数,没有具体的定义,而由个派生类根据实际需要给出各自的定义。

  • 声明纯虚函数的一般格式如下:

    virtual 函数类型 函数名(参数表) = 0;
    
  • 纯虚函数没有函数体,参数标后要写= 0。派生类中必须重写这个函数。按照纯虚函数名调用时,执行的是派生类中重写的语句,即调用的是派生类中的版本。

纯虚函数不同于函数体为空的虚函数,
它们的不同之处如下:

  1. 纯虚函数没有函数体,而空的虚函数的函数体为空
  2. 纯虚函数所在的类是抽象类,不能直接进行实例化;而空的虚函数所在的类是可以实例化的。

它们的共同特点是:
纯虚函数与函数体为空的虚函数都可以派生出新的类,然后在新类中给出虚函数的实现,而且这种新的实现具有多态特征。

抽象类

包含纯虚函数的类称为抽象类。因为抽象类中有尚未完成的函数定义,所以它不能实例化一个对象。抽象类的派生类中,如果没有给出全部纯虚函数的定义,则派生类继续是抽象类。直到派生类中给出全部纯虚函数定义后,它才不再是抽象类,也才能实例化一个对象。****虽然不能创建抽象类的对象,但可以定义抽象类的指针和引用。这样的指针和引用可以指向并访问派生类的成员,这种访问具有多态性。

#include 
using namespace std;

class A {
public:
    virtual void Print() = 0;//纯虚函数
    void func1() {
        cout << "A_func1" << endl;
    };
};

class B : public A {
public:
    void Print();
    void func1() {
        cout << "B_func1" << endl;
    };
};
void B::Print() {
    cout << "B_print" << endl;
};

int main() {
    //A a;           //❌,抽象类不能实例化
    //A *pa = new A; //❌,不能创建抽象类类A的示例
    //B b[2];        //❌,不能声明抽象类的数组

    A *pa;         //✅,可以声明抽象类的指针
    A *pb = new B; //使用基类指针指向派生类对象
    pb->Print();   //多态,调用的是类B中的函数,B_print
    
    B b;
    A *pb1 = &b;
    pb1->func1();//不是虚函数,调用的是类A中的函数,A_func1
    
    return 0;
};

虚基类

定义虚基类的一般格式如下:

class 派生类名 : virtual 派生方式 基类名 {
    派生类体
};

多重继承的模型结构图如下:

多重继承

为了避免产生二义性,C++提供虚基类机制,使得在派生类中,继承同一个间接基类的成员仅保留一个版本。

#include 
using namespace std;

class A {
public:
    int a;
    void showa() {
        cout << "a = " << a << endl;
    };
};

class B : virtual public A {//对类A进行了虚继承
public:
    int b;
};

class C : virtual public A {//对类A进行了虚继承
public:
    int c;
};

class D : public B, public C {
//派生类D的两个基类B、C具有共同的基类A
//采用了虚继承,从而使类D的对象中只包含着类A的一个示例
public:
    int d;
};

int main() {
    D dObj;     //声明派生类D的对象
    dObj.a = 11;//若不是虚继承,这里会报错!因为“D::a”具有二义性
    dObj.b = 22;
    
    dObj.showa();//a = 11
    //若不是虚继承,这里会报错!因为“D::showa”具有二义性
    
    cout << "dObj.b = " << dObj.b << endl;//dObj.b = 22

    return 0;
};

你可能感兴趣的:(六、多态与虚函数)