C++之我见--多态(虚函数和纯虚函数)

 目录

多态性

虚函数原理

有关基类是否必须要实现虚函数、子类是否必须实现基类纯虚函数

重载与多态无关


 


进入正题之前先温故几个点:

  • 多态,字面意思理解就是多种形态,对于C++而言则是调用成员函数时,会根据调用函数的对象的类型来执行不同的函数;
  • C++多态的前提是,类之间存在层次结构,并且类之间是通过继承关联的;
  • 虚函数,基类中使用virtual关键字声明的函数;
  • 纯虚函数,是一种特殊的虚函数,在许多情况下,在基类中不能对虚函数给出有意义的实现,就可以把它声明为纯虚函数,它的实现留给基类的派生类去做。这就是纯虚函数的作用。一旦基类中声明了纯虚函数,子类中必须重写。纯虚函数的声明需要在函数原型后加“=0”。
  • 重载,是指同一可访问区域内声明的几个具有不同参数列表(参数个数、类型、顺序不同)的同名函数,根据参数列表决定调用那个函数。重载不关心函数返回类型。重载必须存在以下一个或者两个方面的差别:1、函数的参数个数不同。2、函数的参数类型不同或者参数类型顺序不同。
  • 重写,也叫覆盖,子类中重新定义了父类中有相同名称和参数列表的虚函数。函数特征相同,但是实现不同,主要是在继承关系中出现的。

多态性


指相同对象收到不同消息或者不同对象收到相同消息时产生不同的实现动作。C++支持2中多态性:编译时多态运行时多态

  • 编译时多态:通过重载函数或者重载运算符实现
  • 运行时多态:通过虚函数和继承实现

这里需要注意一点,通常所说的多态偏向于运行时多态多一些。圈内还有一种说法“重载与多态无关”,如果从运行时多态的角度去理解这句话确实不对,但是从整个C++的多态性去理解,这句话是对的。文末有对这句话的详细解释。

多态的3个必备条件:

  • 继承
  • 重写
  • 父类引用指向子类对象Parent P = new Child;

 

虚函数原理


看一个简单的栗子:

class Animation
{
public:
	void Speek()
	{
		std::cout << "动物叫太笼统了" << endl;
	}
};

class People : public Animation
{
public:
	void Speek()
	{
		std::cout << "人说各种语言" << endl;
	}
};
void main()
{
	People peo;
	Animation *ani = &peo;//隐式类型转换
	ani->Speek();
}

结果:

如果你已知晓输出结果的原因,也不迷惑为什么输出的不是“人说各种语言”,那么就不用听我唠叨了。不明的话就听我一言。

首先需要明白一点,只有子类能转换成父类,父类是不可能转换成子类的。举个栗子,你可以说人是动物,但是你能说动物是人吗?动物即使转化成人,那也不能叫人,那是**。(郭嘉规定了建国后动物不能成精!安利下,动物管理局戳这里可以看哦!挺好看的,但是后续几集的感情戏好水,像是在凑集数)。

这个也很好理解,子类是从父类继承而来,它在包含了父类的所有成员,还可以增加自己特有的成员。如果硬要说父类转换成子类,那么转换后的子类中将会有一部分未知成员,这是不允许的。但是你如果说转吧,没问题,一切都在我掌控中,那么如你所愿,使用强制类型转换

Animation *ani = new Animation;
People *peo = dynamic_cast<People *>(ani);

但是这样强转的前提是多态,即父类中得有虚函数

class Animation
{
public:
	virtual void Speek()
	{
		std::cout << "动物叫太笼统了" << endl;
	}
};

扯远了,拽回来。

记一点:在子类转换成父类的过程中,意味着对子类进行了一个切割,只是将子类中的父类部分赋值给了父类对象。

现在开始正式解释为什么调用的是Animation的Speek。

从编译的角度来看:

c++编译器在编译的时候,要确定每个对象调用的函数(非虚函数)的地址,这称为早期绑定,当我们将People类的对象peo的地址赋给ani时,c++编译器进行了类型转换,此时c++编译器认为变量ani保存的就是Animation对象的地址,当在main函数中执行ani->Speek(),调用的当然就是Animation对象的Speek函数。

从内存的角度看:

People类对象的内存模型如上图,我们构造People类的对象时,首先要调用Animation类的构造函数去构造Animation类的对象,然后才调用People类的构造函数完成自身部分的构造,从而拼接出一个完整的People类对象。当我们将People类对象转换为Animation类型时,该对象就被认为是原对象整个内存模型的上半部分,也就是上图中“Animation的对象所占内存”,那么当我们利用类型转换后的对象指针去调用它的方法时,当然也就是调用它所在的内存中的方法,因此,输出“动物叫太笼统了”,也就顺理成章了。

正如很多人那么认为,在上面的代码中,我们知道ani实际上指向的是People类的对象,我们希望输出的结果是People类的Speek方法,那么想到达到这种结果,就要用到虚函数了。

前面输出的结果是因为编译器在编译的时候,就已经确定了对象调用的函数的地址,要解决这个问题就要使用晚绑定,当编译器使用晚绑定时候,就会在运行时再去确定对象的类型以及正确的调用函数,而要让编译器采用晚绑定,就要在基类中声明函数时使用virtual关键字,这样的函数我们就称之为虚函数,一旦某个函数在基类中声明为virtual,那么在所有的派生类中该函数都是virtual,而不需要再显式地声明为virtual。

所以当我们将Animation中的Speek声明为虚函数后就OK了,输出就是“人说各种语言”。那么当我们将Speek声明为virtual后发生了什么呢?编译器在编译的时候,发现Animation类中有虚函数,此时编译器会为每个包含虚函数的类创建一个虚表(即 vtable),该表是一个一维数组,在这个数组中存放每个虚函数的地址。

