在课程“指向与引用派生对象的基类”中,我们看到使用指向基类的指针或引用有助于简化代码。然而,在全部示例中都存在一个共同问题:基类指针或引用只能调用基类版本的成员函数,无法调用派生类版本。
下面给出一个演示该行为的简化示例:
#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
由于 rBase
是 Base
类型的引用,因此调用的是 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
对象;rBase
是 A
的引用,绑定到该 C
对象的 A
部分。rBase.getName()
原应调用 A::getName()
,但因 A::getName()
为虚函数,故在 A
与 C
之间寻找最派生匹配,最终调用 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
虽然B
、C
、D
的函数未写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
B
和C
的函数与基类函数签名不同(缺少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()
。