25-2 虚函数与多态性

在上一节25.1、关于派生对象基类的指针与引用的课程中,我们通过多个示例探讨了使用基类指针或引用简化代码的潜力。然而在所有情况下,我们都遇到了一个问题:基类指针或引用只能调用函数的基类版本,而无法调用派生版本。

以下是一个简单示例:

#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;
}

此示例输出结果:

image

由于 rBase 是 Base 的引用,它会调用 Base::getName(),即使它实际上引用的是 Derived 对象的 Base 部分。

在本节中,我们将展示如何使用虚函数解决此问题。


虚函数

虚函数virtual function是一种特殊的成员函数,当被调用时,会根据被引用或指向的对象的实际类型,解析为该函数最派生版本的实现。

若派生函数与基类函数具有相同的签名signature名称name参数类型parameter types及const属性)和返回类型,则视为匹配。此类函数称为重写overrides

要使函数成为虚函数,只需在函数声明前添加“virtual”关键字。

以下是上述示例中使用虚函数的版本:

#include <iostream>
#include <string_view>

class Base
{
public:
    virtual std::string_view getName() const { return "Base"; } // note addition of virtual keyword
};

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;
}

此示例将输出结果:

image

提示:
某些现代编译器可能会因存在虚函数和可访问的非虚析构函数而报错。若出现此情况,请在基类中添加虚析构函数。在上述程序中,需在基类Base的定义中添加以下内容:
virtual ~Base() = default;
我们在第25.4节讨论虚拟析构函数——虚拟析构函数、虚拟赋值与覆盖虚拟化。

由于 rBase 是对派生对象 Base 部分的引用,当 rBase.getName() 被评估时,通常会解析为 Base::getName()。然而,Base::getName() 是虚函数,这会指示程序去查找是否存在适用于派生对象的更高阶派生版本函数。在此情况下,它将解析为 Derived::getName()!

让我们看一个稍复杂的示例:

#include <iostream>
#include <string_view>

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 is a " << rBase.getName() << '\n';

    return 0;
}

你认为这个程序会输出什么?

让我们看看它是如何工作的。首先,我们实例化一个C类的对象。rBase是一个A类引用,我们将其设置为引用C对象的A部分。最后,我们调用rBase.getName()。rBase.getName()将解析为A::getName()。然而 A::getName() 是虚函数,因此编译器将调用 A 与 C 之间最派生层级的匹配函数。本例中即为 C::getName()。请注意它不会调用 D::getName(),因为原始对象是 C 类而非 D 类,故仅考虑 A 到 C 之间的函数。

最终程序输出:

image

请注意,虚拟函数解析仅在通过指向类类型对象的指针或引用调用虚拟成员函数时生效。这是因为编译器能够区分指针或引用的类型与被指向对象的类型。上例中可见此机制。

直接在对象上调用虚拟成员函数(而非通过指针或引用)时,始终会调用属于该对象自身类型的成员函数。例如:

C c{};
std::cout << c.getName(); // will always call C::getName

A a { c }; // copies the A portion of c into a (don't do this)
std::cout << a.getName(); // will always call A::getName

关键要点:
虚函数解析仅在通过指向类类型对象的指针或引用调用成员函数时生效。


多态性

在编程中,多态性polymorphism指实体具有多种形式的能力(“多态性”一词字面意思即“多种形式”)。例如,考虑以下两个函数声明:

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

标识符 add 有两种形式:add(int, int) 和 add(double, double)。

编译时多态性Compile-time polymorphism指由编译器解析的多态形式,包括函数重载解析和模板解析。

运行时多态性Runtime polymorphism指在运行时解析的多态形式,包括虚函数解析。


一个更复杂的示例

让我们重新审视上一课中使用的动物示例。以下是原始类及其测试代码:

#include <iostream>
#include <string>
#include <string_view>

class Animal
{
protected:
    std::string m_name {};

    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    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
{
public:
    Cat(std::string_view name)
        : Animal{ name }
    {
    }

    std::string_view speak() const { return "Meow"; }
};

class Dog: public Animal
{
public:
    Dog(std::string_view name)
        : Animal{ name }
    {
    }

    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);

    return 0;
}

这将输出:

image

以下是将 speak() 函数设为虚函数的等效类:

#include <iostream>
#include <string>
#include <string_view>

class Animal
{
protected:
    std::string m_name {};

    // We're making this constructor protected because
    // we don't want people creating Animal objects directly,
    // but we still want derived classes to be able to use it.
    Animal(std::string_view name)
        : m_name{ name }
    {
    }

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

class Cat: public Animal
{
public:
    Cat(std::string_view name)
        : Animal{ name }
    {
    }

    virtual std::string_view speak() const { return "Meow"; }
};

class Dog: public Animal
{
public:
    Dog(std::string_view name)
        : Animal{ name }
    {
    }

    virtual 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);

    return 0;
}

该程序输出结果:

image

成功了!

当评估 animal.speak() 时,程序会注意到 Animal::speak() 是虚函数。若 animal 引用的是 Cat 对象的 Animal 部分,程序会遍历 Animal 与 Cat 之间的所有类,寻找更派生出的函数。此时它会找到 Cat::speak()。若 animal 引用的是 Dog 对象的 Animal 部分,程序则将函数调用解析为 Dog::speak()。

请注意我们未将 Animal::getName() 设为虚函数。这是因为 getName() 在任何派生类中均未被重写,故无需如此。

同理,以下数组示例现可按预期运行:

Cat fred{ "Fred" };
Cat misty{ "Misty" };
Cat zeke{ "Zeke" };

