C++:多态、虚函数

基本概念


多态:多态性是面向对象的关键技术之一。多态可以认为就是同一接口有不同的形态。即利用多态技术,可以调用同一个函数名的时候,实现完全不同的功能。

多态分为静态多态和动态多态。函数重载、运算符重载以及模板都是属于静态多态,也称编译时多态。动态多态也称运行时多态,即在程序运行阶段才能确定的关系,例如某些函数的调用关系在编译阶段无法确定,到了运行阶段才能确定。运行时的多态性是通过继承关系和虚函数来实现的。目的也是建立一种通用的程序。通用性是程序追求的主要目标之一

实现运行时多态的三个条件:公有继承、虚函数、指针或引用去调动

联编:联编是指计算机程序彼此关联的过程,是把一个标识符和一个存储地址联系在一起的过程,也就是把一条消息和一个对象的操作相结合的过程,可以狭义的认为是函数间调用关系的确定。按照联编所确定的时刻不同,可分为:静态联编和动态联编。

静态联编:静态联编(绑定)是指联编出现在编译链接阶段,又称为早期联编,通过静态联编可实现静态多态。函数重载,运算符重载,模板是静态联编的,函数的调用关系的确定是在编译阶段

动态联编:动态联编(绑定)是指在程序运行的时候才将函数实现和函数调用关联,因此也叫运行时绑定或者晚绑定,动态联编对函数的选择不是基于指针或者引用,而是基于对象类型,不同的对象类型将做出不同的编译结果,C++中提供的动态联编技术是通过虚函数实现的

虚函数


虚函数是动态联编的基础,虚函数可以使得函数调用关系推迟到运行时,在类中将成员函数定义成虚函数的格式为(类外不可加虚):

virtual 类型 函数名(参数列表)
{...}

只有类的成员函数才能加虚(有this指针才能虚化),因为虚函数仅适用于有继承关系的类对象。由某一个类的一个成员函数被定义为虚函数,则由该类派生出来的所有派生类中,该函数始终保持虚函数的特征

virtua只能加在类设计中,类外不可以,类内声明处加virtual就好 (扩充:参数的默认值也是在声明处给或者理解为函数名字第一次出现的地方)

当在派生类中重新定义虚函数时,可以不加关键字virtual(基类的那个有virtual),但名称、参数列表、返回类型必须与基类中的虚函数一样

虚函数其他部分和正常函数一样,只是最前面加个virtual关键字

虚函数示例如下:
在这里插入图片描述

虚函数的覆盖如下图:子类中若重写了父类的虚函数,子类中就会用重写的覆盖掉继承来的父类的

虚表:
在这里插入图片描述

虚表:含有虚函数的类编译时会建立起虚表(虚表存在.data区的.rodata区),虚函数记录在虚表中,继承的时候会拷贝一份父类的虚表,若派生类中重写了虚函数则用新的覆盖掉父类的,若有新设计的函数则添加进去,在运行的时候通过查虚表来确定对应的关系,含有虚表的类除了正常的成员外,还会含有一个虚表指针(4字节)
在这里插入图片描述
如果使用基类指针或引用指明派生类对象并使用该指针调用虚函数(成员选择符用->),则程序动态地(运行时)选择该派生类的虚函数,动态联编;如果使用对象名和点成员选择运算符" . "引用特定的一个对象来调用虚函数,则被调用的虚函数是在编译时确定的,静态联编

