C++虚函数具体实现机制以及纯虚函数和抽象类(对多态的补充)

前面我们说了虚函数实现了运行时的多态,但是没有详细说明虚函数的具体实现机制,所以这里详细说明一下虚函数,并对多态进行一下补充

上一篇链接:C++多态详解_真的没事鸭的博客-CSDN博客

目录

1,虚函数定义时的注意事项:

2,虚函数的作用:

3,虚函数的声明形式

4,虚函数有两种:一般虚函数和虚析构函数

1,一般虚函数

什么是联编

那什么是静态联编和动态联编呢?

总结:

扫描二维码关注公众号,回复: 14528765 查看本文章

2,虚析构函数

使用虚析构函数的目的

虚析构函数的声明形式:

结论:

5,虚函数的实现机制

1,派生类不重写基类虚函数

2,派生类重写基类函数

3,多重继承

虚函数调用过程

6,纯虚函数

纯虚函数的用法

纯虚函数的声明形式

注意:

7,抽象类

抽象类的用途

抽象类的定义形式

使用纯虚函数和抽象类的注意事项

总结:

抽象类的应用

8,内部类

定义形式

章末总结


1,虚函数定义时的注意事项:

1,类中的静态成员函数不可声明为虚函数

2,派生类对基类函数重新定义时,必须与基类中虚函数的原型完全一致

2,虚函数的作用:

虚函数是运行时多态,若某个基类函数声明为虚函数,则其公有派生类将定义与其基类虚函数原型相同(函数名、返回值类型、参数个数、参数类型及参数顺序均相同)的函数,这时,当使用基类指针或基类引用操作派生类对象时,系统会自动用派生类中的同名函数代替(覆盖)基类虚函数。

下面看一个例子:

#include <iostream>
using namespace std;
class A
{
public:
	void display()
	{
		cout << "输出基类函数" << endl;
	}
};
class B :public A
{
public:
	void display()
	{
		cout << "输出派生类函数" << endl;
	}
};
int main()
{
	A* p = new A();
	p->display();
	p = new B();
	p->display();
}

输出结果:

输出基类函数

输出基类函数

从这个结果可以看出来,我们定义一个基类指针,先指向了基类,然后调用了基类的display函数,之后我们又将这个基类指针指向了派生类,调用派生类的display函数, 但是我们看输出结果可以发现并没有调用派生类的display函数,并没有实现我们想要的结果。

原因:派生类继承并改写了基类同名函数display(),这种改变在静态联编(编译时)条 件下,编译器并不知道,造成了上述结果。

解决方法:若想通知编译器这种改变,则需要通过动态联编(运行时多态),实现方法就是在基类中将可能发生改变的成员函数声明为虚函数。

3,虚函数的声明形式

在成员函数原型前添加virtual关键字

class 类名

{

        virtual 函数返回值类型 函数名(参数表)

};

含义:

声明为虚函数的成员函数,可以被派生类重新定义。

注意:

1,虚函数只能是类中的成员函数,但类中的静态成员函数(归类所拥有,不归某一对象所有)不可声明为虚函数。

2,派生类对基类虚函数重新定义时,必须与基类中虚函数的原型完全一致。包括函数名、返回值类型、参数个数、参数类型及参数顺序。

3,无论派生类中同名函数前是否添加virtual,均被视为虚函数(可在其派生类中再次重新定义,即虚函数是被继承的)。

4,虚函数有两种:一般虚函数和虚析构函数

1,一般虚函数

对于普通成员函数,派生类可以重新定义从基类继承下来的虚函数。派生类对基类虚函数重新定义后,仍作为虚函数可在更下层派生类中被重新定义。

通常,在派生类中重新定义虚函数时,可以不写virtual,但最好保留,以增强程序的可 读性。

我们对上面的代码进行一下修改

