多态性(polymorphisn)是允许你将父对象设置成为和一个或更多的他的子对象相等的技术,赋值之后,父对象就可以根据当前赋值给它的子对象的特性以不同的方式运作。简单的说,就是一句话:允许将子类类型的指针赋值给父类类型的指针。实现多态,有二种方式,覆盖,重载。覆盖:是指子类重新定义父类的虚函数的做法。重载:是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。除了常见的通过类继承和虚函数机制生效于运行期的动态多态(dynamic polymorphism)外,带变量的宏,模板,函数重载,运算符重载,拷贝构造等也允许将不同的特殊行为和单个泛化记号相关联,由于这种关联处理于编译期而非运行期,因此被称为静态多态(static polymorphism)。
为什么要有多态?
我们知道C++有封装,继承和多态等几大特性,封装可以使得代码模块化,继承可以在原有的代码基础上扩展,他们的目的都是为了代码重用。而多态则是为了接口重用。也就是说,不论传递过来的究竟是哪个类的对象,函数都能够通过同一个接口调用到适应各自对象的实现方法。
我们知道C++中虚函数允许子类重新定义成员函数,而子类重新定义父类的做法称为覆盖(override),或者称为重写。在这里我觉得有必要要明白几个概念的区别:即重载,重写(覆盖),以及重定义(同名隐藏)。
所谓重载是指在同一作用域中允许有多个同名函数,而这些函数的参数列表不同,包括参数个数不同,类型不同,次序不同,需要注意的是返回值相同与否并不影响是否重载。比如int fun()和void fun()不构成重载,连编译都不过去,给出的提示是无法重载仅按返回类型区分的函数。
而重写(覆盖)和重定义(同名隐藏)则有点像,区别就是在写重写的函数是否是虚函数,只有重写了虚函数的才能算作是体现了C++多态性,否则即为重定义,在之前的代码中,我们看到子类继承了基类的fun()函数,若是子类没有fun函数,依旧会调用基类的fun函数,若是子类已重定义,则调用自己的fun函数,这就叫做同名隐藏,当然此时如果还想调用基类的fun函数,只需在调用fun函数前加基类和作用域限定符即可。综上他们的关系和区别如下图表明:
1、 函数重载与缺省参数
(1)函数重载的实现原理
假设,我们现在想要写一个函数(如Exp01),它即可以计算整型数据又可以计算浮点数,那样我们就得写两个求和函数,对于更复杂的情况,我们可能需要写更多的函数,但是这个函数名该怎么起呢?它们本身实现的功能都差不多,只是针对不同的参数:
int sum_int(int nNum1, int nNum2)
{
return nNum1 + nNum2;
}
double sum_float(float nNum1, float nNum2)
{
return nNum1 + nNum2;
}
C++中为了简化,就引入了函数重载的概念,大致要求如下:
1、 重载的函数必须有相同的函数名
2、 重载的函数不可以拥有相同的参数
2、 运算符重载
运算符重载也是C++多态性的基本体现,在我们日常的编码过程中,我们经常进行+、—、*、/等操作。在C++中,要想让我们定义的类对象也支持这些操作,以简化我们的代码。这就用到了运算符重载。
比如,我们要让一个日期对象减去另一个日期对象以便得到他们之间的时间差。再如:我们要让一个字符串通过“+”来连接另一个字符串……
要想实现运算符重载,我们一般用到operator关键字,具体用法如下:
返回值 operator 运算符(参数列表)
{
// code
}
例如:
CMyString Operator +(CMyString & csStr)
{
int nTmpLen = strlen(msString.GetData());
if (m_nSpace <= m_nLen+nTmpLen)
{
char *tmpp = new char[m_nLen+nTmpLen+sizeof(char)*2];
strcpy(tmpp, m_szBuffer);
strcat(tmpp, msString.GetData());
delete[] m_szBuffer;
m_szBuffer = tmpp;
}
}
这样,我们的函数就可以写成:
int sum (int nNum1, int nNum2)
{
return nNum1 + nNum2;
}
double sum (float nNum1, float nNum2)
{
return nNum1 + nNum2;
}
到现在,我们可以考虑一下,它们既然拥有相同的函数名,那他们怎么区分各个函数的呢?
那就是通过C++名字改编(C++名字粉碎),,对于重载的多个函数来说,其函数名都是一样的,为了加以区分,在编译连接时,C++会按照自己的规则篡改函数名字,这一过程为"名字改编".有的书中也称为"名字粉碎".不同的C++编译器会采用不同的规则进行名字改编,例如以上的重载函数在VC6.0下可能会被重命sum_int@@YAHHH@Z和sum_float@@YAMMM@Z这样方便连接器在链接时正常的识别和找到正确的函数。
(2)缺省参数
无论是Win系统下的API,还是Linux下的很多系统库,它们的好多的函数存在许多参数,而且大部分都是NULL,倘若我们有个函数大部分的时候,某个参数都是固定值,仅有的时候需要改变一下,而我们每次调用它时都要很费劲的输入参数岂不是很痛苦?C++提供了一个给参数加默认参数的功能,例如:
double sum (float nNum1, float nNum2 = 10);
我们调用时,默认情况下,我们只需要给它第一个参数传递参数即可,但是使用这个功能时需要注意一些事项,以免出现莫名其妙的错误,下面我简单的列举一下大家了解就好。
A、 默认参数只要写在函数声明中即可。
B、 默认参数应尽量靠近函数参数列表的最右边,以防止二义性。比如
double sum (float nNum2 = 10,float nNum1);
这样的函数声明,我们调用时:sum(15);程序就有可能无法匹配正确的函数而出现编译错误。
3.宏多态
带变量的宏可以实现一种初级形式的静态多态:
// macro_poly.cpp
#include
#include
// 定义泛化记号:宏ADD
#define ADD(A, B) (A) + (B);
int main()
{
int i1(1), i2(2);
std::string s1("Hello, "), s2("world!");
int i = ADD(i1, i2); // 两个整数相加
std::string s = ADD(s1, s2); // 两个字符串“相加”
std::cout << "i = " << i << "\n";
std::cout << "s = " << s << "\n";
}
当程序被编译时,表达式ADD(i1, i2)和ADD(s1, s2)分别被替换为两个整数相加和两个字符串相加的具体表达式。整数相加体现为求和,而字符串相加则体现为连接(注:string.h库已经重载了“+”)。程序的输出结果符合直觉:
1 + 2 = 3
Hello, + world! = Hello, world!
4.类中的早期绑定
先看以下的代码:
#include
using namespace std;
class animal
{
public:
void sleep(){
cout<<"animal sleep"<breathe();
}
答案是输出:animal breathe
从编译的角度
C++编译器在编译的时候,要确定每个对象调用的函数的地址,这称为早期绑定(early binding),当我们将fish类的对象fh的地址赋给pAn时,C++编译器进行了类型转换,此时C++编译器认为变量pAn保存的就是animal对象的地址。当在main()函数中执行pAn->breathe()时,调用的当然就是animal对象的breathe函数。
内存模型的角度
对于简单的继承关系,其子类内存布局,是先有基类数据成员,然后再是子类的数据成员,当然后面讲的复杂情况,本规律不一定成立。
我们构造fish类的对象时,首先要调用animal类的构造函数去构造animal类的对象,然后才调用fish类的构造函数完成自身部分的构造,从而拼接出一个完整的fish对象。当我们将fish类的对象转换为animal类型时,该对象就被认为是原对象整个内存模型的上半部分,也就是图中的“animal的对象所占内存”。那么当我们利用类型转换后的对象指针去调用它的方法时,当然也就是调用它所在的内存中的方法。因此,输出animal breathe,也就顺理成章了。
前面输出的结果是因为编译器在编译的时候,就已经确定了对象调用的函数的地址,要解决这个问题就要使用迟绑定(late binding)技术。当编译器使用迟绑定时,就会在运行时再去确定对象的类型以及正确的调用函数。而要让编译器采用迟绑定,就要在基类中声明函数时使用virtual关键字(注意,这是必须的,很多学员就是因为没有使用虚函数而写出很多错误的例子),这样的函数我们称为虚函数。一旦某个函数在基类中声明为virtual,那么在所有的派生类中该函数都是virtual,而不需要再显式地声明为virtual。
动态多态:在运行时期才能确定调用的行为。例如虚函数调用机制。本部分主要讨论的是动态多态。虚函数是实现动态多态的机制,其核心理念就是通过基类指针来访问派生类定义的成员。成员函数在基类为虚函数时,在派生类同样也是虚函数。纯虚函数是指不希望基类对象调用的成员函数,需要派生类覆盖实现这样的纯虚函数。(注:如果某个成员函数在基类中没有用virtual关键字修饰,即普通函数,而在派生类中却又有完全相同的成员函数声明,两个函数即使有相同的名字和相同的参数类型与数量,这两个函数也是完全不同的函数,因为类的作用域不同)
在上面的代码如果我们在基类的fun函数前加virtual即可实现动态绑定:
class Base {
public:
virtual void TestFunc(){
cout << "基类函数调用" << endl;
}
};
class Derived : public Base {
public:
void TestFunc(){
cout << "派生类函数调用" << endl;
}
};
int main()
{
Base b;
Derived d;
//指针方法
Base *pb = &b;
Derived *pd = &d;
pb->TestFunc();//pb指向基类,打印Base::TestFunc()
pd->TestFunc();//pd指向子类,打印Derived::TestFunc()
pb = &d;
pb->TestFunc();//pb指向子类,打印Derived::TestFunc()
//引用方法
Base &rb = b;
Derived &rd = d;
rb.TestFunc();//rb引用基类,打印Base::TestFunc()
rd.TestFunc();//rd引用子类,打印Derived::TestFunc()
Base &rd2 = d;
rd2.TestFunc();//rd2引用子类,打印Derived::TestFunc()
getchar();
return 0;
}
输出结果:
基类函数调用
派生类函数调用
派生类函数调用
基类函数调用
派生类函数调用
派生类函数调用
我们知道,C++继承中有赋值兼容,即基类指针可以指向子类,那么为什么还会出现基类指针指向子类或者基类对象引用子类对象,却调用基类自己的TestFunc()函数呢?
这就是静态联编,在编译时期就将函数实现和函数调用关联起来,不管是引用还是指针在编译时期都是Base类的自然调用Base类的TestFunc()。为了避免这种情况,我们引入了动态多态。 所谓的动态多态是通过继承+虚函数来实现的,只有在程序运行期间(非编译期)才能判断所引用对象的实际类型,根据其实际类型调用相应的方法。具体格式就是使用virtual关键字修饰类的成员函数时,指明该函数为虚函数,并且派生类需要重新实现该成员函数,编译器将实现动态绑定。
顺便下面百度了一下:
联编是指一个程序自身彼此关联的过程。按照联编所进行的阶段不同,可分为静态联编和动态联编。
静态联编又称静态绑定,指在调用同名函数(即重载函数)时编译器将根据调用时所使用的实参在编译时就确定下来应该调用的函数实现。它是在程序编译连接阶段进行联编的,这种联编又称为早期联编,这是因为这种联编工作是在程序运行之前完成的。它的优点是速度快,效率高,但灵活性不够。编译时所进行的联编又称为静态束定。束定是指确定所调用的函数与执行该函数代码之间的关系。
动态联编也称动态绑定,是指在程序运行时,根据当时的情况来确定调用的同名函数的实现,实际上就是在运行时选择虚函数的实现。这种联编又称为晚期联编或动态(束定。实现条件:①要有继承性且要求创建子类型关系;)②要有虚函数;③通过基类的对象指针或引用访问虚函数。继承是动态联编的基础,虚函数是动态联编的关键,虚函数经过派生之后,在类族中就可以实现运行过程中的多态。动态联编要求在运行时解决程序中的函数调用与执行该函数代码间的关系,调用虚函数的对象是在运行时确定的。对于同一个对象的引用,采用不同的联编方式将会被联编到不同类的对象上。即不同联编可以选择不同的实现,这便是多态性。它的优点是灵活性强,但效率较低。
联编说白了就是为了满足实际机制,特别设计出来的一个语法规则!!!
或者我们可以将主函数这么写(精简写法):
class Base {
public:
void TestFunc(){
cout << "基类函数调用" << endl;
}
};
class Derived : public Base {
public:
void TestFunc(){
cout << "派生类函数调用" << endl;
}
};
int main()
{
Base *point = new Derived();
point->TestFunc();
getchar();
}
输出结果:
基类函数调用
class Base {
public:
virtual void TestFunc(){
cout << "基类函数调用" << endl;
}
};
class Derived : public Base {
public:
void TestFunc(){
cout << "派生类函数调用" << endl;
}
};
int main()
{
Base *point = new Derived();
point->TestFunc();
getchar();
}
输出结果:
派生类函数调用
注意上诉两个程序的区别,前者没有virtual()函数
父类子类指针函数调用注意事项
1,如果以一个基础类指针指向一个衍生类对象(派生类对象),那么经由该指针只能访问基础类定义的函数(静态联翩)
2,如果以一个衍生类指针指向一个基础类对象,必须先做强制转型动作(explicit cast),这种做法很危险,也不符合生活习惯,在程序设计上也会给程序员带来困扰。(一般不会这么去定义)
3,如果基础类和衍生类定义了相同名称的成员函数,那么通过对象指针调用成员函数时,到底调用那个函数要根据指针的原型来确定,而不是根据指针实际指向的对象类型确定。
虚函数表(vtable):每个类都拥有一个虚函数表,虚函数表中罗列了该类中所有虚函数的地址,排列顺序按声明顺序排列,例如这样两个类
class Base
{
virtual void f() {}
virtual void g() {}
//其他成员
};
Base b;
class Derive : public Base
{
void f() {}
virtual void d() {}
//其他成员
};
Derive d;
虚表指针(vptr):每个类有一个虚表指针,当利用一个基类的指针绑定基类或者派生类对象时,程序运行时调用某个虚函数成员,会根据对象的类型去初始化虚指针,从而虚表指针会从正确的虚函数表中寻找对应的函数进行动态绑定,因此可以达到从基类指针调用派生类成员的效果。
那么为什么需要虚指针和虚函数表来实现动态多态呢?因为无论是什么函数,包括类内的虚函数和非虚函数,都会储存在内存中的代码段。但是当编译器在编译时,就可以确定普通函数和非虚函数的入口地址,以及其调用的信息,所以这指的是常量指针。当遇到动态多态时,虚函数真正的入口地址的指针要在运行时根据对象的类型才能确定,所以要通过虚指针从虚函数表中找虚函数对应的入口地址。
当然,用基类指针绑定的子类对象,只能通过这个基类指针调用基类中的成员,因为作用域仅限于基类的子对象,子类新增的部分是看不见的。
总结为下面这个例程:
#include
using std::cout;
using std::endl;
class Base
{
public:
void fun() { cout << "Base::fun()" << endl; }
virtual void vfun() { cout << "Base::virtual fun()" << endl; }
};
class Derive : public Base
{
public:
void fun() { cout << "Derive::fun()" << endl; }
virtual void vfun() { cout << "Derive::virtual fun()" << endl; }
void dfun() { cout << "Derive::dfun()" << endl; }
};
int main()
{
Base* bp = new Base();
Base* dp = new Derive();
bp->fun();
bp->vfun();
dp->fun();
dp->vfun();
//dp->dfun(); //编译错误:基类指针指向子类中基类的子对象
//不能看到子类的成员
delete bp;
delete dp;
return 0;
}
输出为:
可以看出,bp绑定一个基类对象,调用自己的成员无异议;dp绑定的是一个子类对象,因此调用fun()时,由于dp是一个基类指针,作用域在于基类中,所以调用的是基类的fun(),而调用vfun()是通过动态绑定调用虚函数表中被子类覆盖的Derive::vfun(),而如果要调用dfun()时则会出现编译错误,因为子类独有成员基类指针不可见。
注:在解有关动态多态的题时,只要把握住一点:这个指针指向的到底是基类对象还是子类对象,如果是基类对象,则调用基类的成员函数,如果是子类对象,则要考虑到这个虚成员函数是否被子类中的成员覆盖掉,即是否产生了动态绑定。另外还有一点,从子类对象强制类型转换为基类对象是允许的,而相反地要从基类对象强制转换成子类对象是错误的(编译不通过)。
Base* dp1 = new Derive();
Derive* dp2 = (Derive*) dp1; //基类指针指向的是子类对象,可以强制转化为子类指针
Base* bp1 = new Base();
Derive* bp2 = (Base*) bp1; //错误,[Error] invalid conversion from 'Base*' to 'Derive*' [-fpermissive]
//基类指针指向的是基类对象,不能强制转化为子类指针
下面我们将上面一段代码进行部分修改
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
|
运行结果:fish bubble
编译器为每个类的对象提供一个虚表指针,这个指针指向对象所属类的虚表。在程序运行时,根据对象的类型去初始化vptr,从而让vptr正确的指向所属类的虚表,从而在调用虚函数时,就能够找到正确的函数。由于pAn实际指向的对象类型是fish,因此vptr指向的fish类的vtable,当调用pAn->breathe()时,根据虚表中的函数地址找到的就是fish类的breathe()函数。正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的。换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数。那么虚表指针在什么时候,或者说在什么地方初始化呢?
答案是在构造函数中进行虚表的创建和虚表指针的初始化。还记得构造函数的调用顺序吗,在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否后还有继承者,它初始化父类对象的虚表指针,该虚表指针指向父类的虚表。当执行子类的构造函数时,子类对象的虚表指针被初始化,指向自身的虚表。
当fish类的fh对象构造完毕后,其内部的虚表指针也就被初始化为指向fish类的虚表。在类型转换后,调用pAn->breathe(),由于pAn实际指向的是fish类的对象,该对象内部的虚表指针指向的是fish类的虚表,因此最终调用的是fish类的breathe()函数。
基类的内存分布情况
对于无虚函数的类A:
class A
{
void g(){.....}
};
则sizeof(A)=1;
如果改为如下:
class A
{
public:
virtual void f()
{
......
}
void g(){.....}
}
则sizeof(A)=4! 这是因为在类A中存在virtual function,为了实现多态,每个含有virtual function的类中都隐式包含着一个静态虚指针vfptr指向该类的静态虚表vtable, vtable中的表项指向类中的每个virtual function的入口地址
例如 我们declare 一个A类型的object :
A c;
A d;
则编译后其内存分布如下:
从 vfptr所指向的vtable可以看出,每个virtual function都占有一个entry,例如本例中的f函数。而g函数因为不是virtual类型,故不在vtable的表项之内。说明:vtab属于类成员静态pointer,而vfptr属于对象pointer
继承类的内存分布状况
假设代码如下:
public B:public A
{
public :
int f() //override virtual function
{
return 3;
}
};
则
A c;
A d;
B e;
编译后,其内存分布如下:
从中我们可以看出,B类型的对象e有一个vfptr指向vtable address:0x00400030 ,而A类型的对象c和d共同指向类的vtable address:0x00400050a
动态绑定过程的实现
我们说多态是在程序进行动态绑定得以实现的,而不是编译时就确定对象的调用方法的静态绑定。
其过程如下:
程序运行到动态绑定时,通过基类的指针所指向的对象类型,通过vfptr找到其所指向的vtable,然后调用其相应的方法,即可实现多态。
例如:
A c;
B e;
A *pc=&e; //设置breakpoint,运行到此处
pc=&c;
此时内存中各指针状况如下:
可以看出,此时pc指向类B的虚表地址,从而调用对象e的方法。继续运行,当运行至pc=&c时候,此时pc的vptr值为0x00420050,即指向类A的vtable地址,从而调用c的方法。
对于虚函数调用来说,每一个对象内部都有一个虚表指针,该虚表指针被初始化为本类的虚表。所以在程序中,不管你的对象类型如何转换,但该对象内部的虚表指针是固定的,所以呢,才能实现动态的对象函数调用,这就是C++多态性实现的原理。
需要注意的几点
总结(基类有虚函数):
1、每一个类都有虚表。
2、虚表可以继承,如果子类没有重写虚函数,那么子类虚表中仍然会有该函数的地址,只不过这个地址指向的是基类的虚函数实现。如果基类3个虚函数,那么基类的虚表中就有三项(虚函数地址),派生类也会有虚表,至少有三项,如果重写了相应的虚函数,那么虚表中的地址就会改变,指向自身的虚函数实现。如果派生类有自己的虚函数,那么虚表中就会添加该项。
3、派生类的虚表中虚函数地址的排列顺序和基类的虚表中虚函数地址排列顺序相同。
4.如果虚函数在基类与派生类中出现,仅仅是名字相同,而形式参数不同,或者是返回类型不同,那么即使加上了virtual关键字,也是不会进行滞后联编的。
5.只有类的成员函数才能说明为虚函数,因为虚函数仅适合用与有继承关系的类对象,所以普通函数不能说明为虚函数。
6.静态成员函数不能是虚函数,因为静态成员函数的特点是不受限制于某个对象。
7.内联(inline)函数不能是虚函数,因为内联函数不能在运行中动态确定位置。即使虚函数在类的内部定义定义,但是在编译的时候系统仍然将它看做是非内联的。
8.构造函数不能是虚函数,因为构造的时候,对象还是一片位定型的空间,只有构造完成后,对象才是具体类的实例。
9.析构函数可以是虚函数,而且通常声名为虚函数。
同时需要了解多态的特性的virtual修饰,不单单对基类和派生类的普通成员 函数有必要,而且对于基类和派生类的析构函数同样重要!!!
下面想将虚函数和纯虚函数做个比较
虚函数
引入原因:为了方便使用多态特性,我们常常需要在基类中定义虚函数。
纯虚函数
引入原因:为了实现多态性,纯虚函数有点像java中的接口,自己不去实现过程,让继承他的子类去实现。
在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔雀等子类,但动物本身生成对象明显不合常理。 这时我们就将动物类定义成抽象类,也就是包含纯虚函数的类
纯虚函数就是基类只定义了函数体,没有实现过程定义方法如下
virtual void Eat() = 0; 直接=0 不要 在cpp中定义就可以了
虚函数和纯虚函数的区别
1虚函数中的函数是实现的哪怕是空实现,它的作用是这个函数在子类里面可以被重载,运行时动态绑定实现动态
纯虚函数是个接口,是个函数声明,在基类中不实现,要等到子类中去实现
2 虚函数在子类里可以不重载,但是虚函数必须在子类里去实现。
(1)一般继承(无虚函数覆盖)
假设有如下所示的一个继承关系:
在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:
对于实例:Derive d; 的虚函数表如下:
我们可以看到下面几点:
1)虚函数按照其声明顺序放于表中。
2)父类的虚函数在子类的虚函数前面。
(2)一般继承(有虚函数覆盖)
在这个类的设计中,假设只覆盖了父类的一个函数:f()。那么,对于派生类的实例,其虚函数表会是下面的一个样子
我们从表中可以看到下面几点,
1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
2)没有被覆盖的函数依旧。
这样,我们就可以看到对于下面这样的程序,
Base *b = new Derive();
b->f();
由b所指的内存中的虚函数表的f()的位置已经被Derive::f()函数地址所取代,于是在实际调用发生时是Derive::f()被调用了。这就实现了多态。
在单继承下,对应于例程:
class A
{
public:
A(){m_A = 0;}
virtual fun1(){};
int m_A;
};
class B:public A
{
public:
B(){m_B = 1;}
virtual fun1(){};
virtual fun2(){};
int m_B;
};
int main(int argc, char* argv[])
{
B* pB = new B;
return 0;
}
则在VC6.0下的内存分配图:
在该图中,子类只有一个虚函数表,与以上的两种情况向符合。
多继承情况下子类实例的内存结构(非虚继承)
(1)多重继承(无虚函数覆盖)
假设有下面这样一个类的继承关系。注意:子类并没有覆盖父类的函数:
对于子类实例中的虚函数表,是下面这个样子:
我们可以看到:
1) 每个父类都有自己的虚表。
2) 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)
(2)多重继承(有虚函数覆盖)
下图中,我们在子类中覆盖了父类的f()函数。
下面是对于子类实例中的虚函数表的图:
我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。如:
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()
在多继承(非虚继承)情况下,对应于以下例程序:
#include
class A
{
public:
A(){m_A = 1;};
~A(){};
virtual int funA(){printf("in funA\r\n"); return 0;};
int m_A;
};
class B
{
public:
B(){m_B = 2;};
~B(){};
virtual int funB(){printf("in funB\r\n"); return 0;};
int m_B;
};
class C
{
public:
C(){m_C = 3;};
~C(){};
virtual int funC(){printf("in funC\r\n"); return 0;};
int m_C;
};
class D:public A,public B,public C
{
public:
D(){m_D = 4;};
~D(){};
virtual int funD(){printf("in funD\r\n"); return 0;};
int m_D;
};
则在VC6.0下的内存分配图:
从该图中可以看出,此时子类中确实有三个来自于父类的虚表。
多继承情况下子类实例的内存结构(存在虚继承)
在虚继承下,Der通过共享虚基类SuperBase来避免二义性,在Base1,Base2中分别保存虚基类指针,Der继承Base1,Base2,包含Base1, Base2的虚基类指针,并指向同一块内存区,这样Der便可以间接存取虚基类的成员,如下图所示:
class SuperBase
{
public:
int m_nValue;
void Fun(){cout<<"SuperBase1"< virtual ~SuperBase(){} }; class Base1: virtual public SuperBase { public: virtual ~ Base1(){} }; class Base2: virtual public SuperBase { public: virtual ~ Base2(){} }; class Der:public Base1, public Base2 { public: virtual ~ Der(){} }; void main() { cout< } 1) GCC中结果为8, 12, 12, 16 解析:sizeof(SuperBase) = sizeof(int) + 虚函数表指针 sizeof(Base1) = sizeof(Base2) = sizeof(int) + 虚函数指针 + 虚基类指针 sizeof(Der) = sizeof(int) + Base1中虚基类指针 + Base2虚基类指针 + 虚函数指针 GCC共享虚函数表指针,也就是说父类如果已经有虚函数表指针,那么子类中共享父类的虚函数表指针空间,不在占用额外的空间,这一点与VC不同,VC在虚继承情况下,不共享父类虚函数表指针,详见如下。 2)VC中结果为:8, 16, 16, 24 解析:sizeof(SuperBase) = sizeof(int) + 虚函数表指针 sizeof(Base1) = sizeof(Base2) = sizeof(int) + SuperBase虚函数指针 + 虚基类指针 + 自身虚函数指针 sizeof(Der) = sizeof(int) + Base1中虚基类指针 + Base2中虚基类指针 + Base1虚函数指针 + Base2虚函数指针 + 自身虚函数指针 如果去掉虚继承,结果将和GCC结果一样,A,B,C都是8,D为16,原因就是VC的编译器对于非虚继承,父类和子类是共享虚函数表指针的。 (1) 部分虚继承的情况下子类实例的内存结构: #include "stdafx.h" class A { public: A(){m_A = 0;}; virtual funA(){}; int m_A; }; class B { public: B(){m_B = 1;}; virtual funB(){}; int m_B; }; class C { public: C(){m_C = 2;}; virtual funC(){}; int m_C; }; class D:virtual public A,public B,public C { public: D(){m_D = 3;}; virtual funD(){}; int m_D; }; int main(int argc, char* argv[]) { D* pD = new D; return 0; } (2)全部虚继承的情况下,子类实例的内存结构 class A { public: A(){m_A = 0;} virtual funA(){}; int m_A; }; class B { public: B(){m_B = 1;} virtual funB(){}; int m_B; }; class C:virtual public A,virtual public B { public: C(){m_C = 2;} virtual funC(){}; int m_C; }; int main(int argc, char* argv[]) { C* pC = new C; return 0; } (3) 菱形结构继承关系下子类实例的内存结构 class A { public: A(){m_A = 0;} virtual funA(){}; int m_A; }; class B :virtual public A { public: B(){m_B = 1;} virtual funB(){}; int m_B; }; class C :virtual public A { public: C(){m_C = 2;} virtual funC(){}; int m_C; }; class D: public B, public C { public: D(){m_D = 3;} virtual funD(){}; int m_D; }; int main(int argc, char* argv[]) { D* pD = new D; return 0; } 对于子类虚表的个数和设置,貌似虚继承与非虚继承的差别不是很大。 3.有关问题
27.虚函数可以声明为inline吗?
1)虚函数用于实现运行时的多态,或者称为晚绑定或动态绑定。而内联函数用于提高效率。内联函数的原理是,在编译期间,对调用内联函数的地方的代码替换成函数代码。内联函数对于程序中需要频繁使用和调用的小函数非常有用。
2)虚函数要求在运行时进行类型确定,而内敛函数要求在编译期完成相关的函数替换;
1)
28.构造函数为什么不能为虚函数?析构函数为什么要虚函数?
1. 从存储空间角度,虚函数相应一个指向vtable虚函数表的指针,这大家都知道,但是这个指向vtable的指针事实上是存储在对象的内存空间的。问题出来了,假设构造函数是虚的,就须要通过 vtable来调用,但是对象还没有实例化,也就是内存空间还没有,怎么找vtable呢?所以构造函数不能是虚函数。
2. 从使用角度,虚函数主要用于在信息不全的情况下,能使重载的函数得到相应的调用。构造函数本身就是要初始化实例,那使用虚函数也没有实际意义呀。所以构造函数没有必要是。的作用在于通过父类的指针或者引用来调用它的时候可以变成调用子类的那个成员函数。而构造函数是在创建对象时自己主动调用的,不可能通过父类的指针或者引用去调用,因此也就规定构造函数不能是虚函数。
3. 构造函数不须要是虚函数,也不同意是虚函数,由于创建一个对象时我们总是要明白指定对象的类型,虽然我们可能通过实验室的基类的指针或引用去訪问它但析构却不一定,我们往往通过基类的指针来销毁对象。这时候假设析构函数不是虚函数,就不能正确识别对象类型从而不能正确调用析构函数。
4. 从实现上看,vbtl在构造函数调用后才建立,因而构造函数不可能成为虚函数从实际含义上看,在调用构造函数时还不能确定对象的真实类型(由于子类会调父类的构造函数);并且构造函数的作用是提供初始化,在对象生命期仅仅运行一次,不是对象的动态行为,也没有必要成为虚函数。
5. 当一个构造函数被调用时,它做的首要的事情之中的一个是初始化它的VPTR。因此,它仅仅能知道它是“当前”类的,而全然忽视这个对象后面是否还有继承者。当编译器为这个构造函数产生代码时,它是为这个类的构造函数产生代码——既不是为基类,也不是为它的派生类(由于类不知道谁继承它)。所以它使用的VPTR必须是对于这个类的VTABLE。并且,仅仅要它是最后的构造函数调用,那么在这个对象的生命期内,VPTR将保持被初始化为指向这个VTABLE, 但假设接着另一个更晚派生的构造函数被调用,这个构造函数又将设置VPTR指向它的 VTABLE,等.直到最后的构造函数结束。VPTR的状态是由被最后调用的构造函数确定的。这就是为什么构造函数调用是从基类到更加派生类顺序的还有一个理由。可是,当这一系列构造函数调用正发生时,每一个构造函数都已经设置VPTR指向它自己的VTABLE。假设函数调用使用虚机制,它将仅仅产生通过它自己的VTABLE的调用,而不是最后的VTABLE(全部构造函数被调用后才会有最后的VTABLE)。
因为构造函数本来就是为了明确初始化对象成员才产生的,然而virtual function主要是为了再不完全了解细节的情况下也能正确处理对象。另外,virtual函数是在不同类型的对象产生不同的动作,现在对象还没有产生,如何使用virtual函数来完成你想完成的动作。
直接的讲,C++中基类采用virtual虚析构函数是为了防止内存泄漏。具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。
29.构造函数和析构函数可以调用虚函数吗,为什么
1)在C++中,提倡不在构造函数和析构函数中调用虚函数;
2)构造函数和析构函数调用虚函数时都不使用动态联编,如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本;
3)因为父类对象会在子类之前进行构造,此时子类部分的数据成员还未初始化,因此调用子类的虚函数时不安全的,故而C++不会进行动态联编;
4)析构函数是用来销毁一个对象的,在销毁一个对象时,先调用子类的析构函数,然后再调用基类的析构函数。所以在调用基类的析构函数时,派生类对象的数据成员已经销毁,这个时候再调用子类的虚函数没有任何意义。
30.虚析构函数的作用,父类的析构函数是否要设置为虚函数?
1)C++中基类采用virtual虚析构函数是为了防止内存泄漏。具体地说,如果派生类中申请了内存空间,并在其析构函数中对这些内存空间进行释放。假设基类中采用的是非虚析构函数,当删除基类指针指向的派生类对象时就不会触发动态绑定,因而只会调用基类的析构函数,而不会调用派生类的析构函数。那么在这种情况下,派生类中申请的空间就得不到释放从而产生内存泄漏。所以,为了防止这种情况的发生,C++中基类的析构函数应采用virtual虚析构函数。
2)纯虚析构函数一定得定义,因为每一个派生类析构函数会被编译器加以扩张,以静态调用的方式调用其每一个虚基类以及上一层基类的析构函数。因此,缺乏任何一个基类析构函数的定义,就会导致链接失败。因此,最好不要把虚析构函数定义为纯虚析构函数。
31.构造函数析构函数可以调用虚函数吗?
1)在构造函数和析构函数中最好不要调用虚函数;
2)构造函数或者析构函数调用虚函数并不会发挥虚函数动态绑定的特性,跟普通函数没区别;
3)即使构造函数或者析构函数如果能成功调用虚函数, 程序的运行结果也是不可控的。
32.类如何实现只能静态分配和只能动态分配
1)前者是把new、delete运算符重载为private属性。后者是把构造、析构函数设为protected属性,再用子类来动态创建
2)建立类的对象有两种方式:
①静态建立,静态建立一个类对象,就是由编译器为对象在栈空间中分配内存;
②动态建立,A *p = new A();动态建立一个类对象,就是使用new运算符为对象在堆空间中分配内存。这个过程分为两步,第一步执行operator new()函数,在堆中搜索一块内存并进行分配;第二步调用类构造函数构造对象;
3)只有使用new运算符,对象才会被建立在堆上,因此只要限制new运算符就可以实现类对象只能建立在栈上。可以将new运算符设为私有。
33.如果想将某个类用作基类,为什么该类必须定义而非声明?
1)派生类中包含并且可以使用它从基类继承而来的成员,为了使用这些成员,派生类必须知道他们是什么。
到这里
34.抽象基类为什么不能创建对象?
抽象类是一种特殊的类,它是为了抽象和设计的目的为建立的,它处于继承层次结构的较上层。
(1)抽象类的定义:
称带有纯虚函数的类为抽象类。
(2)抽象类的作用:
抽象类的主要作用是将有关的操作作为结果接口组织在一个继承层次结构中,由它来为派生类提供一个公共的根,派生类将具体实现在其基类中作为接口的操作。所以派生类实际上刻画了一组子类的操作接口的通用语义,这些语义也传给子类,子类可以具体实现这些语义,也可以再将这些语义传给自己的子类。
(3)使用抽象类时注意:
抽象类只能作为基类来使用,其纯虚函数的实现由派生类给出。如果派生类中没有重新定义纯虚函数,而只是继承基类的纯虚函数,则这个派生类仍然还是一个抽象类。如果派生类中给出了基类纯虚函数的实现,则该派生类就不再是抽象类了,它是一个可以建立对象的具体的类。
抽象类是不能定义对象的。一个纯虚函数不需要(但是可以)被定义。
一、纯虚函数定义
纯虚函数是一种特殊的虚函数,它的一般格式如下:
class <类名>
{
virtual <类型><函数名>(<参数表>)=0;
…
};
在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。这就是纯虚函数的作用。
纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。凡是含有纯虚函数的类叫做抽象类。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。
二、纯虚函数引入原因
1、为了方便使用多态特性,我们常常需要在基类中定义虚拟函数。
2、在很多情况下,基类本身生成对象是不合情理的。例如,动物作为一个基类可以派生出老虎、孔 雀等子类,但动物本身生成对象明显不合常理。
为了解决上述问题,引入了纯虚函数的概念,将函数定义为纯虚函数(方法:virtual ReturnType Function()= 0;)。若要使派生类为非抽象类,则编译器要求在派生类中,必须对纯虚函数予以重载以实现多态性。同时含有纯虚函数的类称为抽象类,它不能生成对象。这样就很好地解决了上述两个问题。
例如,绘画程序中,shape作为一个基类可以派生出圆形、矩形、正方形、梯形等, 如果我要求面积总和的话,那么会可以使用一个 shape * 的数组,只要依次调用派生类的area()函数了。如果不用接口就没法定义成数组,因为既可以是circle ,也可以是square ,而且以后还可能加上rectangle,等等.
三、相似概念
1、多态性
指相同对象收到不同消息或不同对象收到相同消息时产生不同的实现动作。C++支持两种多态性:编译时多态性,运行时多态性。
a.编译时多态性:通过重载函数实现
b.运行时多态性:通过虚函数实现。
2、虚函数
虚函数是在基类中被声明为virtual,并在派生类中重新定义的成员函数,可实现成员函数的动态重载。
3、抽象类
包含纯虚函数的类称为抽象类。由于抽象类包含了没有定义的纯虚函数,所以不能定义抽象类的对象。
35.介绍一下C++里面的多态?
(1)静态多态(重载,模板)
是在编译的时候,就确定调用函数的类型。
(2)动态多态(覆盖,虚函数实现)
在运行的时候,才确定调用的是哪个函数,动态绑定。运行基类指针指向派生类的对象,并调用派生类的函数。
虚函数实现原理:虚函数表和虚函数指针。
纯虚函数: virtual int fun() = 0;
函数的运行版本由实参决定,在运行时选择函数的版本,所以动态绑定又称为运行时绑定。
当编译器遇到一个模板定义时,它并不生成代码。只有当实例化出模板的一个特定版本时,编译器才会生成代码。
2.
36.虚函数的代价?
1)带有虚函数的类,每一个类会产生一个虚函数表,用来存储指向虚成员函数的指针,增大类;
2)带有虚函数的类的每一个对象,都会有有一个指向虚表的指针,会增加对象的空间大小;
3)不能再是内敛的函数,因为内敛函数在编译阶段进行替代,而虚函数表示等待,在运行阶段才能确定到低是采用哪种函数,虚函数不能是内敛函数。
34.虚函数与纯虚函数的区别在于
1)纯虚函数只有定义没有实现,虚函数既有定义又有实现;
含有纯虚函数的类不能定义对象,含有虚函数的类能定义对象;
构造函数能否为虚函数,析构函数呢?
析构函数:
析构函数可以为虚函数,并且一般情况下基类析构函数要定义为虚函数。
只有在基类析构函数定义为虚函数时,调用操作符delete销毁指向对象的基类指针时,才能准确调用派生类的析构函数(从该级向上按序调用虚函数),才能准确销毁数据。
析构函数可以是纯虚函数,含有纯虚函数的类是抽象类,此时不能被实例化。但派生类中可以根据自身需求重新改写基类中的纯虚函数。
构造函数:
构造函数不能定义为虚函数。在构造函数中可以调用虚函数,不过此时调用的是正在构造的类中的虚函数,而不是子类的虚函数,因为此时子类尚未构造好。
构造函数调用顺序,析构函数呢?
调用所有虚基类的构造函数,顺序为从左到右,从最深到最浅
基类的构造函数:如果有多个基类,先调用纵向上最上层基类构造函数,如果横向继承了多个类,调用顺序为派生表从左到右顺序。
如果该对象需要虚函数指针(vptr),则该指针会被设置从而指向对应的虚函数表(vtbl)。
成员类对象的构造函数:如果类的变量中包含其他类(类的组合),需要在调用本类构造函数前先调用成员类对象的构造函数,调用顺序遵照在类中被声明的顺序。
派生类的构造函数。
析构函数与之相反。
虚函数和纯虚函数区别?
虚函数是为了实现动态编联产生的,目的是通过基类类型的指针指向不同对象时,自动调用相应的、和基类同名的函数(使用同一种调用形式,既能调用派生类又能调用基类的同名函数)。虚函数需要在基类中加上virtual修饰符修饰,因为virtual会被隐式继承,所以子类中相同函数都是虚函数。当一个成员函数被声明为虚函数之后,其派生类中同名函数自动成为虚函数,在派生类中重新定义此函数时要求函数名、返回值类型、参数个数和类型全部与基类函数相同。
纯虚函数只是相当于一个接口名,但含有纯虚函数的类不能够实例化。
什么是虚指针?
虚指针或虚函数指针是虚函数的实现细节。
虚指针指向虚表结构。
28.哪些函数不能是虚函数
1)构造函数,构造函数初始化对象,派生类必须知道基类函数干了什么,才能进行构造;当有虚函数时,每一个类有一个虚表,每一个对象有一个虚表指针,虚表指针在构造函数中初始化;
2)内联函数,内联函数表示在编译阶段进行函数体的替换操作,而虚函数意味着在运行期间进行类型确定,所以内联函数不能是虚函数;
3)静态函数,静态函数不属于对象属于类,静态成员函数没有this指针,因此静态函数设置为虚函数没有任何意义。
4)友元函数,友元函数不属于类的成员函数,不能被继承。对于没有继承特性的函数没有虚函数的说法。
普通函数,普通函数不属于类的成员函数,不具有继承特性,因此普通函数没有虚函数。
2.C++模板是什么,底层怎么实现的?
1)编译器并不是把函数模板处理成能够处理任意类的函数;编译器从函数模板通过具体类型产生不同的函数;编译器会对函数模板进行两次编译:在声明的地方对模板代码本身进行编译,在调用的地方对参数替换后的代码进行编译。
2)这是因为函数模板要被实例化后才能成为真正的函数,在使用函数模板的源文件中包含函数模板的头文件,如果该
3.头文件中只有声明,没有定义,那编译器无法实例化该模板,最终导致链接错误。
模板类和模板函数的区别是什么?
函数模板的实例化是由编译程序在处理函数调用时自动完成的,而类模板的实例化必须由程序员在程序中显式地指定。即函数模板允许隐式调用和显式调用而类模板只能显示调用。在使用时类模板必须加