章节目录

虚函数和多态

本节阅读量:

在上一课中,我们看了一些使用基类指针或引用指向派生类对象的例子,这可以简化代码。然而,在每种情况下都会遇到同一个问题:基类指针或引用只能调用函数的基类版本,而不能调用派生类版本。

以下是这种行为的一个简单示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#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;
}

打印:

1
rBase is a Base

因为 rBase 是一个 Base 引用,所以它调用 Base::getName(),即使它实际上引用的是 Derived 对象中的 Base 部分。

在本课中,我们将展示如何使用虚函数来解决这个问题。


虚函数

虚函数是一种特殊的成员函数。调用虚函数时,它会解析为被引用或指向对象实际类型中最底层的派生版本。

如果派生函数具有与基类函数相同的签名(名称、参数类型以及是否为 const)和返回类型,则认为它是匹配的。这种行为称为「重写」。

要让函数成为虚函数,只需将 “virtual” 关键字放在函数声明之前。

下面是上面程序带有虚函数的示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#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;
}

这打印:

1
rBase is a Derived

一些现代编译器可能会报错,提示类具有虚函数和可访问的非虚析构函数。如果遇到这种情况,请向基类添加虚析构函数。在上述程序中,可以将以下内容添加到 Base 的定义中:

1
virtual ~Base() = default;

因为 rBase 是对 Derived 对象中 Base 部分的引用,所以计算 rBase.getName() 时,它通常会解析为 Base::getName()。然而,Base::getName() 是虚函数,程序会检查派生对象中是否存在更底层的派生版本。在这种情况下,它将解析为 Derived::getName()!

让我们来看一个稍微复杂一些的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#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 之间的函数。

因此,我们的程序输出:

1
rBase is a C

请注意,虚函数解析只在通过指针或引用调用虚成员函数时有效。因为此时指针或引用的静态类型,可能不同于它实际指向或引用的对象类型。我们在上面的例子中已经看到了这一点。

直接在对象上调用虚成员函数(不通过指针或引用)时,将始终调用该对象自身类型对应的成员函数。例如:

1
2
3
4
5
C c{};
std::cout << c.getName(); // 会永远调用 C::getName

A a { c }; // 将 c 的 A 部分 拷贝给 a (不要写这样的代码)
std::cout << a.getName(); // 会永远调用 A::getName

多态

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

1
2
int add(int, int);
double add(double, double);

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

「编译时多态」是指由编译器解析的多态形式。这包括函数重载解析和模板解析。

「运行时多态」是指在运行时解析的多态形式。这包括虚函数解析。


更复杂的例子

让我们再看一下上一课中使用的动物示例。这是原始的类,以及一些测试代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <string>
#include <string_view>

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

    // 将构造函数设置为 protected
    // 因为我们不想Animal被直接构造
    // 但是派生类仍然可以使用
    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;
}

这打印:

1
2
Fred says ???
Garbo says ???

以下是speak()函数变为虚函数的类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <string>
#include <string_view>

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

    // 将构造函数设置为 protected
    // 因为我们不想Animal被直接构造
    // 但是派生类仍然可以使用
    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;
}

这打印:

1
2
Fred says Meow
Garbo says Woof

按预期执行!

当计算 animal.speak() 时,程序会注意到 Animal::speak() 是虚函数。当 animal 引用 Cat 对象中的 Animal 部分时,程序会查看 Animal 和 Cat 之间的所有类,寻找最底层的派生函数。在这种情况下,它会找到 Cat::speak()。当 animal 引用 Dog 对象中的 Animal 部分时,程序会将函数调用解析为 Dog::speak()。

请注意,我们没有将Animal::getName()设置为virtual。这是因为getName()在任何派生类中都不会被重写,因此没有必要。

同样,以下数组示例现在按预期工作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
Cat fred{ "Fred" };
Cat misty{ "Misty" };
Cat zeke{ "Zeke" };

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

// 存储指向动物的数组,里面装着指向Cat和Dog的指针
Animal* animals[]{ &fred, &garbo, &misty, &pooky, &truffle, &zeke };

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

这打印:

1
2
3
4
5
6
Fred says Meow
Garbo says Woof
Misty says Meow
Pooky says Woof
Truffle says Woof
Zeke says Meow

尽管这两个例子只使用了 Cat 和 Dog,但任何从 Animal 派生的其他类也可以与 report() 函数和 Animal 数组一起使用,而无需进一步修改!这可能是虚函数最大的好处——可以用一种方式构建程序,让新派生类自动复用旧代码,而不需要修改旧代码。

警告:为了调用派生类版本,派生类函数的签名必须与基类虚函数的签名完全匹配。如果派生类函数具有不同的参数类型,程序可能仍然可以顺利编译,但虚函数不会按预期解析。在下一课中,我们将讨论如何防范这种情况。

请注意,如果一个函数被标记为virtual,则派生类中的所有匹配的重写函数也都会被隐式地视为virtual,即使它们没有被显式地标记为virtual。

反之则不然——派生类中的virtual重写不会隐式地使基类函数成为virtual的。


虚函数的返回类型

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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() 的匹配重写,编译将失败。


不要从构造函数或析构函数调用虚函数

这是另一个经常让新程序员踩坑的陷阱。你不应该从构造函数或析构函数中调用虚函数。为什么?

请记住,创建派生类时,会先构造 Base 部分。如果你从 Base 构造函数中调用虚函数,而类的 Derived 部分尚未创建,它就无法调用该函数的 Derived 版本,因为 Derived 函数没有 Derived 对象部分可供处理。在 C++ 中,此时会调用 Base 版本。

析构函数也存在类似的问题。如果在基类析构函数中调用虚函数,它将始终解析为函数的基类版本,因为类的派生部分已经被销毁。


虚函数的缺点

既然很多时候你都希望函数是 virtual 的,为什么不把所有函数都设为 virtual 呢?答案是:因为效率更低。解析虚函数调用比解析常规函数调用需要更长时间。此外,编译器还必须为每个具有一个或多个虚函数的类对象分配一个额外的指针。我们将在本章后续课程中继续讨论这一点。


25.0 指向派生对象的基类指针和引用

上一节

25.2 override和final说明符

下一节