Menu
CoddyTech

虚函数与 VTable

本课属于 Coddy C++ 学习旅程的“面向对象编程”单元。第 52 课,共 104 课。

virtual 关键字解决了我们在上一课中遇到的问题。当你将一个方法声明为 virtual 时,C++ 会在运行时根据对象的实际类型,而不是指针类型,来确定要调用哪个版本。

class Animal {
public:
    virtual void speak() {
        std::cout << "Some sound" << std::endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "Woof!" << std::endl;
    }
};

Dog d;
Animal* ptr = &d;
ptr->speak();  // 输出:Woof!

现在调用了正确的方法!override 关键字是可选的,但建议使用。它会告诉编译器你打算重写虚函数,如果函数签名不匹配,就能捕获错误。

这是如何工作的?当一个类有虚函数时,编译器会创建一个虚函数表(vtable)。这是一个隐藏的查找表,其中包含指向该类虚函数的指针。每个有虚函数的对象都会存储一个指向其类的 vtable 的隐藏指针(vptr)。

通过指针调用虚函数时,程序会在运行时从 vtable 中查找正确的函数地址。这称为动态分派。与普通函数调用相比,它会增加少量开销,但能实现强大的多态行为。

一条重要规则:如果一个类有虚函数,并且会被用作基类,那么它的析构函数也应该是虚函数。这样,通过基类指针删除派生对象时,才能正确清理:

class Animal {
public:
    virtual ~Animal() {}  // 虚析构函数
    virtual void speak() {}
};
challenge icon

挑战

简单

让我们构建一个媒体播放器系统,展示虚函数和运行时多态的强大之处。你将创建一个媒体类型层次结构,让每种媒体都以不同的方式播放,并了解虚函数如何确保通过基类指针访问时也能执行正确的行为。

你将把代码分成三个文件:

  • Media.h:定义一个表示任何可播放媒体的基类 Media:
    • 一个受保护的 std::string title 成员
    • 一个接收标题并保存它的构造函数
    • 一个虚 play() 方法,输出:Playing media: <title>
    • 一个虚析构函数,输出:Media [<title>] destroyed
  • AudioTrack.h:定义一个公开继承自 Media 的 AudioTrack 类:
    • 一个私有的 std::string artist 成员
    • 一个接收标题和艺术家姓名、将标题传给基类并保存艺术家姓名的构造函数
    • 使用 override 关键字重写 play(),输出:Playing audio: <title> by <artist>
    • 一个输出 AudioTrack [<title>] destroyed 的析构函数
  • main.cpp:读取三个输入(每个输入单独占一行):
    1. 音轨标题(字符串)
    2. 艺术家姓名(字符串)
    3. 视频标题(字符串)

    在 main.cpp 中直接定义一个公开继承自 Media 的 VideoClip 类:

    • 一个私有的 int duration 成员(单位为秒)
    • 一个接收标题和时长的构造函数(默认时长为 120)
    • 重写 play(),输出:Playing video: <title> (<duration>s)
    • 一个输出 VideoClip [<title>] destroyed 的析构函数

    在一个代码块作用域内创建一个包含三个 Media* 指针的数组。动态分配以下对象:

    • 一个标题为 "Generic Media" 的基类 Media 对象
    • 一个使用输入的标题和艺术家姓名的 AudioTrack 对象
    • 一个使用输入的视频标题的 VideoClip 对象

    遍历数组,并对每个指针调用 play()。然后按相反顺序删除所有对象。离开代码块后,输出:Playback complete!

例如,输入 Bohemian Rhapsody、Queen 和 Nature Documentary 时:

Playing media: Generic Media
Playing audio: Bohemian Rhapsody by Queen
Playing video: Nature Documentary (120s)
VideoClip [Nature Documentary] destroyed
Media [Nature Documentary] destroyed
AudioTrack [Bohemian Rhapsody] destroyed
Media [Bohemian Rhapsody] destroyed
Media [Generic Media] destroyed
Playback complete!

注意,通过 Media* 指针调用 play() 时,虚函数会调用正确的派生类方法。也请留意,虚析构函数可以确保正确清理对象:通过基类指针删除对象时,派生类和基类的析构函数都会运行。

亲手试试

#include <iostream>
#include <string>
#include "Media.h"
#include "AudioTrack.h"

using namespace std;

// TODO:在这里定义公开继承 Media 的 VideoClip 类
// - 私有 int 类型的 duration 成员(单位:秒)
// - 构造函数接收 title 和 duration(duration 默认值为 120)
// - 重写 play(),输出:Playing video: <title> (<duration>s)
// - 析构函数输出:VideoClip [<title>] destroyed

class VideoClip : public Media {
private:
    int duration;

public:
    // TODO:实现构造函数
    VideoClip(const std::string& t, int d = 120) : Media(t) {
        // TODO:保存 duration
    }

    // TODO:重写 play() 方法
    void play() override {
        // TODO:实现这个方法
    }

    // TODO:实现析构函数
    ~VideoClip() {
        // TODO:实现这个析构函数
    }
};

int main() {
    // 读取输入
    string audioTitle;
    string artist;
    string videoTitle;
    
    getline(cin, audioTitle);
    getline(cin, artist);
    getline(cin, videoTitle);

    // TODO: 用花括号创建一个块作用域
    // 在代码块中:
    // 1. 创建一个包含三个 Media* 指针的数组
    // 2. 动态分配:
    //    一个标题为 "Generic Media" 的基类 Media 对象
    //    一个使用输入的标题和艺术家信息创建的 AudioTrack 对象
    //    一个使用输入的视频标题创建的 VideoClip 对象
    // 3. 遍历数组,对每个指针调用 play()
    // 4. 按相反顺序删除所有对象

    // TODO: 在代码块之后输出:Playback complete!
    
    return 0;
}
quiz icon自我测试

这节课包含一个小测验。开始学习即可作答,并记录你的进度。

面向对象编程中的所有课

自己练习: C++ 在线编译器