前言
有人前不久去面试的时候,面试官突然抛出了一个问题:
“虚函数也可以有默认函数吗?”
这个问题乍一看很简单,但背后其实能延伸出很多关于 C++ 语言设计和软件工程实践的内容。
这个回答当然是:
可以。虚函数完全可以有默认实现,甚至纯虚函数也能写函数体。
这个问题这其实是个不错的切入点,可以展开聊聊虚函数的方方面面,于是希望这篇文章能帮到准备面试或者想深入理解 C++ 的同学。
一、什么是虚函数?
在 C++ 里,虚函数(virtual function)是实现 运行时多态 的关键。它的本质就是:
-
在基类里定义函数,并声明为
virtual。 -
在派生类中可以重写这个函数。
-
当通过基类指针或引用调用时,实际执行的是派生类的版本(动态绑定)。
举个最经典的例子:
#include <iostream>
using namespace std;
class Base {
public:
virtual void sayHello() {
cout << "Hello from Base" << endl;
}
};
class Derived : public Base {
public:
void sayHello() override {
cout << "Hello from Derived" << endl;
}
};
int main() {
Base* ptr = new Derived();
ptr->sayHello(); // 输出:Hello from Derived
delete ptr;
}
这里 sayHello 就是一个虚函数,它让调用发生在运行时决定,而不是编译时决定。
二、虚函数是否可以有默认实现?
1. 普通虚函数
当然可以。
虚函数并不是等于“必须要子类实现”。 它只是告诉编译器:这个函数要走虚函数表(vtable)的动态分派机制。
所以我们完全可以在基类里提供一个默认实现:
class Base {
public:
virtual void foo() {
cout << "Base default foo()" << endl;
}
};
如果派生类不重写,那么它就会直接继承这个默认逻辑。
2. 纯虚函数
更有意思的是:
纯虚函数(=0)也可以有默认实现!
class Base {
public:
virtual void foo() = 0; // 纯虚函数
};
void Base::foo() {
cout << "Base::foo() default implementation" << endl;
}
class Derived : public Base {
public:
void foo() override {
Base::foo(); // 先用基类逻辑
cout << "Derived::foo()" << endl;
}
};
输出结果:
Base::foo() default implementation
Derived::foo()
这说明即便是纯虚函数,编译器也允许你在基类里提供一个实现。区别只是:
-
有
=0标记后,基类变成抽象类,不能实例化。 -
派生类必须显式重写,除非它自己也想保持抽象。
三、为什么要给虚函数一个默认实现?
面试的时候如果只停留在语法层面,答案未免太“干巴巴”。面试官更想听到的是你的设计思维。
1. 避免重复代码
很多情况下,子类可能大多数逻辑都一样,只在少数地方有差异。
这时基类提供一个默认实现,派生类只需要覆盖差异部分即可,避免重复造轮子。
2. 提供兜底逻辑
有些接口必须实现,但又希望在特殊情况下给出一个“保底”逻辑。
例如:
-
如果派生类没有实现日志系统,那至少打印到
stdout。 -
如果派生类没有实现错误处理,那至少抛个异常。
3. 便于扩展
假如将来要增加新的派生类,它可以直接继承基类的默认实现,快速使用,而不用立刻写一堆重复代码。
四、普通虚函数 vs 纯虚函数
很多同学容易混淆两者,我整理了一张表:
| 特性 | 普通虚函数 | 纯虚函数 |
|---|---|---|
| 定义方式 | virtual void f(); | virtual void f() = 0; |
| 是否必须实现 | 否,可以直接用基类实现 | 是,派生类必须重写 |
| 是否能有实现体 | 可以 | 可以(少见但合法) |
| 是否使类抽象化 | 否 | 是,类不可实例化 |
| 适用场景 | 框架基类、可选重写 | 接口定义、强制重写 |
关键点:
-
=0并不是说“这个函数没有实现”,而是说“这个类是抽象类”。 -
实现体写不写,是两回事。
五、虚函数表(vtable)机制简析
面试官有时会追问:“为什么虚函数能有默认实现?它底层是怎么实现的?”
这就涉及到 虚函数表(vtable)。
-
每个包含虚函数的类,编译器会生成一张虚函数表,里面存放函数指针。
-
对象里有一个指向这张表的指针(vptr)。
-
调用虚函数时,会通过 vptr 找到实际函数地址执行。
举个例子:
class Base {
public:
virtual void foo() { cout << "Base foo()" << endl; }
};
class Derived : public Base {
public:
void foo() override { cout << "Derived foo()" << endl; }
};
在运行时,Derived 的 vtable 会把 foo 指向 Derived::foo,
而如果没有 override,就会沿用 Base::foo。
所以说,虚函数能否有默认实现,完全取决于 vtable 是否有指针指向它。答案显然是肯定的。
六、实际开发中的用法
1. 接口类(Interface)
通常我们会写一个纯虚函数接口类:
class IShape {
public:
virtual double area() const = 0;
virtual double perimeter() const = 0;
virtual ~IShape() {}
};
这样强制子类必须实现。
但如果我们想给一个默认逻辑(比如 perimeter 里先调用 area),那也可以给它加实现。
2. 框架基类
比如写一个 Logger:
class Logger {
public:
virtual void log(const string& msg) {
cout << "[Default log] " << msg << endl;
}
};
子类如果不关心日志,就直接用默认实现;
如果要写文件,就 override。
3. 混合设计
有时一个类既有普通虚函数,也有纯虚函数:
class AbstractHandler {
public:
virtual void init() { cout << "Default init" << endl; }
virtual void handle() = 0; // 强制实现
};
这种混搭的设计在框架里很常见:
-
init给一个默认逻辑,但允许子类定制; -
handle强制子类必须实现。
七、常见面试陷阱
面试官可能会绕几个弯子:
-
虚函数能是构造函数吗?
→ 不行,构造函数不能是虚函数。 -
析构函数要不要写成虚的?
→ 如果类可能被继承,必须写虚析构,否则 delete 基类指针会内存泄漏。 -
纯虚函数能否有实现?
→ 可以,但类依然是抽象类,不能直接实例化。 -
虚函数影响性能吗?
→ 有一点点开销(一次指针间接寻址),但大多数情况下可以忽略。
八、最佳实践总结
-
如果希望可选重写,就用普通虚函数并给一个默认实现。
-
如果希望必须重写,就用纯虚函数,但仍可提供兜底实现。
-
如果基类会被继承,一定要有虚析构函数。
-
避免在构造函数和析构函数中调用虚函数(因为此时 vtable 可能不完整)。
九、结语
回到最初的问题:
虚函数可以有默认实现吗?
答案是:可以,甚至纯虚函数也能写函数体。
理解了这一点,就不会被 =0 迷惑。虚函数机制的精髓在于 运行时多态,而默认实现只是语法和设计上的灵活性。
从设计角度看,虚函数的默认实现其实是 “接口与抽象” 与 “复用与扩展” 的平衡点。
这也是为什么很多优秀的 C++ 框架源码里,你会看到“纯虚函数带实现”的写法——它并不是奇技淫巧,而是一种深思熟虑的工程实践。


被折叠的 条评论
为什么被折叠?



