理一下虚函数相关的基础概念,以及虚函数的各种使用场景。包括虚函数使用时的一些较佳实践,和一些需要注意的地方。
目录
虚函数介绍
虚函数是基类希望其派生类进行覆盖的函数。当使用指针或引用调用虚函数时,会检查指针指向的是基类还是派生类,并执行相应版本的函数。
class Base
{
public:
virtual void function1() {
std::cout << "Base" << std::endl;
};
};
class Derived : public Base
{
public:
virtual void function1() override {
std::cout << "Derived" << std::endl;
};
};
int main() {
Derived d1;
Base *p = &d1;
p->function1(); // "Derived"
return 0;
}
这就是虚函数的基本用法,用指针可以调用到相应类中的同名同参函数。这也是面向对象的多态性,相同的接口表现出不同行为。在 C++ 的面向对象体系中,子类可以重写 (override)父类的函数,使得子类在同样的接口表现出不一样的行为。
使用虚函数
虚函数的一些规则:
1. 继承类中重写 (override) 的虚函数的函数名与形参列表必须一致,返回类型也要一致
一旦一个函数被定义为
virtual
,则该函数在整个继承体系里都是虚函数,即使子类的函数没有virtual
关键字virtual
会去掉类内函数的内联属性 (inline
),因为内联函数可能会被编译器优化掉,不能在运行中动态确定位置。在 C++11 的新标准中,引入了
override
和final
,使用这两个限定符可以获得编译器的帮助。
使用 override
限定符显式重载
class Base
{
public:
virtual void function();
};
class Derived : public Base
{
public:
virtual void fnuction();
};
在上面这个例子里,Derived
类的函数名写错了,但编译器会认为 Derived
类声明了一个新的函数。这个问题只有等到运行时才会被暴露出来。
class Derived : public Base
{
public:
virtual void fnuction() override; // compile error, function is not an override
};
如果添加了限定符 override
,则编译器会报错说没有可以重写的函数。virtual
关键字虽然可以省略,但也推荐写上,可以减少代码阅读的困难。
使用 final
限定符限制继承
class Base
{
public:
virtual void function() final;
};
class Derived : public Base
{
public:
virtual void function(); // compile error, cannot override
};
假如不需要再继承此函数,可以使用 final
来获得编译器的帮助。override
和 final
的使用规则和 const
差不多,都可以帮助我们把问题暴露在编译期。只要有需要,尽量都加上。
需要注意的地方
1. 默认参数是静态绑定的
class Base
{
public:
virtual void function(int a = 0) {
std::cout << "Base : " << a << std::endl;
};
};
class Derived : public Base
{
public:
virtual void function(int b = 1) override {
std::cout << "Derived : " << b << std::endl;
};
};
int main() {
Derived d;
Base *p = &d;
p->function(); // Derived : 0
return 0;
}
虚函数的默认实参是在编译期决定的,当编译器发现调用的参数个数少了,就把默认参数传入。所以在这个例子里,p
的静态类型为 Base *
,编译器根据其静态类型预先分配好了默认参数,程序又在运行时根据其动态类型找到了 Derived
的虚函数,所以结果为 Derived : 0
。
最好不要在重写虚函数时的更改默认实参,如果非要有默认实参,可以在非虚函数中指定默认参数,然后在非虚函数中调用虚函数
更不要在子类中隐藏非虚函数的默认实参,最好是不要在子类隐藏非虚函数
2. 返回参数不同的虚函数
这是虚函数的一个特例,如果父类返回的是一个类的指针或引用,重写的函数可以返回其继承类的指针或引用。虽然允许这样写,但重写的函数仍然只会返回父类的指针或引用。
class Base
{
public:
virtual Base* getPointer() {
std::cout << "get Base" << std::endl;
return this;
}
void printType() {
std::cout << "Base" << endl;
}
};
class Derived : public Base
{
public:
virtual Derived* getPointer() override { // covariant return type
std::cout << "get Derived" << std::endl;
return this;
}
void printType() {
std::cout << "Derived" << endl;
}
};
int main()
{
Derived d;
Base *b = &d;
d.getPointer()->printType(); // get Derived, Derived
b->getPointer()->printType(); // get Derived, Base
return 0;
}
getPointer
函数返回的分别是 Base *
和 Derived *
。使用 d
对象直接调用函数时,首先调用了 Derived::getPointer()
,得到了 Derived *
类型的 this
指针,再通过这个指针调用了非虚函数 Derived::printType()
。
使用指针 b
调用对象时,因为Base::getPointer()
是虚函数,所以会调用Derived::getPointer()
,虽然这个函数返回 Derived *
,但因为这个虚函数的基类版本返回的是 Base *
,所以返回的 this
会被转换成 Base *
,导致之后调用了 Base::printType()
。
3. 调用特定版本的虚函数
可以通过作用域限定符 ::
调用特定版本的虚函数。
d.getPointer()->Base::getPointer()->printType();
// get Derived, get Base, Base
通过 d
得到了 Derived *
类型的指针,但可以通过 Base::
作用域限定符去调用 Base::getPointer
。
什么场合使用虚函数?
1. 虚析构
如果一个类会被继承,那么它的析构函数需要是虚函数。
来看一个例子
class Base
{
public:
~Base() {
std::cout << "~Base" << std::endl;
}
};
class Derived : public Base
{
private:
int *p;
public:
Derived(int size) {
p = new int[size];
}
~Derived() {
std::cout << "~Derived" << std::endl;
delete[] p;
}
};
int main()
{
Derived *d = new Derived(100);
Base *b = d;
delete b; // ~Base
return 0;
}
上面这个例子中,delete b
时,b
是 Base *
类型,通过 b
去析构其指向的 Derived
类,因为析构函数不是虚函数,调用的是 Base
的析构函数,并没有清理掉 Derived
类的成员,造成了内存泄漏。
所以,会被继承的类必须要使用虚析构。不用来继承的类不需要虚析构,因为虚函数比普通函数要消耗更多的性能,这一点之后会说。
class Base
{
public:
virtual ~Base() {
std::cout << "~Base" << std::endl;
}
};
STL 容器都是非虚析构,最好不要继承
2. 在类中其他地方使用虚函数
构造函数可以被声明成虚函数吗?需要被声明成虚函数吗?
构造函数并不能被声明成虚函数。在一个对象构造时,这个对象的类型还不是完整的(有成员还没有生成完),并不能确定这个对象是什么类型。而虚函数是要通过对象的动态类型来解析的,显然不能将虚函数的机制运用在构造函数上。
构造与析构函数内部可以使用虚函数吗?
如上所述,在对象构造时,该类型还不完全,构造函数内部的函数会被静态地当作该类型的成员函数。虽然可以在构造函数中使用虚函数,但意义不大。
析构的时候也是类似的,析构会先调用子类的析构,再调用基类的析构。如果在这期间调用虚函数,可能会调用已经析构了的子类对象的函数,这是很危险的。所以析构函数里的虚函数也会被静态地当作该类型的成员函数。
静态成员函数可以是虚函数吗?
静态成员函数 (static
member function) ,动态多态,这两个从命名上来说就没有交集。静态成员函数是编译期绑定的,其在调用的时候不会传入 this
指针,也无从判别其动态类型了。
在非虚函数里调用虚函数会怎么样?
类的成员函数在被调用时都会得到 this
指针(除了静态成员函数),在成员函数内部调用虚函数,等同于通过 this
指针去调用虚函数。例如在 func1()
中调用 virtual func2()
,func2()
会是动态绑定的,要注意的是, func1()
还是静态绑定的。
输出流运算符需要是虚函数吗?
通常,输出一个类对象需要一个输出流的友元函数 (friend
),如果不使用虚函数,那就需要给继承体系中每一个子类都添加一个友元函数。但是,友元函数并不是类的成员函数,不能给友元函数添加 virtual
关键字。
可以采用在非虚函数里调用虚函数的方法,在基类友元函数的函数体中调用另一个自定义的输出虚函数。就可以保证整个继承体系都能被输出了。
class Base
{
public:
friend std::ostream& operator<<(std::ostream &out, const Base &b)
{
return b.print(out);
}
virtual std::ostream& print(std::ostream& out) const
{
out << "Base";
return out;
}
};
class Derived : public Base
{
public:
virtual std::ostream& print(std::ostream& out) const override
{
out << "Derived";
return out;
}
};
3. 纯虚函数与抽象类
在面向对象编程中,经常会有抽象概念,比如说视频文件,而我们播放的是某种格式的视频文件。
class VideoFile
{
public:
virtual void play() {
std::cout << "VideoFile" << std::endl;
}
};
class mp4 : public VideoFile {
public:
};
int main()
{
mp4 m;
m.play(); // Video File
return 0;
}
假如忘记写 mp4 的播放函数了,然后又播放了mp4,这样就会调用基类的 play
函数。而且,我们也不希望基类函数的 play
有任何行为,但如果基类的 play
函数体为空,我们连播放 mp4 出错了都不知道。
C++ 提供了纯虚函数来解决这个问题。
class VideoFile
{
public:
virtual void play() = 0;
};
class mp4 : public VideoFile {
public:
// compile error : pure virtual function Videofile::play() has no override
};
int main()
{
VideoFile v; // compile error : object of abstract class type is not allowed
return 0;
}
在一个类中定义了纯虚函数后,该类就会被编译器视为抽象类,不能生成该类的对象。另外,编译器也会强制要求抽象类的继承类 override
纯虚函数。之前说的问题都可以通过纯虚函数来解决。
纯虚函数是为了抽象类而存在的,抽象类一般都是基类,只为了提供接口,便于继承。没有成员的抽象类也被称为接口类。
纯虚函数也可以有函数体,但只能放在类外,不能嵌入。
class VideoFile
{
public:
virtual void play() = 0;
};
void VideoFile::play() {
std::cout << "File format is not supported" << std::endl;
}
class mp4 : public VideoFile {
public:
virtual void play() override {
VideoFile::play();
}
};
int main()
{
VideoFile v; // compile error : object of abstract
mp4 m;
m.play(); // File format is not supported
return 0;
}
有函数体的纯虚类仍然不能生成对象,但可以为继承类提供一个默认实现,可以手动调用。
纯虚析构必须要有定义
当子类被析构时,必定会调用父类的析构函数,即使父类的析构是纯虚析构,也需要定义函数体,否则链接器会报错。
小小的总结一下
什么时候该选用纯虚函数呢?
- 基类不需要实例化
- 子类基本都需要
override
基类的虚函数时 - 抽象类的析构函数必须是纯虚函数
什么时候不用虚函数呢?
- 不需要多态特性时
- 不是基类,不要虚析构
函数类型 | 功能 |
---|---|
纯虚函数 | 只继承接口 |
虚函数 | 继承接口和一份默认实现 |
非虚函数 | 继承接口和一份强制实现(不要隐藏非虚函数) |