C++ 多态详解:虚函数、动态绑定、抽象类与虚表原理

C++ 多态详解:虚函数、动态绑定、抽象类与虚表原理

多态的代码往往很短:基类函数前加一个 virtual,派生类中写一个同名函数,再通过基类指针调用。真正容易出问题的地方并不在关键字本身,而在于调用究竟何时绑定、怎样才算正确重写、对象内部如何支持动态分派,以及为什么多态基类通常需要虚析构函数。

这篇文章从一个完整示例开始,逐步拆解虚函数重写、overridefinal、协变返回、抽象类和虚函数表。默认读者已经了解类、对象和公有继承。

一、多态是什么

多态的字面含义是"多种形态"。在程序中,它表现为同一个调用接口面对不同类型的对象时,可以执行不同的行为。

C++ 中常见的多态可以分成两类:

类型 决定调用目标的时间 常见形式
静态多态 编译期 函数重载、运算符重载、函数模板
动态多态 运行期 继承、虚函数、基类指针或引用

函数重载为什么属于静态多态?因为编译器根据实参类型和候选函数,在生成程序时就能确定调用哪个版本。

cpp 复制代码
void Print(int value);
void Print(double value);

Print(10);   // 编译期确定为 Print(int)
Print(3.14); // 编译期确定为 Print(double)

动态多态则要等到程序运行时,根据对象的实际类型决定调用目标。它适合解决这样的问题:调用方只依赖统一接口,但具体行为由不同实现类完成。

二、一个完整的动态多态示例

假设程序需要通过不同通道发送消息。调用方不应该为每种通道分别编写一套流程,只要面向共同接口调用 Send 即可。

下面是为了帮助理解而补充的完整示例:

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>

class MessageChannel {
public:
    virtual void Send(const std::string& message) const {
        std::cout << "generic: " << message << '\n';
    }

    virtual ~MessageChannel() = default;
};

class ConsoleChannel : public MessageChannel {
public:
    void Send(const std::string& message) const override {
        std::cout << "console: " << message << '\n';
    }
};

class AuditChannel : public MessageChannel {
public:
    void Send(const std::string& message) const override {
        std::cout << "audit: " << message << '\n';
    }
};

void Notify(const MessageChannel& channel,
            const std::string& message) {
    channel.Send(message);
}

int main() {
    MessageChannel generic;
    ConsoleChannel console;
    AuditChannel audit;

    Notify(generic, "service started");
    Notify(console, "service started");
    Notify(audit, "service started");

    std::vector<const MessageChannel*> channels{
        &console, &audit
    };

    for (const MessageChannel* channel : channels) {
        channel->Send("health check passed");
    }

    return 0;
}

编译并运行:

bash 复制代码
g++ -std=c++11 -Wall -Wextra -pedantic polymorphism_basic.cpp -o polymorphism_basic
./polymorphism_basic

预期输出:

text 复制代码
generic: service started
console: service started
audit: service started
console: health check passed
audit: health check passed

Notify 的形参始终是 const MessageChannel&,循环中的变量也始终是 const MessageChannel*,但调用结果取决于引用或指针实际绑定的对象。这就是运行时多态最直观的表现。

这里有三个关键点:

  1. ConsoleChannelAuditChannel 公有继承 MessageChannel
  2. 基类的 Send 是虚函数,派生类正确重写了它;
  3. 调用发生在基类引用或基类指针上。

少了其中任何一项,都可能得不到预期的动态分派效果。

三、虚函数与动态多态的构成条件

1. 什么是虚函数

在非静态成员函数声明前添加 virtual,该函数就成为虚函数:

cpp 复制代码
class Base {
public:
    virtual void Run() {
        std::cout << "Base::Run\n";
    }
};

虚函数属于类的成员函数。普通非成员函数和 static 成员函数都不能声明为虚函数,因为动态分派依赖调用对象,而静态成员函数没有 this 指针。

派生类重写虚函数时,即使省略 virtual,函数仍然保持虚函数属性:

cpp 复制代码
class Derived : public Base {
public:
    void Run() override {
        std::cout << "Derived::Run\n";
    }
};

现代 C++ 更推荐使用 override。它既表达意图,也让编译器检查这个函数是否真的完成了重写。

2. 为什么需要基类指针或引用

基类指针和引用既能关联基类对象,也能关联公有派生类对象:

cpp 复制代码
Base base;
Derived derived;

Base* pointer = &base;
pointer = &derived;

Base& reference = derived;