#include <iostream>
using namespace std;
class A
{
public:
	int a = 10;
public:
	virtual void display()
	{
		cout << "输出基类函数" << endl;
	}
};
class B :public A
{
public:
	void display()
	{
		cout << "输出派生类函数" << endl;
		a += 10;
	}
};
int main()
{
	A* p = new A();
	p->display();
	p = new B();
	p->display();
}

在基类的display函数前面加上virtual关键字,将成员函数声明为虚函数,这样就可以通过p指针调用派生类的函数了

结论有了虚函数后,通过基类指针或基类引用调用派生类对象的虚函数时,会实际调用指针或引用指向的派生类对象中那个重定义版本,即操作派生类的虚函数。

虚函数通过动态联编实现了运行时多态

什么是联编

联编是指将源代码中的函数调用解释为执行特定的函数代码块

在c语言中的函数没有重载一说,所以一个函数就对应一个函数代码块,但是在c++中允许函数重载,所以比c语言复杂,必须根据函数名和形参列表来对应一个函数代码块。

那什么是静态联编和动态联编呢?

静态联编:在编译过程进行联编

动态联编:编译器生成能够在程序运行时选择正确的虚方法的代码

在编译过程中进行联编就是静态联编,但是虚函数的存在使静态联编变得困难,因为子类对继承父类的函数进行了重写,当我们用一个父类指针指向一个子类对象时,编译器阶段可以知道父类指针的类型,然后调用父类指针类型的函数。但是我们想要调用的是子类继承父类后重写的函数。

所以便有了动态联编,对于虚函数,编译器可以通过动态联编确定对应的函数代码块, 即在运行时根据对象类型,针对不同的对象调用对应的函数代码块。

总结:

可以简单的理解为,静态联编在编译时根据对象的类型,调用对应的函数代码块,动态联编在运行时指针不同的对象类型调用对应的函数代码块

2,虚析构函数

在C++中不能声明虚构造函数,因为构造函数执行时,对象还没有构建好,不可能按虚函数方式进行调用,但可以声明虚析构函数。

使用虚析构函数的目的

虚析构函数是为了解决基类指针指向派生类对象,并用基类指针销毁派生类对象的应用产生的。(注意:动态内存的分配和回收必须使用指针变量来存放空间地址)

通常,使用基类指针指向一个new生成的派生类对象,通过delete销毁基类指针指向的派生类对象时,有以下两种情况:

如果基类析构函数不是虚析构函数,则只会调用基类的析构函数,派生类的析构函数不被调用,此时派生类中的申请资源不被回收

如果基类析构函数为虚析构函数,则释放基类指针指向的对象时会调用基类及派生类析构函数,派生类对象中的所有资源被回收

虚析构函数的声明形式:

virtual  ~类名(;)

下面看一个例子

#include <iostream>
using namespace std;
class Father
{
public:
		~Father()
		{
			cout << "调用父类析构函数" << endl;
		}
};
class Son :public Father
{
public:
		~Son()
		{
			cout << "调用基类虚构函数" << endl;
		}
};
int main()
{
		Father* f=new Son();
		delete f;
}

输出结果

调用父类析构函数

从结果可以看出来,我们将父类指针指向派生类对象,销毁指针时只会调用父类的析构函数,派生类的申请资源没有被回收

我们使用虚析构函数修改一下上面的代码

#include <iostream>
using namespace std;
class Father
{
public:
	virtual ~Father()
	{
		cout << "调用父类析构函数" << endl;
	}
};
class Son :public Father
{
public:
	~Son()
	{
		cout << "调用基类虚构函数" << endl;
	}
};
int main()
{
	Father* f=new Son();
	delete f;
}

输出结果

调用基类虚构函数

调用父类析构函数

通过结果可以发现,使用虚析构函数之后,销毁基类指针会调用基类和派生类的析构函数,派生类的资源也会被回收,资源全部被释放

结论:

虚析构函数可以完成基类及派生类对象资源的释放,因此在继承结构中,通常将基类析构函数声明为虚函数。

5,虚函数的实现机制

