C++里的struct到底能不能有虚函数?这个问题看似简单,却能筛掉一批对语言机制理解不深的开发者。答案是肯定的:struct不仅可以定义虚函数,还能参与继承体系、被dynamic_cast转换,甚至拥有虚析构函数。既然如此,struct和class的多态行为有什么差别?编译器又是如何在结构体上实现动态绑定的?下面从底层机制到实践技巧逐一展开。

一、struct与class的本质区别在哪里
在C++标准中,struct和class几乎做的是同一件事,两者都能包含成员函数、构造函数、析构函数、虚函数、静态成员、模板成员,也都能被继承。唯一实质性的差别有两点:第一,class的成员默认是private,struct的成员默认是public;第二,class被继承时默认采用private继承,struct被继承时默认采用public继承。
也就是说,下面两段代码在语义上完全等价,编译器生成的机器码也没有区别:
struct Shape {
virtual double area() { return 0.0; }
};
class Shape2 {
public:
virtual double area() { return 0.0; }
};
除了这两条默认规则,C++标准再没有给struct和class设置任何差异。虚函数的引入与否、虚表指针的布局、动态绑定的触发条件,struct与class完全一致。所以从语言机制上说,结构体多态不是什么特殊技巧,而是C++对象模型的正常组成部分。之所以有人产生“struct不能有虚函数”的印象,多半是把C风格的POD结构体和C++的struct混为一谈了。
二、虚函数在结构体中的内存布局
一旦结构体声明了虚函数,编译器就会在对象内存布局的最前面插入一个虚表指针(vptr)。这个指针指向该类型对应的虚函数表(vtable),表中存放着各虚函数的实际入口地址。通过基类指针或引用调用虚函数时,程序先从对象头部取出vptr,再查表跳转到真正的函数,这就是运行时多态的底层实现。
看一个具体的例子:
#include <iostream>
#include <memory>
struct Base {
int id; // 数据成员
virtual void speak() { std::cout << "Base::speak" << std::endl; }
virtual ~Base() = default; // 虚析构,保证通过基类指针正确释放
};
struct Derived : Base {
void speak() override { std::cout << "Derived::speak" << std::endl; }
};
int main() {
std::unique_ptr<Base> p = std::make_unique<Derived>();
p->speak(); // 输出 Derived::speak,动态绑定生效
std::cout < sizeof(Base) << std::endl; // 64位平台通常输出16
return 0;
}
在64位平台上,sizeof(Base)通常是16:8字节给vptr,4字节给id,再加上对齐填充的4字节。如果去掉virtual关键字,大小会缩到4。这多出来的8字节就是多态的代价——空间上每个对象多一个指针,时间上每次虚调用多一次间接寻址。
还有一点容易被忽视:含有虚函数的结构体会自动失去POD(Plain Old Data)性质,无法再用于与C代码交互的二进制接口,也不能通过memset之类的操作随意初始化。如果你的结构体原本承担跨语言数据传递的职责,往里面加虚函数之前要三思。
三、结构体多态的完整实践与常见陷阱
实现结构体多态的步骤和class完全一样:基类声明虚函数,派生类用override重写,通过基类指针或引用触发动态绑定。其中最经典的陷阱是忘记虚析构函数——通过基类指针delete派生类对象时,如果析构函数不是虚的,行为是未定义的,往往只析构了基类部分。
#include <iostream>
#include <vector>
#include <memory>
struct Animal {
virtual std::string name() const = 0; // 纯虚函数,Animal成为抽象类型
virtual ~Animal() = default;
};
struct Dog : Animal {
std::string name() const override { return "Dog"; }
};
struct Cat : Animal {
std::string name() const override { return "Cat"; }
};
int main() {
std::vector<std::unique_ptr<Animal>> zoo;
zoo.push_back(std::make_unique<Dog>());
zoo.push_back(std::make_unique<Cat>());
for (const auto& a : zoo) {
std::cout << a->name() << std::endl; // 依次输出 Dog 和 Cat
}
return 0;
}
这段代码里Animal是抽象结构体,不能实例化,只能作为接口使用。派生的Dog和Cat各自重写name,容器中存放基类指针,循环调用时由虚表分发到具体实现。整个流程和class写法没有任何区别。
除了虚析构,还有几个细节值得注意。第一,构造函数和静态函数不能是virtual,编译器会直接报错;第二,只有通过指针或引用调用才会触发动态绑定,直接用对象值调用或在构造、析构函数内部调用虚函数,都是静态绑定;第三,多重继承下结构体会持有多个vptr,布局更复杂,建议配合static_assert和offsetof谨慎检查内存偏移。
四、什么时候该用struct实现多态
既然struct和class能力等价,选哪个更多是团队约定问题。C++社区的常见习惯是:当类型主要表现为“一堆公开数据,附带少量行为”时用struct,当类型有不变量需要封装保护时用class。按这个思路,如果多态体系中的派生类主要是数据载体(比如图形顶点、消息包、配置节点),用struct写起来更轻快;如果类型强调封装和内部状态保护,class更合适。
一个务实的建议是:在同一个继承体系内保持struct与class的一致性,不要基类用class、派生类用struct混着写,虽然语法允许,但会降低可读性。同时在头文件和代码评审规范里写明团队的取舍标准,避免每个开发者凭个人喜好随意切换。理解了struct与class在对象模型上的等价性之后,结构体多态就不再神秘,它只是C++统一对象模型下的一种自然写法。