我是荔园微风,作为一名在IT界整整25年的老兵,今天针对 Visual C++中的虚函数和纯虚函数的原理来聊聊。本文程序全部在Microsoft Visual Studio 2022上调试通过。
首先,先满足一下急性子的同学,因为有的同学是因为急于了解虚函数和纯虚函数才来看这篇帖子的,那你可以先这样理解:
C++中的虚函数就是JAVA中的普通函数, C++ 中的纯虚函数就是JAVA中的抽象函数, C++ 中的抽象类就是JAVA中的抽象类, C++ 中的虚基类就是JAVA中的接口。
这就是本人的学习方法和别人不一样的地方,我年轻时学C++始终不得要领,于是我把JAVA学会后再去学C++,就全都搞明白了。所以我的帖子都是用这种方法去学C++的。希望那些学C++很吃力的同学可以来借鉴我的这个学习方法。
好了,我们先来看一个程序,一个父亲和一个儿子,希望父亲的学习本领能被儿子继承下去。我们在儿子类中重新定义学习方法。我们希望如果对象是儿子,就调用 儿子类的学习方法,如果对象是父亲,那么就调用父亲类的学习方法。
#include <iostream>
using namespace std;
class Father
{
public:
void eat()
{
cout<<"eat"<<endl;
}
void run()
{
cout<<"run"<<endl;
}
void study()
{
cout<<"study"<<endl;
}
};
class Son : public Father
{
public:
void study()
{
cout<<"new study"<<endl;
}
};
void fn(Father *p)
{
p->study();
}
int main()
{
Father *p;
Son boy;
p=&boy;
fn(p);
return 0;
}
我们在儿子类中重新定义了study()方法,上一代人可能喜欢用做笔记的方法来学习,而新一代人总是有新的学习方法的吧,比如在设计好的教学游戏中学习,哈哈。接着定义了一个全局函数fn(),指向父亲类的指针作为 fn()函数的参数。在main()函数中,定义了一个儿子类的对象,将它的地址赋给了指向父亲类的指针变量 p,然后调用 fn()函数。当我们将儿子类的对象boy的地址直接赋给指向父亲类的指针变量,C+编译器居然不报错。这是因为儿子对象也是一个父亲对象,将儿子类型转换为父亲类型不用强制类型转换,C++编译器会自动进行这种转换。反过来,则不能把父亲对象看成是儿子对象,如果一个父亲对象确实是儿子对象,那么在程序中需要进行强制类型转换,这样编译才不会报错。
大家可以猜想一下上面程序运行的结果,输出的结果是“study”,为什么输出的结果不是“new study”呢?这是因为在我们将儿子类的对象fh的地址赋给p时,C++编译器进行了类型转换,此时C++编译器认为变量p保存的就是父亲对象的地址。当在 fn函数中执行p->study()时,调用的当然就是父亲对象的study函数。为了帮助大家更好地理解对象类型的转换,我们给出了儿子对象内存模型,如图所示:
当构造儿子类的对象时,首先要调用父亲类的构造函数去构造父亲类的对象,然后才调用儿子类的构造函数构完成自身部分的构造,从而拼接出一个完整的儿子对象。
当我们将儿子类的对象转换为父亲类型时,该对象就被认为是原对象整个内存模型的上半部分,也就是上图中的“父亲的对象所占内存”。当我们利用型转换后的对象指针去调用它的方法时,自然也就是调用它所在的内存中的方法。
现在我们在父亲类的study()方法前面加上一个virtual关键字,
#include <iostream>
using namespace std;
class Father
{
public:
void eat()
{
cout<<"eat"<<endl;
}
void run()
{
cout<<"run"<<endl;
}
virtual void study()
{
cout<<"study"<<endl;
}
};
class Son : public Father
{
public:
void study()
{
cout<<"new study"<<endl;
}
};
void fn(Father *p)
{
p->study();
}
int main()
{
Father *p;
Son boy;
p=&boy;
fn(p);
return 0;
}
用virtual关键字申明的函数叫作虚函数。运行这个程序,结果调用的是儿子类的学习方法。程序输出结果是“new study”。
这就是C++中的多态性。当C++编译器在编译的时候,发现父亲类的study()函数是虚函数,这个时候C++就会采用迟绑定(latebinding)技术。也就是编译时并不确定具体调用的函数,而是在运行时,依据对象的类型(在程序中,我们传递的儿子类对象的地址)来确认调用的是哪一个函数,这种能力就叫作C++的多态性。我们没有在study()函数前加virtual关键字时,C++编译器在编译时就确定了哪个函数被调用,这叫作早期绑定(early binding)。
C++的多态性概括就是:在基类的函数前加上 virtual关键字,在派生类中重写该函数,运行时将会根据对象的实际类型来调用相应的函数。如果对象类型是派生类,就调用派生类的函数;如果对象类型是基类,就调用基类的函数。
下面我们将study()函数申明为纯虚函数,会了发生什么事呢?
#include <iostream>
using namespace std;
class Father
{
public:
void eat()
{
cout<<"eat"<<endl;
}
void run()
{
cout<<"run"<<endl;
}
virtual void study()=0;
};
class Son : public Father
{
public:
void study()
{
cout<<"new study"<<endl;
}
};
void fn(Father *p)
{
p->study();
}
int main()
{
Father *p;
Son boy;
p=&boy;
fn(p);
return 0;
}
纯虚函数是指被标明为不具体实现的虚成员函数(注意:纯虚函数也可以有函数体,但这种提供函数体的用法很少见)。纯虚函数可以让类先具有一个操作名称,而没有操作内容,让派生类在继承时再去具体地给出定义。含有纯虚函数的类叫作抽象类,这种类不能声明对象,只是作为基类为派生类服务。在派生类中必须完全实现基类的纯虚函数,否则,派生类也变成了抽象类,不能实例化对象。
纯虚函数多用在一些方法行为的设计上。在设计基类时,不太好确定或将来的行为多种多样,而此行为又是必需的,我们就可以在基类的设计中,以纯虚函数来声明此种行为,而不具体实现它。
C++的多态性是由虚函数来实现的,而不是纯虚函数。在子类中如果有对基类虚函数的覆盖定义,那么无论该覆盖定义是否有virtual关键字,都是虚函数。
作者简介:荔园微风,1981年生,高级工程师,浙大工学硕士,软件工程项目主管,做过程序员、软件设计师、系统架构师,早期的Windows程序员,Visual Studio忠实用户,C/C++使用者,是一位在计算机界学习、拼搏、奋斗了25年的老将,经历了UNIX时代、桌面WIN32时代、Web应用时代、云计算时代、手机安卓时代、大数据时代、ICT时代、AI深度学习时代、智能机器时代,我不知道未来还会有什么时代,只记得这一路走来,充满着艰辛与收获,愿同大家一起走下去,充满希望的走下去。