C++ 之多态技术最佳实践与常见问题总结

简介

多态性(polymorphism)是面向对象设计语言的基本特征之一。仅仅是将数据和函数捆绑在一起,进行类的封装,使用一些简单的继承,还不能算是真正应用了面向对象的设计思想。多态性是面向对象的精髓。多态性可以简单地概括为“一个接口,多种方法”或叫“父类型引用或指针指向子类型对象”。

多态按字面意思就是多种形态。比如说:警车鸣笛,普通人反应一般,但逃犯听见会大惊失色,拔腿就跑。

通常是指对于同一个消息、同一种调用,在不同的场合,针对不同的对象下,执行不同的行为 。

为什么要用多态?

我们知道,封装可以隐藏实现细节,使得代码模块化;继承可以扩展已存在的代码模块(类)。它们的目的都是为了代码重用。而多态除了代码的复用性外,还可以解决项目中紧偶合的问题,提高程序的可扩展性。

如果项目耦合度很高的情况下,维护代码时修改一个地方会牵连到很多地方,会无休止的增加开发成本。而降低耦合度,可以保证程序的扩展性。而多态对代码具有很好的可扩充性。增加新的子类不影响已存在类的多态性、继承性,以及其他特性的运行和操作。实际上新加子类更容易获得多态功能。例如,在实现了圆锥、半圆锥以及半球体的多态基础上,很容易增添球体类的多态性。

C++支持两种多态性:编译时多态和运行时多态

编译时多态:也称为静态多态,我们之前学习过的函数重载、运算符重载就是采用的静态多态,C++编译器根据传递给函数的参数和函数名决定具体要使用哪一个函数,又称为先期联编(early binding)

运行时多态:在一些场合下,编译器无法在编译过程中完成联编,必须在程序运行时完成选择,因此编译器必须提供这么一套称为“动态联编”(dynamic binding)的机制,也叫晚期联编(late binding)

C++通过虚函数来实现动态联编。

接下来,我们提到的多态,不做特殊说明,指的就是动态多态。

虚函数的定义

什么是虚函数呢?虚函数就是在基类中被声明为virtual,并在一个或多个派生类中被重新定义的成员函数。其形式如下:

// 类内部
class 类名
{
	virtual 返回类型 函数名(参数表)
	{
		//...
	}
};
//类之外
virtual 返回类型 类名::函数名(参数表)
{
	//...
}

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

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

1、与基类的虚函数有相同的参数个数
2、与基类的虚函数有相同的参数类型
3、与基类的虚函数有相同的返回类型

总结就是:除了函数的函数体可以不一样之外,其它的都必须一样

class Base
{
public:
	virtual void display()
	{
		cout << "Base::display()" << endl;
	}
	virtual void print()
	{
		cout << "Base::print()" << endl;
	}
};

class Derived
	: public Base
{
public:
	virtual void display()
	{
		cout << "Derived::display()" << endl;
	}
};
void test(Base* pbase)
{
	pbase->display();
}
int main()
{
	Base base;
	Derived derived;
	test(&base);
	test(&derived);
	return 0;
}

在这里插入图片描述

上面的例子中,对于test()函数,如果不管测试的结果,从其实现来看,通过类Base的指针pbase只能调用到Base类型的display函数;但最终的结果是25行的test调用,最终会调用到Derived类的display函数,这里就体现出虚函数的作用了,这是怎么做到的呢,或者说虚函数底层是的怎么实现的呢?

虚函数的实现机制

虚函数的实现是怎样的呢?简单来说,就是通过一张虚函数表(Virtual Fucntion Table)实现的。具体地讲,当类中定义了一个虚函数后,会在该类创建的对象的存储布局的开始位置多一个虚函数指针(vfptr),该虚函数指针指向了一张虚函数表,而该虚函数表就像一个数组表中存放的就是各虚函数的入口地址

先来看一个例子:

#include <iostream>
#include <string.h>
using namespace std;


class Base
{
public:
	virtual void print()
	{
		cout << "Base::print()" << endl;
	}

private:
	long _base;
};



class Derived
	: public Base
{
public:
	virtual void print()
	{
		cout << "Derived::print()" << endl;
	}
private:
	long _derived;
};



void test(Base* pbase)
{
	pbase->print();
}


int main()
{
	cout << "sizeof(Base) = " << sizeof(Base) << endl;
	cout << "sizeof(Derived) = " << sizeof(Derived) << endl;
	Base base;
	Derived derived;
	test(&base);
	test(&derived);
	return 0;
}

在这里插入图片描述

虚函数的原理如下图:

在这里插入图片描述

从上图可知,因为基类中定义了一个虚函数之后,在 base 对象存储布局的前面会多一个虚函数指针 vfptr,然后再加上自己的数据成员 _base。

而对于派生类本来就有一个 _base 和 _derived 两个数据成员,因为我们将基类中的虚函数给吸收过来了(继承),因此派生类中的 print 函数也是虚函数,所以派生类也会有一个 vfptr,所以 Base 类大小为 16 个字节,而 Derived 类大小为 24 个字节。

而虚函数指针会指向一张虚表,这个虚表是用来存储虚函数的入口地址的,因此其实就是一个函数指针数组。

对于上面的代码来说,Base 类中只有一个虚函数也就是 print,而 print 函数的入口地址本身也就是个指针,它会指向虚函数所存放在程序代码区中该函数所真正存放的位置。这就是上图中基类虚函数部分的基本逻辑。

对于 Derived 派生类而言,如果其没有覆盖 Base 基类的虚函数的话,上图的内存布局应该变化如下:

在这里插入图片描述

