《王道》C++面向对象编程之虚函数多态

《王道》C++面向对象编程之虚函数多态

    多态性是面向对象的精髓,多态性可简单地概括为“一个接口,多种方法”,函数重载就是一种简单的多态,一个函数名(调用接口)对应着几个不同的函数原型(方法)。

    更通俗地说,多态性是指同一个操作作用于不同的对象就会产生不同的响应;多态性分为静态多态性和动态多态性,其中函数重载和运算符重载属于静态多态性,虚函数属于动态多态性。本文主讲虚函数。

1 静态联编和动态联编

1.1 定义

    通常来说联编(binding)就是将模块或者函数合并在一起生成可执行代码的处理过程,同时对每个模块或者函数调用分配内存地址,并且对外部访问也分配正确的内存地址,它是计算机程序彼此关联的过程。按照联编所进行的阶段不同,可分为两种不同的联编方法:静态联编和动态联编。

    静态联编(static binding)是指在编译阶段就将函数实现和函数调用关联起来,因此静态联编也叫早绑定,在编译阶段就必须了解所有的函数或模块执行所需要检测的信息,它对函数的选择是基于指向对象的指针(或者引用)的类型,C语言中,所有的联编都是静态联编,并且任何一种编译器都支持静态联编。

    动态联编(dynamic binding)是指在程序执行的时候才将函数实现和函数调用关联,因此也叫运行时绑定或者晚绑定,动态联编对函数的选择不是基于指针或者引用,而是基于对象类型,不同的对象类型将做出不同的编译结果。C++中一般情况下联编也是静态联编,但是一旦涉及到多态和虚拟函数就必须要使用动态联编了。

1.2 虚函数定义

    虚函数定义很简单,只要在成员函数原型前加一个关键字virtual即可。

    如果一个基类的成员函数定义为虚函数,那么,它在所有派生类中也保持为虚函数;即使在派生类中省略了virtual关键字,也仍然是虚函数。

    派生类中可根据需要对虚函数进行重定义,重定义的格式有一定的要求:

    1)与基类的虚函数有相同的参数个数;

    2)与基类的虚函数有相同的参数类型;

    3)与基类的虚函数有相同的返回类型:或者与基类虚函数的相同,或者都返回指针(或引用),并且派生类虚函数所返回的指针(或引用)类型是基类中被替换的虚函数所返回指针(或引用)类型的子类型(派生类型)。

1.3 动态联编触发的条件

    1. 和普通函数一样,虚函数一样可以通过对象名来调用,此时编译器采用的是静态联编。通过对象名访问虚函数时,调用哪个类的函数取决于定义对象名的类型。对象类型是基类时,就调用基类函数;对象类型是子类时,就调用子类的函数。

    2. 使用指针访问非虚函数时,编译器根据指针本身的类型决定要调用哪个函数(静态联编),而不是根据指针指向的数据类型。

    例1:

#include <iostream>
using namespace std;

class A

{
public:

	void f() { cout << "A" << ""; }

};

class B:public A

{ public:

	void f() { cout << "B" << endl; }

};

int main()
{
		   A *pa = NULL;

		   A a; B b;

		   pa = &a; pa->f();

		   pa = &b; pa->f();
                   return 0;
}

    该程序的运行结果为:A   A

    从程序的运行结果可以看出,通过对象指针进行的普通成员函数的调用,仅仅与指针的类型有关,而与此刻指针正指向什么对象无关。要想实现当指针指向不同对象时执行不同的操作,就必须将基类中相应的成员函数定义为虚函数,进行动态联编。

    3. 使用指针访问虚函数时,编译器根据指针所指对象的类型决定要调用哪个函数(动态联编),而与指针本身的类型无关。

    例2:

#include <iostream>
using namespace std;

class A

{
public:

	virtual void f() { cout << "A" << ""; }

};

class B:public A

{ public:

	virtual void f() { cout << "B" << endl; }

};

int main()
{
		   A *pa = NULL;

		   A a; B b;

		   pa = &a; pa->f();

		   pa = &b; pa->f();
                   return 0;
 }

    该程序的运行结果为:A  B

    从程序的运行结果可以看出,将基类A中的函数f()定义为虚函数后,当指针指向不同对象时执行了不同的操作,实现了动态联编。

    4. 使用引用访问虚函数,与使用指针访问虚函数类似,不同的是,引用一经声明后,引用变量本身无论如何改变,其调用的函数就不会再改变,始终指向其开始定义时的函数。因此,在使用上有一定限制,但这在一定程度上提高了代码的安全性,特别体现在函数参数传递等场合中,可以将引用理解为一种“受限制的指针”。

    例3:

