六、多态与虚函数

多态的基本概念

多态

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

虚函数

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

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

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

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

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

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

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

通过基类指针实现多态

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

#include <iostream>
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 <iostream>
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 <iostream>
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 <iostream>
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 <iostream>
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 <iostream>
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 <iostream>
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 <iostream>
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;
};
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,723评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,080评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,604评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,440评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,431评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,499评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,893评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,541评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,751评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,547评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,619评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,320评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,890评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,896评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,137评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,796评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,335评论 2 342