虚函数与多态

在课程“指向与引用派生对象的基类”中,我们看到使用指向基类的指针或引用有助于简化代码。然而,在全部示例中都存在一个共同问题:基类指针或引用只能调用基类版本的成员函数,无法调用派生类版本。

下面给出一个演示该行为的简化示例:

#include <iostream>
#include <string_view>

class Base
{
public:
    std::string_view getName() const { return "Base"; }
};

class Derived: public Base
{
public:
    std::string_view getName() const { return "Derived"; }
};

int main()
{
    Derived derived {};
    Base& rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';
    return 0;
}

程序输出:

rBase is a Base

由于 rBaseBase 类型的引用,因此调用的是 Base::getName(),即便它实际引用的是 Derived 对象中的 Base 部分。

本文将说明如何通过虚函数解决该问题。

虚函数

虚函数是一种特殊成员函数。当通过指向或引用类类型对象的指针或引用调用该函数时,实际执行的是该对象最派生类中的版本。

派生类中的函数若与基类虚函数具有完全相同的签名(函数名、形参类型以及是否 const)和相同的返回类型,则称为重写(override)

仅需在函数声明前加关键字 virtual 即可将其声明为虚函数:

#include <iostream>
#include <string_view>

class Base
{
public:
    virtual std::string_view getName() const { return "Base"; } // 注意 virtual
};

class Derived: public Base
{
public:
    virtual std::string_view getName() const { return "Derived"; }
};

int main()
{
    Derived derived {};
    Base& rBase{ derived };
    std::cout << "rBase is a " << rBase.getName() << '\n';
    return 0;
}

输出:

rBase is a Derived

提示:某些现代编译器会针对“具有虚函数却未声明虚析构函数”给出警告。如遇此情况,可在基类中添加:

virtual ~Base() = default;

关于虚析构函数,在课程“虚析构函数、虚赋值与抑制虚函数”中将详细说明。

由于 rBase 引用的是 Derived 对象中的 Base 部分,而 Base::getName() 被声明为虚函数,因此运行时查找并调用了 Derived::getName()

再看一个略复杂的例子:

class A { public: virtual std::string_view getName() const { return "A"; } };
class B: public A { public: virtual std::string_view getName() const { return "B"; } };
class C: public B { public: virtual std::string_view getName() const { return "C"; } };
class D: public C { public: virtual std::string_view getName() const { return "D"; } };

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';
}

程序首先创建 C 对象;rBaseA 的引用,绑定到该 C 对象的 A 部分。rBase.getName() 原应调用 A::getName(),但因 A::getName() 为虚函数,故在 AC 之间寻找最派生匹配,最终调用 C::getName()。不会调用 D::getName(),因为对象类型为 C 而非 D

输出:

C

关键洞察

**只有当虚成员函数通过指针或引用调用时,才会发生动态绑定。**若直接对对象本身调用虚函数,则始终执行该对象类型的版本:

C c{};
std::cout << c.getName(); // 始终调用 C::getName

A a { c }; // 仅拷贝 c 的 A 子对象(不建议如此使用)
std::cout << a.getName(); // 始终调用 A::getName

多态

在编程中,**多态(polymorphism)**指“多种形态”。例如:

int add(int, int);
double add(double, double);

标识符 add 呈现了两种形态:add(int,int)add(double,double)

  • 编译时多态由编译器在编译期解析,如函数重载、模板实例化。
  • 运行时多态在运行期解析,如虚函数动态绑定。

更复杂的示例:Animal 类

原始版本:

class Animal
{
protected:
    std::string m_name{};
    Animal(std::string_view name) : m_name{ name } {}

public:
    const std::string& getName() const { return m_name; }
    std::string_view speak() const { return "???"; }
};

class Cat: public Animal { /*...*/ std::string_view speak() const { return "Meow"; } };
class Dog: public Animal { /*...*/ std::string_view speak() const { return "Woof"; } };

void report(const Animal& animal)
{
    std::cout << animal.getName() << " says " << animal.speak() << '\n';
}

int main()
{
    Cat cat{ "Fred" };
    Dog dog{ "Garbo" };
    report(cat);
    report(dog);
}

输出:

Fred says ???
Garbo says ???

speak() 声明为虚函数后:

class Animal
{
    ...
    virtual std::string_view speak() const { return "???"; }
};
class Cat: public Animal { ... virtual std::string_view speak() const override { return "Meow"; } };
class Dog: public Animal { ... virtual std::string_view speak() const override { return "Woof"; } };

输出:

Fred says Meow
Garbo says Woof

进一步,可将不同派生类对象纳入统一接口:

Cat fred{ "Fred" };
Dog garbo{ "Garbo" };
// ...
Animal* animals[]{ &fred, &garbo, /*...*/ };
for (const auto* animal : animals)
    std::cout << animal->getName() << " says " << animal->speak() << '\n';

无论将来新增何种派生类,只要继承 Animal 并重写 speak(),即可直接复用上述代码,无需修改。这正是虚函数的最大优势——开闭原则的有力体现。

重要提示

  • 签名必须完全一致:形参、返回类型、const 属性等均须匹配,否则无法构成重写。
  • 继承链中的隐含虚函数:若基类函数已声明为 virtual,其所有派生类中的同名同参同返回类型函数隐式为虚函数,无需再写 virtual
  • 返回类型规则:正常情况下,虚函数与其重写版本返回类型必须相同。若返回类型不同,则无法通过编译。
  • 构造/析构函数中勿调用虚函数
    • 构造基类子对象时,派生类尚未构造完成,此时调用虚函数将解析为基类版本。
    • 析构基类子对象时,派生类已析构,同样只会调用基类版本。 最佳实践:切勿在构造函数或析构函数中调用虚函数。

虚函数的开销

若将所有函数均设为虚函数,则会带来额外的运行时开销与内存开销:

  • 虚函数调用需通过虚表间接寻址,耗时多于普通函数。
  • 每个具有虚函数的类对象需额外存储一个指向虚表的指针,对小对象而言内存开销显著。

测验

请通过代码阅读判断以下程序的输出(勿编译运行)。

1a)

class A { public: virtual std::string_view getName() const { return "A"; } };
class B: public A { public: virtual std::string_view getName() const { return "B"; } };
class C: public B { /* 无 getName() */ };
class D: public C { public: virtual std::string_view getName() const { return "D"; } };

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';
}

解答B C 未重写 getName(),因此最近的有效重写来自 B

1b)

// 同上,但 rBase 为 B&
int main()
{
    C c;
    B& rBase{ c };
    std::cout << rBase.getName() << '\n';
}

解答C 尽管引用类型为 B,但对象类型为 C,且 C 重写了 getName()

1c)

class A { public: /* 无 virtual */ std::string_view getName() const { return "A"; } };
class B: public A { public: virtual std::string_view getName() const { return "B"; } };
class C: public B { public: virtual std::string_view getName() const { return "C"; } };

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';
}

解答A 基类函数未声明为虚,因此无动态绑定。

1d)

class A { public: virtual std::string_view getName() const { return "A"; } };
class B: public A { public: std::string_view getName() const { return "B"; } }; // 无 virtual
class C: public B { public: std::string_view getName() const { return "C"; } };
class D: public C { public: std::string_view getName() const { return "D"; } };

int main()
{
    C c {};
    B& rBase{ c };
    std::cout << rBase.getName() << '\n';
}

解答C 虽然 BCD 的函数未写 virtual,但由于 A::getName() 已为虚函数,派生类同名函数隐式为虚,因此输出 C

1e)

class A { public: virtual std::string_view getName() const { return "A"; } };
class B: public A { public: virtual std::string_view getName() { return "B"; } }; // 非 const
class C: public B { public: virtual std::string_view getName() { return "C"; } };

int main()
{
    C c {};
    A& rBase{ c };
    std::cout << rBase.getName() << '\n';
}

解答A BC 的函数与基类函数签名不同(缺少 const),因此不构成重写,调用的是 A::getName()

1f)

class A
{
public:
    A() { std::cout << getName(); }
    virtual std::string_view getName() const { return "A"; }
};
class B: public A { public: virtual std::string_view getName() const { return "B"; } };
class C: public B { public: virtual std::string_view getName() const { return "C"; } };

int main()
{
    C c {};
}

解答A 在构造 C 对象时,先执行 A 的构造函数。此时派生类部分尚未构造,因此 getName() 调用解析为 A::getName()

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"