#include <iostream>
using namespace std;

class A

{
public:

	virtual void f() { cout << "A" << ""; }

};

class B:public A

{ public:

	virtual void f() { cout << "B" << endl; }

};

int main()
{
	 B b;
	 A &pa = b; pa.f();
	 return 0;
}

    该程序的运行结果为: B

总结如下:

    C++中的函数调用默认不使用动态绑定,要触发动态绑定,需满足两个条件:

    1)只有指定为虚函数的成员函数才能进行动态绑定,成员函数默认为非虚函数,非虚函数不进行动态绑定;

    2)必须通过基类类型的引用或指针进行函数调用。

疑难解答:

    哪些函数不能为虚函数?

    普通函数(非成员函数)、静态成员函数、构造函数、友元函数,而内联成员函数和赋值操作符重载函数即使声明为虚函数也无意义。  

    1)为什么C++不支持普通函数为虚函数?

    普通函数(非成员函数)只能被overload(重载),不能被override(覆盖),声明为虚函数也没有什么意义,因此编译器会在编译时绑定函数。

    2)为什么C++不支持静态成员函数为虚函数?

    静态成员函数对于每个类来说只有一份代码,所有的对象都共享这一份代码,它不归某个具体对象所有,所以她没有要动态绑定的必要性。

    3)构造函数为什么不能为虚函数?

    虚函数是在不同类型的对象产生不同的动作,现在对象还没产生,如何使用虚函数来完成你想完成的动作。

    4)为什么C++不支持友元函数为虚函数?

    因为C++不支持友元函数的继承,没有实现为虚函数的必要。

    5)为什么C++不支持内联成员函数和赋值操作符重载函数为虚函数?

    这两种函数声明为虚函数时,虽然不会报错,但是毫无意义。

    内联函数:内联函数是为了在代码中直接展开,减少函数调用花费的代价,虚函数是为了在继承后,对象能够准确地执行自己的动作,这是不可能统一的。即使虚函数被声明为内联函数,编译器遇到这种情况根本不会把这样的函数内联展开,而是当作普通函数处理。

    赋值运算符:虽然可以在基类中将成员函数operator=定义为虚函数,但这样做没有意义。赋值运算符重载要求形参和类本身类型相同,故基类中的赋值操作符形参类型为基类类型,即使声明为虚函数,也不能作为子类的赋值操作符。

1.4 构造函数和析构函数中的虚函数

    构造派生类对象时,首先运行基类构造函数初始化对象的基类部分。在执行基类构造函数时,对象的派生类部分是未初始化的。实际上,此时对象还不是一个派生类对象。

    撤销派生类对象时,首先撤销它的派生类部分,然后按照与构造顺序的逆顺序撤销它的基类部分。

    在这两种情况下,运行构造函数或析构函数时,对象都是不完整的。为了适应这种不完整,编译器将对象的类型视为在构造或析构期间发生了变化。在基类构造函数或析构函数中,将派生类对象当作基类对象对待。

    如果在构造函数或析构函数中调用虚函数,则运行的是为构造函数或析构函数自身类型定义的版本。

    例4:

#include <iostream>

 class Base
 {
	 public:
		    Base() { Foo(); }   // 打印 1
		
			     virtual void Foo()
			     {
			         std::cout << 1 << std::endl;
			     }
 };

 class Derive : public Base
 {
	 public:
		     Derive() : Base(), m_pData(new int(2)) {}
		     ~Derive() { delete m_pData; }
		
			     virtual void Foo()
			     {
			         std::cout << *m_pData << std::endl;
			     }
		 private:
			     int* m_pData;
 };

 int main()
 {
	     Base* p = new Derive();
	     delete p;
	     return 0;
 }

    这里的结果将打印:1。

    这表明第6行执行的的是Base::Foo()而不是Derive::Foo(),也就是说:虚函数在构造函数中“不起作用”。为什么?

    当实例化一个派生类对象时,首先进行基类部分的构造,然后再进行派生类部分的构造。即创建Derive对象时,会先调用Base的构造函数,再调用Derive的构造函数。

    当在构造基类部分时,派生类还没被完全创建,从某种意义上讲此时它只是个基类对象。即当Base::Base()执行时Derive对象还没被完全创建,此时它被当成一个Base对象,而不是Derive对象,因此Foo绑定的是Base的Foo。

    C++之所以这样设计是为了减少错误和Bug的出现。假设在构造函数中虚函数仍然“生效”,即Base::Base()中的Foo();所调用的是Derive::Foo()。当Base::Base()被调用时派生类中的数据m_pData还未被正确初始化,这时执行Derive::Foo()将导致程序对一个未初始化的地址解引用,得到的结果是不可预料的,甚至是程序崩溃(访问非法内存)。

    总结来说:基类部分在派生类部分之前被构造,当基类构造函数执行时派生类中的数据成员还没被初始化。如果基类构造函数中的虚函数调用被解析成调用派生类的虚函数,而派生类的虚函数中又访问到未初始化的派生类数据,将导致程序出现一些未定义行为和bug。

    在析构函数中调用虚函数,和在构造函数中调用虚函数一样。

    析构函数的调用跟构造函数的调用顺序是相反的,它从最派生类的析构函数开始的。也就是说当基类的析构函数执行时,派生类的析构函数已经执行过,派生类中的成员数据被认为已经无效。假设基类中虚函数调用能调用得到派生类的虚函数,那么派生类的虚函数将访问一些已经“无效”的数据,所带来的问题和访问一些未初始化的数据一样。而同样,我们可以认为在析构的过程中,虚函数表也是在不断变化的。

    结论:

    不能在构造函数或析构函数中调用虚函数!

