c++(22)多态和虚函数、虚函数表和vptr、对象指针的步长

本文详细介绍了C++中的多态性概念,包括其定义、必要条件以及实现原理。多态允许通过基类指针调用子类的方法,增强了程序的灵活性。文章强调了虚析构函数的重要性,防止在删除基类指针时出现内存泄漏。同时,解释了虚函数表和vptr指针在多态中的作用,以及vptr指针在对象创建过程中的分步初始化。最后,讨论了类对象指针的步长问题,提醒程序员在使用数组访问时要格外小心。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

1、什么是多态

父类指针指向子类对象

由继承而产生的相关却不同的类,其对象对同一消息会做出不同的响应。就是说:基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)

多态是对象对象程序设计的一个重要特征,能增加程序的灵活性。可以减轻系统 升级、维护、调试的工作量和复杂度。

C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量。

2、多态的三个必要条件

(1)必须存在继承关系;

(2)继承关系中必须有同名的虚函数,并且它们是覆盖关系(虚函数重写)。

(3)存在基类的指针或引用,通过该指针调用虚函数。(父类指针指向子类对象)

3、虚析构函数

在设计类的时候,只要考虑到该类可能被继承,就要把析构函数写成续析构函数。

然后我们来解释上面这句斜体的话,为什么一定要这么做。先看一下下面的例子。

#include <iostream>
#include <cstring>
#include <memory>
using namespace std;

class father
{
public:
	father()
	{
		cout<<"father()....."<<endl;
		m_name = new char[64];
		if (NULL != m_name)
		{
			strcpy(m_name, "zhangbaba");
		}
	}
	
	~father()
	{
		cout<<"~father()....."<<endl;
		if (NULL != m_name)
		{
			delete[] m_name;
			m_name = NULL;
		}
	}
	
    virtual void showname()
	{
		cout<<"father show name:"<<m_name<<endl;
	}
	
private:
    char *m_name;
};

class son : public father
{
public:
	son()
	{
		cout<<"son()....."<<endl;
		m_name = new char[64];
		if (NULL != m_name)
		{
			strcpy(m_name, "zhang3");
		}
	}
	
	~son()
	{
		cout<<"~son()....."<<endl;
		if (NULL != m_name)
		{
			delete[] m_name;
			m_name = NULL;
		}
	}
	
    virtual void showname()
	{
		cout<<"son show name:"<<m_name<<endl;
	}
	
private:
    char *m_name;
};

void test()
{
	father *p = new son; //基类指针指向子类对象
	p->showname();//期望发生多态
	
	delete p;//期望按照构造顺序发生,相反顺序的析构
	
}

int main(void)
{
	test();
	
	cout<<"---------------------"<<endl;
	return 0;
}


当我们用基类指针指向子类对象时,virtual虚函数方法实现了多态。但是当delete 基类指针的时候,并没有调用子类的析构函数。

这是因为编译器在delete的时候,只针对指针p的类型father *,做了释放。只调用了父类的析构函数。乜有析构子类。我们希望父类子类两个析构函数都被调用,以免出现内存泄露。

要做到以上的目的,其实只要调用子类的析构就可以了,子类析构调用会自动调用父类的析构,所以析构函数也一定要是虚函数,也就是父类析构函数必须定义为虚析构函数。

将父类的定义改为以下的内容

class father
{
public:
	father()
	{
		cout<<"father()....."<<endl;
		m_name = new char[64];
		if (NULL != m_name)
		{
			strcpy(m_name, "zhangbaba");
		}
	}
	
	virtual ~father()
	{
		cout<<"~father()....."<<endl;
		if (NULL != m_name)
		{
			delete[] m_name;
			m_name = NULL;
		}
	}
	
    virtual void showname()
	{
		cout<<"father show name:"<<m_name<<endl;
	}
	
private:
    char *m_name;
};

执行结果达到了预期

4、虚函数表和vptr指针

下面我们来研究一下多态的原理,为什么父类指针指向子类对象时,调用virtual成员函数会发生多态。

当类中声明虚函数时,编译器会在类中生成一个虚函数表,是一个用来储存类成员函数指针的数据结构。这个虚函数表示存在于只读区。

类中的virtual 虚函数会被编译器放入虚函数表中。只要当类中存在虚函数,那么每个对象中对会有一个指向虚函数表的指针(vptr指针)。

#include <iostream>
#include <cstring>
#include <memory>
using namespace std;

class parent
{
public:
	virtual void func1()
	{
		cout<<"parent::func1 ... "<<endl;
	}
	
	virtual void func2()
	{
		cout<<"parent::func2 ~~~ "<<endl;
	}
public:
	int pid;
};

class child : public parent
{
public:
	virtual void func1()
	{
		cout<<"child::func1 ... "<<endl;
	}
	
	virtual void func2()
	{
		cout<<"child::func2 ~~~ "<<endl;
	}
private:
	int cid;
};