那么如何定位虚表呢?编译器另外还为每个对象提供了一个虚表指针(即vptr),这个指针指向了对象所属类的虚表,在程序运行时,根据对象的类型去初始化vptr,从而让vptr正确的指向了所属类的虚表,从而在调用虚函数的时候,能够找到正确的函数,由于ani实际指向的对象类型是People,因此vptr指向的People类的vtable,当调用ani->Speek()时,根据虚表中的函数地址找到的就是People类的Speek()函数.

正是由于每个对象调用的虚函数都是通过虚表指针来索引的,也就决定了虚表指针的正确初始化是非常重要的,换句话说,在虚表指针没有正确初始化之前,我们不能够去调用虚函数,那么虚表指针是在什么时候,或者什么地方初始化呢?

答案是在构造函数中进行虚表的创建和虚表指针的初始化,在构造子类对象时,要先调用父类的构造函数,此时编译器只“看到了”父类,并不知道后面是否还有继承者,它初始化父类对象的虚表指针,该虚表指针指向父类的虚表,当执行子类的构造函数时,子类对象的虚表指针被初始化,指向自身的虚表。

 

 

有关基类是否必须要实现虚函数、子类是否必须实现基类纯虚函数


这个需要分情况,举个栗子

如果在main函数中(或者后续其他代码中)没有父类或者子类的实例化对象,基类可以不实现虚函数;但如果main函数中(或者后续其他代码中)有父类或者子类的实例化对象,那么就需要基类实现虚函数。

class Animation
{
public:
	virtual void Speek();
	
private:

};


class People : public Animation
{
public:
	
protected:

private:

};
//1、main中无基类或者子类实例化对象,基类可不实现虚函数
void main()
{
	
}

//2、但如果main中含有基类或者子类实例化对象,则基类必须实现虚函数
void main()
{
	Animation ani;
}
//这种情况下编译会报错
1>Source.obj : error LNK2001: unresolved external symbol
 "public: virtual void __thiscall Animation::Speek(void)" (?Speek@Animation@@UAEXXZ)
1>E:\Develop\DuoTai\Debug\DuoTai.exe : fatal error LNK1120: 1 unresolved externals
//出现这种错误基类中实现一下虚函数即可
virtual void Speek()
{

}

一般情况下即使不实例化基类也是要实例化子类对象的,要不然创建类干嘛!所以基类还是实现虚函数比较好一点。

对于子类是否必须实现基类纯虚函数也是一样的,后面有实例化对象就需要实现,没有实例化对象就不需要实现。

看个栗子:

class Animation
{
public:
	virtual void Speek()
	{
		std::cout << "动物叫太笼统了" << endl;
	}
	virtual void Run() = 0;
private:

};

class People : public Animation
{
public:
	
protected:

private:

};

class Cat : public Animation
{
public:
	void Speek()
	{
		std::cout << "小猫喵喵叫" << endl;
	}
	virtual void Run()
	{
		std::cout << "小猫跑的很快" << endl;
	}
protected:

private:

};

void main()
{
	Animation* aniCat = new Cat;
	aniCat->Speek();
	//Animation* aniPeople = new People;
}

上面这个栗子中Cat重写了Run方法,而People没有重写Run方法,所以在main中可以实例化Cat,不能实例化People。 

但这句话貌似是句废话。如果基类包含纯虚函数,那么这个基类就是一个抽象类(包含纯虚函数的类称之为抽象类),抽象类是无法实例化它自身的(C++不允许抽象类去实例化对象)。这个很好理解,你说你要实例化一个动物对象,它总得是个具体的动物啊,只说动物俩字谁知道它到底是个啥!所以你实例化一个动物对象就一定会是子类对象的引用,即后面不可能不实例化子类对象,也就是说单纯谈论子类是否必须实现基类纯虚函数是没有实际意义的。

记一点:对于抽象类来说,它无法实例化对象,而对于抽象类的子类来说,只有把抽象类中的纯虚函数全部实现之后,这个子类才可以实例化对象。

 

重载与多态无关


再热一下这个观点,还是个人看法。从编译时多态理解,是对的;从运行时多态理解,是错的。

摘了一段比较好的解释:

重载,是指允许存在多个同名函数,而这些函数的参数表不同(或许参数个数不同,或许参数类型不同,或许两者都不同)。其实,重载的概念并不属于“面向对象编程”,重载的实现是:编译器根据函数不同的参数表,对同名函数的名称做修饰,然后这些同名函数就成了不同的函数(至少对于编译器来说是这样的)。如,有两个同名函数:function func(p:integer):integer;和function func(p:string):integer;。那么编译器做过修饰后的函数名称可能是这样的:int_func、str_func。对于这两个函数的调用,在编译器间就已经确定了,是静态的(记住:是静态)。也就是说,它们的地址在编译期就绑定了(早绑定),因此,重载和多态无关!真正和多态相关的是“覆盖”。当子类重新定义了父类的虚函数后,父类指针根据赋给它的不同的子类指针,动态(记住:是动态!)的调用属于子类的该函数,这样的函数调用在编译期间是无法确定的(调用的子类的虚函数的地址无法给出)。

因此,这样的函数地址是在运行期绑定的(晚邦定)。结论就是:重载只是一种语言特性,与多态无关,与面向对象也无关!
           引用一句Bruce Eckel的话:“不要犯傻,如果它不是晚邦定,它就不是多态。”


 

参考链接:

https://blog.csdn.net/qq_39477053/article/details/80322260 这个链接讲的很细,本文中有很多点都是从该文章中理解而来的,大家有兴趣可以细看一下。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值