虚方法(virtual 和 override)

类旨在描述外部编程接口并提供其内部实现。由于我们的测试程序的功能是绘制各种形状,我们在 Shape 类及其后代中描述了若干变量以供未来实现,并且也为接口预留了 draw 方法。

Shape 基类中,它不应也无法进行任何操作,因为 Shape 不是一个具体的形状:我们稍后会将 Shape 转换为抽象类(稍后我们会详细讨论 抽象类和接口 )。

我们重定义 RectangleEllipse 和其他派生类 (Shapes3.mq5) 中的 draw 方法。这涉及拷贝该方法并相应地修改其内容。尽管许多人将此过程称为“重写”,但我们将区分“重写”和“重定义”这两个术语,将“重写”专门用于虚方法(将在稍后讨论)。

严格来说,重定义方法只要求方法名称匹配。但为了确保在全部代码中用法一致,必须维护相同的参数列表和返回类型。

class Rectangle : public Shape
{
   ...
   void draw()
   {
      Print("Drawing rectangle");
   }
};

由于我们还不知道如何在屏幕上绘制,我们暂时只将消息输出到日志。

务必要注意,通过在派生类中提供该方法的新实现,我们因此得到了该方法的两个版本:第一个引用内置的基对象(内部的 Shape),第二个引用派生类对象(外部的 Rectangle)。

使用 Shape 类型的变量时,将调用第一个版本;使用 Rectangle 类型的变量时,将调用第二个版本。

在更长的继承链中,一个方法可以被多次重定义和多次传播。

可以更改新方法的访问类型,例如,将其从 protected(受保护)改为 public(公开),反之亦然。但在本例中,我们将 draw 方法保留在 public 部分中。

如果需要,程序员可以调用任何祖先类的方法实现:为此,需要使用一个特殊的 上下文解析运算符 ,即两个冒号 '::'。具体而言,我们可以从 Square 类的 draw 方法中调用 Rectangle 类的 draw 实现:为此,我们指定所需类的名称、'::' 和方法名称,例如 Rectangle::draw()。调用 draw 时不指定上下文即表示调用当前类的方法。因此,如果直接在 draw 方法本身中调用,将会导致无限 递归,最终导致堆栈溢出和程序崩溃。

class Square : public Rectangle
{
public:
   ...
   void draw()
   {
      Rectangle::draw();
      Print("Drawing square");
   }
};

然后,在 Square 对象上调用 draw 会在日志中记录两行:

   Square s(10020050clrGreen);
   s.draw(); // Drawing rectangle
             // Drawing square

将方法绑定到声明该方法的类,可以实现静态调度(或静态绑定):编译器在编译阶段决定调用哪个方法,并将找到的匹配项“硬编码”到二进制代码中。

在决策过程中,编译器会在执行取消引用 ('.') 的类的对象中查找要调用的方法。如果该方法存在,则调用该方法;如果不存在,则编译器检查父类中是否存在该方法,依此类推,沿着继承链查找,直到找到该方法。如果在继承链的任何类中都找不到该方法,则会出现编译错误“未声明的标识符”。

具体来说,以下代码在 Rectangle 对象上调用 setColor 方法:

   Rectangle r(1002007550clrBlue);
   r.setColor(clrWhite);

但是,此方法仅在基类 Shape 中定义,并且在所有后代类中仅构建一次,因此它将在此处执行。

我们尝试在 OnStart 函数中从数组开始绘制任意形状(回想一下,我们已经在所有后代类中复制并修改了 draw 方法)。

   for(int i = 0i < 10; ++i)
   {
      shapes[i].draw();
   }

奇怪的是,日志中没有任何输出。这是因为程序调用了 Shape 类的 draw 方法。

静态调度在这里有一个主要缺点:当我们使用指向基类的指针来存储派生类的对象时,编译器会根据指针的类型(而不是对象本身)来选择方法。事实上,在编译阶段,我们并不清楚指针在程序执行期间会指向哪个类对象。

因此我们需要一种更灵活的方法:动态调度(也称为绑定),可将方法的选择(包括后代链中所有被重写的方法版本)推迟到运行时。必须在分析指针所指向对象的实际类之后再进行选择。正是动态调度提供了 多态性原则。

这种方法是在 MQL5 中使用虚方法实现。在描述这种方法时,必须在头文件开头添加 virtual 关键字。

我们将 Shape 类 (Shapes4.mq5) 中的 draw 方法声明为虚方法。这会自动将其在派生类中的所有版本都设为虚方法。

class Shape
{
   ...
   virtual void draw()
   {
   }
};