int main(void)
{
	parent *pp = new parent;
	pp->func1();
	parent *pc = new child;
	pc->func2();
	
	return 0;
}


 上面的例子,当编译器在编译的时候会分别为类parent和类child创建属于各自类的虚函数表。并d当创建父类指针指向对象的时候,会初始化vptr指针,指向该对象所属类的虚函数表。

 通过指向子类对象的父类指针,调用成员函数时,会先判断是否为virtual 虚函数,如果是虚函数的话,会通过vptr访问对应的类虚函数表中所存放的成员函数指针。依次实现多态。

5、vptr指针分步初始化

当创建父类指针指向子类对象时。parent *p = new child;

*p所指向的空间,只有一个vptr,这个毋庸置疑。但是当new child时,会先调用父类的构造函数,此时vptr指向的是父类的虚函数表。当父类构造函数完成的时候,再调用子类构造函数的时候,vptr才会指向子类的虚函数表。这就叫做vptr指针分步初始化。

#include <iostream>
#include <cstring>
#include <memory>
using namespace std;

class parent
{
public:
	parent(int pid)
	{
		cout<<"parent(int pid)..."<<endl;
		func();
		this->pid = pid;
	}
	
	virtual void func()
	{
		cout<<"parent::func ... "<<endl;
	}
private:
	int pid;
};

class child : public parent
{
public:
	child(int cid, int pid):parent(pid)
	{
		cout<<"child(int cid, int pid)..."<<endl;
		func();
		this->cid = cid;
	}
	
	virtual void func()
	{
		cout<<"child::func ... "<<endl;
	}
private:
	int cid;
};

int main(void)
{
	parent *pc = new child(10, 20);
	delete pc;
	
	return 0;
}


上面的例子,new 子类对象创建的空间地址赋值给父类指针。在父类构造函数中,调用了虚函数func,此时由于还没有完成父类构造的执行,所以当前的vptr指向的其实是父类的虚函数表,所以调用func,打印的也是父类的func。

完成父类构造后,执行子类构造,vptr也重新指向了子类的虚函数表。这个就是vptr分步初始化的表现。

但是我们常规写代码的时候不会把成员方法写进构造函数里,这样容易出现歧义,不易于走读代码。正常构造函数里面只对成员变量进行初始化。

6、类对象指针的步长

我们知道类其实也是一种数据结构,我们可以用数组的方式来实例化对象。这个时候使用多态的话,有一个大坑!!!我们先看下面的

例1

#include <iostream>
#include <cstring>
#include <memory>
using namespace std;

class parent
{
public:
	parent(long a)
	{
		this->a = a;
	}
	
	virtual void print()
	{
		cout<<"parent::print ... a="<<this->a<<endl;
	}
public:
	long a;
};

class child : public parent
{
public:
	child(long a):parent(a)
	{
	}
	
	virtual void print()
	{
		cout<<"child::print ... a="<<this->a<<endl;
	}
};

int main(void)
{
	child ArrChild[3] = {child(0), child(1), child(2)};
	
	parent *pc = NULL;
	
	int i=0;
	for(i=0, pc=&ArrChild[0]; i<3; i++, pc++)
	{
		pc->print();
	}
	
	return 0;
}

执行结果

 

我们通过父类指针pc指向数组中的子类对象,然后在for循环中,对pc++然后访问子类的方法实现多态。这样的代码写法,看似岁月静好、人畜无害。可事实真的如此吗。

当我们把上面的例1,稍微改写一下,给子类加上一个私有成员变量int b。其他的代码不变

class child : public parent
{
public:
	child(long a):parent(a)
	{
	}
	
	virtual void print()
	{
		cout<<"child::print ... a="<<this->a<<endl;
	}
private:
	long b;
};

执行结果:

在打印完child数组的第一个对象的方法后,就崩溃了。

因为for循环中的pc++并不代表pc所指向的数组元素,向后+1。而是代表所指向空间的大小++,通俗点说就是pc++  ==》  pc += sizeof(parent)。他会在&ArrChild[0]的地址上,向后偏移sizeof(parent)个字节。

 第一次能够执行成功,纯属侥幸,因为子类没有自己的成员变量,在空间大小上完全和父类是一样的。上面黑框表示的是ArrChild数组的地址空间,pc指针的类型是parent *,pc++会按照父类parent对象的大小偏移。恰好按照一样的大小来偏移。(上图画的其实不够好,应该还有vptr的空间大小)

第二次,当我们给子类加上一个long b的成员变量的时候,空间上子类和父类的大小就不一样了。在执行的pc++的时候,就不会恰好停在元素对象的地址上,从而出现段错误。所以在使用基类指针的时候,也要额外小心,尽量不要使用数组访问的方式。

ps

Q:猜猜我上面的例子,整数数据为什么用long,而不用常见的int?

A:因为我的linux系统是64位的,而且数据结构按照8对齐。父类中一个int是4个字节,再加上vptr 相当于是void *的大小 8个字节,加起来是12字节。我们说过class跟struct是一样的,会有对齐规则,按8对齐就变成了16字节大小。子类,即使再加上一个int,也就恰好才是16字节,也不会出现崩溃。  

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值