调用表达式的静态类型都是 Base,但对象的动态类型可能是 Base,也可能是某个派生类。虚函数机制正是利用这两种类型的差异,在运行时选择最终函数。

  • 静态类型:编译器从表达式本身看到的类型;
  • 动态类型:指针或引用在运行时实际关联对象的类型。

Base* pointer = &derived; 中,pointer 的静态类型是 Base*,它所指对象的动态类型是 Derived

3. 对象调用为什么通常看不出多态价值

cpp 复制代码
Derived object;
object.Run();

object 的静态类型和动态类型都是 Derived,调用目标没有悬念。即使 Run 是虚函数,编译器也常常可以直接确定或优化调用。

动态多态真正有价值的场景,是调用方只知道基类接口,实际对象却可能来自多个派生类。

四、怎样才算虚函数重写

派生类对基类虚函数的重新实现称为重写,也常称为覆盖。常规情况下,下面这些部分需要匹配:

  • 函数名相同;
  • 参数列表相同;
  • 成员函数的 const、引用限定符等保持匹配;
  • 返回类型相同,协变返回是特例。
cpp 复制代码
class Base {
public:
    virtual void Process(int value) const {}
};

class Derived : public Base {
public:
    void Process(int value) const override {}
};

下面几个版本都没有正确重写:

cpp 复制代码
class WrongDerived : public Base {
public:
    // 参数类型不同
    // void Process(double value) const override {}

    // 缺少 const
    // void Process(int value) override {}

    // 函数名拼写不同
    // void Proccess(int value) const override {}
};

保留 override 时,这些错误会在编译期被直接指出。去掉 override 后,代码可能仍能通过编译,但派生类只是声明了另一个函数,运行结果就容易偏离设计意图。

virtual 是否必须在派生类中重复书写

语法上不必重复。基类函数一旦是虚函数,在继承体系中会继续保持虚函数属性。下面两种写法都能构成重写:

cpp 复制代码
void Process(int value) const;
void Process(int value) const override;

第二种写法更适合实际开发,因为它把编译器变成重写规则的检查者。

五、overridefinal

1. override 检查重写是否成立

cpp 复制代码
class Service {
public:
    virtual void Start() {}
};

class FileService : public Service {
public:
    void Start() override {}
};

如果基类写成 Start,派生类误写成 Stratoverride 会让编译立即失败。没有这个说明符,拼错的函数会被当作一个全新的成员,错误可能到运行时才暴露。

2. final 禁止继续重写

final 放在虚函数后,表示后续派生类不能再重写该函数:

cpp 复制代码
class Service {
public:
    virtual void Stop() final {}
};

class FileService : public Service {
public:
    // void Stop() override {} // 编译错误
};

final 也可以修饰类,表示这个类不能继续派生:

cpp 复制代码
class ClosedService final : public Service {};

两种用法表达的约束不同:函数级 final 封闭某个行为,类级 final 封闭整个继承入口。

六、重载、重写与隐藏不要混淆

这三个概念都可能出现同名函数,但判断标准完全不同。

对比项 重载 重写 隐藏
作用域 同一个作用域 基类与派生类 基类与派生类
函数名 相同 相同 相同
参数列表 必须不同 通常必须相同 可以相同,也可以不同
是否要求虚函数 不要求 基类函数必须是虚函数 不要求
返回类型 可以不同,但不能仅靠返回类型区分 通常相同,协变除外 没有统一要求
主要目的 同名接口处理不同参数 派生类替换基类虚行为 名称查找时遮蔽基类成员

看一段容易误判的代码:

cpp 复制代码
class Base {
public:
    virtual void Print(int value) const {
        std::cout << "Base: " << value << '\n';
    }
};

class Derived : public Base {
public:
    void Print(double value) const {
        std::cout << "Derived: " << value << '\n';
    }
};

Derived::Print(double) 没有重写 Base::Print(int),它只是隐藏了基类中的同名函数。通过基类指针调用时,仍会执行 Base::Print(int)

cpp 复制代码
Derived derived;
Base* base = &derived;
base->Print(10); // Base::Print(int)

若派生类需要保留基类的重载集合,可以使用 using

cpp 复制代码
class Derived : public Base {
public:
    using Base::Print;

    void Print(double value) const {
        std::cout << "Derived: " << value << '\n';
    }
};

这里仍然没有发生重写,只是基类版本被引入派生类作用域,可以参与重载解析。

七、虚函数重写的几个特殊情况

1. 协变返回类型