注意:

  • 当在基类中把成员函数定义为虚函数后,若派生类欲定义同名虚函数,则派生类中的虚函数必须与基类中的虚函数同名,且函数的参数个数、参数类型必须完全一致。如果基类中返回基类指针,派生类中返回派生类指针是允许的,这是一个例外

  • 基类中virtual关键字不可省,派生类中可以省,该函数仍为虚函数,但是函数名、返回类型、参数列表必须与基类一样

  • 动态多态必须通过基类对象的引用或基类对象的指针调用虚函数才能实现

  • 友元函数不能定义为虚函数,因为友元函数不是类的成员函数

  • 静态成员函数不能定义为虚函数,因为静态成员函数属于类,与具体的某个对象无关

  • 内联函数不能定义为虚函数,因为内联函数的调用处理是在编译时刻,即在编译时刻,用内联函数的实现代码去替换函数调用,运行时内联函数已不存在;而虚函数的调用是动态联编,即运行时刻调用哪一个函数

  • 不能将构造函数定义为虚函数,但可将析构函数定义为虚函数。如果类的构造函数中有动态申请的存储空间,则应在析构函数中释放该空间,此时,可以将析构函数定义为虚函数,以便实现撤销对象时的多态性

  • 虚函数与一般函数相比,调用时的执行速度要慢一些。这是因为,为了实现动态联编,编译器为每个含有虚函数的对象增加指向虚函数表的指针,通过该指针实现虚函数的间接调用

  • 在一般成员函数中调用虚函数,遵循的动态多态规则,但若在构造函数中调用虚函数,不遵循动态多态规则,即调用的是类自身的虚函数

  • 当基类的指针指向派生类对象时,若通过它调用虚函数,则它指向的是哪个类的对象,调用的就是哪个类的虚函数

虚函数表指针(vptr)创建时机

vptr跟着对象走,所以对象什么时候创建出来,vptr就什么时候创建出来,也就是运行的时候。
当程序在编译期间,编译器会为构造函数中增加为vptr赋值的代码(这是编译器的行为),当程序在运行时,遇到创建对象的代码,执行对象的构造函数,那么这个构造函数里有为这个对象的vptr赋值的语句。

虚函数表创建时机

虚函数表创建时机是在编译期间。编译期间编译器就为每个类确定好了对应的虚函数表里的内容。
所以在程序运行时,编译器会把虚函数表的首地址赋值给虚函数表指针,所以,这个虚函数表指针就有值了。

final与override


override:派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,这仍然是合法行为。编译器将认为新定义的这个函数与基类中原有的函数是相互独立的(重载关系)。这时,派生类的函数并没有覆盖掉基类中的版本。就实际的编程习惯而言,我们可能是想用派生类中的去覆盖基类的,但不小心参数列表写错了。这类错误如果想让编译器能发现,可以使用override关键字来说明派生类中的那个函数。如果使用override标记了某个函数,但该函数并没有覆盖已存在的虚函数,编译器将报错

override示例如下:

struct B
{
    virtual void f1(int) const;
    virtual void f2();
    void f3();
};
struct D1:B
{
    void f1(int) const override; //正确:f1与基类中的f1匹配
    void f2(int) override;       //错误:B没有形参如f2(int)的函数
    void f3() override;          //错误:f3不是虚函数
    void f4() override;          //错误:B没有名为f4的虚函数
};//本段代码摘自C++Primer,结构体也是可以继承的

final:当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final关键字后被继承或重写,编译器会报错

final示例如下:

class Base
{
    virtual void foo();
};
 
class A : Base
{
    void foo() final; // foo 被override并且是最后一个override,在其子类中不可以重写
    void bar() final; // Error: 父类中没有 bar虚函数可以被重写或final
};

class B final : A // 指明B是不可以被继承的
{
    void foo() override; // Error: 在A中已经被final了
};
 
class C : B // Error: B is final
{
};

区分函数重载、同名隐藏、覆盖


在这里插入图片描述

虚函数与默认实参


和其他函数一样,虚函数也可以拥有默认实参。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定

换句话说,如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。如果派生类函数依赖不同的实参,则程序结果将与我们的预期不符

所以如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致

如下代码:

class Base
{
public:
	virtual void print(int a = 10)
    {
        cout<<"Base:: print a: "<<a<<endl;
	}
};
class Derived:public Base
{
private:
    virtual void print(int b = 20)
    {
        cout<<"Derived:: print b: "<<b<<endl;
	}
};
int main()
{
    Derived d1;
    Base *bp = &d1;
    bp->print();
    
    return 0;
}

运行结果:
在这里插入图片描述
编译时确定可访问属性和默认值(10),运行时不在乎可访问属性也不看派生类的那个默认值(20)

虚析构函数


