为了应对继承中的一些常见挑战,C++ 提供了两个与继承相关的标识符:override
和 final
。需要注意的是,这些标识符并不是关键字——它们只是在特定上下文中具有特殊含义的普通单词。C++ 标准将它们称为“具有特殊含义的标识符”,但它们通常被称为“限定符”。
尽管 final
的使用频率不高,但 override
是一个非常有用的特性,应该经常使用。在本课中,我们将探讨这两个限定符,以及一个关于虚函数重写返回类型必须匹配规则的例外情况。
覆盖限定符(override specifier)
正如我们在上一课中提到的,派生类的虚函数只有在其签名和返回类型完全匹配的情况下才被视为重写。这可能导致一些意外问题,即本应作为重写的函数实际上并未重写。
考虑以下示例:
#include <iostream>
#include <string_view>
class A
{
public:
virtual std::string_view getName1(int x) { return "A"; }
virtual std::string_view getName2(int x) { return "A"; }
};
class B : public A
{
public:
virtual std::string_view getName1(short x) { return "B"; } // 注意:参数类型为 short
virtual std::string_view getName2(int x) const { return "B"; } // 注意:函数为 const
};
int main()
{
B b{};
A& rBase{ b };
std::cout << rBase.getName1(1) << '\n';
std::cout << rBase.getName2(2) << '\n';
return 0;
}
由于 rBase
是指向 B
对象的 A
类型引用,本意是通过虚函数调用 B::getName1()
和 B::getName2()
。然而,由于 B::getName1()
的参数类型不同(short
而非 int
),因此它不被视为对 A::getName1()
的重写。更隐蔽的是,由于 B::getName2()
是 const
而 A::getName2()
不是,B::getName2()
也不被视为对 A::getName2()
的重写。
因此,程序输出:
A
A
在这种特定情况下,由于 A
和 B
只是打印它们的名称,因此很容易看出我们的重写出了问题,调用了错误的虚函数。然而,在更复杂的程序中,如果函数的行为或返回值没有打印出来,这样的问题可能很难调试。
为了帮助解决那些本应为重写但未被正确识别的函数问题,可以将 override
限定符应用于任何虚函数,以告知编译器强制该函数为重写。override
限定符放置在成员函数声明的末尾(与函数级别的 const
放在相同位置)。如果成员函数是 const
且为重写,则 const
必须放在 override
之前。
如果标记为 override
的函数未重写基类函数(或应用于非虚函数),编译器会将该函数标记为错误。
#include <string_view>
class A
{
public:
virtual std::string_view getName1(int x) { return "A"; }
virtual std::string_view getName2(int x) { return "A"; }
virtual std::string_view getName3(int x) { return "A"; }
};
class B : public A
{
public:
std::string_view getName1(short int x) override { return "B"; } // 编译错误,函数不是重写
std::string_view getName2(int x) const override { return "B"; } // 编译错误,函数不是重写
std::string_view getName3(int x) override { return "B"; } // 正确,函数是 A::getName3(int) 的重写
};
int main()
{
return 0;
}
上述程序会产生两个编译错误:一个针对 B::getName1()
,另一个针对 B::getName2()
,因为它们都没有重写任何函数。B::getName3()
重写了 A::getName3()
,因此该行没有产生错误。
由于使用 override
限定符没有任何性能开销,并且它有助于确保你确实重写了你认为已经重写的函数,因此所有虚函数重写都应该使用 override
限定符标记。此外,由于 override
限定符隐含了 virtual
,因此不需要在使用 override
限定符的函数上再使用 virtual
关键字。
最佳实践
- 在基类中使用
virtual
关键字标记虚函数。 - 在派生类中使用
override
限定符(而不是virtual
关键字)标记重写函数。这包括虚析构函数。
规则
如果成员函数既是 const
又是重写,则 const
必须先列出。const override
是正确的,而 override const
是错误的。
最终限定符(final specifier)
在某些情况下,你可能不希望别人重写虚函数,或者从某个类继承。final
限定符可以用来告知编译器强制执行这一点。如果用户尝试重写被标记为 final
的函数或从被标记为 final
的类继承,编译器会报编译错误。
如果我们要限制用户重写某个函数,final
限定符的使用位置与 override
限定符相同,如下所示:
#include <string_view>
class A
{
public:
virtual std::string_view getName() const { return "A"; }
};
class B : public A
{
public:
// 注意在以下行中使用 final 限定符——这使得该函数在派生类中无法被重写
std::string_view getName() const override final { return "B"; } // 正确,重写了 A::getName()
};
class C : public B
{
public:
std::string_view getName() const override { return "C"; } // 编译错误:重写了 B::getName(),而它是 final 的
};
在上述代码中,B::getName()
重写了 A::getName()
,这是可以的。但 B::getName()
使用了 final
限定符,这意味着任何对该函数的进一步重写都应该被视为错误。确实,C::getName()
尝试重写 B::getName()
(这里的 override
限定符并不相关,只是出于良好实践而添加),因此编译器会报编译错误。
如果我们要防止从某个类继承,final
限定符应用于类名之后,如下所示:
#include <string_view>
class A
{
public:
virtual std::string_view getName() const { return "A"; }
};
class B final : public A // 注意在这里使用 final 限定符
{
public:
std::string_view getName() const override { return "B"; }
};
class C : public B // 编译错误:不能从 final 类继承
{
public:
std::string_view getName() const override { return "C"; }
};
在上述示例中,类 B
被声明为 final
。因此,当 C
尝试从 B
继承时,编译器会报编译错误。
协变返回类型(Covariant return types)
存在一种特殊情况,派生类的虚函数重写可以具有与基类不同的返回类型,但仍被视为匹配的重写。如果虚函数的返回类型是指向某个类的指针或引用,则重写函数可以返回指向派生类的指针或引用。这些被称为协变返回类型。以下是一个示例:
#include <iostream>
#include <string_view>
class Base
{
public:
// 这个版本的 getThis() 返回指向 Base 类的指针
virtual Base* getThis() { std::cout << "called Base::getThis()\n"; return this; }
void printType() { std::cout << "returned a Base\n"; }
};
class Derived : public Base
{
public:
// 通常情况下,重写函数必须返回与基类函数相同类型的对象
// 然而,由于 Derived 继承自 Base,因此返回 Derived* 而不是 Base* 是可以的
Derived* getThis() override { std::cout << "called Derived::getThis()\n"; return this; }
void printType() { std::cout << "returned a Derived\n"; }
};
int main()
{
Derived d{};
Base* b{ &d };
d.getThis()->printType(); // 调用 Derived::getThis(),返回 Derived*,调用 Derived::printType
b->getThis()->printType(); // 调用 Derived::getThis(),返回 Base*,调用 Base::printType
return 0;
}
程序输出:
called Derived::getThis()
returned a Derived
called Derived::getThis()
returned a Base
关于协变返回类型的一个有趣之处是:C++ 无法动态选择类型,因此你总是会得到与实际被调用的函数版本相匹配的类型。
在上述示例中,我们首先调用 d.getThis()
。由于 d
是 Derived
类型,因此调用的是 Derived::getThis()
,它返回一个 Derived*
。然后使用这个 Derived*
调用非虚函数 Derived::printType()
。
现在来看一个有趣的情况。我们接着调用 b->getThis()
。变量 b
是指向 Derived
对象的 Base
类型指针。Base::getThis()
是一个虚函数,因此调用的是 Derived::getThis()
。尽管 Derived::getThis()
返回的是 Derived*
,但由于基类版本的函数返回的是 Base*
,因此返回的 Derived*
被向上转型为 Base*
。由于 Base::printType()
是非虚函数,因此调用的是 Base::printType()
。
换句话说,在上述示例中,只有当你最初使用 Derived
类型的对象调用 getThis()
时,你才会得到一个 Derived*
。
注意,如果 printType()
是虚函数而不是非虚函数,那么 b->getThis()
的结果(一个 Base*
类型的对象)将进行虚函数解析,调用的是 Derived::printType()
。
协变返回类型通常用于虚成员函数返回指向包含该成员函数的类的指针或引用的情况(例如,Base::getThis()
返回 Base*
,而 Derived::getThis()
返回 Derived*
)。然而,这并不是严格必要的。只要重写成员函数的返回类型是从基类虚成员函数的返回类型派生而来的,就可以使用协变返回类型。
测验时间
问题 1
以下程序的输出是什么?
#include <iostream>
class A
{
public:
void print()
{
std::cout << "A";
}
virtual void vprint()
{
std::cout << "A";
}
};
class B : public A
{
public:
void print()
{
std::cout << "B";
}
void vprint() override
{
std::cout << "B";
}
};
class C
{
private:
A m_a{};
public:
virtual A& get()
{
return m_a;
}
};
class D : public C
{
private:
B m_b{};
public:
B& get() override // 协变返回类型
{
return m_b;
}
};
int main()
{
// 情况 1
D d {};
d.get().print();
d.get().vprint();
std::cout << '\n';
// 情况 2
C c {};
c.get().print();
c.get().vprint();
std::cout << '\n';
// 情况 3
C& ref{ d };
ref.get().print();
ref.get().vprint();
std::cout << '\n';
return 0;
}
问题 2
何时使用函数重载(overloading)与函数重写(overriding)?