一般重写要求返回类型一致,但 C++ 允许一种受限的例外:基类虚函数返回某个类的指针或引用时,派生类重写函数可以返回该类的派生类型指针或引用,这称为协变返回

cpp 复制代码
#include <iostream>

class Document {
public:
    virtual Document* Clone() const {
        std::cout << "clone Document\n";
        return new Document(*this);
    }

    virtual ~Document() = default;
};

class Report : public Document {
public:
    Report* Clone() const override {
        std::cout << "clone Report\n";
        return new Report(*this);
    }
};

int main() {
    Report report;
    Document* source = &report;
    Document* copy = source->Clone();
    delete copy;
}

Document::Clone 返回 Document*Report::Clone 返回 Report*。因为 Report* 可以安全向上转换为 Document*,这个重写是合法的。

协变只适用于指针或引用返回,不意味着普通值类型可以随意改变:

cpp 复制代码
// virtual Document Clone() const;
// Report Clone() const override; // 不构成合法协变

2. 析构函数的重写

基类和派生类的析构函数名字看起来不同,例如 ~Base()~Derived(),但当基类析构函数是虚函数时,派生类析构函数会参与虚分派。

cpp 复制代码
#include <iostream>

class Resource {
public:
    Resource() : buffer_(new int[4]) {}

    virtual ~Resource() {
        std::cout << "Resource destroyed\n";
        delete[] buffer_;
    }

private:
    int* buffer_;
};

class CachedResource : public Resource {
public:
    CachedResource() : cache_(new int[8]) {}

    ~CachedResource() override {
        std::cout << "CachedResource destroyed\n";
        delete[] cache_;
    }

private:
    int* cache_;
};

int main() {
    Resource* resource = new CachedResource;
    delete resource;
}

预期输出:

text 复制代码
CachedResource destroyed
Resource destroyed

如果 Resource::~Resource 不是虚函数,通过 Resource* 删除 CachedResource 对象会产生未定义行为,不能简单理解为"只少调用一次析构函数"。资源泄漏只是可能出现的后果之一。

注意:只要一个类准备作为多态基类,并且对象可能通过基类指针销毁,析构函数就应当是虚函数。写成 virtual ~Base() = default; 通常足够。

3. 默认参数不会动态绑定

默认实参在编译期根据调用点可见的静态类型确定,虚函数的函数体则可能在运行时动态选择。两套规则混在一起,会产生不够直观的结果:

cpp 复制代码
#include <iostream>

class Base {
public:
    virtual void Print(int value = 1) const {
        std::cout << "Base: " << value << '\n';
    }
};

class Derived : public Base {
public:
    void Print(int value = 0) const override {
        std::cout << "Derived: " << value << '\n';
    }
};

int main() {
    Derived object;
    Base* base = &object;

    base->Print();   // Derived: 1
    object.Print();  // Derived: 0
}

base->Print() 的函数体动态分派到 Derived::Print,但 base 的静态类型是 Base*,默认值取自 Base::Print,所以输出 Derived: 1

虚函数接口尽量不要在不同层级提供不同默认值。更稳妥的做法是统一默认值,或者把默认参数移到非虚包装函数中。

八、纯虚函数与抽象类

如果基类只负责规定接口,并不提供有意义的通用实现,可以把函数声明为纯虚函数:

cpp 复制代码
class Storage {
public:
    virtual void Save(const std::string& data) = 0;
    virtual ~Storage() = default;
};

包含纯虚函数的类是抽象类。抽象类不能直接实例化:

cpp 复制代码
// Storage storage; // 编译错误:Storage 是抽象类

派生类只有实现所有尚未实现的纯虚函数,才会成为可以实例化的具体类:

cpp 复制代码
#include <iostream>
#include <string>

class Storage {
public:
    virtual void Save(const std::string& data) = 0;
    virtual ~Storage() = default;
};

class MemoryStorage : public Storage {
public:
    void Save(const std::string& data) override {
        value_ = data;
        std::cout << "saved: " << value_ << '\n';
    }

private:
    std::string value_;
};

void Persist(Storage& storage, const std::string& data) {
    storage.Save(data);
}

int main() {
    MemoryStorage storage;
    Persist(storage, "configuration");
}

预期输出:

text 复制代码
saved: configuration

抽象类的价值不只是"不能创建对象",更重要的是它把调用方与具体实现隔开。Persist 只依赖 Storage 接口,后续可以接入文件存储、数据库存储或测试替身,而不用修改调用流程。

纯虚函数能否有函数体

纯虚函数在语法上可以在类外提供定义,但类仍然是抽象类。派生类也仍需重写该函数,才能成为具体类。