2 虚函数表指针(vptr)及虚基类表指针(bptr)

2.1 含静态变量、虚函数和字节对齐的类的空间计算

    1. 空类的sizeof为1。

    空类也能实例化,而实例化的对象都有内存的地址,因此最少要1个字节来表示实例在对象中的位置。

    例5:sizeof(CEmpty)=1;

class CEmpty
{
};

    2. 静态成员不占用sizeof的大小, 因为静态类成员是存储在静态存储区, 所有的实例和类共享。

    例6:sizeof(CStaticMember)=1;

class CStaticMember
{
	static int a;
};

    3. 当类中声明了虚函数(不管是1个还是多个),那么在实例化对象时,编译器会自动在对象里安插一个指针vPtr指向虚函数表VTabl,占4个字节。

    例7:sizeof(CStaticMember)=4;

class CVirtual() {
	CVirtual();
	virtual ~CVirtual();
}

    4. 字节对齐(缺省情况)

    现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定变量的时候经常在特定的内存地址访问,这就需要各类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。

    C编译器的缺省字节对齐方式(自然对界):

    在缺省情况下,C编译器为每一个变量或是数据单元按其自然对界条件分配空间。
    在结构中,编译器为结构的每个成员按其自然对界(alignment)条件分配空间。各个成员按照它们被声明的顺序在内存中顺序存储(成员之间可能有插入的空字节),第一个成员的地址和整个结构的地址相同。

    C编译器缺省的结构成员自然对界条件为:

  “N字节对齐”,N即该成员数据类型的长度。如int型成员的自然对界条件为4字节对齐,而double类型的结构成员的自然对界条件为8字节对齐。若该成员的起始偏移不位于该成员的“默认自然对界条件”上,则在前一个节面后面添加适当个数的空字节。

    C编译器缺省的结构整体的自然对界条件为:

    该结构所有成员中要求的最大自然对界条件。若结构体各成员长度之和不为“结构整体自然对界条件的整数倍,则在最后一个成员后填充空字节。

    基本数据类型所占内存大小:


    以下例子皆适用于32位机器编译环境。

    例8(分析结构各成员的默认字节对界条界条件和结构整体的默认字节对界条件):

struct Test
{
	char x1; // 成员x1为char型(其起始地址必须1字节对界),其偏移地址为0 

	char x2; // 成员x2为char型(其起始地址必须1字节对界,其偏移地址为1 

	float x3; // 成员x3为float型(其起始地址必须4字节对界),编译器在x2和x3之间填充了两个空字节,其偏移地址为4 

	char x4; // 成员x4为char型(其起始地址必须1字节对界),其偏移地址为8 
};

    因为Test结构体中,最大的成员为flaot x3,因些此结构体的自然对界条件为4字节对齐。则结构体长度就为12字节,内存布局为1100 1111 1000。

    例9

typedef struct
{
	int aa1;                       //4个字节对齐 1111
	char bb1;                      //1个字节对齐 1
	short cc1;                     //2个字节对齐 011
	char dd1;                      //1个字节对齐 1
} testlength1;
int length1 = sizeof(testlength1); //4个字节对齐,占用字节1111 1011 1000,length = 12

