在 C++ 中,虚函数(Virtual Function)是使用 virtual 关键字修饰的类成员函数。当我们通过基类引用或基类指针调用虚函数时,程序不会只看表面上的基类类型,而是会根据实际指向的对象,去执行对应派生类中的同名函数。
为什么要使用虚函数?
根据前面学过的继承规则,如果 Dog(小狗)公有继承自 Animal(动物),那么小狗本身也属于一种动物。利用这一特点,C++ 支持直接使用基类引用 Animal& 绑定派生类对象,或者使用基类指针 Animal* 指向派生类对象。
为什么要这样做呢?因为当程序里有小狗、小猫等多种动物时,我们只需要写一个接收 Animal& 的函数,就能统一处理所有的动物子类对象,不用给每种动物都单独写一个函数。
不过,如果基类 Animal 和派生类 Dog 都定义了同名的 speak() 函数,通过 Animal& 调用时到底会执行哪一个呢?为了看得更直观,我们先在 main() 函数中用基类引用绑定一个 Dog 对象试一下。
示例 1:普通成员函数的调用
#include <iostream>
class Animal {
public:
void speak() {
std::cout << "动物发出叫声\n";
}
};
class Dog : public Animal {
public:
void speak() {
std::cout << "小狗汪汪叫\n";
}
};
int main() {
Dog dog;
Animal& ref = dog; // 基类引用绑定派生类对象
ref.speak();
return 0;
}运行结果如下。
动物发出叫声分析:
从运行结果可以看出,基类引用 ref 明明绑定的是 Dog 对象 dog,执行 ref.speak() 时输出的却是基类的 “动物发出叫声”,并没有调用 Dog 里的同名函数。
为什么会这样呢?因为对于普通的成员函数,编译器在编译阶段只看 ref 表面声明的类型(也就是 Animal&),直接把 ref.speak() 固定绑定到了 Animal 类的函数上,不会在程序运行时去检查它背后是不是 Dog 对象。
C++ 虚函数的基本用法
要想让 ref.speak() 根据背后真正的 Dog 对象去调用 Dog 里的函数,方法非常简单:我们只需要在基类的成员函数前面加上 virtual 关键字,把它声明为 “虚函数” 就可以了。
语法:
class 基类名 {
public:
virtual 返回值类型 函数名(参数列表) {
// 函数体
}
};说明:
virtual 关键字写在基类成员函数的返回值类型前面。它的作用用一句话概括就是:告诉编译器这个函数在派生类中可能有新的实现,通过基类引用或指针调用时,等到程序运行时再根据实际对象决定执行哪个版本。
下面我们保持示例 1 的其他代码不变,只在基类 Animal 的 speak() 前面加上 virtual,看看运行结果有什么变化。
示例 2:声明并调用虚函数
#include <iostream>
class Animal {
public:
// 声明为虚函数
virtual void speak() {
std::cout << "动物发出叫声\n";
}
};
class Dog : public Animal {
public:
void speak() {
std::cout << "小狗汪汪叫\n";
}
};
int main() {
Dog dog;
Animal& ref = dog;
ref.speak();
return 0;
}运行结果如下。
小狗汪汪叫分析:
对比示例 1 可以看到,main() 函数里的代码一行都没改,唯一的变化是在基类 Animal 的 speak() 前面加上了 virtual 关键字。
加上 virtual 之后,当执行 ref.speak() 时,程序发现 speak() 是虚函数,就不会只看表层的 Animal& 类型,而是在运行时检查 ref 实际绑定的对象,最终执行 Dog 里的版本。
这种在程序运行时,根据基类引用或指针实际指向的对象类型来决定执行哪个虚函数的机制,在 C++ 中称为 “动态绑定(Dynamic Binding)”。
注意 virtual 改变的只是同名虚函数的匹配方式,并不会改变 ref 本身的 Animal& 类型。通过基类引用或指针访问对象时,我们依然只能调用基类中已经声明过的成员,不能直接访问派生类独有的新成员。
通过基类引用与指针调用虚函数
在实际开发中,触发虚函数的动态绑定主要有两种途径:一种是使用基类的引用(通过点运算符 . 调用),另一种是使用基类的指针(通过箭头运算符 -> 调用)。
其中,基类指针还可以在运行过程中随时修改指向,先后指向不同的派生类对象。下面我们同时定义 Dog 和 Cat 两个子类来对比看看。
示例 3:引用与指针的动态绑定
#include <iostream>
class Animal {
public:
virtual void speak() {
std::cout << "动物发出叫声\n";
}
};
class Dog : public Animal {
public:
void speak() {
std::cout << "小狗汪汪叫\n";
}
};
class Cat : public Animal {
public:
void speak() {
std::cout << "小猫喵喵叫\n";
}
};
int main() {
Dog dog;
Cat cat;
// 1. 通过基类引用调用
Animal& ref1 = dog;
Animal& ref2 = cat;
ref1.speak();
ref2.speak();
// 2. 通过基类指针切换指向调用
Animal* ptr = &dog;
ptr->speak();
ptr = &cat;
ptr->speak();
return 0;
}运行结果如下。
小狗汪汪叫
小猫喵喵叫
小狗汪汪叫
小猫喵喵叫分析:
在这个例子中,ref1 和 ref2 的声明类型虽然都是 Animal&,但因为背后分别绑定了 dog 和 cat,调用 speak() 时就会自动执行各自子类里的函数。
基类指针 ptr 也是同样的道理:先指向 dog 时,ptr->speak() 调用的是 Dog 的版本。当执行 ptr = &cat; 改指 cat 后,同一行调用代码就自动切换成 Cat 的版本了。
这里小伙伴们要留意,如果直接通过子类对象本身调用(如 dog.speak()),因为 dog 本来就是明确的 Dog 类型,不经过基类引用或指针也会执行子类函数。只有通过 Animal& 或 Animal* 间接访问时,动态绑定才会真正派上用场。
使用虚函数的注意事项
虚函数的语法虽然简单,但在初次使用时,我们需要注意以下 4 点:
- 必须在基类的对应成员函数前加上
virtual,如果只在派生类函数前写virtual而基类没写,通过基类引用或指针调用时依然只会执行基类版本。 - 只要基类中的成员函数声明为了
virtual,派生类中格式匹配的同名函数就会自动成为虚函数,前面加不加virtual效果完全相同。 - 派生类重新实现基类的虚函数时,函数名、参数列表、返回值类型以及末尾的
const修饰都必须与基类保持一致,具体规则我们会在下一节 “C++ 函数重写” 中展开介绍。 - 类的构造函数和
static静态成员函数不能声明为virtual虚函数,否则会直接导致编译报错。
对于上面第二点,在派生类中下面 2 种方式是等价的:
// 方式 1(推荐)
class Dog : public Animal {
public:
void speak() {
std::cout << "小狗汪汪叫\n";
}
};
// 方式 2
class Dog : public Animal {
public:
virtual void speak() {
std::cout << "小狗汪汪叫\n";
}
};在实际开发中,派生类一般采用方式 1 省略前面的 virtual,这样代码看起来更加简洁。
虚函数表(vtable)简介
不少小伙伴可能会感到好奇:既然 ref 表面的类型是 Animal&,程序在运行时是怎么知道它背后是 Dog 对象,并准确找到 Dog::speak() 的呢?
其实,只要类中包含 virtual 虚函数,编译器就会为该类生成一张 “虚函数表”(简称 vtable),用来存放各个虚函数的入口地址。同时,该类创建出来的每个对象内部还会自动多出一个隐藏指针(常称为 vptr),因此对象会多占用一个指针大小的内存空间。
当我们创建 Dog 对象时,它内部的隐藏指针指向的就是 Dog 类的虚函数表。通过基类引用或指针调用 speak() 时,程序顺着这个隐藏指针去查表,就能准确找到并执行 Dog 里的版本。整个查表过程都由编译器自动完成,这里简单了解一下即可。