cpp 复制代码
class Base {
public:
    virtual void Run() = 0;
};

void Base::Run() {
    std::cout << "shared implementation\n";
}

派生类可以在自己的重写中显式调用 Base::Run(),用于复用公共实现。这种写法并不常见,理解它有助于区分"纯虚声明"和"函数完全不存在"。

工程提示:纯虚析构函数必须提供定义,因为销毁派生对象时最终仍要执行基类析构过程。

九、静态绑定与动态绑定

绑定可以理解为把一次函数调用与实际执行的函数实现连接起来。

静态绑定

非虚函数调用、重载解析等通常在编译期确定目标:

cpp 复制代码
class Base {
public:
    void Run() const {
        std::cout << "Base::Run\n";
    }
};

class Derived : public Base {
public:
    void Run() const {
        std::cout << "Derived::Run\n";
    }
};

Derived object;
Base* pointer = &object;
pointer->Run(); // Base::Run

Run 不是虚函数,编译器只看 pointer 的静态类型 Base*,直接绑定到 Base::Run

动态绑定

把基类函数声明为虚函数后,通过基类指针或引用调用它,目标会依据对象的动态类型决定:

cpp 复制代码
class Base {
public:
    virtual void Run() const {
        std::cout << "Base::Run\n";
    }
};

class Derived : public Base {
public:
    void Run() const override {
        std::cout << "Derived::Run\n";
    }
};

Derived object;
Base* pointer = &object;
pointer->Run(); // Derived::Run

调用过程可以抽象为:
#mermaid-svg-Hi8JINLNc0AcRBQy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Hi8JINLNc0AcRBQy .error-icon{fill:#552222;}#mermaid-svg-Hi8JINLNc0AcRBQy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Hi8JINLNc0AcRBQy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Hi8JINLNc0AcRBQy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Hi8JINLNc0AcRBQy .marker.cross{stroke:#333333;}#mermaid-svg-Hi8JINLNc0AcRBQy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Hi8JINLNc0AcRBQy p{margin:0;}#mermaid-svg-Hi8JINLNc0AcRBQy .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster-label text{fill:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster-label span{color:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster-label span p{background-color:transparent;}#mermaid-svg-Hi8JINLNc0AcRBQy .label text,#mermaid-svg-Hi8JINLNc0AcRBQy span{fill:#333;color:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .node rect,#mermaid-svg-Hi8JINLNc0AcRBQy .node circle,#mermaid-svg-Hi8JINLNc0AcRBQy .node ellipse,#mermaid-svg-Hi8JINLNc0AcRBQy .node polygon,#mermaid-svg-Hi8JINLNc0AcRBQy .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Hi8JINLNc0AcRBQy .rough-node .label text,#mermaid-svg-Hi8JINLNc0AcRBQy .node .label text,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape .label,#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape .label{text-anchor:middle;}#mermaid-svg-Hi8JINLNc0AcRBQy .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Hi8JINLNc0AcRBQy .rough-node .label,#mermaid-svg-Hi8JINLNc0AcRBQy .node .label,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape .label,#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape .label{text-align:center;}#mermaid-svg-Hi8JINLNc0AcRBQy .node.clickable{cursor:pointer;}#mermaid-svg-Hi8JINLNc0AcRBQy .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Hi8JINLNc0AcRBQy .arrowheadPath{fill:#333333;}#mermaid-svg-Hi8JINLNc0AcRBQy .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Hi8JINLNc0AcRBQy .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Hi8JINLNc0AcRBQy .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Hi8JINLNc0AcRBQy .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Hi8JINLNc0AcRBQy .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Hi8JINLNc0AcRBQy .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster text{fill:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy .cluster span{color:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Hi8JINLNc0AcRBQy .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Hi8JINLNc0AcRBQy rect.text{fill:none;stroke-width:0;}#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape p,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Hi8JINLNc0AcRBQy .icon-shape .label rect,#mermaid-svg-Hi8JINLNc0AcRBQy .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Hi8JINLNc0AcRBQy .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Hi8JINLNc0AcRBQy .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Hi8JINLNc0AcRBQy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 通过基类指针或引用调用虚函数
读取对象的动态类型信息
定位该类型对应的虚函数入口
调用最终重写函数

这是语言层面的行为。编译器在能够证明对象实际类型时,可能进行去虚拟化优化,直接生成目标函数调用,但程序的可观察结果不能改变。

十、虚函数表如何支持动态分派