如果类的构造函数中有动态申请的存储空间,在析构函数中需要释放该空间,此时可以将析构函数定义为虚函数,以实现通过基类指针释放它所指向的派生类对象时的动态多态

构造函数不能定义为虚函数,因为构造函数的任务之一就是填充虚表指针,如果把构造函数定义为虚的,那么调动构造函数就需要通过虚表指针去查虚表,但是,虚表指针还没填充,因为虚表指针是由构造函数来填充的,这就出现了矛盾,因此不能把构造函数定义为虚函数

回避虚函数的机制


在某些情况下,我们希望对虚函数的调用不要进行动态绑定,而是强迫其执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的,例如下:

double undiscounted = baseP->Quote::net_price(42);

该代码强行调用Quote的net_price函数,而不管实际baseP指向的对象类型到底是什么。该调用将在编译时完成解析

通常情况下,只有成员函数(或友元)中的代码才需要使用作用域运算符来回避虚函数的机制

什么时候我们需要回避虚函数的默认机制呢?通常是当一个派生类的虚函数调用它覆盖的基类的虚函数版本时。在此情况下,基类的版本通常完成继承层次中所有类型都要做的共同任务,而派生类中定义的版本需要执行一些与派生类本身密切相关的操作

如果一个派生类虚函数调用它的基类版本,但是没有使用作用域运算符,则在运行时该调用将被解析为对派生类版本的自身调用,从而导致无限递归

纯虚函数和抽象类


纯虚函数:在定义一个基类时,如果不能给出某些成员函数的具体实现,而在派生类中才可以给出具体的实现,则可以把基类中没有给出具体实现的函数定义为纯虚函数,定义格式如下:

virtual 类型 函数名(参数列表) = 0;

=0表明程序员将不定义该函数,函数声明是为派生类保留一个位置。“=0”本质上是将指向函数体的指针指定为NUL

注意:该定义中没有函数体,即没有函数体的具体实现部分,函数体代之以=0。这与函数体定义成空函数是有区别的,如若将函数体定义为空函数,即有大括号但是大括号里什么都不写,它表示在函数体中不做任何工作

抽象类:含有纯虚函数的类称为抽象类。抽象类是一种特殊的类,它是为了抽象和设计的目的而建立的,它处于继承层次结构中的较上层。抽象类是不能定义对象的,在实际中为了强调一个类是抽象类,可将该类的构造函数说明为保护的访问权限

注意:

  • 抽象类只能做派生类的基类,不能定义抽象类的对象,即抽象类不能实例化对象,但抽象类可以定义指针
  • 若派生类实现了基类的所有重虚函数,则派生类就不再是抽象类了。若派生类没有实现基类所有的纯虚函数,则该派生类依然是抽象类
  • 在一般正常使用的情况下,纯虚函数没有函数体。从语法角度上说,纯虚函数可以写函数体,即=0后面再跟大括号函数体,即使给出了纯虚函数的函数体,该函数依然是纯虚函数

C语言里void就是一种抽象类型,无类型的地址可以指向任何类型的地址

int a = 10;
double dx = 12.23;
int *p = &a;

void *vp = NULL;
vp = &a;
vp = &dx;
vp = &p;
vp = &vp;
//这些都是可以的

总结:抽象类的规定

  • 抽象类只能用作其他类的基类,不能建立抽象类对象
  • 抽象类不能用作参数类型、函数返回类型或显示转换的类型
  • 可以定义指向抽象类的指针或引用,此指针可以指向它的派生类,进而实现多态性

虚函数与虚基类的区分


虚函数:类成员函数的声明中有 virtual 关键字 //定义方式virtual double Area();

虚函数作用:实现动态多态 ,运行时确定函数调用关系

虚基类:virtual继承的类,为使共同基类在派生类中只有一个拷贝 //定义方式class B:virtual public A

虚基类作用:当一个派生类中有多个直接基类,而这些直接基类又有一个共同的基类,则在最终的派生类中会保留该间接基类的数据成员的多份同名成员,就会造成二义性,将这个共同基类设置为虚基类时,同名的数据成员就会只存在一个副本,同一个函数名只有一个映射,就解决了二义性

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值