多态:从虚函数语法到虚表寻址,一次讲透动态绑定

文章目录

一、多态两大分类:结果什么时候定下来

多态的字面意思是"同一个调用,不同的结果"。C++ 里达成这个效果有两条完全不同的路,分界线只有一个问题:结果在什么时候确定。

多态两大分支

1.1 静态多态:编译期就分好工

最熟悉的静态多态是函数重载。

cpp 复制代码
// 静态多态:重载决议在编译期完成,符号表里就是三个不同的函数
#include <iostream>

namespace byte
{
    void Print(int x)          { std::cout << "Print(int):          " << x << std::endl; }
    void Print(double x)       { std::cout << "Print(double):       " << x << std::endl; }
    void Print(const char* x)  { std::cout << "Print(const char*):  " << x << std::endl; }
}

int main()
{
    byte::Print(1);
    byte::Print(3.14);
    byte::Print("hello");
    return 0;
}
text 复制代码
Print(int):          1
Print(double):       3.14
Print(const char*):  hello

三个 Print 在编译期就按实参类型各自确定了调用目标------符号表里是三个独立的函数,运行期没有任何选择动作。这就是静态绑定(早绑定):绑定完成得越早,运行开销越小,但灵活性也锁死在编译那一刻。

1.2 动态多态:把决定权交给对象

动态多态反过来:代码里只写"调这个函数",具体执行谁的版本,等运行时看对象是谁 。同一行 p->Speak(),指向猫就是喵、指向狗就是汪。这个能力靠虚函数机制撑起来,接下来三节把它拆开。

二、动态多态三要素:三个开关全开才通电

动态多态成立需要同时满足三个条件:① 公有继承;② 基类虚函数被子类重写(签名完全一致);③ 通过基类指针或引用调用。

cpp 复制代码
// 动态多态三要素对照:继承、虚函数重写、父类指针或引用------缺一个就失效
#include <iostream>

namespace byte
{
    class Animal
    {
    public:
        void Speak1() const           { std::cout << "Animal::Speak1(非虚)" << std::endl; }
        virtual void Speak2() const   { std::cout << "Animal::Speak2(虚)"   << std::endl; }
        virtual ~Animal() {}
    };

    class Cat : public Animal
    {
    public:
        void Speak1() const           { std::cout << "Cat::Speak1(重定义,非虚)" << std::endl; }
        virtual void Speak2() const   { std::cout << "Cat::Speak2(重写)"         << std::endl; }
    };
}

// 按值传参:发生切片,三要素缺了"指针或引用"
void feed_by_value(byte::Animal a)       { a.Speak2(); }

// 引用:三要素齐全,多态生效
void feed_by_ref(const byte::Animal& a)  { a.Speak2(); }

// 指针:同上
void feed_by_ptr(const byte::Animal* p)  { p->Speak2(); }

int main()
{
    byte::Cat c;
    byte::Animal* p = &c;

    std::cout << "== 要素二(虚函数)开关对比 ==" << std::endl;
    p->Speak1();        // 非虚:按指针类型走,Animal 版本
    p->Speak2();        // 虚:按对象类型走,Cat 版本

    std::cout << "== 要素三(指针/引用)开关对比 ==" << std::endl;
    feed_by_value(c);   // 切片,永远 Animal 版本
    feed_by_ref(c);     // Cat 版本
    feed_by_ptr(&c);    // Cat 版本
    return 0;
}
text 复制代码
== 要素二(虚函数)开关对比 ==
Animal::Speak1(非虚)
Cat::Speak2(重写)
== 要素三(指针/引用)开关对比 ==
Animal::Speak2(虚)
Cat::Speak2(重写)
Cat::Speak2(重写)

输出分两组对照着读。第一组验证要素②:p->Speak1() 没写 virtual,走指针的类型 (Animal 版本);p->Speak2() 是虚函数,走对象的类型 (Cat 版本)------虚函数把绑定依据从"指针类型"换成了"对象类型",这一句是全部机制的核心。第二组验证要素③:同一个虚函数,按值传参走 Animal 版本(切片把 Cat 部分切掉了),引用和指针走 Cat 版本。

第三要素的"公有"也不是摆设。private 继承下父类指针根本指不了子类对象:

text 复制代码
clang++: error: no such file or directory: 'e4_private_base_poly.cpp'

动态多态三要素

三、虚函数语法规则:接口继承与实现继承