C++ 标准规定了虚函数调用应表现出的语义,却没有强制编译器必须使用某种对象布局。主流编译器通常通过虚函数表虚函数表指针实现动态分派。

1. 常见实现模型

含虚函数的对象通常带有一个隐藏指针,常称为 vptr。这个指针指向对象动态类型对应的虚函数表,常称为 vtable。虚表中保存虚函数入口。

text 复制代码
Base 对象
+------------------+       Base 虚表
| vptr ------------|-----> +------------------+
| base_data        |       | &Base::Run       |
+------------------+       | &Base::~Base     |
                           +------------------+

Derived 对象
+------------------+       Derived 虚表
| Base 子对象       |       +------------------+
|   vptr ----------|-----> | &Derived::Run    |
|   base_data      |       | &Derived::~Derived|
+------------------+       +------------------+
| derived_data     |
+------------------+

当派生类重写 Run 时,对应虚表槽位会指向 Derived::Run。没有被重写的基类虚函数通常继续使用基类版本,派生类新增的虚函数则可能占用新的槽位。

基类指针虽然只能看到基类接口,但它指向的基类子对象中保存着与动态类型匹配的信息。调用虚函数时,程序就能沿着这个入口找到派生类实现。

2. 同类型对象通常共享虚表

虚函数代码不会复制进每个对象。常见实现中,同一种动态类型的对象共享一张虚表,每个对象只保存指向相应虚表的隐藏指针以及自己的数据成员。

text 复制代码
Derived object1 --vptr--+
                       +--> Derived vtable
Derived object2 --vptr--+

虚函数本身和普通成员函数一样,最终都是可执行指令。区别在于虚函数的入口还会参与动态分派结构。

3. sizeof 结果为什么不能死记

假设一个类含有 intchar 和虚函数,在某个 32 位环境中可能因为隐藏指针和对齐得到 12 字节。但这个结果不能推广到所有环境:

  • 32 位与 64 位环境的指针大小不同;
  • 编译器 ABI 和对象布局策略可能不同;
  • 对齐、填充、多继承和虚继承都会影响对象大小;
  • 标准并未规定 vptr 必须位于对象开头,也未规定虚表必须存放在哪个内存区域。

因此,sizeof 题目必须结合目标平台、编译器和编译选项分析。能够确定的是语言行为,不是某次调试窗口显示的具体地址。

4. 不要用强制转换手工读取虚表

把对象地址强制转换为整数指针,再把某段内存当成虚表调用,是依赖具体 ABI 的实验手法。它还可能违反对象模型、别名规则和函数指针转换要求,不适合作为可移植业务代码。

理解虚表时使用概念模型即可;需要观察实现时,应把结论限定在明确的编译器、平台和构建配置中。

十一、对象切片会让动态类型消失

多态依赖基类指针或引用保留原对象的动态类型。如果按值把派生类对象复制给基类对象,只会复制基类子对象,派生部分被切掉:

cpp 复制代码
#include <iostream>

class Base {
public:
    virtual void Run() const {
        std::cout << "Base::Run\n";
    }

    virtual ~Base() = default;
};

class Derived : public Base {
public:
    void Run() const override {
        std::cout << "Derived::Run\n";
    }
};

void CallByValue(Base object) {
    object.Run();
}

void CallByReference(const Base& object) {
    object.Run();
}

int main() {
    Derived derived;
    CallByValue(derived);     // Base::Run
    CallByReference(derived); // Derived::Run
}

CallByValue 的形参是一个新建的 Base 对象,其动态类型就是 BaseCallByReference 没有复制对象,引用仍绑定原来的 Derived,因此动态分派得以保留。

需要存放多态对象集合时,也不要使用 std::vector<Base> 保存派生对象。更常见的方式是保存智能指针:

cpp 复制代码
std::vector<std::unique_ptr<Base>> objects;

这里需要包含 <memory><vector>,并确保基类析构函数为虚函数。

十二、常见问题与易错点

1. 派生类重写时不写 virtual 可以吗

可以。虚函数属性会沿继承体系保留,但建议写 override

cpp 复制代码
void Run() override;

它比重复写 virtual 更有价值,因为能够检查签名是否匹配。

2. 只要函数同名就是重写吗

不是。参数列表、const 等限定不匹配时,通常属于隐藏或另一个新函数。给预期重写的函数加 override,不要只靠肉眼比较。

3. 返回类型不同一定不能重写吗

一般不能。只有满足协变规则的类指针或类引用返回类型可以不同,普通值类型不适用。

4. 基类析构函数为什么要声明为虚函数

