虚方法(virtual 和 override)
类旨在描述外部编程接口并提供其内部实现。由于我们的测试程序的功能是绘制各种形状,我们在 Shape 类及其后代中描述了若干变量以供未来实现,并且也为接口预留了 draw 方法。
在 Shape 基类中,它不应也无法进行任何操作,因为 Shape 不是一个具体的形状:我们稍后会将 Shape 转换为抽象类(稍后我们会详细讨论 抽象类和接口 )。
我们重定义 Rectangle、Ellipse 和其他派生类 (Shapes3.mq5) 中的 draw 方法。这涉及拷贝该方法并相应地修改其内容。尽管许多人将此过程称为“重写”,但我们将区分“重写”和“重定义”这两个术语,将“重写”专门用于虚方法(将在稍后讨论)。
严格来说,重定义方法只要求方法名称匹配。但为了确保在全部代码中用法一致,必须维护相同的参数列表和返回类型。
class Rectangle : public Shape
|
由于我们还不知道如何在屏幕上绘制,我们暂时只将消息输出到日志。
务必要注意,通过在派生类中提供该方法的新实现,我们因此得到了该方法的两个版本:第一个引用内置的基对象(内部的 Shape),第二个引用派生类对象(外部的 Rectangle)。
使用 Shape 类型的变量时,将调用第一个版本;使用 Rectangle 类型的变量时,将调用第二个版本。
在更长的继承链中,一个方法可以被多次重定义和多次传播。
可以更改新方法的访问类型,例如,将其从 protected(受保护)改为 public(公开),反之亦然。但在本例中,我们将 draw 方法保留在 public 部分中。
如果需要,程序员可以调用任何祖先类的方法实现:为此,需要使用一个特殊的 上下文解析运算符 ,即两个冒号 '::'。具体而言,我们可以从 Square 类的 draw 方法中调用 Rectangle 类的 draw 实现:为此,我们指定所需类的名称、'::' 和方法名称,例如 Rectangle::draw()。调用 draw 时不指定上下文即表示调用当前类的方法。因此,如果直接在 draw 方法本身中调用,将会导致无限 递归,最终导致堆栈溢出和程序崩溃。
class Square : public Rectangle
|
然后,在 Square 对象上调用 draw 会在日志中记录两行:
Square s(100, 200, 50, clrGreen);
|
将方法绑定到声明该方法的类,可以实现静态调度(或静态绑定):编译器在编译阶段决定调用哪个方法,并将找到的匹配项“硬编码”到二进制代码中。
在决策过程中,编译器会在执行取消引用 ('.') 的类的对象中查找要调用的方法。如果该方法存在,则调用该方法;如果不存在,则编译器检查父类中是否存在该方法,依此类推,沿着继承链查找,直到找到该方法。如果在继承链的任何类中都找不到该方法,则会出现编译错误“未声明的标识符”。
具体来说,以下代码在 Rectangle 对象上调用 setColor 方法:
Rectangle r(100, 200, 75, 50, clrBlue);
|
但是,此方法仅在基类 Shape 中定义,并且在所有后代类中仅构建一次,因此它将在此处执行。
我们尝试在 OnStart 函数中从数组开始绘制任意形状(回想一下,我们已经在所有后代类中复制并修改了 draw 方法)。
for(int i = 0; i < 10; ++i)
|
奇怪的是,日志中没有任何输出。这是因为程序调用了 Shape 类的 draw 方法。
静态调度在这里有一个主要缺点:当我们使用指向基类的指针来存储派生类的对象时,编译器会根据指针的类型(而不是对象本身)来选择方法。事实上,在编译阶段,我们并不清楚指针在程序执行期间会指向哪个类对象。
因此我们需要一种更灵活的方法:动态调度(也称为绑定),可将方法的选择(包括后代链中所有被重写的方法版本)推迟到运行时。必须在分析指针所指向对象的实际类之后再进行选择。正是动态调度提供了 多态性原则。
这种方法是在 MQL5 中使用虚方法实现。在描述这种方法时,必须在头文件开头添加 virtual 关键字。
我们将 Shape 类 (Shapes4.mq5) 中的 draw 方法声明为虚方法。这会自动将其在派生类中的所有版本都设为虚方法。
class Shape
|
一旦方法被虚拟化,在派生类中对其进行修改便称为重写,而不是重定义。重写要求与方法的名称、参数类型和返回值匹配(考虑 const 修饰符存在与否)。
请注意,重写虚函数不同于 函数重载。重载使用相同的函数名,但参数不同(具体来说,我们在结构体的示例中看到了重载构造函数的可能性,请参见 构造函数和析构函数一节),而重写则要求函数签名完全匹配。
被重写的函数必须定义在不同的类中,这些类之间存在继承关系。重载函数必须位于同一个类中,否则它不会被视为重载,而很可能被视为重定义(并且工作方式会有所不同,参见对 OverrideVsOverload.mq5 示例的深入分析)。
如果您运行新脚本,日志中将出现预期的行,表明调用了对每个类中 draw 方法的特定版本。
Drawing square
|
在重写虚方法的派生类中,建议在其头文件中添加 override 关键字(虽然不是强制要求)。
class Rectangle : public Shape
|
这可以让编译器知道我们是有意重写该方法。如果将来基类的 API 突然发生变化,导致被重写的方法不再是虚拟的(或直接被移除),编译器将生成一条错误消息:“声明方法时使用了 'override' 说明符,但并未重写任何基类方法”。请注意,即使为方法添加或移除 const 修饰符,也会更改其签名,重写可能会因此而中断。
也可以在被重写的方法前使用 virtual 关键字,但并非强制要求。
为了使动态调度正常工作,编译器会为每个类生成一个虚函数表。每个对象都会添加一个隐式字段,其中包含指向其给定类表的链接。表由编译器进行填充,内容是关于特定类的继承链上所有虚方法及其重写版本的信息。
对虚方法的调用在二进制程序映像中以一种特殊的方式编码:首先,查找该表以查找特定对象(位于指针处)的类版本,然后转换到相应的函数。
因此,动态调度比静态调度慢。
在 MQL5 中,无论是否存在虚方法,类始终包含一个虚函数表。
如果虚方法返回指向类的指针,则在重写该类时,可以更改(使其更具体、更特化)返回值的对象类型。换句话说,指针的类型不仅可以与虚方法的初始声明相同,还可以与其任何后继类型相同。这种类型称为“协变”或可互换。
例如,如果我们在 Shape 类中将 setColor 方法设为虚方法:
class Shape
|
我们可以在 Rectangle 类中重写它,如下所示(仅作为该技术的演示):
class Rectangle : public Shape
|
请注意,返回类型是指向 Rectangle 的指针,而非指向 Shape 的指针。
如果方法的重写版本修改了对象中不属于基类的部分,导致对象实际上不再符合基类所允许的状态(不变式),那么使用类似技巧很有用。
我们绘制形状的示例即将完成。剩下的就是用实际内容填充虚方法 draw。我们将在 图形 一章中完成这项工作(参见示例 ObjectShapesDraw.mq5),但我们将在学习 图形资源后对其进行改进。
考虑到继承的概念,编译器选择合适方法的过程看起来有点令人困惑。根据方法名称和调用指令中具体的自变量(其类型)列表,编译器会编译所有可用候选方法的列表。
如果不是虚方法,一开始只会分析当前类的方法。如果没有匹配的方法,编译器将继续搜索基类(然后再搜索更远的祖先类,直到找到匹配的方法)。如果在当前类的方法中找到一个合适的方法(即使需要隐式转换自变量类型),编译器会优先选取这个方法。即使基类中的方法具有更合适的自变量类型(无需转换或转换次数较少),编译器也不会选用。换句话说,非虚方法的分析将从当前对象的类开始,逐级向祖先类进行,直到找到第一个“有效”匹配。
对于虚方法,编译器首先在指针类中按名称查找所需方法,然后在虚函数表中,为指针类型和对象类型之间的继承链中实例化程度最高的类(最远的后代)选择重写了该方法的实现。在这种情况下,如果自变量类型没有完全匹配,也可以使用隐式自变量转换。
我们考虑以下示例 (OverrideVsOverload.mq5)。链中有 4 个类:Base、Derived、Concrete 和 Special。它们都包含自变量类型为 int 和 float 的方法。在 OnStart 函数中,整数变量 i 和实数变量 f 用作所有方法调用的自变量。
class Base
|
首先,我们创建 Concrete 类的对象和一个指向此对象的指针 Base *ptr。然后,我们调用它们的非虚方法和虚方法。在第二部分中,通过 Base 和 Derived 类指针来调用 Special 对象的方法。
void OnStart()
|
日志输出如下所示。
void Base::nonvirtual(float) 1.0
|
使用静态绑定调用 ptr.nonvirtual(i),并将整数 i 预先转换为参数类型 float。
对 c.nonvirtual(i) 的调用也是静态的,由于 Concrete 类中没有 void nonvirtual(int) 方法,因此编译器会在 Derived 父类中查找这样的方法。
对同一对象使用 float 类型的值调用同名函数会将编译器转到 Base::nonvirtual(float) 方法,因为 Derived::nonvirtual(int) 不适用(转换会造成精度损失)。在此过程中,编译器会发出警告“行为已被弃用,调用了隐藏方法”。
重载的方法与重写的方法(名称相同,但参数不同)看似相同,但实际并不同,因为重写的方法位于不同的类中。当派生类中的方法重写父类中的方法时,它会替换父类方法的行为,这有时可能会导致意外的结果。程序员可能期望编译器选择其他合适的方法(就像重载一样),但实际上却调用了子类。
为了避免可能的警告,如果需要实现父类,则应在派生类中将其编写为完全相同的函数,并从中调用基类。
class Derived : public Base
|
我们回到 OnStart 中的测试。
调用 ptr.process(i) 演示了上述所示的重载和重写之间的区分难点。Base 类有一个 process(float) 虚方法,而 Derived 类添加了一个新的虚方法 process(int),,由于参数类型不同,该方法在本例中不属于重写。编译器根据基类中的名称选择一个方法,并在虚函数表中检查继承链中直至 Concrete 类(包含 Concrete 本身,这是指针指向的对象类)是否存在重写。由于未找到重写,编译器采用了 Base::process(float) 并将自变量的类型转换应用于参数(int 转换为 float)。
如果我们遵循“在表示重定义的情况下使用采用override”这一规则,并将其添加到 Derived 中,则会遇到错误:
class Derived : public Base
|
编译器会报告“声明 'Derived::process' 方法时使用了 'override' 说明符,但并未重写任何基类方法”。这条错误可以作为修复问题的提示。
在 Concrete 对象上调用 process(i) 是通过 Derived::process(int) 完成的。虽然我们在 Special 类中进行了进一步的重定义,但这无关紧要,因为它是在 Concrete 类之后的继承链中完成的。
当 ptr 指针稍后赋值给 Special 对象时,编译器会将对 process(i) 和 process(f) 的调用解析为 Special::process(float),因为 Special 重写了 Base::process(float)。选择 float 参数的原因与前面描述的相同:Base::process(float) 方法被 Special 重写了。
如果我们应用 Derived 类型的指针 d,那么我们最终会得到对字符串 d.process(i) 的预期调用 Special::process(int)。关键在于,process(int) 是在 Derived 中定义的,并且属于编译器的搜索范围。
请注意,Special 类不仅重写了继承的虚方法,还重载了类本身中的两个方法。
请勿在构造函数或析构函数中调用虚函数!虽然技术上可行,但构造函数和析构函数中的虚行为会完全失效,您可能会得到意想不到的结果。不仅应避免显式调用,还应避免间接调用(例如,从构造函数调用简单方法,而该方法又调用虚方法)。
我们以构造函数为例更详细地分析一下这种情况。事实上,在构造函数执行时,对象尚未完全沿着整个继承链组装完成,而仅组装到当前类。所有派生部分围绕现有核心部分的收尾工作尚未完成。因此,所有后续的虚方法重写(如果有)此时均不可用。因此,从构造函数中调用的是方法的当前版本。