如果没有发生覆盖行为(重写函数体),那么在派生类中所产生的虚函数表内的虚函数 print 的入口地址就依然还是会指向原来在基类中所声明的虚函数的入口地址。但当我们改写函数体发生覆盖之后,派生类中的 print 虚函数就会改变指向转而指向派生类自己实现的虚函数所存在程序代码区中的真实函数体了,也就是上上张图所示的样子。

小结如下:

当基类定义了虚函数的时候,就会在基类的对象的存储布局前面多一个虚函数指针,该虚函数指针指向基类自己的虚函数表,该虚表存放的是虚函数的入口地址;当派生类继承基类的时候,会吸收基类中的虚函数,此时派生类自己也会有自己的虚函数,有虚函数就会在派生类对象的存储布局前面产生一个虚函数指针,该虚函数指针会指向派生类自己的虚函数表,派生类自己的虚表存放的是自己的虚函数的入口地址,如果此时派生类重定义了该虚函数,就会用派生类自己的虚函数的地址去覆盖从基类吸收过来的虚函数的入口地址。

当一个基类中设有虚函数,而一个派生类继承了该基类,并对虚函数进行了重定义,我们称之为覆盖(override). 这里的覆盖指的是派生类的虚函数表中相应虚函数的入口地址被覆盖。

那么虚函数机制是如何被激活的呢,或者说动态多态是怎么表现出来的呢?从上面的例子,可以得出结论:

  1. 基类定义虚函数
  2. 派生类重定义(覆盖、重写)虚函数
  3. 创建派生类对象
  4. 基类的指针指向派生类对象
  5. 基类指针调用虚函数

哪些函数不能被设置为虚函数?

  1. 普通函数(非成员函数):定义虚函数的主要目的是为了重写达到多态,所以普通函数声明为虚函数没有意义,因此编译器在编译时就绑定了它。
  2. 静态成员函数:静态成员函数对于每个类都只有一份代码,所有对象都可以共享这份代码,他不归某一个对象所有,所以它也没有动态绑定的必要。(静态函数发生在编译时,虚函数体现多态发生在运行时)
  3. 内联成员函数:内联函数本就是为了减少函数调用的代价,所以在代码中直接展开。但虚函数一定要创建虚函数表,这也就使得函数失去了内联的含义(就和普通函数没什么区别了),这两者不可能统一。另外,内联函数在编译时被展开,而虚函数在运行时才动态绑定。
  4. 构造函数:这个原因很简单,主要从语义上考虑。因为构造函数本来是为了初始化对象成员才产生的,然而虚函数的目的是为了在完全不了解细节的情况下也能正确处理对象,两者根本不能“ 好好相处 ”。因为虚函数要对不同类型的对象产生不同的动作,如果将构造函数定义成虚函数,那么对象都没有产生,怎么完成想要的动作呢?另外构造函数发生的时机在编译的时候,而虚函数要体现动态多态,发生的时机在运行时候;如果将构造函数设置为虚函数,那么就需要在虚表中存放虚函数的入口地址,需要通过虚函数指针找到虚表,而虚函数指针位于对象的内存布局的前面,而构造函数不调用,对象本身是不完整的(初始化还没有成功,在内存中的布局还不完整),就不知道虚函数指针是否存在,就不能找到虚表,就不能调用虚函数。最后,从继承角度看,构造函数不能被继承,而虚函数是可以被继承的。
  5. 友元函数:当我们把一个函数声明为一个类的友元函数时,它只是一个可以访问类内成员的普通函数,并不是这个类的成员函数,自然也不能在该类的内部将它声明为虚函数。(但是当友元函数是成员函数的时候是可以设置为虚函数的,比如在自己类里面设置为虚函数,但是作为另一个类的友元)

虚函数的访问

指针访问

使用指针访问非虚函数时,编译器根据指针本身的类型决定要调用哪个函数,而不是根据指针指向的对象类型;

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

引用访问

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

对象访问

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

成员函数中访问

在类内的成员函数中访问该类层次中的虚函数,采用动态联编,要使用this指针。

#include <iostream>
#include <string.h>

using namespace std;


class Base
{
public:
	virtual void print()
	{
		cout << "Base::print()" << endl;
	}

	//在类的成员函数中调用虚函数
	void func1() {
		print();
	}

	void func2() {
		Base::print();
	}

private:
	long _base;
};



class Derived
	: public Base
{
public:
	virtual void print()
	{
		cout << "Derived::print()" << endl;
	}
private:
	long _derived;
};


int main()
{
	Base base;
	Derived derived;
	
	Base* pbase1 = &base;
	pbase1->func1();
	pbase1->func2();

	cout << endl;

	Base* pbase2 = &derived;
	pbase2->func1();
	pbase2->func2();

	return 0;
}

在这里插入图片描述

构造函数和析构函数中访问

构造函数和析构函数是特殊的成员函数,在其中访问虚函数时,C++采用静态联编,即在构造函数或析构函数内,即使是使用“this->虚函数名”的形式来调用,编译器仍将其解释为静态联编的“本类名::虚函数名”。即它们所调用的虚函数是自己类中定义的函数,如果在自己的类中没有实现该函数,则调用的是基类中的虚函数。但绝不会调用任何在派生类中重定义的虚函数

class Grandpa
{
public:
	Grandpa()
	{
		cout << "Grandpa()" << endl;
	}
	~Grandpa()
	{
		cout << "~Grandpa()" << endl;
	}
	virtual
		void func1()
	{
		cout << "Grandpa::func1()" << endl;
	}
	virtual
		void func2()
	{
		cout << "Grandpa::func2()" << endl;
	}
};

