在C++中,虑函数(Virtual Function)是面向对象编程(OOP)中的一个重要概念,它允许派生类(或称为子类)覆盖基类(或称为父类)中的成员函数。当通过基类指针或引用调用一个虚函数时,如果指针或引用实际指向的是一个派生类对象,那么就会调用派生类的虚函数方法,而不是基类中的方法,这种机制被称为动态绑定或运行时绑定。
一、虚函数的作用
C++中的虚函数的作用是允许在派生类中重新定义与基类同名的函数,并且可以通过基类指针或引用来访问基类和派生类中的同名函数。
1.1 通过指针访问同族类对象
示例代码:
#include <iostream>
#include <string>
using namespace std;
// 声明基类Student
class Student{protected:int num;string name;float score;public:Student(int num, string n, float s): num(num), name(n), score(s){}void display(){cout <<"num=" <<num <<", name=" <<name <<", score=" <<score <<endl;}
};
// 声明派生类Graduate
class Graduate: public Student{private:float wages; //工资public:Graduate(int num, string n, float s, float w): Student(num, n, s), wages(w){}void display(){cout <<"num=" <<num <<", name=" <<name <<", score=" <<score <<", wages=" <<wages <<endl;}
};
int main(){// 创建类Student对象sStudent s(1001, "Wei Li", 90);// 创建类Graduate对象gGraduate g(1002, "Qiang Liu", 92, 5000.0);// 定义类Student指针变量,并指向对象sStudent *pt = &s;// 显示对象s结果pt->display();// 将指针指向研究生pt = &g;// 输出研究生的结果pt->display();return 0;
}
输出结果如下图:
以上案例虽然指针变量pt分别指向对象s和对象g,调用两个对象中display()函数,但实际指针变量指向的都是继承基类部分,所以两次输出都是执行基类中的display()函数,在输出研究生对象g的信息时,并未输出工资这一项恰当说明这一点。
1.2 通过对象直接调用
如果输出各自类中的信息,可以通过各自对象直接调用,代码如下:
int main(){// 创建类Student对象sStudent s(1001, "Wei Li", 90);// 创建类Graduate对象gGraduate g(1002, "Qiang Liu", 92, 5000.0);s.display(); //输出学生对象s的信息g.display(); //输出研究生对象g的信息return 0;
}
输出结果如下图:
1.3 多态性的体现
除了用各自对象直接调用display()函数外,如何用指针变量输出也能得到上图效果呢?其实用虚函数就能解决这个问题了,现在在基类Student中定义display()函数前面加上virtual关键字即可。代码如下:
#include <iostream>
#include <string>
using namespace std;
// 声明基类Student
class Student{protected:int num;string name;float score;public:Student(int num, string n, float s): num(num), name(n), score(s){}virtual void display(){cout <<"num=" <<num <<", name=" <<name <<", score=" <<score <<endl;}
};
// 声明派生类Graduate
class Graduate: public Student{private:float wages; //工资public:Graduate(int num, string n, float s, float w): Student(num, n, s), wages(w){}void display(){cout <<"num=" <<num <<", name=" <<name <<", score=" <<score <<", wages=" <<wages <<endl;}
};
int main(){// 创建类Student对象sStudent s(1001, "Wei Li", 90);// 创建类Graduate对象gGraduate g(1002, "Qiang Liu", 92, 5000.0);// 定义类Student指针变量,并指向对象sStudent *pt = &s;// 显示对象s结果pt->display();// 将指针指向研究生pt = &g;// 输出研究生的结果pt->display();return 0;
}
从程序上来看,只是在Student类中display()函数前面加上了关键字virtual,其他部分都未改变;运行后输出结果可看出,指针变量pt指向Student类对象s时,pt->display()调用后输出则是Student类对象的信息;指针变量pt指向Graduate类对象g时,pt->display()调用后输出则是Graduate类对象的信息。如下图:
指针变量pt是一个基类指针,可以调用同一类族中不同类的虑函数,这就是多态性,对同一消息,不同对象有不同的响应结果。
本来,基类指针是用来指向基类对象的,如果用它指向派生类对象,则进行指针类型转换,将派生类对象的指针先转换为基类的指针,所以基类指针指向的是派生类对象中的基类部分。
在基类的display()函数未修改为虚函数前,是无法通过基类指针调用派生类对象中的成员函数的。虚函数突破了这一限制,在派生类的基类部分中,派生类的虚函数取代了基类原来的虚函数,困此在使用基类指针指向派生类对象后,调用虚函数时就调用了派生类的虚函数。
当把基类的某个成员函数声明为虚函数后,允许在派生类中对该函数重新定义,赋予它新的功能,并且可以通过指向基类的指针指向同一类族中不同类的对象,从而调用其中的同名函数。虚函数实现了动态多态性:同一类族中不同类的对象,对同一函数调用作出不同的响应。
虚函数的使用方法:
- 在基类用virtual声明成员函数为虚函数,这样就可以在派生类中重新定义此函数,为它赋予新的功能,并能方便地被调用。
- 在派生类中重新定义此函数,要求函数名、函数类型、函数参数个数和类型全部与基类的虚函数相同,并根据派生类的需要重新定义函数体(当一个成员函数被声明为虚函数后,基派生类中的同名函数都自动成为虚函数)。
- 定义一个指向基类对象的指针变量,并使它指向同一类族中的某一对象。
- 通过该指针变量调用此虚函数,此时调用的就是指针变量指向的对象的同名函数。
二、静态关联与动态关联
在前面的案例中可以看到,同一个display函数在不同的对象中有不同的作用,呈现了多态。对于调用同一类族中的虚函数,应当在调用时用一定的方式告诉编译系统,要调用的是哪个类对象中的函数。例如直接使用对象s.display()或g.display(),这样编译系统在对程序进行编译时,即能确定调用的是哪个类对象中的函数。确定调用的具体对象的过程称为关联(binding)。
下面通过梳理进一步了解函数重载、虚函数与静态关联和动态关联之间的关系。
2.1 函数重载(Overloading)
- 函数重载是指在同一作用域内,可以有一组具有相同函数名但参数列表(参数类型、参数个数或参数顺序)不同的函数。
- 函数重载的解析(即确定调用哪个函数)在编译时完成,因此它是静态关联的。编译器可以根据传递给函数的参数来决定该调用哪个版本的函数。
- 所以说函数重载属于静态关联(static binding)或早期关联(early binding)是正确的。
2.2 虚函数(Virtual Functions)
- 虚函数是C++中实现动态多态性的一种方式,通过基类的函数声明前加上virtual关键字,可以使该函数在派生类中被重写(Override)。
- 当通过基类指针或引用调用虚函数时,实际调用的是指针或引用所指向对象的实际类型(即运行时类型)的虚函数版本。这种在运行时确定调用哪个函数版本的过程称为动态关系(dynamic binding)或滞后关联(late binding)。
- 通过对象名直接调用虚函数实际上与虚函数的动态关联无关,如果直接通过对象名调用虚函数,那么将直接调用该对象的虚函数版本,而不涉及任何动态关联。
2.3 与关联之间关系
- 静态关联是在编译时确定函数调用或对象成员访问的过程,通常涉及函数重载、非虚函数调用和直接的对象成员访问。
- 动态关联是在运行时确定函数调用或对象成员访问的过程,主要涉及通过基类指针或引用调用的虚函数,以及涉及运行时类型信息(RTTI)的其他操作。
2.4 总结
- 函数重载是静态关联的
- 通过基类指针或引用调用的虚函数是动态关联的。
- 直接通过对象名调用虚函数不涉及动态关联,但仍然是虚函数调用(只是不表现出多态性)。
三、何时须声明虚函数
使用虚函数时需要注意,只能用virtual关键字声明类的成员函数,使它成员虚函数,而不能将类外的普通函数声明为虚函数。因为虚函数的作用是允许在派生类中对基类的虚函数重新定义,它只能用于类的继承层次结构中。
什么时候考虑声明虚函数,主要为以下几点:
- 首先要看成员函数所在的类是否会作为基类,然后看成员函数在类的继承后有无可能被更改功能。如果希望更改其功能的,一般应该将它声明为虚函数。
- 如果成员函数在类被继承后功能不需要修改,或派生类用不到该函数,则不要把它声明为虚函数。
- 应考虑对成员函数的调用是通过对象名还是通过基类指针或引用去访问,如果是通过基类指针或引用访问的,则应当声明为虚函数。
- 有时在定义虚函数时,并不定义其函数体,即函数体是空的。它的作用只是定义一个虚函数名,具体功能留给派生类去添加。
使用虚函数,系统要有一定空间开销。当一个类带有虚函数时,编译系统会为该类构造一个虚函数表(virtual function table),它是一个指针数组,存放每个虚函数的入口地址。系统在进行动态关联时的时间开销是很少的,所以多态性是高效的。
四、虚析构函数
析构函数的作用是在对象撤销之前做必要的“清理”工作,当派生类的对象从内存中撤销时一般先调用派生类的析构函数,然后再调用基类的析构函数。
4.1 临时对象
但是如果用new运算符建立了临时对象,若基类中有析构函数,并且定义了一个指向该基类的指针变量,在程序用带指针参数的delete运算符撤销对象时,系统会只执行基类的析构函数,而不执行派生类的析构函数。示例代码如下:
#include <iostream>
using namespace std;
// 声明类Point
class Point{public:Point(float a = 0, float b = 0): x(a), y(b){}// 定义Point析构函数~Point(){cout <<"executing Point destructor" <<endl;}protected:float x, y;
};// 声明Circle类,并公有继承基类Point
class Circle: public Point{private:float radius;public:Circle(float x = 0, float y = 0, float r = 0): Point(x, y), radius(r){} // 定义Circle的析构函数~Circle(){cout <<"executing Circle destructor" <<endl;}
};int main(){Point *p = new Circle;delete p;return 0;
}
运行结果可见,只执行了基类Point中的析构函数,而派生类Circle类中的析构函数未执行。原因是当通过指向基类的指针或引用来删除(或销毁)一个派生类对象时,如果基类的析构函数不是虚函数,那么只会调用基类的析构函数,而不会调用派生类的析构函数。如下图:
4.2 定义虚析构函数
如上例所示,这种结果可能导致资源泄露或其他未定义行为,因为派生类可能拥有一些需要析构时释放的资源(如动态分配的内存、文件句柄等)。为了解决这个问题,应当将基类中声明一个虚析构函数。代码如下:
#include <iostream>
using namespace std;
// 声明类Point
class Point{public:Point(float a = 0, float b = 0): x(a), y(b){}// 定义Point析构函数virtual ~Point(){cout <<"executing Point destructor" <<endl;}protected:float x, y;
};// 声明Circle类,并公有继承基类Point
class Circle: public Point{private:float radius;public:Circle(float x = 0, float y = 0, float r = 0): Point(x, y), radius(r){} // 定义Circle的析构函数~Circle(){cout <<"executing Circle destructor" <<endl;}
};int main(){Point *p = new Circle;delete p;return 0;
}
运行结果如下图:
先调用派生类的析构函数,再调用基类的析构函数,比较符合人们正常愿望。这样,所有资源都能得到正确的清理。