virtual 写在基类成员函数声明前,派生类重写时 virtual 可写可不写(写了更醒目)。真正的重点是虚函数家族里混着两种继承:

  • 接口继承 :纯虚函数(=0),只承诺"必须能做这件事",不给做法,第五节展开。
  • 实现继承:普通虚函数给了默认实现,子类不重写就沿用;重写了就换。

判断能不能多态,看签名是否逐字一致 :函数名、参数列表、const 修饰,一个都不能差。C++11 补了 override 关键字替人把关:

cpp 复制代码
#include <iostream>

namespace byte
{
    class Base
    {
    public:
        virtual void greet() const      { std::cout << "Base::greet" << std::endl; }
        virtual void run()              { std::cout << "Base::run" << std::endl; }
        virtual ~Base() {}
    };

    class Derived : public Base
    {
    public:
        // override:签名对不上直接编译报错,把"假重写"拦在编译期
        virtual void greet() const override { std::cout << "Derived::greet" << std::endl; }

        // final:封死 further 重写,再往下重写就是编译错
        virtual void run() final            { std::cout << "Derived::run" << std::endl; }

        virtual ~Derived() {}
    };
}

int main()
{
    byte::Derived d;
    byte::Base* p = &d;
    p->greet();
    p->run();
    return 0;
}
text 复制代码
Derived::greet
Derived::run

override 的价值在编译期拦截笔误:签名对不上直接报错,而不是安静地变成一个新函数。final 则封死 further 重写,按需使用。

四、虚析构:父类指针 delete 子类对象,不加 virtual 必漏

