虚函数

virtual修饰的成员函数被称为虚函数,虚函数的地址会被纳入类的虚函数表(virtual function table)。inline 和 virtual不会同时生效,用virtual修饰内联函数时,编译器会忽视函数的内联属性,此时函数不再是内联。虚函数一定不是内联函数。

虚函数的重写

子类继承父类,并有一个与父类形式相同(函数名、参数、返回值)的虚函数,称子类重写了父类的虚函数。虚函数被重写后,产生一个新的函数地址,子类中被重写的虚函数与父类的虚函数地址不同。虚函数的重写本质上重写的是函数体的内容,函数形式完全相同。

虚函数重写的条件是子类与父类中的虚函数的函数名、参数、返回值相同,但有两个例外情况:协变和虚拟析构函数。

  • 协变:子类和父类虚函数的返回值可以不同,但是必须为父子类关系的指针或者引用。
  • 虚拟析构函数:用virtual修饰析构函数,构成虚函数重写,这是因为在C++中,析构函数在编译后会被统一处理成destructor(),符合重写的基本条件。将析构函数统一处理并使其支持多态,可以避免通过指向子类的父类指针调用析构时的不完全析构问题,从而避免内存泄漏。在实际中,应尽量对析构函数使用virtual修饰。

在C++11中,使用final关键字修饰虚函数,不允许虚函数被重写;使用override关键字修饰虚函数,可以帮助子类检查虚函数是否完成重写。override的作用发生在编译阶段,如果重写成功,则编译通过,否则编译不通过。

C++类和对象_多态_虚函数表

虚函数表

所有虚函数都会被纳入类对象的虚函数表,在存在虚函数的类中,对象会有一个虚函数表指针指向这个表。虚函数表存储的是虚函数的地址,子类重写父类的虚函数后,会将新的虚函数地址纳入子类的虚函数表。因为静态成员函数没有this指针,不能拿到虚函数表,所以静态成员函数不能被virtual修饰

虚函数重写的本质其实是子类重写了父类虚函数的实现,而使用父类虚函数的函数名、参数、返回值。可以认为重写是一种接口继承,重写 = 拷贝(父类的虚函数表) + 覆盖(父类的虚函数地址) + 追加(自己的虚函数地址)。

虚函数表的存在,可以使编译器忽略函数调用者(的指针或引用)的具体类型,而直接访问虚函数表拿到函数地址并调用对应函数。下文会看到,虚函数表的存在是多态实现的关键支持因素。

类的虚函数表:

C++类和对象_多态_纯虚函数_02

虚函数表是类层面的,同类对象共用虚函数表,虚函数表存储在常量区中,这是因为虚函数表不允许被修改。

C++多态

两种多态

当进行某种行为时,不同的对象会产生不同的不同的结果,这便是多态(polymorphism)。例如针对“买票”的行为,不同的对象(人群)票价不尽相同。

在C++种,大体上有两种类型的多态:静态多态和动态多态。静态多态又称编译时多态,这种多态在编译时就确定了具体的行为,例如函数重载,在编译时确定函数地址。动态多态又称运行时多态,这种多态的具体行为无法在编译时确定,而是需要在运行时进行动态绑定,从虚函数表中寻找函数地址并执行。多态调用看的是被引用/被指向的对象类型,而普通调用看的是调用者的类型。下文讨论的即是动态多态及其原理。

多态的两个条件

C++多态有两个条件:在继承体系中,调用的函数必须是被重写的虚函数;调用者必须是父类的指针或者引用。

反观虚函数表

了解多态的行为后,这里探究多态的底层实现原理。首先观察当进行虚函数重写后,子类的虚函数表发生了什么变化:

class A
{
public:
	virtual void func()
	{
		cout << "A::func()" << endl;
	}

	virtual void func_2()
	{
		cout << "A::func_2()" << endl;
	}
};

class B : public A
{
public:
	virtual void func()
	{
		cout << "B::func()" << endl;
	}
};

C++类和对象_多态_抽象类_03

正如上文所说的,当虚函数被重写后,子类会用新的虚函数地址在虚函数表中进行覆盖,而未被重写的虚函数依然保持与原来的地址相同。至此,已经可以大概认识到多态的实现机制:如果调用的是虚函数,则不管调用者是谁,而是在对象的虚函数表中查找虚函数的地址,通过地址调用对应的函数。

通过观察多态调用的汇编代码(为了便于观察,下文用32位机器模拟),可以证明事实确实如此:

void function(A* p)
{
	p->func();
}

int main()
{
	A a;
	B b;
	function(&a);
	function(&b);
	return 0;
}

C++类和对象_多态_虚函数_04

了解虚函数表和汇编后,可以梳理出多态调用的原理如下:

C++类和对象_多态_虚函数_05

至此可以解释和总结多态的诸多条件:

  • 调用的函数为什么必须是重写的虚函数?进行虚函数重写后,子类的会将重写的虚函数地址进行覆盖,以与父类的虚函数地址进行区分,进而进行区别调用。
  • 为什么不能通过子类的指针或引用调用虚函数?这是因为父类指针即可以维护父类对象,也可以维护子类对象,对于父类对象,直接通过虚函数表指针拿去虚函数表即可,对于子类对象,父类指针或引用也可以方便地访问父类部分,拿取虚函数表指针。子类指针或引用则不具备这种能力。

C++类和对象_多态_虚函数表_06

  • 为什么不能通过父类对象调用而必须通过指针或引用?须知多态的前提是: 在面对父类/子类时,拿到的是父类/子类的虚函数表。当子类对象赋值给父类对象时,虚函数表不进行拷贝,父类对象接受的虚函数表依然是父类的虚函数表。对象赋值时虚函数表不拷贝的目的是为了避免父类的虚函数表被污染,而通过指针或引用接受对象则不会发生拷贝,可以直接维护传来的对象的虚函数表。

C++类和对象_多态_纯虚函数_07

多继承中的多态

在多继承中,子类会有多个虚函数表,每个父类部分含一个虚函数表。子类独有的虚函数追加到第一个父类的虚函数表中。

C++类和对象_多态_虚函数_08

由于在实际中应尽量避免进行菱形继承,所以菱形继承的对象模型在这里不予讨论。

纯虚函数和抽象类

在虚函数后面加上= 0,这个函数即为纯虚函数。包含纯虚函数的类叫做抽象类。抽象类不能实例化出对象,只有在继承抽象类,并且重写抽象类的纯虚函数后,才能用子类实例化出对象。

class train
{
public:
	virtual void ride() = 0; //ride()是一个纯虚函数
};

class bullet_train : public train
{
public:
	virtual void ride()
	{
		cout << "cheap" << endl;
	}
};

class high_speed_train : public train
{
public:
	virtual void ride()
	{
		cout << "fast" << endl;
	}
};

void test()
{
	train* p_bullet_train = new bullet_train();
	p_bullet_train->ride(); //这是一个抽象类的多态调用
	train* p_high_speed_train = new high_speed_train();
	p_high_speed_train->ride();
}

抽象类又称接口类,往往对行为进行定义,抽象类体现出了接口继承关系。抽象类强制了对虚函数的重写。