虚函数通过动态联编实现了运行时多态,编译器在执行过程中遇到virtual关键字时, 会为这些包含虚函数的类建立一张虚函数表vtable。在虚函数表中,编译器将按照虚函 数的声明顺序依次保存虚函数地址,同时在每个带有虚函数的类中放置一个vptr指针, 用来指向虚函数表,通常在定义类对象时,为vptr分配空间,该指针被置于对象的起始位置,继而通过对象的构造函数将vptr初始化为本类的虚函数表地址。

下面看三个实例

1,派生类不重写基类虚函数

class Father
{
int father;
		virtual void A();
		virtual void B();
};
class Son :public Father
{
int son;
		virtual void C();
};

解析一下这两个类的内存布局

 

派生类不重写基类虚函数的话,基类和派生类的虚函数表如上图所示,可见在派生类的虚函数表中,派生类的虚函数是在基类虚函数的后面

2,派生类重写基类函数

class Father
{
int father;
		virtual void A();
		virtual void B();
};
class Son :public Father
{
int son;
Virtual void B();
		virtual void C();
};

 

派生类重写基类的虚函数的话,基类和派生类的虚函数表如图所示。经过重写后,基类的虚函数表很好构造,派生类的虚函数表构造相对复杂,构造方式是先拷贝基类的虚函数表替换已重写的虚函数指针追加派生类自己的虚函数指针

3,多重继承

class Father
{
int f;
	virtual void A();
	virtual void B();
};
class Mother
{
Int m;
	virtual void C();
	virtual void D();
};
class Son :public Father,public Mother
{
int s;
virtual void B();
virtual void C();
	virtual void E();
};

 

 

 多重继承的虚函数表和前面类似,不过多了一个虚函数表,多了一次拷贝和替换的过程

虚函数调用过程

我们以上面的派生类重写基类函数为例

class Father
{
	int father;
	virtual void A();
	virtual void B();
};
class Son :public Father
{
	int son;
	Virtual void B();
	virtual void C();
};
void test(Father* p)
{
	p->B();
}

对应的虚函数表为

 这里编译器只知道p是Father类型的指针,并不知道它具体的指向,它可能指向Father 也可能指向Son

需要注意的是,虚函数指针中的vptr部分是虚函数表中偏移值(单位是字节)加1,也 就是说如果我们确定了p指向的类型,再确定被调函数在虚函数表中的偏移值,在运行时就可以调用对应的函数了

不管是在Father的虚函数表,还是Son的虚函数表中,B函数在各自虚函数表的偏移位置是相等的,都在第二个位置

比如Father::B是一个虚函数指针,它的vptr部分是9,它在Father虚函数表的偏移值为8(8+1=9)

当程序执行到p->B()时,就可以根据指向的对象,判断具体调用的函数

如果p指向Father的对象,可以获取到Father对象的vptr,加上偏移值8((char*)vptr+8) 可以找到Father::B

如果p指向Son对象,可以获取到Son对象的vptr,加上偏移值8((char*)vptr+8)可以找 到Son::B

如果p指向其他类型对象,同理

6,纯虚函数

在定义一个表示抽象概念的基类时,有时无法或者不需要给出某些成员函数的具体实现, 函数的实现在派生类中完成,基类中这样的函数声明为纯虚函数。

例如:动物都有叫声,但不同的动物叫声不相同,因此,不需要在动物类中实现描述叫声的函数,而只需保留一个接口,具体的实现在各派生类中完成。

纯虚函数的用法

纯虚函数不存在函数体,只有函数声明,用来在基类中为派生类保留一个函数接口,方 便派生类根据需要对它实现,实现多态。因此纯虚函数通常在基类中声明,派生类根据 自身需要提供函数体,实现纯虚函数。

纯虚函数的声明形式

virtual 函数返回值类型 函数名(参数表) =  0;

注意:

虽然纯虚函数与虚函数都用virtual关键字声明,但纯虚函数只有函数名,没有函数体, 不是完整的函数,不具备函数功能,无法被调用,也无法为其分配内存空间。

