- 第 15 章 面向对象程序设计
- 15.3 虚函数
- 15.4 抽象基类
- 15.5 访问控制与继承
- 15.6 继承中的类作用域
我们必须为每一个虚函数提供定义,不论它是否被用上。因为编译器无法确定到底会使用哪个函数。
(1)对虚函数的调用可能在运行时才被解析
-
指针、引用调用虚函数:动态绑定
-
类类型调用虚函数:静态绑定(编译时就会进行绑定)
(2)派生类中的虚函数
-
一旦某个函数被声明成虚函数,在其所有派生类中都将是虚函数。因此在派生类覆盖了某个虚函数时,最好使用 virtual 关键字标明(非必须)。
-
派生类中覆盖虚函数时,函数名、形参列表和返回类型都必须一致。
例外:
- 当虚函数返回的是类本身的引用或指针类型时,派生类的虚函数可以返回自己类型对应的引用或指针。
(3)final 和 override 说明符
派生类如果定义了一个函数与基类中虚函数的名字相同但是形参列表不同,编译器将认为新定义的这个函数与基类中原有的函数是相互独立的。这时,派生类的函数并没有覆盖掉基类中的版本。
要想调试并发现这样的错误显然非常困难。在 C++11 新标准中可以使用 override 关键字来说明派生类中的虚函数。(只有虚函数才能被覆盖)
好处:在使得程序员的意图更加清晰的同时让编译器可以为我们发现一些错误。
我们还能把某个函数指定为 final,如果我们已经把函数定义成 final 了,则之后任何尝试覆盖该函数的操作都将引发错误。
(4)虚函数与默认实参
虚函数也可以拥有默认实参。如果某次函数调用使用了默认实参,则该实参值由本次调用的静态类型决定。
如果我们通过基类的引用或指针调用函数,则使用基类中定义的默认实参,即使实际运行的是派生类中的函数版本也是如此。此时,传入派生类函数的将是基类函数定义的默认实参。
如果虚函数使用默认实参,则基类和派生类中定义的默认实参最好一致。
(5)回避虚函数的机制
在某些情况下,我们希望强迫执行虚函数的某个特定版本。使用作用域运算符可以实现这一目的:
// 强行调用基类中定义的函数版本而不管 baseP 的动态类型到底是什么
double undiscounted = baseP->Quote::net_price(42);
该代码强行调用 Quote 的 net_price 函数,而不管 baseP 实际指向的对象类型到底是什么。该调用将在编译时完成解析。
15.4 抽象基类(1)纯虚函数
我们可以将 net_price 定义成纯虚函数,这样做可以清晰明了地告诉用户当前这个 net_price 函数是没有实际意义的。我们通过在函数体的位置(即在声明语句的分号之前)书写 = 0 就可以将一个虚函数说明为纯虚函数。其中,= 0 只能出现在类内部的虚函数声明语句处。
(2)含有纯虚函数的类是抽象基类
含有(或者未经覆盖直接继承)纯虚函数的类是抽象基类。抽象基类负责定义接口,而后续的其他类可以覆盖该接口。我们不能(直接)创建一个抽象基类的对象。
我们可以定义 Disc_quote 的派生类的对象,前提是这些类覆盖了 net_price 函数。
15.5 访问控制与继承Cannot read properties of undefined (reading 'type')
可以使用 using 声明改变某个成员的可访问性,using 声明语句中名字的访问权限由该 using 声明语句之前的访问说明符来决定:
- using 声明语句位于 private 部分,则该名字只能被类的成员和友元访问
- using 声明语句位于 public 部分,则类的所有用户都能访问它
- using 声明语句位于 protected 部分,则该名字对于成员、友元和派生类是可访问的。
派生类的作用域位于基类的作用域之内,因此派生类才能像使用自己的成员一样使用基类成员。
(1)在编译时进行名字查找
一个对象、引用或指针的静态类型决定了该对象的哪些成员是可见的。
class Quote { /* ... */ };
class Disc_quote : public Quote
{
std::pair discount_policy() const { return {quantity, discount} };
};
我们只能通过 Disc_quote 及其派生类的对象、引用或指针使用 discount_policy:
Disc_quote bulk;
Disc_quote *bulkP = &bulk; // 静态类型与动态类型一致
Quote *itemP = &bulk; // 静态类型与动态类型不一致
bulkP->discount_policy(); // 正确:bulkP 的类型是 Bulk quote*
itemP->discount_policy(); // 错误:itemP 的类型是 Quote*
(2)名字冲突与继承
派生类也能重用定义在其直接基类或间接基类中的名字,此时定义在内层作用域(即派生类)的名字将隐藏定义在外层作用域(即基类)的名字。
可以通过作用域运算符 ::
来使用隐藏的成员。
(3)名字查找先于类型检查
理解函数调用的解析过程对于理解 C++ 的继承至关重要,假定我们调用 p->mem()
(或者 obj.mem()
),则依次执行以下 4个步骤:
-
首先确定 p(或 obj)的静态类型。因为我们调用的是一个成员,所以该类型必然是类类型。
-
在 p(或 obj)的静态类型对应的类中查找 mem。如果找不到,则依次在直接基类中不断查找直至到达继承链的顶端。如果找遍了该类及其基类仍然找不到,则编译器将报错。
-
一旦找到了 mem,就进行常规的类型检查以确认对于当前找到的 mem,本次调用是否合法。
-
假设调用合法,则编译器将根据调用的是否是虚函数而产生不同的代码:
—— 如果 mem 是虚函数且我们是通过引用或指针进行的调用,则编译器产生的代码将在运行时确定到底运行该虚函数的哪个版本,依据是对象的动态类型。
—— 反之,如果 mem 不是虚函数或者我们是通过对象(而非引用或指针)进行的调用,则编译器将产生一个常规函数调用。
声明在内层作用域的函数并不会重载声明在外层作用域的函数。因此,定义派生类中的函数也不会重载其基类中的成员。如果派生类(即内层作用域)的成员与基类(即外层作用域)的某个成员同名,则派生类将在其作用域内隐藏该基类成员。即使派生类成员和基类成员的形参列表不一致,基类成员也仍然会被隐藏掉。
struct Base
{
int memfcn ();
};
struct Derived : Base
{
int memfcn(int); // 隐藏基类的 memfcn
};
Derived d; Base b;
b.memfcn(); // 调用 Base::memfcn
d.memfcn(10); // 调用 Derived::memfcn
d.memfcn(); // 错误∶参数列表为空的 memfcn 被隐藏了
d.Base::memfcn(); // 正确∶调用 Base::memfcn
(4)覆盖重载的函数
有时一个类仅需覆盖重载集合中的一些而非全部函数,此时,如果我们不得不覆盖基类中的每一个版本的话,显然操作将极其烦琐。
一种好的解决方案是为重载的成员提供一条 using 声明语句,这样我们就无须覆盖基类中的每一个重载版本了。using 声明语句指定一个名字而不指定形参列表,所以一条基类成员函数的 using 声明语句就可以把该函数的所有重载实例添加到派生类作用域中。此时,派生类只需要定义其特有的函数就可以了。而无需为继承而来的其他函数重新定义。