typedef struct
{
	char bb2;                      //1个字节对齐 1
	int aa2;                       //4个字节对齐 01111
	short cc2;                     //2个字节对齐 11
	char dd2;                      //1个字节对齐 1
} testlength2;
int length2 = sizeof(testlength2); //4个字节对齐,占用字节1000 1111 1110,length = 12


typedef struct
{
	char bb3;                      //1个字节对齐 1
	char dd3;                      //1个字节对齐 1
	int aa3;                       //4个字节对齐 001111
	short cc23                     //2个字节对齐 11

} testlength3;
int length3 = sizeof(testlength3); //4个字节对齐,占用字节1100 1111 1100,length = 12


typedef struct
{
	char bb4;                      //1个字节对齐 1
	char dd4;                      //1个字节对齐 1
	short cc4;                     //2个字节对齐 11
	int aa4;                       //4个字节对齐 1111
} testlength4;
int length4 = sizeof(testlength4); //4个字节对齐,占用字节1111 1111,length = 8

5. 字节对齐(使用伪指令#pragma pack (n))

    改变缺省的对界条件(指定对界)

    · 使用伪指令#pragma pack (n),C编译器将按照n个字节对齐。

    · 使用伪指令#pragma pack (),取消自定义字节对齐方式。

    这时,对齐规则为:

    1)数据成员对齐规则:结构(struct)(或联合(union))的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员的对齐按照#pragma pack指定的数值和这个数据成员自身长度中,比较小的那个进行。

    2)结构(或联合)的整体对齐规则:在数据成员完成各自对齐之后,结构(或联合)本身也要进行对齐,对齐将按照#pragma pack指定的数值和结构(或联合)最大数据成员长度中,比较小的那个进行。

    结合1)和2)推断:当#pragma pack的n值等于或超过所有数据成员长度的时候,这个n值的大小将不产生任何效果。

    例10:

#pragma pack(2)
typedef struct
{
	int aa1;                       //2个字节对齐 1111
	char bb1;                      //1个字节对齐 1
	short cc1;                     //2个字节对齐 011
	char dd1;                      //1个字节对齐 1
} testlength1;
int length1 = sizeof(testlength1); //2个字节对齐,占用字节11 11 10 11 10,length = 10

typedef struct
{
	char bb2;                      //1个字节对齐 1
	int aa2;                       //2个字节对齐 01111
	short cc2;                     //2个字节对齐 11
	char dd2;                      //1个字节对齐 1
} testlength2;
int length2 = sizeof(testlength2); //2个字节对齐,占用字节10 11 11 11 10,length = 10


typedef struct
{
	char bb3;                     //1个字节对齐 1
	char dd3;                     //1个字节对齐 1
	int aa3;                      //2个字节对齐 11 11
	short cc23                    //2个字节对齐 11

} testlength3;
int length3 = sizeof(testlength3); //2个字节对齐,占用字节11 11 11 11,length = 8


typedef struct
{
	char bb4;                     //1个字节对齐 1
	char dd4;                     //1个字节对齐 1
	short cc4;                    //2个字节对齐 11
	int aa4;                      //2个字节对齐 11 11
} testlength4;
int length4 = sizeof(testlength4); //2个字节对齐,占用字节11 11 11 11,length = 8


2.2 虚函数表vftbl和虚函数表指针vfptr

    参考博客

2.3 虚基类和虚基类表指针vbptr

    C++虚基类详解

    在虚拟继承基类的子类中,子类会增加某种形式的指针,或者指向虚基类子对象,或者指向一个相关的表格。表格中存放的不是虚基类子对象的地址,就是其偏移量。此指针被称为虚基类表指针。

2.4 虚拟继承时构造函数的书写

    参考博客

2.5 纯虚函数

    参考博客

    纯虚函数作用:在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。

    抽象类:凡是含有纯虚函数的类称为抽象类。这种类不能声明对象,只是作为基类为派生类服务。除非在派生类中完全实现基类中所有的纯虚函数,否则,派生类也是抽象类,不能实例化对象;只定义了protected型构造函数的类也是抽象类。对一个类来说,如果只定义了protected类型的构造函数而没有提供public构造函数,无论是在外部还是在派生类中都不能创建该类的对象,但可以由其派生出新的类,这张能派生出新类,却不能创建自己对象的类是另一种形式的抽象类。

    抽象类不能定义对象,但是可以作为指针或者引用类型使用。

    抽象类中的纯虚函数没有具体的实现,所以没办法实例化。

3 动态运行时类型识别和显式转换

    参考博客1

    参考博客2







猜你喜欢

转载自blog.csdn.net/qq_27022241/article/details/80055148