20170327_请说出虚函数的工作原理
1、多态的分类:
- 静态多态:函数重载、泛型编程
- 动态多态:虚函数
2、静态联编与动态联编:
(1)联编的作用:根据内存分区的知识可知,程序代码以二进制的形式存储在程序代码区。在程序调用函数时,编译器决定使用哪个可执行代码块,也即是确定调用的具体对象。
(2)举例如下:
class A
{
public:
void Fun();
};
class B:public class A
{
public:
void Fun();
};
int main()
{
B b;
b.Fun(); //究竟调用A类的Fun()函数还是B类的Fun()函数。确定具体对象的过程叫做联编!
return 0;
}
(3)如上所知,联编的分类是根据程序进行的阶段的不同划分的。
在程序编译阶段进行的联编称之为静态联编。
在程序执行过程中才动态的确定操作的是哪个对象的联编称之为动态联编。
3、指针和引用类型的兼容性:(上行转换、下行转换的区别)
(1)一般情况下,C++是不允许将一种类型的指针赋给另一种类型的指针的,也不允许将一种类型的引用赋值给另一种类型的引用的。举例如下:
float f;
int &a = f; //编译器报错
double *p = &f; //编译器报错
(2)但是,在赋值兼容转换规则中,基类指针和基类引用是可以直接指向派生类对象的,而不需要进行显示类型转换。
举例如下:
class Base
{
public:
int b;
};
class Derive:public Base
{
public:
int d;
};
int main()
{
Base b;
Derive d;
Base *pb = &b; //基类指针指向基类对象
pb = &d; //基类指针指向派生类对象
Base &B = b; //基类引用指向基类对象
Base &D = d; //基类引用指向派生类对象
//编译运行都不会出错!!!
return 0;
}
(3)说到这里,牵扯到了上行转换和下行转换的概念!
- 上行转换:将派生类指针或 派生类引用转换为基类指针或 基类引用!
- 举例如下:
class Base { public: int b; }; class Derive : public Base { public: int d; }; int main() { Base b; Derive d; Base *pb = &d; //隐式上行转换 pb->b = 10; //可以赋值 cout << "Base::b = " << pb->b << endl; return 0; }
- 运行结果是:Base::b = 10 。说明:上行转换是可以传递的。假设:我在该派生类的基础上再派生出一个类,这时依然可以进行上行转换。
- 下行转换:将基类指针或 基类引用转换为派生类指针或 派生类引用!注意:如果不使用强制类型转换符static_cast 或dynam_cast,下行转换是不允许的!!!
- 举例如下:
class Fruit { public: int weight; }; class Banana:public Fruit { public: int yellow; }; int main() { Fruit b; Banana d; Banana *pb = &b; //隐式下行转换是错误的!//会报错! Banana *p = ( Banana * )&b; //显式下行转换!//不会报错! return 0; }
4、什么是虚函数?
(1)刚才已经说了隐式的上行转换是不会报错的,所以基类指针或基类引用可以指向基类对象、也可以指向派生类对象,那么在程序运行时进行调用的时候到底调用的是哪一个类中的呢?因此需要动态联编。 C++使用虚成员函数来满足这种需求。
(2)虚函数:virtual 函数返回值类型 函数名(形参列表) { 函数体 };
(3)实现多态性:
- 通过指向派生类的基类指针 或 基类引用,访问派生类中的同名覆盖成员函数。
- 如果基类中的函数没有使用关键字virtual, 那么程序将根据 指针类型 或 引用类型 选择哪一个类中的方法。
- 如果基类中的函数使用了关键字 virtual, 那么程序将根据 指针 或 引用 所指向的那个对象 选择该对象所属的类中的方法。
- 举例如下 1 :
class Base { public: int b; void Fun() { cout << "Base::Fun()" << endl; } }; class Derive : public Base { public: int d; void Fun() { cout << "Derive::Fun()" << endl; } }; int main() { Base b; Derive d; Base *pb = &d; //上行转换 pb->Fun(); //调用的是哪一个类中的Fun()函数呢?输出:Base::Fun() return 0; }
- 分析:上面的程序调用的Fun() 函数是基类的,也就是上面我所说的(如果基类中的函数没有使用关键字 virtual , 那么程序将根据引用类型 或 指针类型选择哪一个类中的方法)。 在上面代码中,指针变量 pb 的类型是 Base 类,所以程序调用的是基类的成员函数 Fun() 。
- 现在我们要是加上 virtual 关键字,将基类中的 Fun() 函数变为 虚函数,此时又会输出什么结果?
- 举例如下 2 :
class Base { public: int b; virtual void Fun() { cout << "Base::Fun()" << endl; } }; class Derive:public Base { public: int d; void Fun() { cout << "Derive::Fun()" << endl; } }; int main() { Base b; Derive d; Base *pb = &d; //上行转换 pb->Fun(); //调用的是哪一个类中的Fun()函数呢?输出:Derive::Fun() return 0; }
- 分析:上面的程序调用的Fun() 函数是派生类的,也就是上面我所说的( 如果基类中的函数使用了关键字 virtual, 那么程序将根据 指针 或 引用 所指向的那个对象 选择该对象所属类中的方法)。 在上面代码中,指针变量 pb 的类型是 Base 类,但是它指向了派生类的对象 d ,所以程序调用的是该对象所属的类(派生类Derived)中的成员函数 Fun()。
- 如果将上述的指针变为引用,结果类似。
(4)需要说明的是:
- 如果在基类中定义了虚函数,那么派生类中的同名函数将自动变为虚函数。但是,我们可以在派生类同名函数前也加上virtual关键字,这样会增加程序的可读性。
- 如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚函数。这样程序将根据对象类型而不是引用或指针的类型来选择方法也就是函数的版本。
- 这里一定要注意什么时候用虚函数,必须是用指针或引用调用方法的时候用虚函数。因为如果是通过对象调用方法,那么编译的时候就知道应该用哪个方法了。
5、请说一下虚函数的工作原理?
(1)C++语言规定了虚函数的行为,但是它的实现留给了编译器。
(2)编译器处理虚函数的方法是:给每个对象都添加了一个隐藏成员!隐藏成员中保存了一个指向虚函数表 vtbl 的指针,而这个 虚函数表 中存储的是为类对象声明的虚函数的地址。
(3)例如:
- 基类对象中包含了一个虚函数表指针 vptr ,该指针指向了基类的虚函数表 vtbl,基类的虚函数表里面存放的是基类的所有虚函数的入口地址。
- 派生类对象中也包含了一个虚函数表指针 vptr ,该指针指向了派生类的虚函数表 vtbl,派生类的虚函数表里面存放的是三部分内容:第一部分是从基类那继承过来的没被改写的虚函数的原始的入口地址,第二部分是从基类那继承过来的已经被改写的虚函数的新的入口地址,第三部分是派生类自己新添加的虚函数的入口地址。
- 注意:无论类中包含的虚函数是一个还是多个,都只需要在对象中添加一个地址成员,只是表的大小不同而已。
(4)、对(3)进行举例子:
class Base { public: int b; virtual void Fun1() { cout << "Base::Fun1()" << endl; } virtual void Fun2() { cout << "Base::Fun2()" << endl; } }; class Derive: public Base { public: int d; }; int main() { Base bclass; bclass.b = 1; Derive dclass; dclass.b = 1; dclass.d = 2; return 0; }
- 上述代码中并没有在派生类中重新定义虚函数也没有新增加虚函数。仅仅只是作了继承。
- 先看基类对象Base b ;的内存分布:
- 对上分析可知:编译器为基类对象b 添加了一个隐藏的虚函数表指针vptr ,它指向了基类的虚函数表vtbl ,vtbl 中存放的是基类的两个虚函数Fun1() 和Fun2() 的入口地址,其中每一个入口地址均占据4个字节,同时虚函数表的最后存放的是NULL ,代表着虚函数表已经结束,也用4个字节。因此,该基类的虚表占据大小是3*4=12个字节。
- 再看派生类对象Derived d;的内存分布:
-
- 对上分析可知:上述代码中并没有在派生类中重新定义虚函数也没有新增加虚函数。仅仅只是作了继承。 编译器为派生类对象d 添加了一个隐藏的虚函数表指针vptr ,它指向了派生类的虚函数表vtbl ,vtbl 中(应该存放三部分内容,但此类中仅有第一部分内容)存放的是从基类继承过来的未被改写的两个虚函数Fun1() 和Fun2() 的入口地址,其中每一个入口地址均占据4个字节,同时虚函数表的最后存放的是NULL ,代表着虚函数表已经结束,也用4个字节。因此,该派生类的虚表占据大小是3*4=12个字节。
(5)所以可以知道,为什么默认的是静态联编而不是动态联编?
因为动态联编会使用虚函数表的查询,在执行速度和内存开销是有一定成本的。
- 每个对象都将增大,增大量是存储地址的空间。
- 对每个类,编译器均会创建一个虚函数表。
- 每个虚函数的调用,都要额外执行一步到虚函数表进行查表的工作。
- 虽然非虚函数的执行效率比虚函数较高,但它不具备动态联编的功能。
(6)虚函数的一些注意事项:
- 构造函数:构造函数不能是虚函数。
- 析构函数:析构函数应当是虚函数。
- 友元:友元不能是虚函数。因为友元不是类的成员,而只有类的成员才能定义为虚函数。
- 重定义,覆盖和隐藏