多态体系里最危险的一条规则:用基类指针 delete 一个子类对象时,如果基类析构不是虚函数,只会执行基类析构------子类自己申请的资源原地泄漏。`

cpp 复制代码
// 虚析构问题现场:父类指针 delete 子类对象,只跑了父类析构
#include <iostream>

namespace byte
{
    class Job
    {
    public:
        Job() : _buf(new char[100])
        {
            std::cout << "  Job 构造:分配 100 字节" << std::endl;
        }

        // 注意:这里故意不写 virtual
        ~Job()
        {
            delete[] _buf;
            std::cout << "  ~Job:释放 100 字节" << std::endl;
        }

        char* _buf;
    };

    class OnlineJob : public Job
    {
    public:
        OnlineJob() : _ext(new char[200])
        {
            std::cout << "  OnlineJob 构造:分配 200 字节" << std::endl;
        }

        ~OnlineJob()
        {
            delete[] _ext;
            std::cout << "  ~OnlineJob:释放 200 字节" << std::endl;
        }

        char* _ext;
    };
}

int main()
{
    byte::Job* p = new byte::OnlineJob;
    std::cout << "== delete 父类指针 ==" << std::endl;
    delete p;
    std::cout << "== 结束(观察 ~OnlineJob 是否出现)==" << std::endl;
    return 0;
}
text 复制代码
  Job 构造:分配 100 字节
  OnlineJob 构造:分配 200 字节
== delete 父类指针 ==
  ~Job:释放 100 字节
== 结束(观察 ~OnlineJob 是否出现)==

看输出:~OnlineJob 那行根本没出现 ------子类析构没跑,_ext 那 200 字节悬空。

text 复制代码
Process 20834: 1 leak for 224 total leaked bytes.
STACK OF 1 INSTANCE OF 'ROOT LEAK: <malloc in byte::OnlineJob::OnlineJob()>':

leaks 点名泄漏发生在 byte::OnlineJob::OnlineJob() 里------正是构造函数中 new char[200] 的那一块。修复只要一个词,给基类析构加上 virtual:

cpp 复制代码
// 虚析构修复:析构函数加 virtual,delete 父类指针先跑子类析构
#include <iostream>

namespace byte
{
    class Job
    {
    public:
        Job() : _buf(new char[100])
        {
            std::cout << "  Job 构造:分配 100 字节" << std::endl;
        }

        virtual ~Job()          // 唯一的改动:加 virtual
        {
            delete[] _buf;
            std::cout << "  ~Job:释放 100 字节" << std::endl;
        }

        char* _buf;
    };

    class OnlineJob : public Job
    {
    public:
        OnlineJob() : _ext(new char[200])
        {
            std::cout << "  OnlineJob 构造:分配 200 字节" << std::endl;
        }

        ~OnlineJob()
        {
            delete[] _ext;
            std::cout << "  ~OnlineJob:释放 200 字节" << std::endl;
        }

        char* _ext;
    };
}

int main()
{
    byte::Job* p = new byte::OnlineJob;
    std::cout << "== delete 父类指针 ==" << std::endl;
    delete p;
    std::cout << "== 结束(~OnlineJob 先跑,~Job 后跑)==" << std::endl;
    return 0;
}
text 复制代码
  Job 构造:分配 100 字节
  OnlineJob 构造:分配 200 字节
== delete 父类指针 ==
  ~OnlineJob:释放 200 字节
  ~Job:释放 100 字节
== 结束(~OnlineJob 先跑,~Job 后跑)==
text 复制代码
Process 20839: 0 leaks for 0 total leaked bytes.

析构顺序恢复正确(~OnlineJob 先、~Job 后),leaks 报告归零:0 leaks for 0 total leaked bytes。结论写成纪律:只要一个类会被继承、会经基类指针 delete,析构函数就必须是 virtual。成本是对象多一根 vptr------对有虚函数的类来说这根指针本来就存在,几乎白赚。

五、vtable 与 vptr:动态绑定的底层账本

前几节的现象需要一个底层解释:运行期凭什么知道对象是谁 。答案是每个多态类一张虚表(vtable) 、每个对象一根虚表指针(vptr)。

cpp 复制代码
// vtable 与 vptr:观察对象里多出来的指针和虚表的分工
#include <iostream>

namespace byte
{
    // 对照组:没有虚函数的空类
    class NoVirtual
    {
    public:
        void h() { std::cout << "NoVirtual::h" << std::endl; }
    };

    class Base
    {
    public:
        virtual void f() { std::cout << "Base::f" << std::endl; }
        virtual void g() { std::cout << "Base::g" << std::endl; }
        void h()         { std::cout << "Base::h(非虚,不进虚表)" << std::endl; }
        virtual ~Base() {}
    };

    class Derived : public Base
    {
    public:
        virtual void f() { std::cout << "Derived::f" << std::endl; }   // 只重写 f
        // g 不重写,虚表里沿用 Base::g
    };
}

// 观察辅助:读对象前 8 字节(vptr),再从虚表取第 i 项调用。
typedef void (*VF)();
static void* vptr_of(void* obj) { return *reinterpret_cast<void**>(obj); }

static VF entry_of(void* obj, int i)
{
    // 函数指针与对象指针的搬运借 union 完成,绕开类型系统只为观察
    union Slot
    {
        void* addr;
        VF    fn;
    };
    Slot s;
    s.addr = reinterpret_cast<void**>(vptr_of(obj))[i];
    return s.fn;
}

int main()
{
    std::cout << "sizeof(NoVirtual) = " << sizeof(byte::NoVirtual) << std::endl;
    std::cout << "sizeof(Base)      = " << sizeof(byte::Base)
              << "(多出来的就是 vptr)" << std::endl;

    byte::Base b1, b2;
    byte::Derived d;

    void* v1 = vptr_of(&b1);
    void* v2 = vptr_of(&b2);
    void* vd = vptr_of(&d);
    std::cout << std::boolalpha;
    std::cout << "b1 与 b2 的 vptr 相同 ? " << (v1 == v2)
              << "(同一张虚表)" << std::endl;
    std::cout << "b1 与 d  的 vptr 相同 ? " << (v1 == vd)
              << "(Derived 重写了 f,虚表是独立一份)" << std::endl;

    std::cout << "== 从虚表第 0 项调用(对应 f)==" << std::endl;
    entry_of(&b1, 0)();     // Base::f
    entry_of(&d, 0)();      // Derived::f ------ 重写只换了这一格

    std::cout << "== 从虚表第 1 项调用(对应 g)==" << std::endl;
    entry_of(&b1, 1)();     // Base::g
    entry_of(&d, 1)();      // Derived 没重写,还是 Base::g
    return 0;
}
text 复制代码
sizeof(NoVirtual) = 1
sizeof(Base)      = 8(多出来的就是 vptr)
b1 与 b2 的 vptr 相同 ? true(同一张虚表)
b1 与 d  的 vptr 相同 ? false(Derived 重写了 f,虚表是独立一份)
== 从虚表第 0 项调用(对应 f)==
Base::f
Derived::f
== 从虚表第 1 项调用(对应 g)==
Base::g
Base::g

四个数据点连成完整逻辑。第一 ,sizeof(NoVirtual) = 1(空类占位字节),sizeof(Base) = 8------多出来的 8 字节就是 vptr,有虚函数的对象头部藏着一根指针 。第二 ,b1 与 b2 的 vptr 相同------虚表属于类,同类对象共享一张。第三 ,b1 与 d 的 vptr 不同------Derived 重写了 f,编译器为它生成了独立的虚表。第四 ,从虚表槽位直接调用:第 0 项 b 走 Base::f、d 走 Derived::f;第 1 项两边都是 Base::g------重写的本质就是"把子类虚表对应槽位的函数地址换掉",没重写的槽位原样沿用基类条目。

p->f() 的运行期三步由此清晰:取对象头部的 vptr → 找到该类的虚表 → 按槽位取地址调用。这三步就是"动态绑定"的全部成本,也是"虚看对象、非虚看指针"的物理解释------非虚函数的地址编译期就焊死在指令里,根本不查表。

配图 4|单继承下虚表与虚指针布局

六、overload / override / hiding:三兄弟一份判决书

笔试最爱把这三个概念搅在一起。判定只要两问:先问两个函数在不在同一层作用域,再问基类函数是不是虚函数:

cpp 复制代码
// 重载、重写、隐藏三者辨析:一份代码看清三种关系
#include <iostream>

namespace byte
{
    class Base
    {
    public:
        // 重载:同一作用域、同名、参数不同------编译期就选定
        void fun()                { std::cout << "Base::fun()" << std::endl; }
        void fun(int x)           { std::cout << "Base::fun(int) " << x << std::endl; }

        virtual void greet()      { std::cout << "Base::greet()" << std::endl; }

        virtual void show() const { std::cout << "Base::show() const" << std::endl; }

        virtual ~Base() {}
    };

    class Derived : public Base
    {
    public:
        // 重写(override):签名与基类虚函数完全一致
        virtual void greet()      { std::cout << "Derived::greet()" << std::endl; }

        // 隐藏(hiding):与 Base::fun 同名不同作用域,基类两个 fun 全被挡住
        void fun(int x)           { std::cout << "Derived::fun(int) " << x << std::endl; }
    };
}

int main()
{
    byte::Derived d;
    byte::Base* p = &d;

    std::cout << "== 重载:同一作用域内,编译期选定 ==" << std::endl;
    d.Base::fun();
    d.Base::fun(10);

    std::cout << "== 隐藏:派生类 fun(int) 挡住基类两个 fun ==" << std::endl;
    d.fun(10);          // Derived::fun(int)

    std::cout << "== 重写:签名一致,多态生效 ==" << std::endl;
    p->greet();         // Derived::greet()
    return 0;
}
text 复制代码
== 重载:同一作用域内,编译期选定 ==
Base::fun()
Base::fun(int) 10
== 隐藏:派生类 fun(int) 挡住基类两个 fun ==
Derived::fun(int) 10
== 重写:签名一致,多态生效 ==
Derived::greet()
  • 重载 :同一个作用域内、同名、参数不同,Base::fun() 与 Base::fun(int) 并存,编译期按实参选定。
  • 重写 :跨作用域,基类函数是 virtual、子类签名逐字一致,greet() 经基类指针调用走子类版本。
  • 隐藏 :跨作用域、其余全部情况。Derived::fun(int) 与基类 fun 同名,基类的两个 fun 整组被挡------继承篇的"名字查找内层命中即停"在这里再次生效。

隐藏里埋着笔试第一大坑:假重写。签名差一个 const 就不算重写,

p->show() 走的还是基类版本------虚表槽位根本没被换 ,子类那个 show() 是另一个独立的新虚函数。

overload / override / hiding 对照

七、构造与析构期间调虚函数:多态暂时下岗

在构造函数或析构函数里调用虚函数,永远不会落到子类版本------这不是 bug,是语言规则。

cpp 复制代码
// 构造与析构期间调用虚函数:多态失效,永远走当前正在构造/析构的那一层的版本
#include <iostream>

namespace byte
{
    class Base
    {
    public:
        Base()
        {
            init();     // 构造期间调虚函数
            std::cout << "  Base 构造完成" << std::endl;
        }

        virtual void init()
        {
            std::cout << "  Base::init 被调用" << std::endl;
        }

        virtual ~Base()
        {
            release();  // 析构期间调虚函数
            std::cout << "  Base 析构完成" << std::endl;
        }

        virtual void release()
        {
            std::cout << "  Base::release 被调用" << std::endl;
        }
    };

    class Derived : public Base
    {
    public:
        Derived()
        {
            std::cout << "  Derived 构造完成" << std::endl;
        }

        virtual void init()
        {
            std::cout << "  Derived::init 被调用" << std::endl;
        }

        virtual void release()
        {
            std::cout << "  Derived::release 被调用" << std::endl;
        }

        virtual ~Derived()
        {
            std::cout << "  Derived 析构完成" << std::endl;
        }
    };
}

int main()
{
    std::cout << "== 构造 Derived ==" << std::endl;
    byte::Derived d;

    std::cout << "== main 作用域结束,自动析构 ==" << std::endl;
    return 0;
}
text 复制代码
== 构造 Derived ==
  Base::init 被调用
  Base 构造完成
  Derived 构造完成
== main 作用域结束,自动析构 ==
  Derived 析构完成
  Base::release 被调用
  Base 析构完成

构造 Derived 时 init() 输出的是 Base::init,析构时 release() 输出的是 Base::release。vptr 是构造过程中一步一步换指向的 ------进入 Base::Base() 时 vptr 指向 Base 虚表,此刻的虚函数调用自然落在 Base 层;进入 Derived::Derived() 后 vptr 才换过去。析构反向同理,一进入 ~Base,vptr 就换回 Base 虚表。语言这么设计是在保护程序:Derived 还没构造完时它的成员不存在,真把调用派下去等于操作半成品对象。

构造析构期间的虚函数行为

八、对象切片与上下转型

切片在继承篇算过 sizeof 账本,这次补上多态视角的后果:按值传参不仅丢数据,连多态也一起丢------切片拷走的是基类子对象,vptr 所在的那份对象头会按基类重新安放,函数体内看到的彻头彻尾是个基类对象:

cpp 复制代码
// 对象切片:按值传参丢掉派生部分;向下转型要自己担保安全
#include <iostream>

namespace byte
{
    class Animal
    {
    public:
        virtual void kind() const { std::cout << "Animal(基类版本)" << std::endl; }
        virtual ~Animal() {}
    };

    class Dog : public Animal
    {
    public:
        virtual void kind() const { std::cout << "Dog(子类版本)" << std::endl; }
        void fetch()              { std::cout << "Dog::fetch:子类专属能力" << std::endl; }
    };
}

void by_value(byte::Animal a)        { a.kind(); }   // 按值:切片
void by_ref(const byte::Animal& a)   { a.kind(); }   // 引用:不切片

int main()
{
    byte::Dog d;

    std::cout << "== 按值传参 vs 引用传参 ==" << std::endl;
    by_value(d);    // Animal ------ 切片,派生部分没进函数
    by_ref(d);      // Dog

    std::cout << "== 向上转型:隐式、安全 ==" << std::endl;
    byte::Animal* pa = &d;
    pa->kind();     // Dog,多态生效

    std::cout << "== 向下转型:static_cast,安全性自己担保 ==" << std::endl;
    byte::Dog* pd = static_cast<byte::Dog*>(pa);    // pa 确实指向 Dog,没问题
    pd->kind();
    pd->fetch();
    return 0;
}
text 复制代码
== 按值传参 vs 引用传参 ==
Animal(基类版本)
Dog(子类版本)
== 向上转型:隐式、安全 ==
Dog(子类版本)
== 向下转型:static_cast,安全性自己担保 ==
Dog(子类版本)
Dog::fetch:子类专属能力

by_value(d) 输出基类版本、by_ref(d) 输出子类版本,同一只狗,两种命运。想保住多态,指针或引用,别无选择 。向上转型隐式安全;向下转型 C++98 里用 static_cast,安全性完全自己担保------pa 确实指向 Dog 时没问题,指向真 Animal 时行为未定义。标准库提供了运行期检查的 dynamic_cast

九、协变返回值:重写时返回值可以"降级"

一般规则说重写要求签名逐字一致,返回值却有一个合法的例外:基类虚函数返回基类指针/引用,子类重写时可以返回子类指针/引用,这叫协变:

cpp 复制代码
// 协变返回值:重写时返回值可以换成子类指针/引用
#include <iostream>

namespace byte
{
    class Base
    {
    public:
        virtual Base* clone() const
        {
            std::cout << "  Base::clone" << std::endl;
            return new Base(*this);
        }

        virtual ~Base() {}
    };

    class Derived : public Base
    {
    public:
        // 协变:返回 Derived* 也算重写(必须是基类返回值的子类指针/引用)
        virtual Derived* clone() const
        {
            std::cout << "  Derived::clone" << std::endl;
            return new Derived(*this);
        }
    };
}

int main()
{
    byte::Derived d;
    byte::Base* p = &d;

    std::cout << "== 经基类指针调用 clone ==" << std::endl;
    byte::Base* c1 = p->clone();        // 多态:走 Derived::clone,返回值自动收窄为 Base*

    std::cout << "== 直接对对象调用 clone ==" << std::endl;
    byte::Derived* c2 = d.clone();      // 拿到的直接就是 Derived*,不用再向下转

    delete c1;
    delete c2;
    return 0;
}
text 复制代码
== 经基类指针调用 clone ==
  Derived::clone
== 直接对对象调用 clone ==
  Derived::clone

p->clone() 走的是 Derived::clone(重写生效),返回的 Derived* 自动收窄成 Base*;而 d.clone() 直接拿到 Derived*,省掉一次向下转型。工厂方法、克隆接口常用这招。限制条件要记牢:只有指针和引用允许协变,按值返回不行;子类返回类型必须是基类返回类型的子类。

十、难点拆解

10.1 "虚看对象、非虚看指针"到底换了什么

非虚函数的调用目标在编译期由静态类型 (指针/引用声明类型)决定;虚函数运行期由动态类型 (对象真实身份)决定,载体就是 vptr。同一根指针,静态类型永远是 Animal*,动态类型随指向的对象变------虚函数只是把"看谁"从前者换成后者。理解了这层,切片为什么丢多态(对象都换了,动态类型成了 Animal)、构造析构为什么失效(vptr 尚未/已经换回基层)全部自动成立。

102 虚析构的泄漏为什么恰好是子类那块

delete p 编译器只看静态类型 Job*:非虚析构 → 静态绑定调 ~Job → 子类析构不在调用链上 → 子类构造时分配的资源没人释放。虚析构 → 查表调到 ~OnlineJob → 子类析构跑完后按继承篇的镜像律自动接 ~Job。泄漏的永远是"子类多出来的那一部分" ,leaks 报告把泄漏点定位在 OnlineJob 构造函数,与此完全吻合。

10.3 假重写为什么不报错只警告

C++98 的名字查找规则:派生类声明了同名函数,基类同名集整个被隐藏,这是合法语言行为(隐藏机制本身正当)。至于"这个同名函数本意是不是重写",编译器只能猜------它选择用 -Woverloaded-virtual 提醒,但语言层面放行。C++11 的 override 把猜测变成契约,写上 override 又对不上签名就直接报错。C++98 时代的替代纪律:重写前把基类声明原样复制。

10.4 抽象类连按值传参都不行,为什么指针引用可以

按值传参需要完整拷贝一个对象,而抽象类的纯虚函数没有实现,它的虚表里有"空槽",对象造不出来也拷不出来。指针和引用不产生新对象,只是透过它调用子类提供的实现------抽象类把自己"不能实例化"的限制,精确地转化成了"只能以多态方式使用"的设计意图。

十一、易踩坑汇总

编号 坑 现象 根因 处理
1 该加 virtual 没加 父类指针调用永远走基类版本 非虚函数静态绑定 预期被重写的函数一律 virtual
2 基类析构不写 virtual 子类析构不执行,leaks 点名泄漏 delete 按静态类型绑定 会被继承的类析构写 virtual
3 假重写(少 const / 参数不同) 编译警告 hides overloaded virtual function,多态失效 签名不逐字一致 复制基类声明再改函数体
4 构造/析构里调虚函数期待多态 永远走当前层版本 vptr 指向正在构造/析构层的虚表 两段式 init 或非虚下沉
5 按值传递多态对象 切片 + 多态失效 拷贝只搬基类子对象 指针或引用传参
6 派生类重写函数不写 virtual 且以为没生效 实际生效了(基类有 virtual 即可) virtual 属性会被继承 知道规则即可,写了更醒目
7 抽象类试图实例化/按值传参 is an abstract class 报错 纯虚函数无实现,虚表有空槽 用指针或引用
8 子类未实现全部纯虚函数就实例化子类 子类也报 abstract 纯虚函数被继承下来 补实现,或子类继续当抽象类
9 隐藏当重载用 基类同名整组函数调不到 内层作用域命中即停 类名限定 d.Base::fun()
10 static_cast 向下转错对象 行为未定义,不报错 转换安全性自己担保 只在确知动态类型时使用
11 协变写成按值返回 不算重写,编译报错或变隐藏 协变仅限指针/引用 返回类型保持指针/引用形态
12 虚函数太多担心对象变大 对象不变大,只多一根 vptr vptr 每对象一根,虚表在类侧 放心设计接口

第 2 条的泄漏报告、第 3 条的警告文本、第 5 条的切片输出全部本机实测,见正文各节。

十二、闭环复盘

这篇串一遍,落成六条。

第一,先分静态动态再谈机制。 重载编译期定结果,虚函数运行期定结果;分界问题是"要不要看对象身份"。

第二,三要素是"与"关系。 公有继承、签名逐字一致的虚函数重写、父类指针或引用,缺一即失效;按值传参从根上断送多态。

第三,虚析构是纪律不是可选项。 家族有虚函数,基类析构就 virtual;实测泄漏报告与归零报告就是这句话的成本收益表。

第四,虚表虚指针是全篇的物理解释。 一类一表、一对象一指针、重写换槽位、调用走三步------三要素、虚析构、构造失效、切片全是它的推论。

第五,overload / override / hiding 用两问判定。 先问作用域层级,再问虚函数与签名;假重写靠 -Wall 警告和复制声明的纪律防。

第六,构造析构期间虚函数不可靠。 vptr 指向正在出生或死亡的那一层,别在这两处依赖多态。

最后附笔试简答背诵精简版:

  • 多态分类:静态多态(重载/模板,编译期绑定,零运行开销);动态多态(虚函数,运行期绑定,一次查表)。
  • 动态多态三条件:公有继承;基类虚函数被子类重写(签名一致);基类指针或引用调用子类对象。
  • virtual 作用:把函数绑定依据从静态类型换成动态类型,实现上经 vptr 查虚表。
  • 虚析构必要性:基类指针 delete 子类对象时保证完整析构链,否则子类资源泄漏。
  • 纯虚函数 :virtual 返回类型 函数名(参数) = 0;;含纯虚函数的类是抽象类,不能实例化,强制派生类实现接口。
  • 虚表虚指针:每个多态类一张虚表存虚函数地址;每个对象一根 vptr 指向所属类的虚表;重写即替换子类虚表槽位。
  • 重载/重写/隐藏:重载同作用域同名不同参、编译期绑定;重写跨作用域、基类虚函数、签名一致、运行期绑定;隐藏是同名即遮蔽基类整组函数。
  • 构造析构中调虚函数:不发生多态,调用落在当前正在构造/析构的层,因为 vptr 指向该层虚表。
  • 切片:子类对象按值赋给/传给基类时丢掉派生部分,多态随之失效;指针引用不切片。
  • 协变返回值:重写时返回值可为基类返回类型的子类指针/引用,其余情况签名必须逐字一致。

相关推荐
Hello.Reader1 小时前
Three.js / Cesium 卡顿怎么办?把三维渲染搬到 GPU 服务器
服务器·开发语言·javascript
Solange__1 小时前
AI世界模型、3D场景生成和游戏引擎怎么选?按5类任务比较交互、物理与导出
开发语言·人工智能·游戏·平面·3d·自然语言处理·游戏引擎
01二进制代码漫游日记1 小时前
[C++]之内存管理(一)
jvm·c++·学习
椒颜皮皮虾྅1 小时前
模型部署不再卡在环境上:DeploySharp 四大推理引擎与 Windows/Linux 离线交付实战
linux·运维·开发语言·人工智能·windows·计算机视觉·cuda
codebolt2 小时前
C++程序crash分析和内存检测
开发语言·c++
椒颜皮皮虾྅2 小时前
让 C# 读懂整张图:DeploySharp 驱动 PaddleOCR v4/v5/v6,打通 OCR 全流程
开发语言·人工智能·深度学习·学习·c#·ocr·cuda
昂好的day2 小时前
C# 同步异步与多线程:Thread / Task / 线程池一次讲透(上篇)
开发语言·windows·c#
Java后端的Ai之路2 小时前
01、Java并发编程:学习攻略如何才能学好并发编程?
java·开发语言·学习
ShineWinsu2 小时前
对于Qt:QLabel常见属性的解析
c++·qt·面试·笔试·gui·标签·qlabel