一、多态的定义
多态可以简单地概括为“一个接口,多种方法”,程序在运行时才决定调用的函数。用父类的指针指向子类的实例(对象),然后通过父类的指针调用实际子类的成员函数。
c++多态性是通过虚函数来实现的,只有重写了虚函数的才能算作是体现了c++的多态性。多态的目的是为了接口重用,不论传递过来的究竟是哪个类的对象,函数都能够通过同一个接口调用到适应各自对象的实现方法。
二、虚函数的定义
(1)函数的一般形式为:
virtual 函数返回值类型 虚函数名(形参表){函数体}
虚函数必须是类的非静态成员函数(且非构造函数),且访问权限是public。
例子:
#include<iostream>
usingnamespace std;
class A{
public:
virtual void foo(){
cout<<"A::foo() iscalled"<<endl;
}
};
class B:publicA{
public:
void foo(){
cout<<"B::foo() iscalled"<<endl;
}
};
int main(){
A *a = new B();
a->foo();
return 0;
}
输出:
B::foo() iscalled
(2)定义虚函数的限制
1. 非类的成员函数不能定义为虚函数,类的成员函数中静态成员函数和构造函数也不能定义为函数,但可以将析构函数定义为虚函数。(更多不能定义为虚函数的函数以及为什么不能定义为虚函数在文末解释)
2. 只需要在声明函数的类体中使用关键字“virtual”将函数声明为虚函数,而定义函数时不需要使用关键字“virtual”。
(3)这里科普一下重载、重写、重定义的区别
1、重载(overload)
需要满足三个条件:
(1)同一作用域
(2)函数名相同
(3)函数参数必须不相同(具体的参数列表不同表现在三个方面:参数类型不一样,参数个数不一样,参数顺序不一样。只要满足上述三个条件的一条或一条以上,就算作参数列表不同)
2、重写(override)
重写也称为覆盖,子类重新定义父类中有相同名称和参数的虚函数,主要在继承关系中出现。实现多态就用到了对虚函数进行重写。
基本条件:
(1)重写的函数和被重写的函数分别位于派生类和基类中,被重写的函数必须是虚函数。
(2)函数名、函数参数和函数返回值必须一致。
注意:
(1)重写的函数的访问修饰符可以不同于被重写的函数,如基类的virtual函数的修饰符为private,派生类可以改为public或者protected。
(2)静态方法不能被重写,即static和virtual不能同时使用。
3、重定义(redefining)
也叫隐藏,子类重新定义父类中的非虚函数,屏蔽了父类的同名函数。
隐藏的两种情况:
(1)子类和父类的函数名称相同,但参数不同,此时不管父类函数是不是虚函数,都将被隐藏。(此条与重载的区别在于作用域不同)
(2)子类和父类的函数名称相同,参数也相同,但是父类函数不是虚函数,父类的函数将被隐藏。
(4)、虚函数实现的基本原理
例子:
struct B{
long b;
virtual void foo(){}
virtual void bar(){}
};
structD:public B{
long d;
virtual void bar(){}
virtual void quz(){}
};
此时内存中的布局如下:(忽略内存对齐对布局的影响)
其中:
(1)B的虚函数表中存放着B::foo和B::bar两个函数指针
(2)D的虚函数表中存放的既有继承自B的虚函数B::foo,又有重写了基类虚函数B::bar的D::bar,还有新增的虚函数D::quz。
D的虚函数表构造过程如下,该过程由编译器完成。所以可以说:虚函数替换过程发生在编译时。
虚函数调用过程:
以下面的程序为例
编译的时候编译器只知道pb是B*类型的指针,并不知道它指向的具体对象类型:pb可能指向的是B的对象,也可能指向的是D的对象。
但对于“pb->bar()”,编译时能够确定的是:此处operator->的另一个参数是B::bar(因为pb是B*类型的,编译器认为bar是B::bar),而B::bar和D::bar在各自虚函数表中的偏移位置是相等的。
无论pb指向哪种类型的对象,只要能够确定被调函数在虚函数中的偏移量,待运行时,能够确定具体类型,并能找到相应vptr了,就能找出真正应该调用的函数。
B::bar是一个虚函数指针,它的ptr部分内容为9,它在B的虚函数表中的偏移值为8(8+1=9)。当程序执行到“pb->bar()”时,已经能够判断pb指向的具体类型了:
1、如果pb指向B的对象,可以获取到B对象的vptr,加上偏移值8((char*)vptr+8),可以找到B::bar
2、如果pb指向D的对象,可以获取到D对象的vptr,加上偏移量8((char*)vptr+8),可以找到D::bar。
如果pb指向其他类型对象...同理...
多重继承时,当一个类继承多个类,且多个基类都有虚函数时,子类对象中将包含多个虚函数表的指针(即多个vptr)。
例如:
其中:D自身的虚函数与B基类共用了同一个虚函数表,因此也称B为D的主基类。
虚函数替换过程与前面描述类似,只是多了一个虚函数表,多了一次拷贝和替换的过程。
虚函数的调用过程,与前面描述基本类似,区别在于基类指针指向的位置可能不是派生类对象的起始位置。例如:
三、纯虚函数
(1)定义
纯虚函数是在基类中声明的虚函数,它在基类中没有定义,但要求任何派生类都要定义自己的实现方法。在基类中实现纯虚函数的方法是在函数原型后加“=0”。
virtual voidfunction1() = 0
(2)引入原因
1、为了方便使用多态特性,我们常常需要在基类中定义虚拟函数
2、在很多情况下,基类本身生成对象是不合情理。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()=0;),则编译器要求在派生类中必须予以重写以实现多态性。同时含有纯虚拟函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
总结:
1、声明了纯虚函数的类是一个抽象类。所以,用户不能创建类的实例,只能创建它的派生类的实例。
2、纯虚函数最显著的特征是:它们必须在继承类中重新声明函数(不要后面的=0,否则该派生类也不能实例化),而且它们在抽象类中往往没有定义。虚函数必须在派生类中实现,如果不实现,编译器将报错。定义纯虚函数的目的在于,使派生类仅仅只是继承函数的接口。
3、在有动态分配堆上内存的时候,析构函数必须是虚函数,但没有必要是纯虚的。因此,如果指针指向的是子类对象,将调用子类的析构函数,然后自动调用基类的析构函数。
4、友元不是成员函数,只有成员函数才可以是虚拟的,因此友元不能是虚拟函数。但可以通过让友元函数调用虚拟成员函数来解决友元的虚拟问题。
最后补充一些面试题中可能会问到的虚函数相关的题:
1、有纯虚函数的类能不能实例化?
答:不能,有纯虚函数的类是抽象类,只能被继承,不能实例化。
2、哪些函数不能定义为虚函数?
常见的不能声明为虚函数的有:普通函数(非成员函数,俗点说就是不是类里面的函数)、静态成员函数、内联成员函数、构造函数、友元函数。
为什么?
(1)为什么不支持普通函数为虚函数?
普通函数只能被overload(重载),不能被override(覆盖),声明为虚函数也没有什么意义,因为编译器会在编译时就绑定函数。
(2)为什么不支持静态成员函数为虚函数?
静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,没有要动态绑定的必要性。而虚函数和对象是动态绑定的。
(3)为什么不支持内联成员函数为虚函数?
内联函数需要在编译阶段展开,在函数调用处用整个函数体去替换,减少函数调用执行的开销,而虚函数是运行时动态绑定的,在运行时才能够确定如何去调用,所以编译时无法展开,显然内联不可能是虚函数,一切虚函数也都不可能是内联函数。
(4)为什么不支持构造函数为虚函数?
构造函数是为了明确初始化对象成员才产生的,而虚函数主要是为了在不了解细节的情况下也能正确处理对象。虚函数是在不同类型的对象产生不同的动作,现在对象还没有产生,如何使用虚函数来完成你想完成的动作(这是典型的悖论)
(5)为什么不支持友元函数为虚函数?
c++不支持友元函数的继承,对于没有继承特性的函数没有虚函数的说法。友元函数不属于类的成员函数,不能被继承。