Dog garbo{ "Garbo" };
Dog pooky{ "Pooky" };
Dog truffle{ "Truffle" };

// Set up an array of pointers to animals, and set those pointers to our Cat and Dog objects
Animal* animals[]{ &fred, &garbo, &misty, &pooky, &truffle, &zeke };

for (const auto* animal : animals)
    std::cout << animal->getName() << " says " << animal->speak() << '\n';

这产生了以下结果:

image

尽管这两个示例仅使用了Cat和Dog类,但任何从Animal派生的其他类也能直接与report()函数和animal数组配合使用,无需额外修改!这或许是虚函数的最大优势——它能让代码结构化,使新派生类无需修改即可自动兼容旧代码!

需注意:派生类函数的签名必须与基类虚函数完全一致,才能被正确调用。若派生类函数的参数类型不同,程序虽可能编译通过,但虚函数将无法按预期解析。下节课我们将探讨如何防范此类问题。

需注意:若函数被标记为虚函数,则派生类中所有匹配的重写函数也会隐式视为虚函数,即使未显式标记。

规则:
若函数为虚函数,则派生类中所有匹配的重写函数均隐式成为虚函数。

反之则不成立——派生类的虚函数重写不会使基类函数隐式成为虚函数。


虚拟函数的返回类型

在正常情况下,虚拟函数及其重写函数的返回类型必须匹配。请看以下示例:

class Base
{
public:
    virtual int getValue() const { return 5; }
};

class Derived: public Base
{
public:
    virtual double getValue() const { return 6.78; }
};

在此情况下,Derived::getValue() 不被视为对 Base::getValue() 的匹配重写,编译将失败。


请勿在构造函数或析构函数中调用虚函数

这里还有一个常让毫无防备的新手程序员中招的陷阱:切勿在构造函数或析构函数中调用虚函数。为什么?

请记住:创建派生类时,基类部分会先被构造。若在基类构造函数中调用虚函数,而派生类部分尚未创建,则无法调用派生版本的函数——因为此时尚无派生对象供该函数操作。此时C++会调用基类版本的函数。

析构函数同样存在类似问题。若在基类析构函数中调用虚函数,系统始终会解析为基类版本的函数,因为此时派生类的实例已然被销毁。

最佳实践:
切勿在构造函数或析构函数中调用虚函数。


虚函数的弊端

既然大多数情况下你都希望函数具有虚性,为何不直接将所有函数设为虚函数呢?答案在于效率问题——解析虚函数调用比解析普通函数调用耗时更长。

此外,为使虚函数正常工作,编译器必须为每个包含虚函数的类对象额外分配一个指针。这会给原本体积较小的对象带来大量开销。本章后续课程将对此进行更深入的探讨。


测验时间

  1. 下列程序会输出什么?本练习旨在通过观察代码完成,而非使用编译器编译示例程序。

1a)

#include <iostream>
#include <string_view>

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:
// Note: no getName() function here
};

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';

    return 0;
}

显示方案

B. rBase 是指向 C 对象的 A 引用。通常 rBase.getName() 会调用 A::getName(),但由于 A::getName() 是虚函数,因此它会转而调用 A 与 C 之间最派生出的匹配函数。即 B::getName(),该函数会输出 B。

(竟然真的是B,这出乎我的意料)
image

1b)

#include <iostream>
#include <string_view>

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;
    B& rBase{ c }; // note: rBase is a B this time
    std::cout << rBase.getName() << '\n';

    return 0;
}

显示方案

C. 这相当直截了当,因为 C::getName() 是类 B 和类 C 之间最派生出的匹配调用。

1c)

#include <iostream>
#include <string_view>

class A
{
public:
    // note: no virtual keyword
    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';

    return 0;
}

显示方案

A. 由于 A 不是虚函数,当调用 rBase.getName() 时,会调用 A::getName()。

1d)

#include <iostream>
#include <string_view>

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

class B: public A
{
public:
    // note: no virtual keyword in B, C, and D
    std::string_view getName() const { return "B"; }
};

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 }; // note: rBase is a B this time
    std::cout << rBase.getName() << '\n';

    return 0;
}

显示方案

C. 尽管B和C未被标记为虚函数,但A::getName()是虚函数,而B::getName()和C::getName()是重写函数。因此,B::getName()和C::getName()被视为隐式虚函数,故对rBase.getName()的调用将解析为C::getName(),而非B::getName()。

1e)

#include <iostream>
#include <string_view>

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

class B: public A
{
public:
    // Note: Functions in B, C, and D are non-const.
    virtual std::string_view getName() { return "B"; }
};

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

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

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

    return 0;
}

显示方案

A. 这个情况稍复杂些。rBase 是指向 C 对象的 A 引用,因此 rBase.getName() 通常会调用 A::getName()。但 A::getName() 是虚函数,故会调用 A 与 C 之间最派生版本的函数——即 A::getName()。由于 B::getName() 和 C::getName() 并非 const 函数,它们不被视为重写!因此该程序将输出 A。

1f)

#include <iostream>
#include <string_view>

class A
{
public:
	A() { std::cout << getName(); } // note addition of constructor (getName() now called from here)

	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 {};

	return 0;
}

显示方案

A. 另一个棘手的问题。当我们创建一个C对象时,首先构造的是A部分。此时调用A构造函数时,它会调用虚函数getName()。由于类中B和C部分尚未初始化,这将解析为A::getName()。
posted @ 2026-02-02 05:45  游翔  阅读(0)  评论(0)    收藏  举报