一旦方法被虚拟化,在派生类中对其进行修改便称为重写,而不是重定义。重写要求与方法的名称、参数类型和返回值匹配(考虑 const 修饰符存在与否)。

请注意,重写虚函数不同于 函数重载。重载使用相同的函数名,但参数不同(具体来说,我们在结构体的示例中看到了重载构造函数的可能性,请参见 构造函数和析构函数一节),而重写则要求函数签名完全匹配。
 
被重写的函数必须定义在不同的类中,这些类之间存在继承关系。重载函数必须位于同一个类中,否则它不会被视为重载,而很可能被视为重定义(并且工作方式会有所不同,参见对 OverrideVsOverload.mq5 示例的深入分析)。

如果您运行新脚本,日志中将出现预期的行,表明调用了对每个类中 draw 方法的特定版本。

Drawing square
Drawing circle
Drawing triangle
Drawing ellipse
Drawing triangle
Drawing rectangle
Drawing square
Drawing triangle
Drawing square
Drawing triangle

在重写虚方法的派生类中,建议在其头文件中添加 override 关键字(虽然不是强制要求)。

class Rectangle : public Shape
{
   ...
   void draw() override
   {
      Print("Drawing rectangle");
   }
};

这可以让编译器知道我们是有意重写该方法。如果将来基类的 API 突然发生变化,导致被重写的方法不再是虚拟的(或直接被移除),编译器将生成一条错误消息:“声明方法时使用了 'override' 说明符,但并未重写任何基类方法”。请注意,即使为方法添加或移除 const 修饰符,也会更改其签名,重写可能会因此而中断。

也可以在被重写的方法前使用 virtual 关键字,但并非强制要求。

为了使动态调度正常工作,编译器会为每个类生成一个虚函数表。每个对象都会添加一个隐式字段,其中包含指向其给定类表的链接。表由编译器进行填充,内容是关于特定类的继承链上所有虚方法及其重写版本的信息。

对虚方法的调用在二进制程序映像中以一种特殊的方式编码:首先,查找该表以查找特定对象(位于指针处)的类版本,然后转换到相应的函数。

因此,动态调度比静态调度慢。

在 MQL5 中,无论是否存在虚方法,类始终包含一个虚函数表。

如果虚方法返回指向类的指针,则在重写该类时,可以更改(使其更具体、更特化)返回值的对象类型。换句话说,指针的类型不仅可以与虚方法的初始声明相同,还可以与其任何后继类型相同。这种类型称为“协变”或可互换。

例如,如果我们在 Shape 类中将 setColor 方法设为虚方法:

class Shape
{
   ...
   virtual Shape *setColor(const color c)
   {
      backgroundColor = c;
      return &this;
   }
   ...
};

我们可以在 Rectangle 类中重写它,如下所示(仅作为该技术的演示):