class Father
	: public Grandpa
{
public:
	Father()
	{
		cout << "Father()" << endl;
		func1();
	}
	~Father()
	{
		cout << "~Father()" << endl;
		func2();
	}
	virtual
		void func1()
	{
		cout << "Father::func1()" << endl;
	}
	virtual
		void func2()
	{
		cout << "Father::func2()" << endl;
	}
};
class Son
	: public Father
{
public:
	Son()
	{
		cout << "Son()" << endl;
	}
	~Son()
	{
		cout << "~Son()" << endl;
	}
	virtual void func1()
	{
		cout << "Son::func1()" << endl;
	}
	virtual void func2()
	{
		cout << "Son::func2()" << endl;
	}
};
void test()
{
	Son son;
}

在这里插入图片描述

为什么会产生上面的现象?

其实很好分析,在 Son() 构造函数执行之前势必要先执行基类的构造函数,因此在基类的构造尚未成功之前,派生类的构造函数是不可能先执行的,也就意味着派生类还未产生,所以不会执行派生类中的 func 函数而是执行本类的 func 函数(因为本类 Father 已经覆盖重写了基类 Grandpa 中的 func 函数)。销毁时也是一样的分析方法,因为先销毁的是派生类,然后才是销毁基类,因此在基类 Father 中析构函数执行的依然是本类的 func 函数而不是子类 Son ,因为 Son 已经销毁了。