声明后的“=0”并不表示函数返回值为0,只是以这样的形式说明该函数为纯虚函数。

若在一个类中声明了纯虚函数,但是在其派生类中没有实现该函数,则该函数在派生类中仍为纯虚函数(不可被调用)。即:基类中的纯虚函数(没有函数体)必须在派生类中有实现部分,否则不可调用。而基类中的虚函数可以为空函数,只是在派生类中可以重定义而已。

下面看一个案例

#include <iostream>
using namespace std;

class Anima
{
public:
	virtual void Speak() = 0;
};
class Dog :public Anima
{
public:
	void Speak()
	{
		cout << "Dog叫" << endl;
	}
};
class Cat :public Anima
{
public:
	void Speak()
	{
		cout << "Cat叫" << endl;
	}
};
class Chicken :public Anima
{
public:
	void Speak()
	{
		cout << "Chicken叫" << endl;
	}
};
int main()
{
	Anima* p;
	p = new Cat();
	p->Speak();
	p = new Dog();
	p->Speak();
	p = new Chicken();
	p->Speak();
}

输出结果:

Cat叫

Dog叫

Chicken叫

7,抽象类

包含纯虚函数的类称为抽象类。之所以说它抽象,是因为它无法实例化,也就是无法创建对象。上面例子中基类Anima中声明了纯虚函数Speak(),则Anima类是抽象类。

抽象类的用途

建立公共接口,在各派生类中完成各自的实现,更好地发挥多态的特性。 抽象类声明了公共接口,而接口的完整实现(即纯虚函数的函数体)要由派生类自己定 义。

抽象类的定义形式

class 类名

{

public:

        virtual 函数返回值类型 函数名(参数表) =  0;

        其他函数声明

};

使用纯虚函数和抽象类的注意事项

抽象类只是定义操作接口,不是一个类的完整实现,只能做基类来派生新类,不能声明抽象类对象,但可以声明抽象类指针或引用,通过指针或引用操作派生类对象。

抽象类中可以有多个纯虚函数,在派生类中应该实现这些纯虚函数,使得派生类不再是抽象类。派生类中若没有实现所有纯虚函数,则未重新定义的函数仍为纯虚函数,派生类也是抽象类。

总结:

基类指针指向派生类对象,如果基类声明的不是虚函数就调用基类的,如果基类中是虚函数并且在派生类中实现,就调用派生类的函数。

基类指针指向派生类对象,但只能访问派生类从基类继承的那些成员函数,若想访问只在派生类才有的成员函数,则只能通过在基类中声明为纯虚函数或虚函数来实现。

抽象类的应用

实际开发应用时:

(1)可以定义一个抽象基类,只完成部分功能,未完成的功能交给派生类去实现(谁 派生谁实现)。这部分未完成的功能,往往是基类不需要的,或者在基类中无法实现的。 虽然抽象基类没有完成,但是却强制要求派生类完成,这就是抽象基类的“霸王条款”。

(2)抽象基类除了约束派生类的功能,还可以实现多态。这是C++提供纯虚函数的主 要目的。

注意

(1)一个纯虚函数就可以使类成为抽象基类,但是抽象基类中除了包含纯虚函数外, 还可以包含其它的成员函数(虚函数或普通函数)和成员变量。

(2)只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明 为纯虚函数。

8,内部类

C++允许在类内部定义类,这样的类称为内部类,这个内部类所在的类称为外部类。内 部类可以作为外部类的基础,外部类在内部类基础上扩充新的功能并且不会相互影响。

定义形式

class 外部类名

{

        外部类成员;

访问限定符

        class 内部类名

        {

                内部类成员;

        };

};

内部类了解即可,这里不做过多说明

章末总结

最需要掌握还是虚函数和纯虚函数,尤其是虚函数的实现机制一定要搞懂,明白是怎么实现的。最后,如有错漏之处,敬请指正!

猜你喜欢

转载自blog.csdn.net/qq_52905520/article/details/127034506