class Rectangle : public Shape
{
   ...
   virtual Rectangle *setColor(const color coverride
   {
      // call original method
      // (by pre-lightening the color,
      // no matter what for)
      Rectangle::setColor(c | 0x808080);
      return &this;
   }
};

请注意,返回类型是指向 Rectangle 的指针,而非指向 Shape 的指针。

如果方法的重写版本修改了对象中不属于基类的部分,导致对象实际上不再符合基类所允许的状态(不变式),那么使用类似技巧很有用。

我们绘制形状的示例即将完成。剩下的就是用实际内容填充虚方法 draw。我们将在 图形 一章中完成这项工作(参见示例 ObjectShapesDraw.mq5),但我们将在学习 图形资源后对其进行改进。

考虑到继承的概念,编译器选择合适方法的过程看起来有点令人困惑。根据方法名称和调用指令中具体的自变量(其类型)列表,编译器会编译所有可用候选方法的列表。
 
如果不是虚方法,一开始只会分析当前类的方法。如果没有匹配的方法,编译器将继续搜索基类(然后再搜索更远的祖先类,直到找到匹配的方法)。如果在当前类的方法中找到一个合适的方法(即使需要隐式转换自变量类型),编译器会优先选取这个方法。即使基类中的方法具有更合适的自变量类型(无需转换或转换次数较少),编译器也不会选用。换句话说,非虚方法的分析将从当前对象的类开始,逐级向祖先类进行,直到找到第一个“有效”匹配。
 
对于虚方法,编译器首先在指针类中按名称查找所需方法,然后在虚函数表中,为指针类型和对象类型之间的继承链中实例化程度最高的类(最远的后代)选择重写了该方法的实现。在这种情况下,如果自变量类型没有完全匹配,也可以使用隐式自变量转换。

我们考虑以下示例 (OverrideVsOverload.mq5)。链中有 4 个类:BaseDerivedConcreteSpecial。它们都包含自变量类型为 intfloat 的方法。在 OnStart 函数中,整数变量 i 和实数变量 f 用作所有方法调用的自变量。

class Base
{
public:
   void nonvirtual(float v)
   {
      Print(__FUNCSIG__" "v);
   }
   virtual void process(float v)
   {
      Print(__FUNCSIG__" "v);
   }
};
 
class Derived : public Base
{
public:
   void nonvirtual(int v)
   {
      Print(__FUNCSIG__" "v);
   }
   virtual void process(int v// override
   // error: 'Derived::process' method is declared with 'override' specifier,
   // but does not override any base class method
   {
      Print(__FUNCSIG__" "v);
   }
};
 
class Concrete : public Derived
{
};
 
class Special : public Concrete
{
public:
   virtual void process(int voverride
   {
      Print(__FUNCSIG__" "v);
   }
   virtual void process(float voverride
   {
      Print(__FUNCSIG__" "v);
   }
};

首先,我们创建 Concrete 类的对象和一个指向此对象的指针 Base *ptr。然后,我们调用它们的非虚方法和虚方法。在第二部分中,通过 BaseDerived 类指针来调用 Special 对象的方法。

void OnStart()
{
   float f = 2.0;
   int i = 1;
 
   Concrete c;
   Base *ptr = &c;
   
   // Static link tests
 
   ptr.nonvirtual(i); // Base::nonvirtual(float), conversion int -> float
   c.nonvirtual(i);   // Derived::nonvirtual(int)
 
   // warning: deprecated behavior, hidden method calling
   c.nonvirtual(f);   // Base::nonvirtual(float), because
                      // method selection ended in Base,
                      // Derived::nonvirtual(int) does not suit to f
 
   // Dynamic link tests
 
   // attention: there is no method Base::process(int), also
   // there are no process(float) overrides in classes up to and including Concrete
   ptr.process(i);    // Base::process(float), conversion int -> float
   c.process(i);      // Derived::process(int), because
                      // there is no override in Concrete,
                      // and the override in Special does not count
 
   Special s;
   ptr = &s;
   // attention: there is no method Base::process(int) in ptr
   ptr.process(i);    // Special::process(float), conversion int -> float
   ptr.process(f);    // Special::process(float)
 
   Derived *d = &s;
   d.process(i);      // Special::process(int)
 
   // warning: deprecated behavior, hidden method calling
   d.process(f);      // Special::process(float)
}

日志输出如下所示。

void Base::nonvirtual(float) 1.0
void Derived::nonvirtual(int) 1
void Base::nonvirtual(float) 2.0
void Base::process(float) 1.0
void Derived::process(int) 1
void Special::process(float) 1.0
void Special::process(float) 2.0
void Special::process(int) 1
void Special::process(float) 2.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
{
public:
   ...
   // this override will suppress the warning
   // "deprecated behavior, hidden method calling"
   void nonvirtual(float v)
   {
      Base::nonvirtual(v);
      Print(__FUNCSIG__" "v);
   }
...

我们回到 OnStart 中的测试。

调用 ptr.process(i) 演示了上述所示的重载和重写之间的区分难点。Base 类有一个 process(float) 虚方法,而 Derived 类添加了一个新的虚方法 process(int),,由于参数类型不同,该方法在本例中不属于重写。编译器根据基类中的名称选择一个方法,并在虚函数表中检查继承链中直至 Concrete 类(包含 Concrete 本身,这是指针指向的对象类)是否存在重写。由于未找到重写,编译器采用了 Base::process(float) 并将自变量的类型转换应用于参数(int 转换为 float)。

如果我们遵循“在表示重定义的情况下使用采用override”这一规则,并将其添加到 Derived 中,则会遇到错误:

class Derived : public Base
{
   ...
   virtual void process(int voverride // error!
   {
      Print(__FUNCSIG__" "v);
   }
};

编译器会报告“声明 '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 类不仅重写了继承的虚方法,还重载了类本身中的两个方法。

请勿在构造函数或析构函数中调用虚函数!虽然技术上可行,但构造函数和析构函数中的虚行为会完全失效,您可能会得到意想不到的结果。不仅应避免显式调用,还应避免间接调用(例如,从构造函数调用简单方法,而该方法又调用虚方法)。
 
我们以构造函数为例更详细地分析一下这种情况。事实上,在构造函数执行时,对象尚未完全沿着整个继承链组装完成,而仅组装到当前类。所有派生部分围绕现有核心部分的收尾工作尚未完成。因此,所有后续的虚方法重写(如果有)此时均不可用。因此,从构造函数中调用的是方法的当前版本。