首页 / 资讯中心 / 文章详情

C++虚函数和多态超详细分析

C++虚函数和多态超详细分析 ★ FEATURED ARTICLE
前言多态是面向对象三大特性里最容易被讲得含糊的一个。很多资料把它等同于父类指针指向子类对象调用同名函数执行子类版本这句话描述了现象却没解释机制为什么必须是指针或引用为什么构造函数里调用虚函数不会走到子类为什么基类析构函数不加virtual就会资源泄漏为什么把派生类按值传给基类参数多态就消失了C 的多态分为两类编译期多态函数重载、模板、CRTP和运行期多态虚函数。本文聚焦后者从静态绑定与动态绑定的区别讲起说明虚函数表这一主流实现方式标准并不规定实现细节再讨论override/final、纯虚函数与抽象类、构造析构期间的特殊规则以及对象切片等高频陷阱。示例以 C17 为基准。先把结论放在前面虚函数解决的是通过基类接口调用实际执行派生类实现的问题它依赖运行期类型因此只能通过指针或引用触发。任何按值传递、按值存储的场景都会发生对象切片多态随之丢失。一、静态绑定与动态绑定静态绑定static binding也称早期绑定在编译期就能确定调用哪个函数普通函数调用、重载决议、非虚成员函数、模板实例化都属于这一类。动态绑定dynamic binding晚期绑定把选择推迟到运行期依据的是对象的动态类型而非表达式的静态类型。// 适用于 C17struct Base {void normal() const { /* 非虚 */ }virtual void poly() const { /* 虚函数 */ }};struct Derived : Base {void normal() const { /* 隐藏基类版本 */ }void poly() const override { /* 覆盖基类版本 */ }};void call(const Base b) {b.normal(); // 静态绑定调用 Base::normalb.poly(); // 动态绑定看实际对象可能是 Derived::poly}关键条件是通过指针或引用调用。如果写成Base b derived;发生的是切片derived的派生部分被丢弃b是一个纯粹的Base对象动态类型就是Base怎么调用都不会走到Derived。同理在容器里存std::vectorBase会切片必须存std::vectorstd::unique_ptrBase或std::reference_wrapperBase。动态绑定还有一个前提调用表达式必须能确定为对某个对象的虚函数调用。通过obj.Base::poly()这种限定写法会显式关闭动态绑定直接调用指定版本在构造函数/析构函数中调用虚函数也会退化为当前正在构造/析构的类版本下一节详述。二、虚函数表主流实现方式标准只规定行为不规定实现。主流编译器GCC、Clang、MSVC采用的方案是虚函数表virtual tablevtable加虚表指针vptr每个含虚函数的类更准确地说每个需要动态分派的类有一张虚表本质是一个函数指针数组按声明顺序具体顺序由 ABI 决定存放各虚函数的地址。每个该类的对象在内存布局中通常在最前面有一个隐藏的 vptr指向所属类的虚表。调用p-poly()时编译器生成取 vptr → 按固定偏移取函数指针 → 间接调用的序列。由此可以解释几个常见现象虚函数调用比普通函数多一次间接寻址通常难以内联除非编译器能通过去虚化 devirtualization 证明具体类型。但不要据此断言虚函数一定慢多少——具体开销取决于调用频率、缓存命中、能否去虚化应当用实际测量而不是拍脑袋。对象变大每个多态对象多一个指针大小的 vptr。空类sizeof为 1含一个虚函数的类在 64 位下通常是 8 或 16 字节取决于是否还有别的成员这是实现细节标准不保证。虚表是每个类一份不是每个对象一份对象只存 vptr。构造期间 vptr 会被设置为当前正在构造的类的虚表构造完成后进入派生类构造函数时再改为派生类的表——这正是构造函数里调用虚函数不会分派到派生类的原因。需要强调虚拟继承virtual inheritance的布局更复杂通常引入虚基类表指针不同 ABI 差异很大。不要依赖sizeof、成员偏移、vptr 位置写代码这些都不是标准保证的可移植行为。dynamic_cast和typeid能工作也依赖编译器生成的运行时类型信息RTTI它与虚表机制配套但同样是实现层面的东西。三、override、final 与纯虚函数C11 引入的override是最值得养成的习惯。虚函数覆盖要求函数名、参数列表、const/volatile 限定、引用限定全部一致返回类型可以协变。少写一个const编译器不会报错而是认为你声明了一个新函数悄悄隐藏了基类版本——这是最难查的 bug 之一。// 适用于 C17struct Base {virtual void run() const;virtual ~Base() default;};struct Derived : Base {void run() override; // 编译错误缺少 const没有覆盖任何函数};加上override后编译器会检查确实覆盖了某个基类虚函数签名不匹配立即报错。final用在虚函数上表示禁止进一步覆盖用在类上表示禁止继承。它既是给编译器的约束也是给读者的文档。纯虚函数用 0声明含纯虚函数的类是抽象类不能实例化。纯虚函数可以有定义必须在类外定义派生类仍必须覆盖它才能成为具体类// 适用于 C17struct Interface {virtual void start() 0;virtual ~Interface() default;};void Interface::start() { /* 可选的默认实现 */ }struct Impl : Interface {void start() override { Interface::start(); /* 再做自己的事 */ }};纯虚析构函数也必须提供定义否则派生类析构时链接会失败。关于访问权限有一个常被误解的点虚函数可以是private的仍然可以被派生类覆盖。访问控制检查的是调用点能否访问这个名字与动态分派无关。NVINon-Virtual Interface模式正是利用这一点基类提供 public 非虚函数作为入口内部调用 private 虚函数从而把做什么和怎么做分离。虚函数不能被模板化不能是函数模板因为虚表需要在编译期确定条目但虚函数可以是模板类的成员这是两回事。四、构造、析构与对象切片构造函数和析构函数中调用虚函数不会分派到派生类。原因很直接基类构造时派生类部分尚未构造调用派生版本会访问未初始化的成员基类析构时派生类部分已经销毁。此时 vptr 指向当前类的虚表调用的是当前类的版本。如果当前类是抽象类且该函数是纯虚的甚至可能链接失败或运行时出错——总之不要在构造/析构里调用虚函数尤其是纯虚函数。基类析构函数必须是virtual否则通过基类指针delete派生对象是 UB。实际后果通常是只调用了基类析构函数派生类的资源new出来的内存、文件句柄没有释放。标准规定如果基类析构函数非虚通过基类指针删除派生对象是未定义行为不是只泄漏一点这么轻。// 适用于 C17struct BadBase {~BadBase() default; // 非虚};struct BadDerived : BadBase {~BadDerived() { /* 释放资源 */ }};void leak(BadBase* p) {delete p; // UB不会调用 ~BadDerived}对象切片object slicing发生在按值传递或按值存储时派生类对象被截断成基类子对象派生类的成员和覆盖行为全部丢失。它不会报错行为看起来正常只是多态静默失效。避免方式是传引用/指针容器存智能指针。// 适用于 C17void by_value(Base b); // 切片void by_ref(const Base b); // 保持多态还有一个相关规则默认实参是静态绑定的。即使虚函数分派到了派生类默认实参仍取静态类型基类声明里的值。这经常导致参数怎么不是我写的那个的困惑。完整示例下面实现一个简单的图形系统演示虚函数分派、纯虚接口、虚析构和override// 适用于 C17#include iostream#include memory#include string#include vectorclass Shape {public:virtual ~Shape() default; // 多态基类必须虚析构virtual std::string name() const 0; // 纯虚接口virtual double area() const 0;virtual void print() const { // 虚函数可以有默认实现std::cout name() area area() \n;}};class Circle : public Shape {public:explicit Circle(double r) : r_(r) {}std::string name() const override { return Circle; }double area() const override { return 3.141592653589793 * r_ * r_; }private:double r_;};class Rectangle : public Shape {public:Rectangle(double w, double h) : w_(w), h_(h) {}std::string name() const override { return Rectangle; }double area() const override { return w_ * h_; }private:double w_, h_;};class Square final : public Rectangle { // final禁止再被继承public:explicit Square(double s) : Rectangle(s, s) {}std::string name() const override { return Square; }};void render(const std::vectorstd::unique_ptrShape shapes) {for (const auto s : shapes) {s-print(); // 基类 print 内部再分派 name/area}}int main() {std::vectorstd::unique_ptrShape shapes;shapes.push_back(std::make_uniqueCircle(1.0));shapes.push_back(std::make_uniqueRectangle(2.0, 3.0));shapes.push_back(std::make_uniqueSquare(2.0));render(shapes);// 切片演示按值传递会丢失派生部分Circle c(1.0);// 下面这行会编译报错Shape 是抽象类无法实例化也就无法切片// Shape sliced c;(void)c;return 0;}上面被注释掉的那行是刻意写的反例Shape含纯虚函数本身不能实例化所以切片在编译期就被挡住了——这是抽象基类的一个额外好处。如果基类不是抽象的比如Shape的area有默认实现Shape sliced c;就不会报错多态静默丢失。把render改成按值接收std::vectorstd::unique_ptrShape也不行unique_ptr不可拷贝编译期会报错如果换成std::vectorShape则会在push_back处因抽象类无法实例化而失败。真正危险的是基类可实例化的情况。按标准语义程序会依次打印Circle area3.14159、Rectangle area6、Square area4。三行都来自基类Shape::print其中name()与area()都是虚函数分别动态分派到各自的派生类实现Square没有覆盖area()但它继承的Rectangle::area()使用构造时传入的s, s所以结果是 4。浮点输出的具体位数由std::cout的默认格式决定。常见坑点❌ 多态基类析构函数不写virtual✅ 声明virtual ~Base() default;否则通过基类指针delete派生对象是 UB❌ 覆盖虚函数时漏写const或参数类型差一点✅ 一律加override让编译器替你检查签名❌ 在构造函数或析构函数里调用虚函数指望走到派生类✅ 此时只分派到当前类版本把需要多态的行为推迟到对象完全构造之后❌ 用std::vectorBase存放派生对象✅ 用std::vectorstd::unique_ptrBase避免对象切片❌ 以为虚函数靠同名就能覆盖✅ 覆盖要求签名一致签名不同会隐藏基类重载需要using Base::f;才能一并可见❌ 派生类里定义同名函数后基类的其他重载消失了还以为编译器有 bug✅ 名字隐藏规则如此using声明可以把基类重载引入派生类作用域❌ 认为虚函数调用一定是一次 indirect call没有别的成本✅ 还有去虚化失败、无法内联、缓存不友好等影响是否值得虚化要按场景测量❌ 依赖sizeof或对象内存布局判断 vptr 位置✅ 布局由 ABI 决定标准不保证跨编译器代码不要碰这些假设总结主题规则后果动态绑定条件通过指针或引用调用虚函数按值传递会切片多态失效覆盖要求签名完全一致返回类型可协变加override让编译器检查析构多态基类必须虚析构否则delete基类指针是 UB构造/析构中调用只分派到当前类版本不要依赖此时的动态类型纯虚函数类变抽象可提供定义派生类必须覆盖才能实例化默认实参静态绑定分派到派生类也用基类默认值名字隐藏派生类同名声明隐藏基类所有重载需要using Base::f;虚函数和多态的超详细之处不在于记住 vtable 长什么样而在于理解三组分离静态类型与动态类型、名字查找与函数分派、对象生命周期与可调用性。把这三组关系理清什么时候该用虚函数、什么时候该用模板或std::variant、什么时候该把行为下沉到派生类就有了判断依据。
阅读完成 · 觉得有帮助?
咨询建站