因为通过基类指针删除派生对象时,需要根据动态类型进入派生类析构过程。非虚析构会导致未定义行为,不能保证派生资源被正确释放。

5. 抽象类能否定义构造函数和普通成员

可以。抽象类不能直接实例化,不代表它不能保存基类状态或提供公共实现。创建派生对象时,抽象基类的构造函数仍会参与基类子对象的初始化。

6. 纯虚函数是否必须由第一层派生类实现

不必须。如果某个派生类仍然不实现全部纯虚函数,它会继续保持抽象,后续派生类可以再完成实现。

7. 虚函数是否一定比普通函数慢

动态分派通常多一次间接访问,也可能影响内联,但不能脱离场景笼统判断性能。编译器能够证明动态类型时,还可能去虚拟化。接口设计正确性通常比猜测一次调用成本更值得优先考虑,性能敏感路径应通过实际测量判断。

8. 虚表中是否保存普通成员函数

通常不会。虚表服务于虚函数动态分派,普通成员函数采用静态绑定,不需要占用虚函数入口。

9. 每个对象都有一整张虚表吗

常见实现不是这样。同类型对象通常共享虚表,对象中保存的是指向相应虚表的隐藏指针。具体布局仍属于实现细节。

10. 构成多态后一定在运行时查表吗

语言要求的是动态分派语义。主流实现常通过虚表完成,但优化器若能推断唯一目标,可以把间接调用优化成直接调用。不能用"源码写了虚函数"推断最终机器指令一定包含某种固定查表过程。

十三、实际设计中的使用建议

1. 用抽象接口表达稳定能力

当多个类型确实需要通过统一入口被替换使用时,可以定义小而清晰的抽象基类。接口只描述调用方真正需要的行为,不要为了复用几个成员就创建庞大的继承体系。

2. 多态基类提供虚析构函数

这是多态所有权设计的底线。即使当前代码只使用栈对象,也要考虑接口是否允许未来通过基类指针管理对象。

3. 重写函数统一使用 override

它能捕获拼写、参数和限定符错误,还能让阅读者一眼识别函数在继承体系中的角色。

4. 默认参数不要随层级改变

默认实参静态绑定、函数体动态绑定,两者组合很容易产生反直觉结果。统一默认值或使用非虚包装接口更稳妥。

5. 用引用或智能指针传递多态对象

按值传递会发生对象切片。无所有权传递可以使用引用或观察指针;需要表达独占所有权时,可以使用 std::unique_ptr<Base>

6. 区分语言规则与编译器实现

动态分派、重写和抽象类属于语言语义;vptr 位置、虚表布局、对象大小和虚表所在区域属于实现策略。调试观察可以帮助理解,但不要把单个平台的结果写进跨平台逻辑。

十四、总结

C++ 动态多态建立在继承、虚函数和基类指针或引用之上。调用表达式的静态类型决定编译期能看到哪些接口,对象的动态类型决定虚函数最终执行哪个重写版本。

重写要求严格匹配函数签名,override 能把许多隐蔽错误提前到编译期;final 用于封闭重写或继承;协变返回和虚析构属于重写规则中的特殊情况;纯虚函数则把基类塑造成不能直接实例化的抽象接口。

从常见实现看,对象通过隐藏的虚表指针关联所属类型的虚函数表,重写函数替换对应入口,从而完成运行时分派。但对象布局、虚表位置和具体大小并不是标准承诺。掌握多态的关键,是把接口语义、调用规则和实现模型分层理解,再用虚析构、override、引用与智能指针把这些规则落实到代码中。

相关推荐
神奇小汤圆1 小时前
吃透MySQL核心原理:日志、事务、MVCC、锁机制深度详解
后端
xywww1681 小时前
AWS 账号权限怎么分:根用户和 IAM 用户区别及日常使用建议
大数据·开发语言·人工智能·python·gpt·云计算·aws
老孙讲技术1 小时前
零售后端笔记:AI 能力接不上?先分清 numberstat / alarm / faceAnalysis 三条管
后端·物联网
维基框架1 小时前
维基框架(Wiki-Framework) 1.2.0:Mybatis 升级为主从读写分离
java·后端·架构
Larcher1 小时前
MCP协议实战:让AI Agent跨进程调用工具的正确姿势
人工智能·后端
泷寂1 小时前
最小生成树 (MST基础)
算法
Daniel_1232 小时前
数组——总结篇
算法
炸薯条!2 小时前
从零开始学C++ (内存管理)
java·jvm·c++