另外补充一个小知识点,从上面的代码中我们其实还可以判断出一件事情,那就是类的初始化完成的时间节点应该是在类的构造函数的函数体的第一个{ 出现的时候,也就是初始化表达式结束之后,函数体开始执行之前:

class Father{
	//other
	Father()
		{
			cout << "Father()" << endl;
			func1();
		}
	//other
};

为什么可以做出这样的判断?因为在上面的代码中我们发现 func1 函数被执行了,而这个函数的执行是肯定有一个 this 指针对它进行调用的,众所周知 this 指针是指向当前类的对象的,因此只有类完成初始化了才能够产生类的对象啊。不过这都是我们猜测的说法,因为现在并没有哪本书或者官方有对这个问题进行说明。

接下来,我们看一个练习,大家思考一下,会输出什么呢?

//主要考虑点在绑定时机
class A
{
public:
	virtual
		void func(int val = 1)
	{
		cout << "A->" << val << endl;
	}
	virtual void test()
	{
		func();
	}
private:
	long _a;
};
class B
	: public A
{
public:
	virtual
		void func(int val = 10)
	{
		cout << "B->" << val << endl;
	}
private:
	long _b;
};
int main(void)
{
	B b;
	//之前的写法:A* p1 = &b;其实是执行的隐式类型转换,和下面这一行效果是一样的
	A* p1 = (A*)&b;
	B* p2 = &b;
	p1->func();
	p2->func();
	return 0;
}

在这里插入图片描述

为什么结果会是这样?

简单的说就是因为函数重载的时候是不看参数列表中的参数默认值的,而同时参数默认值又是在编译时就确定了的,因此哪怕程序还没执行编译器实际上就已经知道了类型 A 的 func 这个函数的这个 int 类型的参数 val 的默认值为 1,B 类型的 func 函数的参数 val 默认值为 10(编译器准备好了所有的静态内容,就等运行了),而多态是在运行时才可以确定的因此如果我们在运行时没有给该参数传别的值的话那么编译器就会按照之前编译好的内容进行 val 变量的解释。

画图来解释吧,首先是类 A 的内存分布图,在静态编译时也就是程序还没有开始运行时实际上下面的内容编译器就已经准备好了:

在这里插入图片描述

然后再来看类 B 在静态编译时产生的内存分布图,假设此时还没有重写 func 函数:

在这里插入图片描述

当重写了 func 函数之后:

在这里插入图片描述

从上述过程中不难看出,在静态编译时 func 函数参数的默认值就已经被静态编译进去了,而多态机制则是在程序运行时才能够产生出效果,因此就是由于下面这四行代码中的两种不同的声明定义方式

	A* p1 = (A*)&b;
	B* p2 = &b;
	p1->func();
	p2->func();

才导致了最后的输出结果由于默认参数值的不同而不同:

在这里插入图片描述

用基类 A 类型指针进行指向时执行的就还是 A 类型静态编译时的虚函数 func,而用派生类 B 类型指针进行指向时执行的就还是 B 类型静态编译时的虚函数 func;两个 func 函数的函数类型是一样的,都是 void(int) ,只是含有的默认参数不一样。

退一万步讲,如果没有 virtual 关键字的话,那么 p1 指向调用的不就是类 A 的 void func( int val = 1) 的函数吗?指针 p2 也是,指向调用的不就是类 B 的 void func(int val = 10) 的函数吗?而这全部都是在静态编译时就已经决定了的,因此加上 virtual 关键字之后就只是多了一个多态的效果而已,而多态效果仅仅就是让这两个 func 函数可以在程序运行时根据函数体的不同做不同的事情。

总而言之就一句话:真正去进行体现出动态多态的应该是虚函数的函数体,而不是其它的东西。只有虚函数的函数体才有可能在运行时确定,而其它的全部在编译时就已经确定了,包括函数的默认参数

最后如果传的有参数的话还需要分析吗,显然是不用的,此时默认参数就不会被使用到了,那么之前说的多态是啥样的那么现在就是啥样的呗,我们可以试一下:

	A* p1 = (A*)&b;
	B* p2 = &b;
	p1->func(10000);
	p2->func(2000);

结果如下:

在这里插入图片描述

纯虚函数

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

class 类名
{
public:
	virtual 返回类型 函数名(参数包) = 0;
};

设置纯虚函数的意义,就是让所有的类对象(主要是派生类对象)都可以执行纯虚函数的动作,但类无法为纯虚函数提供一个合理的缺省实现。所以类纯虚函数的声明就是在告诉子类的设计者,“你必须提供一个纯虚函数的实现,但我不知道你会怎样实现它”。

class Base
{
public:
	//纯虚函数,声明了虚函数,但是没有实现
	virtual void display() = 0;
};
class Derived
	: public Base
{
public:
	virtual void display()
	{
		cout << "Derived::display()" << endl;
	}
};

声明纯虚函数的目的在于,提供一个与派生类一致的接口

class Figure
{
public:
	virtual void display() const = 0;
	virtual double area() const = 0;
};

class Circle
	: public Figure
{
public:
	explicit Circle(double radius)
		: _radius(radius)
	{
	}
	void display() const
	{
		cout << "Circle";
	}
	double area() const
	{
		return 3.14159 * _radius * _radius;
	}
private:
	double _radius;
};

class Rectangle
	: public Figure
{
public:
	Rectangle(double length, double width)
		: _length(length)
		, _width(width)
	{
	}
	void display() const
	{
		cout << "Rectangle";
	}
	double area() const
	{
		return _length * _width;
	}
private:
	double _length;
	double _width;
};

class Triangle
	: public Figure
{
public:
	Triangle(double a, double b, double c)
		: _a(a)
		, _b(b)
		, _c(c)
	{
	}
	void display() const
	{
		cout << "Triangle";
	}
	//海伦公式计算三角形的面积
	double area() const
	{
		double p = (_a + _b + _c) / 2;
		return sqrt(p * (p - _a) * (p - _b) * (p - _c));
	}
private:
	double _a;
	double _b;
	double _c;
};

抽象类

一个类可以包含多个纯虚函数。只要类中含有一个纯虚函数,该类便为抽象类一个抽象类只能作为基类来派生新类,不能创建抽象类的对象

和普通的虚函数不同,在派生类中一般要对基类中纯虚函数进行重定义。如果该派生类没有对所有的纯虚函数进行重定义,则该派生类也会成为抽象类。这说明只有在派生类中给出了基类中所有纯虚函数的实现时,该派生类才不再是抽象类。

除此以外,还有另外一种形式的抽象类。对一个类来说,如果只定义了 protected 型的构造函数而没有提供 public 构造函数,无论是在外部还是在派生类中作为其对象成员都不能创建该类的对象,但可以由其派生出新的类,这种能派生新类,却不能创建自己对象的类是另一种形式的抽象类

class Base
{
protected:
	Base(long base)
		: _base(base)
	{
		cout << "Base()" << endl;
	}
protected:
	long _base;
};
class Derived
	: public Base
{
public:
	Derived(long base, long derived)
		: Base(base)
		, _derived(derived)
	{
		cout << "Derived(long, long)" << endl;
	}
	void print() const
	{
		cout << "_base:" << _base
			<< ", _derived:" << _derived << endl;
	}
private:
	long _derived;
};
void test()
{
	Base base(1);//error
	Derived derived(1, 2);
}

最后还有一点要注意:虽然不能创建抽象类的对象,但是可以定义抽象类的指针或者引用

虚析构函数

虽然构造函数不能被定义成虚函数,但析构函数可以定义为虚函数,一般来说,如果类中定义了虚函数,析构函数也应被定义为虚析构函数,尤其是类内有申请的动态内存,需要清理和释放的时候。

class Base
{
public:
	Base(const char* pbase)
		: _pbase(new char[strlen(pbase) + 1]())
	{
		cout << "Base(const char *)" << endl;
		strcpy(_pbase, pbase);
	}

	virtual
	void print() {
		cout << "Base::pbase = " << _pbase << endl;
	}

	/*virtual*/
	~Base()
	{
		if (_pbase)
		{
			delete[] _pbase;
			_pbase = nullptr;
		}
		cout << "~Base()" << endl;
	}
private:
	char* _pbase;
};


class Derived
	: public Base
{
public:
	Derived(const char* pbase, const char* pderived)
		: Base(pbase)
		, _pderived(new char[strlen(pderived) + 1]())
	{
		cout << "Derived(const char *, const char *)" << endl;
		strcpy(_pderived, pderived);
	}

	virtual
		void print() {
		cout << "Derived::pderived = " << _pderived << endl;
	}

	~Derived()
	{
		cout << "~Derived()" << endl;
		if (_pderived)
		{
			delete[] _pderived;
			_pderived = nullptr;
		}
	}
private:
	char* _pderived;
};


void test()
{
	Base* pbase = new Derived("hello", "wuhan");
	pbase->print();
	delete pbase;
}

在这里插入图片描述

如上,在例子中,如果基类Base的析构函数没有设置为虚函数, 则在执行delete pbase;语句时,不会调用派生类 Derived 的析构函数,这样就会造成内存泄漏。此时,将基类Base的析构函数设置为虚函数,就可以解决该问题

除了上面这种方式,还有下面这种方式也可以解决,就是将 pbase 指针向下转型成派生类类型:

delete dynamic_cast<Derived*>(pbase);

转型之后编译器就知道 delete 的是派生类中的析构函数,在处理完派生类中的析构函数之后编译器就会去处理基类的析构函数,就不会有内存泄漏了(但这不是推荐的做法,它不方便也不雅观,总不能每个派生类都这样写一次吧,因此知道有这个事情即可)。

如果有一个基类的指针指向派生类的对象,并且想通过该指针delete派生类对象,系统将只会执行基类的析构函数,而不会执行派生类的析构函数。为避免这种情况的发生,往往把基类的析构函数声明为虚的,此时,系统将先执行派生类对象的析构函数,然后再执行基类的析构函数。

如果基类的析构函数声明为虚的,派生类的析构函数也将自动成为虚析构函数,无论派生类析构函数声明中是否加virtual关键字,并且认为派生类的析构函数属于重定义,也就是重写,这是唯一一种明明不满足重写条件但依然有重写效果的类内函,能这么做的原因也很好想到,因为对于任何一个类而言,析构函数只有一个,是唯一的,编译器就将析构函数统一给命名为 destructor 了,因此表面上似乎不符合多态重写的标准,但实际上是符合的

那么为什么将基类型中的析构函数声明成虚析构函数就可以解决这个问题呢?

很简单,因为虚析构函数也遵从多态原理,同时对于析构函数来说,它是可以被对象调用的(要注意构造函数就不可以嗷),关于这几点我们可以从下面的代码中窥见一二:

void test()
{
	Base* pbase = new Derived("hello", "wuhan");
	pbase->print();

	delete pbase; //delete表达式的工作步:1、先执行析构函数 2、再执行 operator delete 运算符函数
	//下面这行可以编译通过,说明析构函数是可以被对象用指针进行调用的
	pbase->~Base();
	//那么析构函数既然可以看作是一个普通函数的调用,那么我们就可以使用virtual关键字
	//让其产生多态效果,也就是让执行 delete pbase;的时候让效果等同于下面这行即可
	//pbase->~Derived();
	//因此我们给基类中的析构函数~Base()加上virtual关键字成为虚析构函数
	//这样派生类中的析构函数~Derived()就相当于(注意是相当于,实际上并不行)是重载了基类中的virtual ~Base()虚析构函数
	//那么对于上面的这行 pbase->~Base(); 代码实际上执行的就是 ~Derived() 函数
	//也就是多态的体现,在执行到派生类的析构函数~Derived()后编译器就会自动去执行基类型的
	//析构函数了,这在继承那一章节是有说过的,就不再赘述了

	Base& rbase = *pbase;
	//编译通过,说明析构函数同样也可以被对象引用调用
	rbase.~Base();

	//顺便提一嘴,那为什么构造函数不可以变成虚函数呢?
	//因为其没办法被对象以任何形式进行调用
	Base base("sad"); //这当然是没问题的
	//构造函数的作用就是用来初始化一个对象的,而下面的代码
	//试图通过一个已经存在的对象调用作用为初始化类对象的构造函数
	//这不就是先有鸡先有蛋的问题吗?因此为了避免这种歧义问题
	//C++标准使得构造函数没有办法被对象所调用
	//base.Base("wqe"); error
	base.~Base(); //但是析构函数就没有问题捏
}

重载、隐藏、覆盖

重载:发生在同一个作用域中,函数名称相同,但参数的类型、个数、顺序不同(参数列表不一样)。

覆盖(重写、重定义):发生在基类与派生类中,同名虚函数,参数列表亦完全相同。

隐藏:发生在基类与派生类中,指的是在某些情况下,派生类中的函数屏蔽了基类中的同名函数。(同名数据成员也有隐藏)。

//针对于隐藏的例子
class Base
{
public:
	Base(int m)
		: _member(m)
	{
		cout << "Base(int)" << endl;
	}
	void func(int x)
	{
		cout << "Base::func(int)" << endl;
	}
	~Base()
	{
		cout << "~Base()" << endl;
	}
protected:
	int _member;
};
class Derived
	: public Base
{
public:
	Derived(int m1, int m2)
		: Base(m1)
		, _member(m2)
	{
		cout << "Derived(int, int)" << endl;
	}
	void func(int*)
	{
		cout << "_member: " << _member << endl;
		cout << "Derived::func(int*)" << endl;
	}
	~Derived()
	{
		cout << "~Derived()" << endl;
	}
private:
	int _member;
};

int main(){
	Derived derived(100, 200);
	//下面这行error,因为Base类中的 func(int)函数被Derived类中的同名 func 给隐藏掉了
	//但如果Derived类中的 func 函数被删掉的话,那么下面这行代码就是正确的,可以顺利调用到Base类中的func(int)函数
	//derived.func(20); 
	//但实在想调用也没问题,加作用域限定符即可
	derived.Base::func(20); //ok
	int a = 20;
	derived.func(&a); //ok
}

测试虚表的存在

从前面的知识讲解,我们已经知道虚表的存在,但之前都是理论的说法,我们是否可以通过程序来验证呢?答案是肯定的。接下来我们看看下面的例子:

class Base
{
public:
	Base(long data1) : _data1(data1)
	{
	}
	virtual
		void func1()
	{
		cout << "Base::func1()" << endl;
	}
	virtual
		void func2()
	{
		cout << "Base::func2()" << endl;
	}
	virtual
		void func3()
	{
		cout << "Base::func3()" << endl;
	}
protected:
	long _data1;
};


class Derived
	: public Base
{
public:
	Derived(long data1, long data2)
		: _data1(data1)
		, _data2(data2)
	{}
	virtual
		void func1()
	{
		cout << "Derived::func1()" << endl;
	}
	virtual
		void func2()
	{
		cout << "Derived::func2()" << endl;
	}
private:
	long _data2;
};


void test()
{
	Derived derived(10, 100);
	//derived的地址就是一个指针,而虚函数指针正好位于类对象的内存布局前面
	//因此这个地址也正好就是虚函数指针所在的位置,共八个字节(64位系统下)
	printf("Derived的地址:%p\n",&derived);
	//long类型也占八个字节大小,因此我们可以让地址强转成long*类型,这样就可以进行解引用了
	//直接解引用语法上不支持:*&derived; 这样的写法是报错的,因此我们要转成 long* 后再进行解引用
	printf("Derived的地址:%p\n",(long*)&derived);
	//对于虚表进行一层解引用,因为解引用之后得到的是虚表的地址,因此我们再强转成long一下即可
	//(从前文的内存布局图中可以分析出来)
	printf("虚表的地址:%p\n",(long*)*(long*)derived);
	//按照上面的思路,就可以获取位于程序代码区中的函数入口地址了
	printf("第一个虚函数的地址:%p\n",(long*)*(long*)*(long*)derived);
	
	//再创建一个对象,看一下一个类究竟会有几张虚表
	Derived derived2(30, 30);
	//derived的地址就是一个指针,而虚函数指针正好位于类对象的内存布局前面
	//因此这个地址也正好就是虚函数指针所在的位置,共八个字节(64位系统下)
	printf("Derived2的地址:%p\n",&derived2);
	//long类型也占八个字节大小,因此我们可以让地址强转成long*类型,这样就可以进行解引用了
	printf("Derived2的地址:%p\n",(long*)&derived2);
	//对于虚表进行一层解引用,因为解引用之后得到的是虚表的地址,因此我们再强转成long一下即可
	//(从前文的内存布局图中可以分析出来)
	printf("虚表的地址:%p\n",(long*)*(long*)derived2);
	//按照上面的思路,就可以获取位于程序代码区中的函数入口地址了
	printf("第一个虚函数的地址:%p\n",(long*)*(long*)*(long*)derived2);

	//运行后不难发现,对于普通的单继承而言,虚表只有一个

	//用函数指针的方式进行虚函数的访问
	long** pVtable = (long**)&derived;
	typedef void(*Function)();
	for (int idx = 0; idx < 3; ++idx)
	{
		Function f = (Function)pVtable[0][idx];
		f();
	}
}

以上例子充分说明了虚表的存在。那虚表到底存在什么位置呢?实际上虚表位于程序的只读段

再来看个图示,印象会更清晰:

在这里插入图片描述

因此按照上面图示和代码的逻辑,我们实际上也是可以将私有的数据成员通过指针的形式访问出来的,那么这不就破坏了面向对象的封装特性了吗?

这就是指针的强大之处,指针可以帮助我们做很多事情,封装继承多态只是语言站在更高层的抽象层的角度来看的,而从内存的角度看的话这样做就完全没有问题。

之前我们的例子都比较简单,接下来我们看看相对复杂一些的例子。

带虚函数的多基派生

class Base1
{
public:
	Base1()
		: _iBase1(10)
	{
	}
	virtual
		void f()
	{
		cout << "Base1::f()" << endl;
	}
	virtual
		void g()
	{
		cout << "Base1::g()" << endl;
	}
	virtual
		void h()
	{
		cout << "Base1::h()" << endl;
	}
private:
	int _iBase1;
};
class Base2
{
public:
	Base2()
		: _iBase2(100)
	{
	}
	virtual void f()
	{
		cout << "Base2::f()" << endl;
	}
	virtual
		void g()
	{
		cout << "Base2::g()" << endl;
	}
	virtual
		void h()
	{
		cout << "Base2::h()" << endl;
	}
private:
	int _iBase2;
};
class Base3
{
public:
	Base3()
		: _iBase3(1000)
	{
	}
	virtual
		void f()
	{
		cout << "Base3::f()" << endl;
	}
	virtual
		void g() {
		cout << "Base3::g()" << endl;
	}
	virtual
		void h()
	{
		cout << "Base3::h()" << endl;
	}
private:
	int _iBase3;
};
class Derived
	: public Base1
	, public Base2
	, public Base3
{
public:
	Derived()
		: _iDerived(10000)
	{
	}
	void f()
	{
		cout << "Derived::f()" << endl;
	}
	virtual
		void g1()
	{
		cout << "Derived::g1()" << endl;
	}
private:
	int _iDerived;
};
void test()
{
	Derived d;
	Base2* pBase2 = &d;
	Base3* pBase3 = &d;
	Derived* pDerived = &d;
	pBase2->f();
	cout << "sizeof(d) = " << sizeof(d) << endl;
	cout << "&Derived = " << &d << endl;
	cout << "pBase2 = " << pBase2 << endl;
	cout << "pBase3 = " << pBase3 << endl;
}
//直接看结论即可:多重继承(带虚函数)
//1. 每个基类都有自己的虚函数表
//2. 派生类如果有自己新增的虚函数,会被加入到第一个虚函数表之中
//3. 内存布局中,其基类的布局按照基类被继承时的顺序进行排列
//4. 派生类会覆盖基类的虚函数,只有第一个虚函数表中存放的是真实的被覆盖的虚函数的地址;
//	 其它的虚函数表中存放的并不是真实的对应的虚函数的地址,而只是一条跳转指令

多基派生的二义性

class A
{
public:
	virtual
		void a()
	{
		cout << "a() in A" << endl;
	}
	virtual
		void b()
	{
		cout << "b() in A" << endl;
	}
	virtual
		void c()
	{
		cout << "c() in A" << endl;
	}
};
class B
{
public:
	virtual
		void a()
	{
		cout << "a() in B" << endl;
	}
	virtual
		void b()
	{
		cout << "b() in B" << endl;
	}
	void c()
	{
		cout << "c() in B" << endl;
	}
	void d()
	{
		cout << "d() in B" << endl;
	}
};
class C : public A
	, public B
{
public:
	virtual
		void a()
	{
		cout << "a() in C" << endl;
	}
	void c()
	{
		cout << "c() in C" << endl;
	}
	void d()
	{
		cout << "d() in C" << endl;
	}
};
void test()
{
	C c;
	printf("&c: %p\n", &c);
	c.b();
	cout << endl;
	A* pA = &c;
	printf("pA: %p\n", pA);
	pA->a();
	pA->b();
	pA->c();
	cout << endl;
	B* pB = &c;
	printf("pB: %p\n", pB);
	pB->a();
	pB->b();
	pB->c();
	pB->d();
	cout << endl;
	C* pC = &c;
	printf("pC: %p\n", pC);
	pC->a();
	pC->b();//此处就是二义性,直接 error
	pC->c();//此处的c()走的是虚函数机制还是非虚函数机制,如何判别?
			//运行就知道了或者加override,再或者直接再让一个类继承 C 类实现一下 c() 看看 c() 函数输出什么即可
			//最后我们能发现走的其实是虚函数机制
	pC->d();//此处是隐藏,不是重写
	return 0;
}

结合图示会看的更加清晰:

在这里插入图片描述

虚拟继承

在讲虚拟继承之前,我们首先问大家一个问题:从语义上来讲,为什么动态多态和虚继承都使用virtual关键字来表示?

virtual在词典中的解释有两种:

  1. Existing or resulting in essence or effect though not in actual fact, form, or name.实质上的,实际上的:虽然没有实际的事实、形式或名义,但在实际上或效果上存在或产生的;
  2. Existing in the mind, especially as a product of the imagination. Used in literary criticism oftext. 虚的,内心的:在头脑中存在的,尤指意想的产物,用于文学批评中。

C++中的virtual关键字采用第一个定义,即被virtual所修饰的事物或现象在本质上是存在的,但是没有直观的形式表现,无法直接描述或定义,需要通过其他的间接方式或手段才能够体现出其实际上的效果。关键就在于存在、间接和共享这三种特征:

1、虚函数是存在的
2、虚函数必须要通过一种间接的运行时(而不是编译时)机制才能够激活(调用)的函数
3、共享性表现在基类会共享被派生类重定义后的虚函数

那虚拟继承又是如何表现这三种特征的呢?

1、存在即表示虚继承体系和虚基类确实存在
2、间接性表现在当访问虚基类的成员时同样也必须通过某种间接机制来完成(通过虚基表来完成)
3、共享性表现在虚基类会在虚继承体系中被共享,而不会出现多份拷贝

虚拟继承是指在继承定义中包含了virtual关键字的继承关系。虚基类是指在虚继承体系中的通过virtual继承而来的基类。 语法格式如下:

class Baseclass;
class Subclass
	: public / private / protected virtual Baseclass
{
public:
	//...
	private:
		//...
		protected:
			//...
};
//其中Baseclass称之为Subclass的虚基类, 而不是说Baseclass就是虚基类

接下来,我们看看例子:

#pragma vtordisp(off)
#include <iostream>
using std::cout;
using std::endl;
class A
{
public:
	A()
		: _ia(10)
	{
	}
	virtualvoid f()
	{
		cout << "A::f()" << endl;
	}
private:
	int _ia;
};
class B
	: virtual public A
{
public:
	B()
		: _ib(20)
	{
	}
	void fb()
	{
		cout << "A::fb()" << endl;
	}
	/*virtual*/
	void f()
	{
		cout << "B::f()" << endl;
	}
	virtual
		void fb2()
	{
		cout << "B::fb2()" << endl;
	}
private:
	int _ib;
};
void test(void)
{
	cout << sizeof(A) << endl;
	cout << sizeof(B) << endl;
	B b;
	return 0;
}
// 结论一:单个虚继承,不带虚函数
// 虚继承与继承的区别
// 1. 多了一个虚基指针,虚基指针指向虚基表,虚基表中存放的是虚基指针距离派生类对象首地址的偏移信息,
//	  以及虚基指针距离虚基类的偏移信息;此时派生类对象的数据成员存放在基类数据成员的前面
// 2. 虚基类位于派生类存储空间的最末尾
// 结论二:单个虚继承,带虚函数
// 1. 如果派生类没有自己的虚函数,此时派生类对象不会产生虚函数指针
// 2. 如果派生类拥有自己的虚函数,此时派生类对象就会产生自己本身的虚函数指针,
//    并且该虚函数指针位于派生类对象存储空间的开始位置

总结一下:

有虚函数就会有虚函数指针,有虚拟继承就会有虚基指针,虚函数指针指向虚函数的入口地址,虚基指针指向虚基表的入口地址。

如果是普通继承的话,基类的布局形式会存在派生类对象的前面,派生类的布局形式就会排在后面。如果派生类自己新增虚函数,对派生对象的大小是没有影响的,新增虚函数只会存在之前的虚表中。

如果是虚拟继承的话,派生类的布局形式会在派生类对象的前面,基类的布局形式在后面。如果派生类有新增虚函数,派生类对象的大小会发生变化,会多产生一个虚函数指针,用来存放新的虚函数的入口地址。

虚拟继承时派生类对象的构造和析构

在普通的继承体系中,比如A派生出B,B派生出C,则创建C对象时,在C类构造函数的初始化列表中调用B类构造函数,然后在B类构造函数初始化列表中调用A类的构造函数,即可完成对象的创建操作。但在虚拟继承中,则有所不同。

class A
{
public:
	A()
	{
		cout << "A()" << endl;
	}
	A(int ia)
		: _ia(ia)
	{
		cout << "A(int)" << endl;
	}
	void f()
	{
		cout << "A::f()" << endl;
	}
protected:
	int _ia;
};
class B
	: virtual public A
{
public:
	B()
	{
		cout << "B()" << endl;
	}
	B(int ia, int ib)
		: A(ia)
		, _ib(ib)
	{
		cout << "B(int,int)" << endl;
	}
protected:
	int _ib;
};
class C
	: public B
{
public:
	C(int ia, int ib, int ic)
		: B(ia, ib)
		, _ic(ic)
	{
		cout << "C(int,int,int)" << endl;
	}
	void show() const
	{
		cout << "_ia: " << _ia << endl
			<< "_ib: " << _ib << endl
			<< "_ic: " << _ic << endl;
	}
private:
	int _ic;
};
void test()
{
	C c(10, 20, 30);
	c.show();
}

从最终的打印结果来看,在创建对象c的过程中,我们看到C带三个参数的构造函数执行了,同时B带两个参数的构造函数也执行了,但A带一个参数的构造函数没有执行,而是执行了A的默认构造函数。这与我们的预期是有差别的。如果想要得到预期的结果,我们还需要在C的构造函数初始化列表最后,显式调用A的相应构造函数。那为什么需要这样做呢?

在 C++ 中,如果继承链上存在虚继承的基类,则最底层的子类要负责完成该虚基类部分成员的构造。即我们需要显式调用虚基类的构造函数来完成初始化,如果不显式调用,则编译器会调用虚基类的缺省构造函数,不管初始化列表中次序如何,对虚基类构造函数的调用总是先于普通基类的构造函数。如果虚基类中没有定义的缺省构造函数,则会编译错误。因为如果不这样做,虚基类部分会在存在的多个继承链上被多次初始化。很多时候,对于继承链上的中间类,我们也会在其构造函数中显式调用虚基类的构造函数,因为一旦有人要创建这些中间类的对象,我们要保证它们能够得到正确的初始化。这种情况在菱形继承中非常明显, 我们接下来看看这种情况。

菱形继承

#include <iostream>
using std::cout;
using std::endl;
class B
{
public:
	B()
		: _ib(10)
		, _cb('B')
	{
		cout << "B()" << endl;
	}
	B(int ib, char cb)
		: _ib(ib)
		, _cb(cb)
	{
		cout << "B(int,char)" << endl;
	}virtual
		void f()
	{
		cout << "B::f()" << endl;
	}
	virtual
		void Bf()
	{
		cout << "B::Bf()" << endl;
	}
private:
	int _ib;
	char _cb;
};
class B1
	: virtual public B
{
public:
	B1()
		: _ib1(100)
		, _cb1('1')
	{
	}
	B1(int ib, char ic, int ib1, char cb1)
		: B(ib, ic)
		, _ib1(ib1)
		, _cb1(cb1)
	{
		cout << "B1(int,char,int,char)" << endl;
	}
	virtual
		void f()
	{
		cout << "B1::f()" << endl;
	}
	virtual
		void f1()
	{
		cout << "B1::f1()" << endl;
	}
	virtual
		void Bf1()
	{
		cout << "B1::Bf1()" << endl;
	}
private:
	int _ib1;
	char _cb1;
}; class B2
	: virtual public B
{
public:
	B2()
		: _ib2(1000)
		, _cb2('2')
	{
	}
	B2(int ib, char ic, int ib2, char cb2)
		: B(ib, ic)
		, _ib2(ib2)
		, _cb2(cb2)
	{
		cout << "B2(int,char,int,char)" << endl;
	}
	//virtual
	void f()
	{
		cout << "B2::f()" << endl;
	}
	//virtual
	void f2()
	{
		cout << "B2::f2()" << endl;
	}
	//virtual
	void Bf2()
	{
		cout << "B2::Bf2()" << endl;
	}
private:
	int _ib2;
	char _cb2;
};
class D
	: public B1
	, public B2
{
public:
	D()
		: _id(10000)
		, _cd('3')
	{
	}
	D(int ib, char cb
		, int ib1, char ib
		, int ib2, char cb2
		, int id, char cd) : B1(ib1, ib1)
		, B2(ib2, cb2)
		, B(ib, cb)
		, _id(id)
		, _cd(cd)
	{
		cout << "D(...)" << endl;
	}
	virtual
		void f()
	{
		cout << "D::f()" << endl;
	}
	virtual
		void f1()
	{
		cout << "D::f1()" << endl;
	}
	virtual
		void f2()
	{
		cout << "D::f2()" << endl;
	}
	virtual
		void Df()
	{
		cout << "D::Df()" << endl;
	}
private:
	int _id;
	char _cd;
};
void test(void)
{
	D d;
	cout << sizeof(d) << endl;
	B1* pb1 = &d;
	pb1->f();
	B2* pb2 = &d;
	pb2->f();
	d.f();
	return 0;
}
//结论:虚基指针所指向的虚基表的内容
// 1. 虚基指针的第一条内容表示的是该虚基指针距离所在的子对象的首地址的偏移
// 2. 虚基指针的第二条内容表示的是该虚基指针距离虚基类子对象的首地址的偏移

如果是在若干类层次中,从虚基类直接或间接派生出来的派生类的构造函数初始化列表均有对该虚基类构造函数的调用,那么创建一个派生类对象的时候只有该派生类列出的虚基类的构造函数被调用,其他类列出的将被忽略,这样就保证虚基类的唯一副本只被初始化一次。即虚基类的构造函数只被执行一次。

对于虚继承的派生类对象的析构,析构函数的调用顺序为:

1、先调用派生类的析构函数;
2、然后调用派生类中成员对象的析构函数;
3、再调用普通基类的析构函数;
4、最后调用虚基类 的析构函数。

效率分析

通过以上的学习我们可以知道,多重继承和虚拟继承对象模型较单一继承复杂的对象模型,造成了成员访问低效率,表现在两个方面:对象构造时vptr的多次设定,以及this指针的调整。对于多种继承情况的效率比较如下:

在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

在地球迷路的怪兽

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值