Appearance
类和接口
在基于面向对象编程(OOP)方法的程序开发中,类是主要的构建模块。从广义上讲,“类”这个术语指的是具有某些共同特征的事物(如物品、人、公式等)的集合。在 OOP 的语境中,这一逻辑同样适用:一个类可以生成具有相同属性集和行为的对象。
在本书的前几章中,我们已经了解了 MQL5 的内置类型,如 double
、int
或 string
。编译器知道如何存储这些类型的值,以及可以对它们执行哪些操作。然而,在描述任何应用领域时,这些类型可能不太方便使用。例如,交易者需要处理诸如交易策略、信号过滤器、货币篮子和未平仓头寸组合等实体。每个实体都由一组相关的属性组成,并且需要遵循特定的处理和一致性规则。
一个用于自动化处理这些对象的程序可以仅由内置类型和简单函数组成,但那样的话,你就必须想出一些复杂的方法来存储和关联这些属性。这时,OOP 技术就派上用场了,它为此提供了现成、统一且直观的机制。
OOP 建议将所有用于存储属性、正确填充属性以及对特定用户定义类型的对象执行允许操作的指令,编写在一个包含源代码的单一容器中。它以特定的方式将变量和函数组合在一起。如果按照功能和相关性从高到低排列,这些容器可分为类、结构体和联合体。
在前一章中,我们已经了解了结构体和联合体。这些知识对于理解类也很有用,但类提供了更多 OOP 工具。
与结构体类似,类是对用户定义类型的描述,它具有任意的内部存储方法和使用规则。基于类,程序可以创建该类的实例,这些对象可被视为复合变量。
所有用户定义的类型都有一些基本概念,你可以将其称为 OOP 理论,而这些概念对于类尤为重要,其中包括:
- 抽象
- 封装
- 继承
- 多态
- 组合(设计)
尽管这些名称听起来很复杂,但它们代表的是现实世界中非常简单且熟悉的准则,并被应用到了编程领域。我们将从探讨这些概念开始深入了解 OOP。至于类的描述语法以及如何创建对象,我们将在后面进行讨论。
面向对象编程基础:抽象
在日常生活中,我们常常使用概括性的概念来传达信息的核心,而无需深入细节。例如,当被问到“你是怎么来这儿的”,一个人可能只需回答“开车来的”。大家都能明白,这里说的是一种有四个轮子、带有发动机和乘客舱的交通工具。汽车的具体品牌、颜色或制造年份对我们来说并不重要。
在使用程序时,用户实际上也不太关心程序内部实现了何种算法,只要程序能正确完成其任务即可。例如,对一个列表进行排序可以有十几种不同的方法。
因此,抽象意味着提供一个简单的编程接口,将实现过程中的所有复杂性和细节都隐藏起来。
编程接口是在类的上下文中定义的一组函数,这些函数会根据对象的用途执行一系列操作。除了这些接口函数外,可能还会有一些辅助性的小函数,但它们仅在类的内部可用。和结构体类似,类的所有函数有一个专门的名称:它们被称为方法。
实现部分通常会使用属于对象(根据类的描述)的变量或数组来存储信息。这些被称为“字段”(这个术语的由来是,对象的属性通常与用户界面中的输入字段,或者与数据库中的字段存在一一对应的关系,在数据库中可以保存对象的当前状态,以便程序下次启动时能够恢复)。
虽然字段和方法是在类中进行描述的,但它们与特定的对象相关:每个实例都有自己分配的一组变量,这些变量的值独立于其他对象的状态,并且方法会对其所属实例的字段进行操作。
接口和实现必须相互独立。如果有需要,应该能够轻松地用一种实现方法替换另一种,而不会对编程接口产生任何影响。同样重要的是,要根据特定任务的需求来设计接口,而不是专门为实现方式去定制接口。类的开发者必须能够从两个不同的角度看待自己的作品:1) 作为内部算法和数据结构的设计者;2) 作为一个潜在的挑剔客户,将类视为一个“黑匣子”,其控制面板就是接口。建议在开发类时,先构思编程接口,再去寻找和选择实现方法。
面向对象编程基础:封装
为了理解什么是封装,让我们暂时回到现实生活中。当我们购买一台家用电器时,它通常是“密封”的并且处于保修期内。我们被允许在正常模式下使用它,但制造商并不鼓励我们打开外壳并开始“摆弄内部结构”。例如,你可以使用特殊的工具来超频计算机处理器,但这也会使我们失去保修资格,因为这些操作可能会导致设备故障。
类的开发也是如此。不应该允许任何人访问内部实现,以免破坏类的功能。这就叫做封装,即将所有重要的东西都包含在一个“胶囊”中。在 MQL5 中,和 C++ 一样,存在三个级别的访问权限。默认情况下,类的组织方式是私有的,也就是说,对所有用户都是隐藏的。只有类本身的源代码才能访问其内容。
类的用户同样也是程序员。即使你是在为自己编写一个类,利用最大程度的限制也是有意义的,这样可以避免不小心破坏类(毕竟,人往往会犯错,而且过一段时间后会忘记自己代码的特点,并且程序有无限增长的趋势)。
第二级访问权限允许“亲属”(更准确地说,是子类;我们将在接下来的几段中详细讨论)查看内部情况。
最后,你可以选择的第三级访问权限是公共的。它专门用于外部编程接口,这些接口允许程序的任何部分出于主要目的来使用对象。
每个方法或字段都有三个访问级别之一,这由类的开发者来决定。
面向对象编程基础:继承
在构建大型复杂项目时,人们会寻找提高开发效率的方法。其中一种常用的方法是利用现有的开发成果。例如,在先前蓝图的基础上制定建筑规划,比从零开始要容易得多。
在编程中,代码复用也非常普遍。我们已经知道一种这样的技术:将一段代码分离成一个函数,然后在需要相应功能的不同地方调用它。但面向对象编程(OOP)提供了一种更强大的机制:在开发一个新类时,它可以从另一个类继承,获取其所有的内部结构和外部接口,只需进行最小限度的调整以适应自身的用途。因此,从父类开始,你可以快速“衍生”出一个具有额外或改进功能的子类。此外,对父类的任何后续更改(如功能增强或错误修复)都会自动影响到所有子类。
当一个类是另一个类的父类时,它被称为基类。反过来,从基类继承的类被称为派生类。
当然,继承链(或者更确切地说,类的继承关系树)可以延续下去:每个类可以有几个子类,这些子类又可以有它们自己的子类,依此类推。继承规则唯一不允许的是继承关系中出现循环,例如,孙子类不能成为其祖父类的父类。
任何类与其任意代的子类之间的关系都可以用“是一个”来描述,也就是说,子类能够充当父类的角色,但反之则不行。这是因为派生类对象实际上包含了父类的数据模型,并通过新的字段和行为对其进行了补充。
通过类之间的继承,我们有机会以统一的方式处理相关对象,因为它们的一些功能是相同的。
例如,一个假设的绘图程序可以用来实现几种类型的图形,包括圆形、正方形、三角形等等。每个对象在屏幕上都有坐标(为简单起见,我们假设指定了图形中心的一对 X 和 Y 值)。此外,每个图形都使用自己的背景颜色、边框颜色和边框粗细来绘制。
这意味着我们可以在描述抽象图形的父类中只实现一次设置坐标和设置绘图风格的函数,而这些函数将自动被子类继承。
此外,为了简化源代码,不仅希望以某种方式统一设置,还希望统一不同图形的绘制方式。这句话似乎有点矛盾:因为图形各不相同,而且每个图形都必须以自己的方式显示,那我们所说的统一是什么呢?我们说的是统一的软件接口。实际上,根据抽象的概念,必须将外部接口与内部实现分开。而特定图形的显示本质上是一个实现细节。
对于图形类型的统一接口和不同实现,自然地引出了下一个概念——多态性。
面向对象编程基础:多态
“多态”这个术语意味着可变性或多样性。它是抽象与继承机制相结合的反向体现。当我们有一个通用的编程接口时,不同的、具有继承关系的类可以对其进行不同的实现。这样一来,调用接口方法时,任务就会以不同的方式执行。
例如,想象有一个抽象的交通工具家族,其中包含几种特定类型,如汽车和直升机。从 A 点移动到 B 点的指令,它们都能很好地执行,但汽车会在地面行驶,而直升机则会在空中飞行。
我们接着看绘图程序的例子。可以说,该程序的多样性体现在图形形状层面。用户可以自由绘制圆形、正方形和三角形的任意组合。这些对象中的每一个都必须能够利用自身的坐标和样式在屏幕上显示出来,而最重要的是,要以形成合适形状的方式来显示。
该程序很可能会有一个数组(或其他容器)来存储用户创建的所有图形,而在屏幕上显示整个绘图应该是依次绘制每个图形。如果我们把绘制图形的指令归纳成一个单独的方法(我们称之为 draw
),那么每个类都会有自己的实现。不过,这些函数的头部会完全相同,因为它们执行的是相同的任务,并且从对象中获取初始数据。
因此,我们有机会统一源代码,因为在遍历图形的循环中对 draw
方法的相同调用体现了多态性:显示的图形将取决于对象的类型。
面向对象编程基础:组合
在使用面向对象编程(OOP)设计程序时,存在一个问题,即如何根据某些给定的特性找到最优的类划分以及类与类之间的关系。“组合”这个术语可能有多种含义,并且常常在不同的意义上被使用,其中包括“组合”类的一种特殊情况。这里有必要进行一下说明,因为在阅读其他计算机相关文献时,你可能会发现“组合”这个术语有不同的解释,既可以是广义的,也可以是狭义的。我们将尝试解释这个概念,并在每种情况下明确术语的含义(何时指的是软件接口的一般“设计/项目开发”,何时指的是“组合聚合”)。
正如我们所知,类由字段(属性)和方法组成。属性反过来又可以由自定义类型来描述,也就是说,它们可以是另一个类的对象。有几种方法可以在逻辑上连接这些对象:
- 对象字段的组合(完全包含或组合聚合):将对象字段组合到一个所有者对象中。这样的对象之间的关系可以用“整体-部分”关系来描述,并且部分不能脱离整体而存在。可以说所有者对象“拥有”一个属性对象,而属性对象是所有者对象的“一部分”。所有者对象创建并销毁它的各个部分。删除所有者对象会移除它的所有部分;所有者对象没有部分就无法存在。
- 所有者对象对对象字段的聚合:这是一种更“松散”的包含方式。尽管这种关系同样被描述为“整体-部分”,但所有者对象只包含对部分的引用,这些部分可以被赋值、更改,并且可以独立于整体而存在。此外,一个部分可以被多个“所有者”使用。
- 关联:即独立对象之间的单向或双向连接,这种连接具有任意的应用意义;可以说一个对象“使用”另一个对象。
另一种需要记住的关系类型是“是一个”,这在前面的继承部分已经讨论过。
一个完全包含的例子是汽车和它的发动机。这里,汽车被理解为一种完备的交通工具。没有发动机就不能称其为汽车。而且一个特定的发动机在同一时间只属于一辆汽车。当汽车还没有发动机(在工厂时)或者发动机已经不存在(在汽车修理店时)的情况,就相当于我们破坏了程序的源代码。
聚合的一个例子是为学习特定课程而组成的学生小组:每个课程的小组包含若干学生,并且任何一个学生都可以属于其他小组(如果同时学习多门课程)。小组“拥有”听课的学生。一个学生离开小组不会影响小组的学习进程(其他学生继续学习)。
最后,为了说明关联的概念,我们来看一下计算机和打印机。我们可以说计算机使用打印机来进行打印。打印机可以根据需要开启或关闭,并且同一台打印机可以被不同的计算机使用。所有的计算机和打印机都相互独立地存在,但可以共享使用。
至于通常用于指导类设计的特性,最著名的有:
- DRY(不要重复自己):相反,应将公共部分移到父类(可能是抽象类)中。
- SRP(单一职责原则):一个类应该执行一项任务,如果不是这样,就需要将其拆分成更小的类。
- OCP(开闭原则):“编写对扩展开放但对修改封闭的代码”。如果在 X 类中硬编码了几种计算选项,并且可能会出现新的选项,那么就为单独的计算创建一个基类(抽象类),并在此基础上创建特定的选项(功能的“扩展”),在不修改 X 类的情况下与 X 类连接。
这些只是类设计最佳实践中的一部分。在掌握了本书范围内的 OOP 基础知识之后,查看其他关于该主题的专业信息来源可能会有所帮助,因为它们为许多常见情况下的对象分解提供了现成的解决方案。
类的定义
类的定义语句有许多可选的组成部分,这些部分会影响类的特性。其通用形式可以表示如下:
cpp
class class_name [: modifier_access name_parent_class...]
{
[ modifier_access:]
[description_member...]
...
};
为了便于讲解,我们将从最基本的足够语法开始,然后在讲解过程中逐步扩展它。
作为起点,我们以一个带有条件的绘图程序任务为例,该程序支持多种类型的图形。
要定义一个新类,需使用 class
关键字,后面跟着类标识符和一个用花括号括起来的代码块。和所有语句一样,这样的定义必须以分号结尾。
代码块可以为空。例如,绘图程序中 Shape
类的可编译模板如下所示:
cpp
class Shape
{
};
从本书前面的章节我们知道,花括号表示变量的上下文或作用域。当这样的代码块出现在函数定义中时,它们定义了函数的局部上下文。除此之外,还有一个全局上下文,函数本身以及全局变量都在这个上下文中定义。
这次,类定义中的花括号定义了一种新的上下文,即类上下文。它是在类内部声明的变量和函数的容器。
在代码块内部使用常规语句来描述用于存储类属性的变量(Shapes1.mq5
)。
cpp
class Shape
{
int x, y; // 中心坐标
color backgroundColor; // 填充颜色
};
在这里,我们声明了理论部分中讨论的一些字段:图形中心的坐标和填充颜色。
经过这样的描述后,用户自定义类型 Shape
就可以和内置类型一样在程序中使用了。特别地,我们可以创建一个这种类型的变量,并且它内部会包含指定的字段。然而,我们还不能对这些字段做任何操作,甚至无法确定它们是否存在。
cpp
void OnStart()
{
Shape s;
// 错误: 无法访问在类 'Shape' 中声明的私有成员
Print(s.x, " ", s.y);
}
类成员默认是私有的,因此不能从类外部的其他代码部分访问。这就是封装原则在起作用。
如果我们尝试将一个图形输出到日志中,由于几个原因,结果会让我们失望。
最直接的方法会导致“对象只能通过引用传递”的错误(我们在结构体中也遇到过这种情况):
cpp
Print(s); //'s' - 对象只能通过引用传递
对象可能由许多字段组成,由于它们的体积较大,按值传递对象是低效的。因此,编译器要求对象类型的参数通过引用传递,而 Print
函数接受的是值。
从关于函数参数的章节(见“值参数和引用参数”部分)我们知道,符号 &
用于描述引用。合理的假设是,为了获取一个变量(在这种情况下是 Shape
类型的对象 s
)的引用,需要在它的名称前面加上相同的符号。
cpp
Print(&s);
这条语句可以编译并运行,没有问题,但并没有完全达到预期的效果。
程序在执行过程中会输出一些整数值,例如 1 或 2097152(很可能会有所不同)。变量名称前面的 &
符号表示获取该变量的指针,而不是引用(这与函数参数的描述不同)。
指针将在单独的章节中详细讨论。不过请注意,MQL5 不提供对内存的直接访问,对象的指针是一个描述符,或者简单地说,是一个唯一的对象编号(由终端本身分配)。但即使指针指向内存中的一个地址(就像在 C++ 中那样),这也不是读取对象内容的合法方式。
要将 Shape
对象的内容输出到日志或其他地方,需要一个类成员函数。我们将其称为 toString
:它应该返回一个包含对象某些描述的字符串。我们可以稍后决定在其中显示什么内容。我们还为绘制图形保留了 draw
方法。目前,它将作为未来对象编程接口的声明。
cpp
class Shape
{
int x, y; // 中心坐标
color backgroundColor; // 填充颜色
string toString()
{
...
}
void draw() { /* 未来绘图接口存根 */ }
};
方法函数的定义方式和常规方式一样,唯一的区别是它们位于构成类的代码块内部。
在未来,我们将学习如何在类代码块内部声明函数,而在代码块外部定义函数。这种方法通常用于将声明放在头文件中,而将定义“隐藏”在 mq5
文件中。这使得代码更易于理解(因为编程接口以紧凑的形式单独呈现,没有实现细节)。如果需要,它还允许将软件库作为 ex5
文件进行分发(没有主源代码,但提供一个头文件,足以调用外部接口方法)。
因为 toString
方法是类的一部分,所以它可以访问变量,并可以将它们转换为字符串。例如:
cpp
string toString()
{
return (string)x + " " + (string)y;
}
然而,现在 toString
和 draw
与其他字段一样是私有的。我们需要让它们能够从类外部访问。
访问权限
MQL5 提供了一种特殊的语法来设置对类成员的访问权限(在结构体章节中我们已经接触过)。在类代码块内,任何类成员描述之前,你可以插入一个访问修饰符,即三个关键字之一——private
、protected
、public
,后面再加上一个冒号。
在该修饰符之后的所有成员,直到遇到另一个修饰符或者到达类的结尾,都会受到相应的可见性限制。
例如,下面的代码与之前对 Shape
类的描述是等效的,因为在没有修饰符的情况下,类的默认访问模式是 private
:
cpp
class Shape
{
private:
int x, y; // 中心坐标
color backgroundColor; // 填充颜色
...
};
如果想让所有字段都能被外部访问,我们可以将修饰符改为 public
:
cpp
class Shape
{
public:
int x, y; // 中心坐标
...
};
但这样做会违背封装原则,所以我们不会这么做。相反,我们使用 protected
修饰符。它允许派生类访问这些成员,同时对外部代码隐藏这些成员。我们计划从 Shape
类派生出其他几个图形类,这些派生类需要访问父类的变量。
cpp
class Shape
{
protected:
int x, y; // 中心坐标
color backgroundColor; // 填充颜色
public:
string toString() const
{
return (string)x + " " + (string)y;
}
void draw() { /* 图形绘制接口存根 */ }
};
顺便说一下,我们把这两个函数都设置为 public
访问权限。
在类的描述中,访问修饰符可以任意交错使用,也可以多次重复。不过,为了提高代码的可读性,建议将 public
、protected
和 private
成员分别放在不同的部分,并且在项目的所有类中保持相同的顺序。
注意,我们在 toString
函数的头部末尾添加了 const
关键字。这表示该函数不会改变对象字段的状态。虽然不是必需的,但它有助于防止意外修改变量,同时也让类的使用者和编译器知道调用该函数不会产生任何副作用。
在 toString
函数以及任何类方法中,可以直接通过字段名来访问这些字段。后续我们会了解如何将方法声明为 static
:静态方法完全与类相关,而不是与对象实例相关,因此不能访问对象的字段。
现在我们可以从对象变量 s
调用 toString
方法了:
cpp
void OnStart()
{
Shape s;
Print(s.toString());
}
这里我们看到使用了点号 .
作为特殊的解引用运算符:它用于访问对象的成员,包括字段和方法。点号左边应该是一个对象,右边是可用属性的标识符。
toString
方法是 public
的,因此可以从类外部的 OnStart
函数中访问。如果在 OnStart
函数中尝试通过解引用访问字段 s.x
或 s.y
,会得到编译错误 “cannot access protected member declared in class 'Shape'”(无法访问类 'Shape' 中声明的受保护成员)。
对于熟悉 C++ 的人来说,需要注意的是 MQL5 不支持所谓的 “友元”(对于不熟悉的人,解释一下:在 C++ 中,如果需要的话,可以创建一个第三方类和方法的 “白名单”,这些类和方法虽然不是 “亲属” 但具有扩展的访问权限)。
当运行这个程序时,会看到输出了两个数字。不过,坐标值是随机的。即使你运气好看到了零,也不能保证下次运行脚本时还会出现零。通常情况下,如果终端中正在执行的 MQL 程序列表没有变化,再次运行任何脚本时,会为其分配相同的内存区域,这可能会让人误以为对象的状态是稳定的。实际上,和局部变量一样,对象的字段默认不会进行任何初始化(见 “初始化” 部分)。
为了对这些字段进行初始化,需要使用类的特殊函数——构造函数。
构造函数
我们在结构体章节(见“构造函数和析构函数”部分)已经接触过构造函数了。对于类而言,构造函数的工作方式在很大程度上是相似的。我们先回顾一下要点,然后再探讨更多的特性。
构造函数是一种方法,它与类同名且类型为 void
,这意味着它不返回任何值。通常,在构造函数名前省略 void
关键字。一个类可以有多个构造函数,它们必须在参数的数量或类型上有所不同。当创建一个新对象时,程序会调用构造函数,以便它可以为字段设置初始值。
我们使用的创建对象的方法之一是在代码中描述相应类的变量。构造函数会在这行代码执行时被调用,这是自动发生的。
根据参数的有无和类型,构造函数可分为:
- 默认构造函数:没有参数;
- 拷贝构造函数:只有一个参数,该参数是对同一类对象的引用类型;
- 参数化构造函数:具有任意一组参数,但不包括上述用于拷贝的单个引用参数。
默认构造函数
最简单的构造函数是没有参数的,被称为默认构造函数。与 C++ 不同,MQL5 不认为带有参数且所有参数都有默认值的构造函数是默认构造函数(也就是说,所有参数都是可选的,见“可选参数”部分)。
让我们为 Shape
类定义一个默认构造函数:
cpp
class Shape
{
...
public:
Shape()
{
...
}
};
当然,它应该在类的 public
部分中定义。
有时会故意将构造函数设置为 protected
或 private
,以便通过工厂方法等方式控制对象的创建。但在这里,我们考虑的是类组合的标准版本。
为了为对象变量设置初始值,我们可以使用常规的赋值语句:
cpp
public:
Shape()
{
x = 0;
y = 0;
...
}
不过,构造函数的语法还提供了另一种选择,称为初始化列表。它写在函数头部之后,用冒号分隔。初始化列表本身是一个由逗号分隔的字段名序列,每个字段名右边的括号中是期望的初始值。
例如,对于 Shape
构造函数,可以这样写:
cpp
public:
Shape() :
x(0), y(0),
backgroundColor(clrNONE)
{
}
使用这种语法比在构造函数体中赋值有几个优点:
- 首先,在函数体中的赋值是在相应变量创建之后进行的。根据变量的类型,这可能意味着首先调用了它的默认构造函数,然后再覆盖新的值(这意味着额外的开销)。而在初始化列表的情况下,变量会立即使用期望的值创建。虽然编译器可能会在没有初始化列表的情况下优化赋值操作,但一般来说,这并不能保证。
- 其次,一些类字段可以用
const
修饰符声明。这样,它们只能在初始化列表中设置。 - 第三,用户自定义类型的字段变量可能没有默认构造函数(也就是说,它们类中的所有可用构造函数都有参数)。这意味着在创建变量时,需要向它传递实际参数,而初始化列表允许这样做:参数值在括号内指定,就像显式调用构造函数一样。初始化列表只能在构造函数定义中使用,而不能在其他方法中使用。
参数化构造函数
根据定义,参数化构造函数有多个参数(一个或多个)。
例如,假设对于坐标 x
和 y
,描述了一个带有参数化构造函数的特殊结构体:
cpp
struct Pair
{
int x, y;
Pair(int a, int b): x(a), y(b) { }
};
然后,我们可以在 Shape
类中使用新类型 Pair
的坐标字段,而不是两个整数字段 x
和 y
。这种对象的构造方式称为包含或组合聚合。Pair
对象是 Shape
对象的一个组成部分。坐标对会随着“宿主”对象自动创建和销毁。
因为 Pair
没有无参数构造函数,所以 coordinates
字段必须在 Shape
构造函数的初始化列表中指定,带有两个参数(int
, int
):
cpp
class Shape
{
protected:
// int x, y;
Pair coordinates; // 中心坐标(对象包含)
...
public:
Shape() :
// x(0), y(0),
coordinates(0, 0), //对象初始化
backgroundColor(clrNONE)
{
}
};
如果没有初始化列表,就无法创建这样的自动对象。
考虑到对象中存储坐标方式的变化,我们需要更新 toString
方法:
cpp
string toString() const
{
return (string)coordinates.x + " " + (string)coordinates.y;
}
但这不是最终版本,我们很快还会做一些更改。
回想一下,“声明/定义指令”部分中描述了自动变量。它们被称为自动变量,是因为编译器会自动创建它们(分配内存),并且当程序执行离开创建变量的上下文(代码块)时,也会自动删除它们。
对于对象变量,自动创建不仅意味着分配内存,还包括调用构造函数。对象的自动删除伴随着调用它的析构函数(见下面“析构函数”部分)。此外,如果一个对象是另一个对象的一部分,那么它的生命周期与它的“所有者”的生命周期一致,就像 Shape
对象中 Pair
实例的 coordinates
字段一样。
静态(包括全局)对象也由编译器自动管理。
自动分配的另一种选择是通过指针进行动态对象创建和操作。
在继承部分,我们将学习一个类如何从另一个类继承。在这种情况下,初始化列表是调用基类参数化构造函数的唯一方式(编译器无法像对默认构造函数那样隐式地自动生成带有参数的构造函数调用)。
让我们向 Shape
类添加另一个构造函数,它允许为变量设置特定的值。这只是一个参数化构造函数(你可以根据不同的目的和参数集创建任意多个这样的构造函数):
cpp
Shape(int px, int py, color back) :
coordinates(px, py),
backgroundColor(back)
{
}
初始化列表确保在执行构造函数体时,所有内部字段(包括嵌套对象,如果有的话)都已经被创建和初始化。
类成员的初始化顺序与初始化列表的顺序无关,而是与它们在类中声明的顺序一致。
如果在类中声明了一个带有参数的构造函数,并且需要允许创建没有参数的对象,程序员必须显式实现默认构造函数。
如果类中根本没有构造函数,编译器会隐式地提供一个默认构造函数的存根,它负责初始化以下类型的字段:字符串、动态数组以及具有默认构造函数的自动对象。如果没有这样的字段,隐式默认构造函数不执行任何操作。其他类型的字段不受隐式构造函数的影响,所以它们将包含随机的“垃圾”值。为了避免这种情况,程序员必须显式声明构造函数并设置初始值。
拷贝构造函数
拷贝构造函数允许根据作为唯一参数通过引用传递的另一个对象来创建一个对象。
例如,对于 Shape
类,拷贝构造函数可能如下所示:
cpp
class Shape
{
...
Shape(const Shape &source) :
coordinates(source.coordinates.x, source.coordinates.y),
backgroundColor(source.backgroundColor)
{
}
...
};
请注意,另一个对象的 protected
和 private
成员在当前对象中是可访问的,因为访问权限是在类级别起作用的。换句话说,当给定引用(或指针)时,同一类的两个对象可以相互访问对方的数据。
如果有这样的构造函数,可以使用以下两种语法类型之一来创建对象:
cpp
void OnStart()
{
Shape s;
...
Shape s2(s); // 可以:语法 1 - 拷贝
Shape s3 = s; // 可以:语法 2 - 通过初始化进行拷贝
// (如果有拷贝构造函数)
// - 或赋值
// (如果没有拷贝构造函数,
// 但有默认构造函数)
Shape s4; // 定义
s4 = s; // 赋值,不是拷贝构造函数!
}
必须区分对象在创建时的初始化和赋值操作。
第二种选择(标记为“语法 2”注释)即使没有拷贝构造函数,但有默认构造函数时也能工作。在这种情况下,编译器会生成效率较低的代码:首先,使用默认构造函数创建接收变量(在这种情况下是 s3
)的空实例,然后逐个元素地拷贝样本(在这种情况下是 s
)的字段。实际上,这与变量 s4
的情况相同,对于 s4
,定义和赋值是通过单独的语句执行的。
如果没有拷贝构造函数,那么尝试使用第一种语法会导致“不允许参数转换”的错误,因为编译器会尝试使用其他可用的、参数集不同的构造函数。
请记住,如果类中有带有 const
修饰符的字段,出于明显的原因,禁止对这样的对象进行赋值:常量字段不能被更改,它只能在创建对象时设置一次。因此,拷贝构造函数成为复制对象的唯一方式。
特别是,在接下来的部分中,我们将完成 Shape1.mq5
示例,并且 Shape
类中会出现以下字段(带有描述字符串类型)。然后赋值运算符将生成错误(特别是对于像变量 s4
这样的代码行):
尝试引用已删除的函数
'void Shape::operator=(const Shape&)'
函数 'void Shape::operator=(const Shape&)' 已被隐式删除
因为成员 'type' 具有 'const' 修饰符
由于编译器的详细说明,你可以理解发生这种情况的本质和原因:首先,提到的是赋值运算符(=
),而不是拷贝构造函数;其次,报告说由于 const
修饰符的存在,赋值运算符被隐式删除了。在这里,我们遇到了一些还不熟悉的概念,我们将在以后学习:类中的运算符重载、对象类型转换以及将方法标记为已删除的能力。
在继承部分,在我们学习如何描述派生类之后,我们需要对类层次结构中的拷贝构造函数做一些说明。
析构函数
在结构体章节中,我们了解了析构函数(见“构造函数和析构函数”部分)。让我们简要回顾一下:析构函数是在对象被销毁时调用的一种方法。析构函数与类同名,但前面有一个波浪线字符(~)作为前缀。析构函数不返回值,也没有任何参数。一个类只能有一个析构函数。
即使类没有析构函数或者析构函数为空,编译器也会隐式地对以下类型的字段进行“垃圾回收”:字符串、动态数组和自动对象。
通常,析构函数放在类的 public
部分,不过,在某些特定情况下,开发者可以将其移到 private
或 protected
成员组中。private
或 protected
的析构函数将不允许在代码中声明该类的自动变量。然而,我们稍后会看到动态对象的创建,对于动态对象,这样的限制可能是有意义的。
特别是,一些对象可以以这样的方式实现:当它们不再被需要时必须自行销毁(确定需求的概念可能会有所不同)。换句话说,只要程序的任何部分在使用这些对象,它们就存在,而一旦任务完成,它们就会自我销毁(private
析构函数使得从类方法中删除对象成为可能)。
对于有经验的 C++ 程序员来说,值得注意的是,在 MQL5 中析构函数总是虚函数(关于虚方法的更多内容将在“虚方法(virtual 和 override)”部分介绍)。但这一因素并不影响描述的语法。
在绘图程序的示例中,从技术上讲,对于图形来说可能并不一定需要析构函数。然而,为了跟踪构造函数和析构函数的调用顺序,我们还是会添加一个析构函数。让我们从一个简化的大纲开始,该大纲“打印”方法的完整名称:
cpp
class Shape
{
...
~Shape()
{
Print(__FUNCSIG__);
}
};
我们很快会向这个方法以及其他方法添加更多内容,以便能够区分对象的不同实例。
考虑以下示例。在两个不同的上下文中描述了一对 Shape
对象:全局上下文(函数外部)和局部上下文(OnStart
函数内部)。全局对象的构造函数将在脚本加载后且 OnStart
函数调用之前被调用,析构函数将在脚本卸载之前被调用。局部对象的构造函数将在变量定义的那一行被调用,析构函数将在包含变量定义的代码块退出时被调用,在这种情况下就是 OnStart
函数结束时。
cpp
// 全局构造函数和析构函数与脚本加载和卸载相关
Shape global;
// 对象引用不会创建副本,也不会影响生命周期
void ProcessShape(Shape &shape)
{
// ...
}
void OnStart()
{
// ...
Shape local; // <- 调用局部构造函数
// ...
ProcessShape(local);
// ...
} // <- 调用局部析构函数
通过引用将对象传递给其他函数不会创建对象的副本,也不会调用构造函数和析构函数。
自我引用:this
在每个类的上下文中,在其方法代码中,都存在对当前对象的特殊引用:this
。基本上,它是一个隐式定义的变量。所有处理对象变量的方法都适用于它。特别是,可以对其进行解引用,以引用对象的字段或调用方法。例如,Shape
类的一个方法中的以下语句是等效的(我们仅以 draw
方法为例进行演示):
cpp
class Shape
{
...
void draw()
{
backgroundColor = clrBlue;
this.backgroundColor = clrBlue;
}
};
如果在相同的上下文中存在其他同名的变量/参数,可能就需要使用较长的形式(即使用 this
)。一般来说,这种做法并不被提倡,但如有必要,this
关键字允许你引用对象中被覆盖的成员。
如果任何局部变量或方法参数的名称与类成员变量的名称重叠,编译器会发出警告。
在下面这个假设的示例中,我们实现了 draw
方法,该方法带有一个可选的字符串参数 backgroundColor
,表示颜色名称。由于参数名称与 Shape
类的成员名称相同,编译器会发出第一个警告“‘backgroundColor’ 的定义隐藏了字段”。
名称重叠的结果是,随后将 clrBlue
值错误地赋值给了参数,而不是类成员。由于值和参数的类型不匹配,编译器会发出第二个警告“隐式将数字转换为字符串”(这里的数字是常量 clrBlue
)。但是 this.backgroundColor = clrBlue
这行代码会将值写入对象的字段。
cpp
void draw(string backgroundColor = NULL) // 警告 1:
// ‘backgroundColor’ 的声明隐藏了成员
{
...
backgroundColor = clrBlue; // 警告 2:
// 从 '数字' 到 '字符串' 的隐式转换
this.backgroundColor = clrBlue; // 正确
{
bool backgroundColor = false; // 警告 3:
// ‘backgroundColor’ 的声明隐藏了局部变量
...
this.backgroundColor = clrRed; // 正确
}
...
}
随后定义的局部布尔型变量 backgroundColor
(在嵌套的花括号代码块中)再次覆盖了该名称之前的定义(这就是我们得到第三个警告的原因)。然而,通过对 this
进行解引用,this.backgroundColor = clrRed
这一语句仍然引用的是对象的字段。
如果不指定 this
,编译器总是会选择(按上下文)最近的名称定义。
还有另一种需要使用 this
的情况:将当前对象作为参数传递给另一个函数。特别是,有一种方法是,同一类的对象负责创建/删除另一个类的对象,并且从属对象必须知道它的“上级”。然后在“上级”类中使用构造函数创建相关对象,并将“上级”对象的 this
传递给它。这种技术通常使用动态对象分配和指针,因此相关示例将在“指针”部分展示。
this
的另一个常见用途是从成员函数返回当前对象的指针。这允许将成员函数调用排列成链式。由于我们尚未详细研究指针,只需知道通过在类名后添加字符 *
来描述指向某个类对象的指针,并且可以像直接操作对象一样通过指针来操作对象。
例如,我们可以为用户提供几个方法来分别设置图形的属性:更改颜色、水平或垂直移动。每个方法都将返回当前对象的指针。
cpp
Shape *setColor(const color c)
{
backgroundColor = c;
return &this;
}
Shape *moveX(const int x)
{
coordinates.x += x;
return &this;
}
Shape *moveY(const int y)
{
coordinates.y += y;
return &this;
}
然后就可以方便地将这些方法的调用排列成链式。
cpp
Shape s;
s.setColor(clrWhite).moveX(80).moveY(-50);
当类中有许多属性时,这种方法允许紧凑且有选择地配置对象。
在“类定义”部分,我们尝试记录一个对象变量,但发现只有在 Print
调用中给对象变量名加上 &
(取地址符)才能得到一个指针,或者实际上是一个唯一的编号(句柄)。在对象上下文中,通过 &this
也可以得到相同的句柄。
为了调试目的,可以通过对象的描述符来识别对象。我们即将探讨类的继承,当存在多个对象时,识别对象会很有用。因此,在所有构造函数和析构函数中,我们添加(并且在派生类中也会添加)以下 Print
调用:
cpp
~Shape()
{
Print(__FUNCSIG__, " ", &this);
}
现在,所有的创建和删除步骤都会在日志中用类名和对象编号进行标记。
我们在 Pair
结构体中实现了类似的构造函数和析构函数,然而遗憾的是,结构体中不支持指针,即不能使用 &this
。因此,我们只能通过它们的内容(在这种情况下是坐标)来识别它们:
cpp
struct Pair
{
int x, y;
Pair(int a, int b): x(a), y(b)
{
Print(__FUNCSIG__, " ", x, " ", y);
}
...
};
继承
在定义一个类时,开发者可以让它从另一个类继承,从而体现继承的概念。为此,在类名后面加上一个冒号、一个可选的访问权限修饰符(关键字 public
、protected
、private
之一)以及父类的名称。例如,下面是我们如何定义一个从 Shape
类派生的 Rectangle
类:
cpp
class Rectangle : public Shape
{
};
类头部的访问修饰符控制着包含在子类中的父类成员的“可见性”:
public
— 所有继承的成员保留其原有的权限和限制;protected
— 将继承的public
成员的权限更改为protected
;private
— 使所有继承的成员变为私有(private
)。
在绝大多数定义中使用的是 public
修饰符。另外两个选项只在特殊情况下才有意义,因为它们违反了继承的基本原则:派生类的对象应该是“是一个”—— 父类的完整代表,如果我们“截断”它们的权限,它们就会失去部分特性。结构体也可以以类似的方式相互继承。不允许从结构体继承类,也不允许从类继承结构体。
与 C++ 不同,MQL5 不支持多重继承。一个类最多只能有一个父类。
派生类对象中内置了一个基类对象。考虑到基类又可以从其他某个父类继承,创建的对象可以比作一个套一个的俄罗斯套娃。
在新类中,我们需要一个构造函数,它以与基类相同的方式填充对象的字段。
cpp
class Rectangle : public Shape
{
public:
Rectangle(int px, int py, color back) :
Shape(px, py, back)
{
Print(__FUNCSIG__, " ", &this);
}
};
在这种情况下,初始化列表变成了对 Shape
构造函数的单一调用。不能在初始化列表中直接设置基类变量,因为基类构造函数负责初始化它们。然而,如果有必要,我们可以在 Rectangle
构造函数的函数体中更改基类的 protected
字段(函数体中的语句是在初始化列表中基类构造函数调用完成之后执行的)。
矩形有两个维度,所以我们将它们作为 protected
字段 dx
和 dy
添加进来。为了设置它们的值,需要补充构造函数的参数列表。
cpp
class Rectangle : public Shape
{
protected:
int dx, dy; // 维度(宽,高)
public:
Rectangle(int px, int py, int sx, int sy, color back) :
Shape(px, py, back), dx(sx), dy(sy)
{
}
};
需要注意的是,Rectangle
对象隐式地包含了从 Shape
类继承的 toString
函数(同样,draw
函数也存在,但仍然为空)。因此,下面的代码是正确的:
cpp
void OnStart()
{
Rectangle r(100, 200, 50, 75, clrBlue);
Print(r.toString());
};
这不仅展示了 toString
函数的调用,还展示了使用我们新的构造函数创建矩形对象的过程。
Rectangle
类中没有默认构造函数(无参数的构造函数)。这意味着类的用户不能在没有参数的情况下以简单的方式创建矩形对象:
cpp
Rectangle r; // 'Rectangle' - 错误的参数数量
编译器会显示错误“参数数量无效”。
让我们创建另一个子类 —— Ellipse
(椭圆)。目前,除了名称之外,它与 Rectangle
没有任何区别。稍后我们会引入它们之间的差异。
cpp
class Ellipse : public Shape
{
protected:
int dx, dy; // 维度(长半轴和短半轴)
public:
Ellipse(int px, int py, int rx, int ry, color back) :
Shape(px, py, back), dx(rx), dy(ry)
{
Print(__FUNCSIG__, " ", &this);
}
};
随着类的数量增加,在 toString
方法中显示类名会很不错。在“特殊的 sizeof 和 typename 运算符”部分,我们描述了 typename
运算符。让我们尝试使用它。
回想一下,typename
期望一个参数,并返回该参数的类型名称。例如,如果我们分别创建 Shape
类和 Rectangle
类的一对对象 s
和 r
,我们可以通过以下方式了解它们的类型:
cpp
void OnStart()
{
Shape s;
Rectangle r(100, 200, 75, 50, clrRed);
Print(typename(s), " ", typename(r)); // Shape Rectangle
}
但我们需要以某种方式在类内部获取这个名称。为此,让我们向 Shape
的参数化构造函数添加一个字符串参数,并将其存储在一个新的字符串字段 type
中(注意 protected
部分和 const
修饰符:这个字段对外部世界是隐藏的,并且在对象创建后不能被编辑):
cpp
class Shape
{
protected:
...
const string type;
public:
Shape(int px, int py, color back, string t) :
coordinates(px, py),
backgroundColor(back),
type(t)
{
Print(__FUNCSIG__, " ", &this);
}
...
};
在派生类的构造函数中,我们使用 typename(this)
来填充基类构造函数的这个参数:
cpp
class Rectangle : public Shape
{
...
public:
Rectangle(int px, int py, int sx, int sy, color back) :
Shape(px, py, back, typename(this)), dx(sx), dy(sy)
{
Print(__FUNCSIG__, " ", &this);
}
};
现在我们可以使用 type
字段来改进 toString
方法。
cpp
class Shape
{
...
public:
string toString() const
{
return type + " " + (string)coordinates.x + " " + (string)coordinates.y;
}
};
让我们确保我们的小类层次结构按照预期生成对象,并在调用构造函数和析构函数时打印测试日志条目。
cpp
void OnStart()
{
Shape s;
// 通过 'this' 链式调用设置对象
s.setColor(clrWhite).moveX(80).moveY(-50);
Rectangle r(100, 200, 75, 50, clrBlue);
Ellipse e(200, 300, 100, 150, clrRed);
Print(s.toString());
Print(r.toString());
Print(e.toString());
}
结果,我们得到了大致如下的日志条目(特意添加了空行来分隔不同对象的输出):
Pair::Pair(int,int) 0 0
Shape::Shape() 1048576
Pair::Pair(int,int) 100 200
Shape::Shape(int,int,color,string) 2097152
Rectangle::Rectangle(int,int,int,int,color) 2097152
Pair::Pair(int,int) 200 300
Shape::Shape(int,int,color,string) 3145728
Ellipse::Ellipse(int,int,int,int,color) 3145728
Shape 80 -50
Rectangle 100 200
Ellipse 200 300
Ellipse::~Ellipse() 3145728
Shape::~Shape() 3145728
Pair::~Pair() 200 300
Rectangle::~Rectangle() 2097152
Shape::~Shape() 2097152
Pair::~Pair() 100 200
Shape::~Shape() 1048576
Pair::~Pair() 80 -50
日志清楚地显示了构造函数和析构函数的调用顺序。
对于每个对象,首先创建其中描述的对象字段(如果有的话),然后调用基类构造函数以及沿着继承链的所有派生类构造函数。如果派生类中有某些对象类型的自身(添加的)字段,它们的构造函数将在调用该派生类的构造函数之前立即被调用。当有多个对象字段时,它们按照在类中描述的顺序创建。
析构函数的调用顺序则完全相反。
在派生类中可以定义拷贝构造函数,我们在“构造函数:默认构造函数、参数化构造函数、拷贝构造函数”部分已经学习过。对于特定的图形类型,如矩形,它们的语法类似:
cpp
class Rectangle : public Shape
{
...
Rectangle(const Rectangle &other) :
Shape(other), dx(other.dx), dy(other.dy)
{
}
...
};
范围稍微扩展了一下。派生类对象可用于拷贝到基类(因为派生类包含了基类的所有数据)。然而,在这种情况下,当然,派生类中添加的字段将被忽略。
cpp
void OnStart()
{
Rectangle r(100, 200, 75, 50, clrBlue);
Shape s2(r); // 可以:将派生类拷贝到基类
Shape s;
Rectangle r4(s); // 错误:没有一个重载可以应用
// 需要显式的构造函数重载
}
要以相反的方向进行拷贝,需要在基类中提供一个带有对派生类引用的构造函数版本(这在理论上与 OOP 的原则相矛盾),否则会出现编译错误“没有一个重载可以应用于函数调用”。
现在我们可以编写几个或更多的图形变量脚本,然后使用 draw
方法“要求”它们绘制自身。
cpp
void OnStart()
{
Rectangle r(100, 200, 50, 75, clrBlue);
Ellispe e(100, 200, 50, 75, clrGreen);
r.draw();
e.draw();
};
然而,这样的代码意味着图形的数量、它们的类型和参数都硬编码在程序中,而程序应该能够选择绘制什么以及在哪里绘制。因此,需要以动态的方式创建图形。
对象的动态创建:new 与 delete
到目前为止,我们仅尝试创建自动对象,也就是OnStart
函数内部的局部变量。在全局环境(OnStart
或其他函数外部)声明的对象同样会被自动创建(脚本加载时)和删除(脚本卸载时)。
除了这两种模式,我们还提到了描述对象类型字段的能力(在我们的示例中,Shape
对象里的coordinates
字段使用了Pair
结构)。所有这些对象也都是自动的:它们由编译器在“宿主”对象的构造函数中创建,并在其析构函数中被删除。
然而,在程序里仅依靠自动对象往往是不够的。以绘图程序为例,我们需要根据用户的请求创建图形。此外,图形需要存储在数组中,而这就要求自动对象必须有默认构造函数(但在我们的例子中并非如此)。
针对这类情况,MQL5 提供了动态创建和删除对象的功能。创建操作通过new
运算符实现,删除操作则通过delete
运算符完成。
new
运算符
关键字new
后面跟着所需类的名称,以及括号内用于调用任意现有构造函数的参数列表。执行new
运算符会创建该类的一个实例。
new
运算符返回一种特殊类型的值——对象指针。若要描述这种类型的变量,需在类名后面添加星号*
。例如:
cpp
Rectangle *pr = new Rectangle(100, 200, 50, 75, clrBlue);
这里的变量pr
是指向Rectangle
类对象的指针。指针将在单独的章节中详细讨论。
需要注意的是,声明对象指针类型的变量本身并不会为对象分配内存,也不会调用其构造函数。当然,指针会占用空间——8 字节,但实际上它是一个无符号整数ulong
,系统会以特殊方式对其进行解释。
你可以像操作对象一样操作指针,即通过解引用运算符调用可用的方法并访问字段。
cpp
Print(pr.toString());
尚未被赋予动态对象描述符的指针变量(例如,若new
运算符不是在初始化新变量时调用,而是被移到源代码的后续行中),会包含一个特殊的空指针,用NULL
表示(以区别于数字),但实际上它等于 0。
delete
运算符
通过new
获取的指针应在算法结束时使用delete
运算符释放。例如:
cpp
delete pr;
若不这样做,new
运算符分配的实例将保留在内存中。若以这种方式不断创建新对象,且在不再需要时不进行删除,会导致不必要的内存消耗。残留的未释放动态对象会在程序终止时打印警告信息。例如,若不删除指针pr
,脚本卸载后日志中会出现类似如下内容:<segment 0809>
1 个未删除的对象残留
1 个 Rectangle 类型的对象残留
168 字节的内存泄漏
终端会报告程序员遗忘了多少个对象以及它们所属的类,还有它们占用了多少内存。
一旦对指针调用了delete
运算符,该指针就会失效,因为对象已不复存在。后续尝试访问其属性会导致运行时错误“访问无效指针”:
运行脚本 'shapes (EURUSD,H1)' 时发生严重错误。
访问无效指针。
随后 MQL 程序将中断。
但这并不意味着同一个指针变量不能再使用。只需将其赋值为指向另一个新创建的对象实例即可。
MQL5 有一个内置函数可用于检查变量中指针的有效性——CheckPointer
:
cpp
ENUM_POINTER_TYPE CheckPointer(object *pointer);
它接受一个指向类型类的指针作为参数,并返回ENUM_POINTER_TYPE
枚举中的一个值:
POINTER_INVALID
—— 无效指针;POINTER_DYNAMIC
—— 指向动态对象的有效指针;POINTER_AUTOMATIC
—— 指向自动对象的有效指针。
只有当函数返回POINTER_DYNAMIC
时,执行delete
语句才有意义。对于自动对象,该操作不会产生任何效果(此类对象会在控制从定义变量的代码块返回时自动删除)。
以下宏可简化并确保指针的正确清理:
cpp
#define FREE(P) if(CheckPointer(P) == POINTER_DYNAMIC) delete (P)
显式“清理”的必要性是动态对象和指针带来的灵活性所必须付出的代价。
指针
正如我们在“类定义”部分所说,MQL5 中的指针是对象的某些描述符(唯一编号),而不像 C++ 中那样是内存地址。对于自动对象,我们通过在其名称前加上&
符号来获取指针(在这种情况下,&
符号是“取地址”运算符)。所以,在下面的示例中,变量p
指向自动对象s
。
cpp
Shape s; // 自动对象
Shape *p = &s; // 指向同一个对象的指针
s.draw(); // 调用对象的方法
p.draw(); // 执行相同的操作
在前面的部分中,我们学习了如何在使用new
动态创建对象时获取指向该对象的指针。此时,获取描述符不需要&
符号:指针的值就是描述符。
MQL5 API 提供了GetPointer
函数,它执行的操作与&
运算符相同,即返回指向对象的指针:
cpp
void *GetPointer(Class object);
使用这两种方式中的哪一种取决于个人偏好。
指针常被用于将对象链接在一起。让我们通过一个示例来阐述创建从属对象的概念,从属对象会接收指向其创建者对象的this
指针(ThisCallback.mq5
)。我们在关于this
关键字的部分提到过这个技巧。
我们尝试使用它来实现一种机制,让从属对象能够不时地向“创建者”通知其计算进度的百分比:我们曾使用函数指针实现过类似的功能。Manager
类控制计算过程,而计算本身(很可能使用不同的公式)则在单独的类中执行——在这个示例中,展示了其中一个类Element
。
cpp
class Manager; // 前置声明
class Element
{
Manager *owner; // 指针
public:
Element(Manager &t): owner(&t) { }
void doMath()
{
const int N = 1000000;
for(int i = 0; i < N; ++i)
{
if(i % (N / 20) == 0)
{
// 将自身传递给控制类的方法
owner.progressNotify(&this, i * 100.0f / N);
}
// ... 大量计算
}
}
string getMyName() const
{
return typename(this);
}
};
class Manager
{
Element *elements[1]; // 指针数组(这里为了演示只有 1 个元素)
public:
Element *addElement()
{
// 在数组中查找空插槽
// ...
// 传递给子类的构造函数
elements[0] = new Element(this); // 动态创建对象
return elements[0];
}
void progressNotify(Element *e, const float percent)
{
// Manager 选择如何向用户通知:
// 显示、打印、发送到互联网
Print(e.getMyName(), "=", percent);
}
};
从属对象可以使用接收到的链接向“上级”通知工作进度。当计算结束时,会向控制对象发送一个信号,表明可以删除计算对象,或者让另一个对象开始工作。当然,Manager
类中固定的单元素数组看起来可能不太起眼,但作为演示,它能够说明问题。Manager
不仅管理计算任务的分配,还提供了一个抽象层来向用户通知:除了输出到日志,它还可以将消息写入单独的文件、显示在屏幕上,或者发送到互联网上。
顺便提一下,请注意在Element
类定义之前对Manager
类的前置声明。这是为了在Element
类中描述指向Manager
类的指针,而Manager
类在代码的后面部分定义。如果省略前置声明,我们会得到错误“‘Manager’ - 意外的标记,可能缺少类型?”。
当前置声明的必要性出现在两个类通过它们的成员相互引用的情况:在这种情况下,无论我们以何种顺序排列这两个类,都无法完整地定义其中任何一个。前置声明允许在不进行完整定义的情况下保留类型名称。
指针的一个基本属性是,指向基类的指针可以用于指向任何派生类的对象。这是多态性的一种表现形式。这种行为是可行的,因为派生对象包含了像套娃一样嵌入的基类“子对象”。
特别是对于我们处理图形的任务,很容易描述一个指向Shape
的动态指针数组,并根据用户的请求向其中添加不同类型的对象。
类的数量将扩展到五个(Shapes2.mq5
)。除了Rectangle
和Ellipse
,我们添加Triangle
,并且为正方形(Square
)创建一个从Rectangle
派生的类,为圆形(Circle
)创建一个从Ellipse
派生的类。显然,正方形是边长相等的矩形,圆形是长半轴和短半轴相等的椭圆。
为了在继承链中传递字符串类型的类名,我们在Rectangle
和Ellipse
类的protected
部分添加带有额外字符串参数t
的特殊构造函数:
cpp
class Rectangle : public Shape
{
protected:
Rectangle(int px, int py, int sx, int sy, color back, string t) :
Shape(px, py, back, t), dx(sx), dy(sy)
{
}
...
};
然后,在创建正方形时,我们不仅设置边长相等,还从Square
类传递typename(this)
:
cpp
class Square : public Rectangle
{
public:
Square(int px, int py, int sx, color back) :
Rectangle(px, py, sx, sx, back, typename(this))
{
}
};
此外,我们将Shape
类中的构造函数移到protected
部分:这将禁止直接创建Shape
对象——它只能作为其派生类的基类。
我们定义addRandomShape
函数来生成图形,该函数返回指向新创建对象的指针。为了演示,它现在将实现图形的随机生成:包括图形的类型、位置、大小和颜色。
支持的图形类型汇总在SHAPES
枚举中:它们对应于已实现的五个类。
给定范围内的随机数由random
函数返回(它使用内置函数rand
,每次调用时返回一个范围从 0 到 32767 的随机整数。图形的中心在 0 到 500 像素的范围内生成,图形的大小在最大 200 的范围内。颜色由三个 RGB 分量组成(请参阅“颜色”部分),每个分量的范围从 0 到 255。
cpp
int random(int range)
{
return (int)(rand() / 32767.0 * range);
}
Shape *addRandomShape()
{
enum SHAPES
{
RECTANGLE,
ELLIPSE,
TRIANGLE,
SQUARE,
CIRCLE,
NUMBER_OF_SHAPES
};
SHAPES type = (SHAPES)random(NUMBER_OF_SHAPES);
int cx = random(500), cy = random(500), dx = random(200), dy = random(200);
color clr = (color)((random(256) << 16) | (random(256) << 8) | random(256));
switch(type)
{
case RECTANGLE:
return new Rectangle(cx, cy, dx, dy, clr);
case ELLIPSE:
return new Ellipse(cx, cy, dx, dy, clr);
case TRIANGLE:
return new Triangle(cx, cy, dx, clr);
case SQUARE:
return new Square(cx, cy, dx, clr);
case CIRCLE:
return new Circle(cx, cy, dx, clr);
}
return NULL;
}
cpp
void OnStart()
{
Shape *shapes[];
// 模拟用户创建任意图形
ArrayResize(shapes, 10);
for(int i = 0; i < 10; ++i)
{
shapes[i] = addRandomShape();
}
// 处理图形:目前,仅输出到日志
for(int i = 0; i < 10; ++i)
{
Print(i, ": ", shapes[i].toString());
delete shapes[i];
}
}
我们生成 10 个图形并将它们输出到日志(由于类型和属性的选择是随机的,结果可能会有所不同)。不要忘记使用delete
删除对象,因为它们是动态创建的(这里在同一个循环中进行删除,是因为这些图形后续不再使用;在实际程序中,图形数组很可能会以某种方式存储到文件中,以便以后加载并继续处理图像)。
0: Ellipse 241 38
1: Rectangle 10 420
2: Circle 186 38
3: Triangle 27 225
4: Circle 271 193
5: Circle 293 57
6: Rectangle 71 424
7: Square 477 46
8: Square 366 27
9: Ellipse 489 105
图形成功创建并显示了它们的属性。
现在我们准备好访问我们类的 API 了,即draw
方法。
虚方法(virtual 和 override)
类旨在描述外部编程接口并提供其内部实现。由于我们测试程序的功能是绘制各种图形,因此我们在Shape
类及其派生类中描述了一些变量,以备将来实现,并为接口保留了draw
方法。
在基类Shape
中,它不应该也不能做任何实际操作,因为Shape
不是一个具体的图形:我们稍后会将Shape
转换为抽象类(我们将在后面更多地讨论抽象类和接口)。
让我们在Rectangle
、Ellipse
和其他派生类中重新定义draw
方法(Shapes3.mq5
)。这涉及到复制该方法并相应地修改其内容。尽管许多人将这个过程称为“覆盖”,但我们将区分这两个术语,将“覆盖”专门用于虚方法,这将在后面讨论。
严格来说,重新定义一个方法只需要方法名匹配即可。然而,为了确保在整个代码中一致地使用该方法,保持相同的参数列表和返回类型是至关重要的。
cpp
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
方法本身调用它,将会得到一个无限递归,最终导致栈溢出和程序崩溃。
cpp
class Square : public Rectangle
{
public:
...
void draw()
{
Rectangle::draw();
Print("Drawing square");
}
};
然后在Square
对象上调用draw
方法将在日志中记录两行:
cpp
Square s(100, 200, 50, clrGreen);
s.draw(); // Drawing rectangle
// Drawing square
将方法绑定到声明它的类提供了静态分派(或静态绑定):编译器在编译阶段决定调用哪个方法,并将找到的匹配项“硬编码”到二进制代码中。
在决策过程中,编译器在执行解引用(.
)操作的类对象中查找要调用的方法。如果该方法存在,则调用它;如果不存在,编译器会检查父类中是否存在该方法,依此类推,沿着继承链查找,直到找到该方法。如果在继承链中的任何类中都没有找到该方法,则会发生“未声明的标识符”编译错误。
特别是,以下代码在Rectangle
对象上调用setColor
方法:
cpp
Rectangle r(100, 200, 75, 50, clrBlue);
r.setColor(clrWhite);
然而,这个方法仅在基类Shape
中定义,并且在所有派生类中只内置一次,因此它将在这里执行。
让我们尝试在OnStart
函数中从数组开始绘制任意图形(回想一下,我们已经在所有派生类中复制并修改了draw
方法)。
cpp
for(int i = 0; i < 10; ++i)
{
shapes[i].draw();
}
奇怪的是,日志中没有输出任何内容。这是因为程序调用的是Shape
类的draw
方法。
这里静态分派有一个主要缺点:当我们使用指向基类的指针来存储派生类的对象时,编译器根据指针的类型而不是对象的类型来选择方法。事实上,在编译阶段,还不知道在程序执行时它将指向哪个类的对象。
因此,需要一种更灵活的方法:动态分派(或绑定),它将把方法的选择(从派生链中所有被覆盖的方法版本中)推迟到运行时。选择必须基于对指针所指向对象的实际类的分析。正是动态分派提供了多态性的原则。
这种方法在 MQL5 中通过虚方法来实现。在描述这样的方法时,必须在方法头的开头添加关键字virtual
。
让我们在Shape
类(Shapes4.mq5
)中将draw
方法声明为虚方法。这将自动使派生类中该方法的所有版本也成为虚方法。
cpp
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
(尽管这不是必需的)。
cpp
class Rectangle : public Shape
{
...
void draw() override
{
Print("Drawing rectangle");
}
};
这允许编译器知道我们是有意覆盖该方法。如果将来基类的 API 突然改变,被覆盖的方法不再是虚方法(或者只是被删除),编译器将生成一个错误消息:“方法声明带有‘override’说明符,但未覆盖任何基类方法”。请记住,即使在方法中添加或删除const
修饰符也会改变其签名,并且覆盖可能会因此而中断。
在被覆盖的方法前使用关键字virtual
也是允许的,但不是必需的。
为了使动态分派起作用,编译器为每个类生成一个虚函数表。每个对象中都会添加一个隐式字段,其中包含指向其类的给定虚函数表的链接。编译器根据关于特定类的继承链中所有虚方法及其被覆盖版本的信息来填充该表。
对虚方法的调用在程序的二进制映像中以一种特殊的方式编码:首先,在表中查找特定对象(指针所指向的对象)类的方法版本,然后跳转到相应的函数。
结果,动态分派比静态分派慢。
在 MQL5 中,类总是包含一个虚函数表,无论是否存在虚方法。
如果一个虚方法返回一个指向类的指针,那么在覆盖它时,可以更改(使其更具体、更专业)返回值的对象类型。换句话说,指针的类型不仅可以与虚方法初始声明中的类型相同,还可以是其任何后继类型。这样的类型称为“协变”或可互换类型。
例如,如果我们在Shape
类中将setColor
方法声明为虚方法:
cpp
class Shape
{
...
virtual Shape *setColor(const color c)
{
backgroundColor = c;
return &this;
}
...
};
我们可以在Rectangle
类中像这样覆盖它(仅作为该技术的演示):
cpp
class Rectangle : public Shape
{
...
virtual Rectangle *setColor(const color c) override
{
// 调用原始方法
// (通过预先淡化颜色,
// 不管出于什么目的)
Rectangle::setColor(c | 0x808080);
return &this;
}
};
请注意,返回类型是指向Rectangle
的指针,而不是Shape
。
如果被覆盖的方法版本在对象中不属于基类的那部分进行了一些更改,使得对象实际上不再符合基类允许的状态(不变量),那么使用类似的技巧是有意义的。
我们绘制图形的示例几乎完成了。还需要用实际内容填充虚方法draw
。我们将在“图形”一章中进行此操作(请参阅ObjectShapesDraw.mq5
示例),但在学习图形资源之后我们会对其进行改进。
考虑到继承的概念,编译器选择合适方法的过程看起来有点复杂。根据调用指令中的方法名称和特定的参数列表(它们的类型),会编译出所有可用候选方法的列表。
对于非虚方法,一开始只分析当前类的方法。如果没有匹配的方法,编译器将继续搜索基类(然后是更远的祖先类,直到找到匹配项)。如果在当前类的方法中存在合适的方法(即使需要对参数类型进行隐式转换),它将被选中。如果基类有一个具有更合适参数类型的方法(无需转换或转换较少),编译器仍然不会选择它。换句话说,非虚方法是从当前对象的类开始向祖先类分析,直到找到第一个“可行”的匹配项。
对于虚方法,编译器首先在指针类型的类中按名称找到所需的方法,然后在虚函数表中为指针类型和对象类型之间的继承链中最具体的类(最远的派生类)选择该方法被覆盖的实现。在这种情况下,如果参数类型之间没有完全匹配,也可以使用隐式参数转换。
让我们考虑以下示例(OverrideVsOverload.mq5
)。有 4 个类形成了一个继承链:Base
、Derived
、Concrete
和Special
。它们都包含带有int
和float
类型参数的方法。在OnStart
函数中,整数i
和实数f
变量用作所有方法调用的参数。
cpp
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) // 覆盖
// 错误:'Derived::process' 方法声明带有 'override' 说明符,
// 但未覆盖任何基类方法
{
Print(__FUNCSIG__, " ", v);
}
};
class Concrete : public Derived
{
};
class Special : public Concrete
{
public:
virtual void process(int v) override
{
Print(__FUNCSIG__, " ", v);
}
virtual void process(float v) override
{
Print(__FUNCSIG__, " ", v);
}
};
首先,我们创建一个Concrete
类的对象和一个指向它的Base *ptr
指针。然后我们为它们调用非虚方法和虚方法。在第二部分,通过Base
和Derived
类指针调用Special
对象的方法。
cpp
void OnStart()
{
float f = 2.0;
int i = 1;
Concrete c;
Base *ptr = &c;
// 静态链接测试
ptr.nonvirtual(i); // Base::nonvirtual(float), 转换 int -> float
c.nonvirtual(i); // Derived::nonvirtual(int)
// 警告:已弃用的行为,隐藏方法调用
c.nonvirtual(f); // Base::nonvirtual(float), 因为
// 方法选择在 Base 类结束,
// Derived::nonvirtual(int) 不适合 f
// 动态链接测试
// 注意:Base 类中没有 Base::process(int) 方法,并且
// 在 Concrete 类及之前的类中没有覆盖 process(float) 的方法
ptr.process(i); // Base::process(float), 转换 int -> float
c.process(i); // Derived::process(int), 因为
// Concrete 类中没有覆盖,
// Special 类中的覆盖不考虑
Special s;
ptr = &s;
// 注意:ptr 中没有 Base::process(int) 方法
ptr.process(i); // Special::process(float), 转换 int -> float
ptr.process(f); // Special::process(float)
Derived *d = &s;
d.process(i); // Special::process(int)
// 警告:已弃用的行为,隐藏方法调用
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)
不适合(转换会导致精度损失)。在此过程中,编译器会发出“已弃用的行为,隐藏方法调用”警告。
重载的方法可能看起来像被覆盖的方法(具有相同的名称但不同的参数),但它们是不同的,因为它们位于不同的类中。当派生类中的方法覆盖父类中的方法时,它会替换父类方法的行为,这有时可能会导致意外的效果。程序员可能期望编译器选择另一个合适的方法(就像在重载中那样),但实际上调用的是子类的方法。
为了避免潜在的警告,如果需要父类的实现,应该在派生类中编写与父类完全相同的函数,并从该函数中调用基类的方法。
cpp
class Derived : public Base
{
public:
...
// 这个覆盖将抑制
// “已弃用的行为,隐藏方法调用” 警告
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
类中,我们会得到一个错误:
cpp
class Derived : public Base
{
...
virtual void process(int v) override // 错误!
{
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
类不仅覆盖了继承的虚方法,还在类本身中重载了两个方法。
不要从构造函数或析
静态成员
到目前为止,我们讨论的类的字段和方法主要用于描述该类对象的状态和行为。然而,在程序中,有时可能需要存储与整个类相关的属性,或者对整个类执行操作,而非针对类的某个对象。这类类属性被称为静态属性,通过在类型前添加static
关键字来进行描述。结构体和联合体也支持静态属性。
例如,在绘图程序里,我们可以统计用户创建的图形数量。为此,在Shape
类中,我们定义一个静态变量count
(Shapes5.mq5
):
cpp
class Shape
{
private:
static int count;
protected:
...
Shape(int px, int py, color back, string t) :
coordinates(px, py),
backgroundColor(back),
type(t)
{
++count;
}
public:
...
static int getCount()
{
return count;
}
};
count
变量被定义在private
部分,因此外部无法直接访问它。为了读取当前计数器的值,提供了一个公共的静态方法getCount()
。从理论上来说,由于静态成员是在类的上下文中定义的,它们会根据所在部分的修饰符受到可见性的限制。
我们会在Shape
类的带参数构造函数中让计数器的值加 1,同时移除默认构造函数。这样,任何派生类型的图形实例都会被统计在内。
需要注意的是,静态变量必须在类块外部显式地进行定义(并且可以选择进行初始化):
cpp
static int Shape::count = 0;
类的静态变量与全局变量以及函数内部的静态变量(可参考“静态变量”部分)有相似之处,它们都是在程序启动时创建,在程序卸载前被删除。因此,与对象变量不同,静态变量从一开始就必须作为唯一实例存在。
在这种情况下,零初始化可以省略,因为我们知道,全局变量和静态变量默认会被初始化为零。数组也可以是静态的。
在定义静态变量时,我们会用到特殊的作用域解析运算符::
。借助它,可以形成一个完全限定的变量名。::
左边是变量所属类的名称,右边是变量的标识符。显然,完全限定名是必要的,因为在不同的类中可能会声明具有相同标识符的静态变量,所以需要一种方式来唯一地引用每个变量。
同样的::
运算符不仅用于访问公共的类静态变量,还用于访问静态方法。特别是,为了在OnStart
函数中调用getCount
方法,我们使用Shape::getCount()
这种语法:
cpp
void OnStart()
{
for(int i = 0; i < 10; ++i)
{
Shape *shape = addRandomShape();
shape.draw();
delete shape;
}
Print(Shape::getCount()); // 10
}
由于现在生成了指定数量(10 个)的图形,我们可以验证计数器是否正常工作。
如果你有一个类的对象,也可以通过常规的解引用方式来引用静态方法或属性(例如shape.getCount()
),但这种表示方式可能会造成误导(因为它掩盖了实际上并未访问该对象这一事实)。
需要注意的是,派生类的创建不会对静态变量和方法产生任何影响:它们始终属于定义它们的类。我们的计数器对于所有从Shape
派生的图形类来说都是相同的。
在静态方法内部不能使用this
,因为静态方法的执行不与特定对象绑定。此外,在静态方法中,如果不通过解引用任何对象类型的变量,就无法直接调用普通的类方法或访问其字段。例如,在getCount
方法中调用draw
方法会导致“访问非静态成员或函数”的错误:
cpp
static int getCount()
{
draw(); // 错误: 'draw' - 访问非静态成员或函数
return count;
}
出于同样的原因,静态方法不能是虚方法。
那么,能否利用静态变量来统计图形的类型统计信息,而不是仅仅统计图形的总数呢?答案是肯定的。这个任务留给你自行研究。感兴趣的话,可以在Shapes5stats.mq5
脚本中找到一种实现示例。
嵌套类型、命名空间和作用域运算符 ::
类、结构体和联合体不仅可以在全局作用域中定义,还能在另一个类或结构体内部定义,甚至可以在函数内部进行定义。这样做可以将某个类或结构体运行所需的所有实体都定义在合适的作用域内,从而避免潜在的命名冲突。
在绘图程序中,用于存储坐标的结构体 Pair
之前是在全局作用域中定义的。随着程序规模的扩大,很可能会需要另一个名为 Pair
的实体(尤其是考虑到这个名字比较通用)。因此,将该结构体的定义移到 Shape
类内部是个不错的选择(Shapes6.mq5
):
cpp
class Shape
{
public:
struct Pair
{
int x, y;
Pair(int a, int b): x(a), y(b) { }
};
...
};
嵌套定义的实体具有与指定的访问修饰符相对应的访问权限。在这个例子中,我们将 Pair
设为公开可用的。在 Shape
类内部,对 Pair
结构体类型的使用不会因为这次移动而发生任何改变。但在外部代码中,必须指定一个完全限定名,它包含外部类(作用域)的名称、作用域解析运算符 ::
以及内部实体的标识符。例如,要定义一个包含一对坐标的变量,可以这样写:
cpp
Shape::Pair coordinates(0, 0);
实体定义的嵌套层级没有限制,所以完全限定名可以包含多个由 ::
分隔的作用域标识符。例如,我们可以将所有绘图类都封装在一个外部类 Drawing
的公共部分:
cpp
class Drawing
{
public:
class Shape
{
public:
struct Pair
{
...
};
};
class Rectangle : public Shape
{
...
};
...
};
那么,完全限定的类型名(例如在 OnStart
或其他外部函数中使用时)会变得更长:
cpp
Drawing::Shape::Rect coordinates(0, 0);
Drawing::Rectangle rect(200, 100, 70, 50, clrBlue);
一方面,这会带来一些不便;但另一方面,在拥有大量类的大型项目中,这有时是必要的。在我们的小项目中,采用这种方式只是为了展示技术上的可行性。
为了将逻辑上相关的类和结构体组合成命名组,MQL5 提供了一种比将它们包含在一个“空”包装类中更简单的方法。
命名空间使用 namespace
关键字进行声明,后面跟着命名空间的名称和一个包含所有必要定义的花括号块。下面是使用命名空间实现的同一个绘图程序的示例:
cpp
namespace Drawing
{
class Shape
{
public:
struct Pair
{
...
};
};
class Rectangle : public Shape
{
...
};
...
}
主要有两个区别:命名空间内的内容始终是公开可用的(访问修饰符在其中不适用),并且在结束的花括号后面没有分号。
让我们给 Shape
类添加一个 move
方法,该方法接受一个 Pair
结构体作为参数:
cpp
class Shape
{
public:
...
Shape *move(const Pair &pair)
{
coordinates.x += pair.x;
coordinates.y += pair.y;
return &this;
}
};
然后,在 OnStart
函数中,可以通过调用这个函数将所有图形移动指定的距离:
cpp
void OnStart()
{
// 绘制一组随机图形
for(int i = 0; i < 10; ++i)
{
Drawing::Shape *shape = addRandomShape();
// 移动所有图形
shape->move(Drawing::Shape::Pair(100, 100));
shape->draw();
delete shape;
}
}
注意,Shape
和 Pair
类型必须使用完全限定名:分别是 Drawing::Shape
和 Drawing::Shape::Pair
。
可以有多个具有相同命名空间名称的块:它们的所有内容都会合并到一个具有指定名称的逻辑统一的作用域中。
在全局作用域中定义的标识符,特别是 MQL5 API 的所有内置函数,也可以通过作用域解析运算符来访问,且运算符前面不需要任何符号。例如,调用 Print
函数可以这样写:
cpp
::Print("Done!");
当从全局作用域中定义的任何函数进行调用时,不需要这样的写法。
当在任何类或结构体内部定义了同名的元素(函数、变量或常量)时,就会体现出使用作用域解析运算符的必要性。例如,让我们给 Shape
类添加一个 Print
方法:
cpp
static void Print(string x)
{
// 空实现
// (可能之后会将其输出到一个单独的日志文件中)
}
由于派生类中 draw
方法的测试实现调用了 Print
,现在它们会被重定向到这个 Print
方法:在多个相同的标识符中,编译器会选择在更接近的作用域中定义的那个。在这种情况下,基类中的定义比全局作用域更接近图形类。结果,图形类的日志输出将被抑制。
然而,从 OnStart
函数中调用 Print
仍然有效(因为它在 Shape
类的作用域之外):
cpp
void OnStart()
{
...
Print("Done!");
}
为了“修复”类中的调试打印,需要在所有 Print
调用前加上全局作用域解析运算符:
cpp
class Rectangle : public Shape
{
...
void draw() override
{
::Print("Drawing rectangle"); // 通过全局 Print(...) 重新打印
}
};
通过这些方法,可以更灵活地管理代码中的命名和作用域,避免命名冲突,并确保代码的可读性和可维护性。
类声明与定义的分离
在大型软件项目中,将类分为简要描述(声明)和包含主要实现细节的定义是很方便的。在某些情况下,如果类之间以某种方式相互引用,即如果没有预先声明就无法完全定义其中任何一个类,那么这种分离就变得必要了。
我们在“指标”部分(见文件 ThisCallback.mq5
)中看到了一个前置声明的示例,其中 Manager
类和 Element
类包含相互的指针。在那里,类是以简短的形式进行前置声明的:以关键字 class
和类名组成的头部形式:
cpp
class Manager;
然而,这是可能的最短声明方式。它只注册了类名,并使得可以将编程接口的描述推迟到以后的某个时候,但这个描述必须在代码的后续部分出现。
更常见的情况是,声明中包含接口的完整描述:它指定了类的所有变量和方法头部,但没有它们的主体(代码块)。
方法定义是单独编写的:使用完全限定名的头部,其中包括类名(如果方法的作用域高度嵌套,则包括多个类和命名空间的名称)。所有类的名称和方法名称使用作用域解析运算符 ::
连接起来。
cpp
type class_name [:: nested_class_name...] :: method_name([parameters...])
{
}
理论上,你可以在类描述块中直接定义部分方法(通常对于小函数会这样做),而有些方法可以单独提取出来(通常对于大函数)。但是一个方法必须只有一个定义(也就是说,不能在类块中定义一个方法,然后又单独定义一次)和一个声明(在类块中的定义也是一种声明)。
方法声明和定义中的参数列表、返回类型和 const
修饰符(如果有的话)必须完全匹配。
让我们看看如何分离 ThisCallback.mq5
脚本中类的描述和定义(“指针”部分的一个示例):让我们创建一个名为 ThisCallback2.mq5
的类似脚本。
前置声明 Manager
仍然会出现在开头。接下来,Element
类和 Manager
类都在没有实现的情况下进行声明:方法主体的代码块被一个分号所替代。
cpp
class Manager; // 前置声明
class Element
{
Manager *owner; // 指针
public:
Element(Manager &t);
void doMath();
string getMyName() const;
};
class Manager
{
Element *elements[1]; // 指针数组(用动态数组替换)
public:
~Manager();
Element *addElement();
void progressNotify(Element *e, const float percent);
};
源代码的第二部分包含了所有方法的实现(实现本身没有变化)。
cpp
Element::Element(Manager &t) : owner(&t)
{
}
void Element::doMath()
{
...
}
string Element::getMyName() const
{
return typename(this);
}
Manager::~Manager()
{
...
}
Element *Manager::addElement()
{
...
}
void Manager::progressNotify(Element *e, const float percent)
{
...
}
结构体也支持单独的方法声明和定义。
请注意,构造函数初始化列表(在名称和 :
之后)是定义的一部分,因此必须在函数体之前(换句话说,在只有头部的构造函数声明中不允许有初始化列表)。
单独编写声明和定义允许开发库,其源代码必须是封闭的。在这种情况下,声明被放置在一个扩展名为 .mqh
的单独头文件中,而定义则被放置在同名的扩展名为 .mq5
的文件中。程序被编译并作为一个 .ex5
文件与描述外部接口的头文件一起分发。
在这种情况下,可能会出现一个问题,为什么部分内部实现,特别是数据(变量)的组织,在外部接口中是可见的。严格来说,这表明类层次结构中的抽象级别不够。所有提供外部接口的类都不应该暴露任何实现细节。
换句话说,如果我们设定目标是从某个库中导出上述类,那么我们需要将它们的方法分离到基类中,这些基类将提供 API 的描述(没有数据字段),并且 Manager
类和 Element
类从它们继承。同时,在基类的方法中,我们不能使用派生类的任何数据,并且从根本上说,它们根本不能有实现。这怎么可能呢?
为此,有抽象方法、抽象类和接口的技术。
抽象类和接口
为了探究抽象类和接口,让我们回到我们的端到端绘图程序示例。为简单起见,其 API 由单个虚方法 draw
组成。到目前为止,它一直是空的,但即便如此,这样一个空的实现也是一个具体的实现。然而,Shape
类的对象是无法绘制的——它们的形状未被定义。因此,将 draw
方法设为抽象方法,或者换句话说,设为纯虚方法是有意义的。
要做到这一点,应该移除带有空实现的代码块,并在方法头部添加 “= 0
”:
cpp
class Shape
{
public:
virtual void draw() = 0;
...
一个至少拥有一个抽象方法的类也会变成抽象类,因为它的对象无法被创建:没有实现。特别是,我们的 Shape
构造函数对派生类是可用的(多亏了 protected
修饰符),并且从理论上讲,派生类的开发者可以创建一个 Shape
对象。但那是以前的情况,在声明了抽象方法之后,我们阻止了这种行为,因为这是我们绘图接口的设计者所禁止的。编译器会抛出一个错误:
'Shape' -cannot instantiate abstract class
'void Shape::draw()' is abstract
描述接口的最佳方法是为其创建一个抽象类,该类只包含抽象方法。在我们的例子中,draw
方法应该被移到新的 Drawable
类中,并且 Shape
类应该从它继承(Shapes.mq5
)。
cpp
class Drawable
{
public:
virtual void draw() = 0;
};
class Shape : public Drawable
{
public:
...
// virtual void draw() = 0; // 已移到基类
...
};
当然,接口方法必须在 public
部分。
MQL5 提供了另一种方便的描述接口的方式,即使用 interface
关键字。接口中的所有方法都在没有实现的情况下进行声明,并且被认为是 public
和 virtual
的。与上述类等效的 Drawable
接口的描述如下:
cpp
interface Drawable
{
void draw();
};
在这种情况下,如果抽象类中没有字段(如果有字段则会违反抽象原则),则不需要在派生类中进行任何更改。
现在是时候扩展接口了,让 setColor
、moveX
、moveY
这三个方法也成为接口的一部分。
cpp
interface Drawable
{
void draw();
Drawable *setColor(const color c);
Drawable *moveX(const int x);
Drawable *moveY(const int y);
};
请注意,这些方法返回一个 Drawable
对象,因为对于 Shape
我一无所知。在 Shape
类中,我们已经有了适合覆盖这些方法的实现,因为 Shape
类从 Drawable
继承(Shape
“在某种程度上” 是 Drawable
对象)。
现在,第三方开发者可以向绘图程序中添加其他 Drawable
类族,特别是不仅有图形,还有文本、位图,令人惊讶的是,还有其他 Drawable
的集合,这允许对象相互嵌套并创建复杂的组合。只需从接口继承并实现其方法即可。
cpp
class Text : public Drawable
{
public:
Text(const string label)
{
...
}
void draw()
{
...
}
Text *setColor(const color c)
{
...
return &this;
}
...
};
如果图形类作为二进制的 ex5
库(没有源代码)进行分发,我们将为其提供一个头文件,该头文件只包含接口的描述,而不包含任何关于内部数据结构的提示。
由于虚函数在程序执行期间是动态地(稍后)绑定到对象上的,所以可能会出现 “纯虚函数调用” 的致命错误:程序会终止。如果程序员不小心 “忘记” 提供实现,就会发生这种情况。编译器并不总是能够在编译时检测到这种遗漏。
运算符重载
在“表达式”章节中,我们学习了为内置类型定义的各种运算。例如,对于double
类型的变量,我们可以计算以下表达式:
cpp
double a = 2.0, b = 3.0, c = 5.0;
double d = a * b + c;
在处理用户定义的类型(如矩阵)时,使用类似的语法会很方便:
cpp
Matrix a(3, 3), b(3, 3), c(3, 3); // 创建 3x3 矩阵
// ... 以某种方式填充 a、b、c
Matrix d = a * b + c;
由于运算符重载,MQL5 提供了这样的可能性。
这种技术是通过描述以关键字operator
开头,然后包含一个支持的运算符号(或符号序列)的方法来实现的。一般形式可以表示如下:
cpp
result_type operator@ ( [type parameter_name] );
这里@
是运算的符号。
MQL5 运算的完整列表已在“运算优先级”部分提供,然而,并非所有运算都允许重载。
禁止重载的运算有:
- 冒号
::
,作用域解析; - 括号
()
,“函数调用”或“分组”; - 点
.
,“解引用”; - 与符号
&
,“取地址”,一元运算符(不过,&
作为二元运算符“按位与”是可用的); - 条件三元运算符
? :
; - 逗号
,
。
所有其他运算符都可用于重载。重载运算符的优先级不能改变,它们保持与标准优先级相等,所以如果需要,应使用括号进行分组。
不能为标准列表中未包含的新字符创建重载。
所有运算符在重载时都要考虑它们的一元性和二元性,即所需操作数的数量保持不变。与任何类方法一样,运算符重载可以返回某种类型的值。在这种情况下,应根据在表达式中使用函数结果的计划逻辑来选择类型本身(见后文)。
运算符重载方法具有以下形式(用所需运算符的符号替换@
符号):
名称 | 方法头部 | 在表达式中的使用 | 函数等效于 |
---|---|---|---|
一元前缀 | type operator@() | @object | object.operator@() |
一元后缀 | type operator@(int) | object@ | object.operator@(0) |
二元 | type operator@(type parameter_name) | object@argument | object.operator@(argument) |
索引 | type operator[](type index_name) | object[argument] | object.operator[](argument) |
一元运算符不接受参数。在一元运算符中,除了前缀形式外,只有递增++
和递减--
运算符支持后缀形式,所有其他一元运算符只支持前缀形式。指定一个int
类型的匿名参数用于表示后缀形式(以区别于前缀形式),但该参数本身会被忽略。
二元运算符必须接受一个参数。对于同一个运算符,可能有多个参数类型不同的重载变体,包括与当前对象的类相同的类型。在这种情况下,作为参数的对象只能通过引用或指针传递(后者仅适用于类对象,不适用于结构体)。
重载的运算符既可以通过作为表达式一部分的运算语法(这是重载的主要原因)使用,也可以通过方法调用语法使用;两种方式都如上表所示。函数等效形式更明显地表明,从技术上讲,运算符只不过是对对象的方法调用,对于前缀运算符,对象在运算符的右边,对于其他运算符,对象在符号的左边。二元运算符方法将把运算符右边的值或表达式作为参数传递(特别是,这可以是另一个对象或内置类型的变量)。
由此可知,重载的运算符不具有交换性:a@b
通常不等于b@a
,因为对于a
,@
运算符可能被重载,但b
没有。此外,如果b
是内置类型的变量或值,那么原则上不能重载它的标准行为。
作为第一个示例,考虑用于生成斐波那契数列数字的Fibo
类(我们已经使用函数完成了这个任务的一种实现,见“函数定义”)。在类中,我们将提供两个字段来分别存储数列的当前数字和前一个数字:current
和previous
。默认构造函数将它们初始化为1
和0
。我们还将提供一个复制构造函数(FiboMonad.mq5
)。
cpp
class Fibo
{
int previous;
int current;
public:
Fibo() : current(1), previous(0) { }
Fibo(const Fibo &other) : current(other.current), previous(other.previous) { }
...
};
对象的初始状态:当前数字是1
,前一个数字是0
。为了找到数列中的下一个数字,我们重载前缀和后缀递增运算符。
cpp
Fibo *operator++() // 前缀
{
int temp = current;
current = current + previous;
previous = temp;
return &this;
}
Fibo operator++(int) // 后缀
{
Fibo temp = this;
++this;
return temp;
}
请注意,前缀方法在数字修改后不返回指向当前Fibo
对象的指针,而后缀方法返回一个保存了先前计数器的新对象,这符合后缀递增的原则。
如果需要,程序员当然可以以任意方式重载任何运算。例如,在递增的实现中,可以计算乘积、将数字输出到日志中或做其他事情。然而,建议遵循运算符重载执行直观操作的方法。
我们以类似的方式实现递减操作:它们将返回数列的前一个数字。
cpp
Fibo *operator--() // 前缀
{
int diff = current - previous;
current = previous;
previous = diff;
return &this;
}
Fibo operator--(int) // 后缀
{
Fibo temp = this;
--this;
return temp;
}
为了根据给定的序号获取数列中的一个数字,我们将重载索引访问操作。
cpp
Fibo *operator[](int index)
{
current = 1;
previous = 0;
for(int i = 0; i < index; ++i)
{
++this;
}
return &this;
}
为了获取存储在current
变量中的当前数字,让我们重载~
运算符(因为它很少使用)。
cpp
int operator~() const
{
return current;
}
如果没有这个重载,仍然需要实现一些公共方法来读取私有字段current
。我们将使用这个运算符通过Print
输出数字。
为了方便起见,还应该重载赋值运算符。
cpp
Fibo *operator=(const Fibo &other)
{
current = other.current;
previous = other.previous;
return &this;
}
Fibo *operator=(const Fibo *other)
{
current = other.current;
previous = other.previous;
return &this;
}
让我们检查一下这一切是如何工作的。
cpp
void OnStart()
{
Fibo f1, f2, f3, f4;
for(int i = 0; i < 10; ++i, ++f1) // 前缀递增
{
f4 = f3++; // 后缀递增和赋值重载
}
// 比较通过递增和索引 [10] 获得的所有值
Print(~f1, " ", ~f2[10], " ", ~f3, " ", ~f4); // 89 89 89 55
// 以相反方向计数,直到 0
Fibo f0;
Fibo f = f0[10]; // 复制构造函数(由于初始化)
for(int i = 0; i < 10; ++i)
{
// 前缀递减
Print(~--f); // 55, 34, 21, 13, 8, 5, 3, 2, 1, 1
}
}
结果符合预期。不过,我们必须考虑一个细节。
cpp
Fibo f5;
Fibo *pf5 = &f5;
f5 = f4; // 调用 Fibo *operator=(const Fibo &other)
f5 = &f4; // 调用 Fibo *operator=(const Fibo *other)
pf5 = &f4; // 不调用任何东西,将 &f4 赋值给 pf5!
为指针重载赋值运算符仅在通过对象访问时起作用。如果通过指针访问,则是一个指针到另一个指针的标准赋值。
重载运算符的返回类型可以是内置类型之一、对象类型(类或结构体)或指针(仅适用于类对象)。
要返回一个对象(实例,不是引用),类必须实现一个复制构造函数。这种方式会导致实例复制,这可能会影响代码的效率。如果可能,应该返回一个指针。
然而,在返回指针时,需要确保它返回的不是一个局部自动对象(当函数退出时该对象将被删除,指针将变得无效),而是某个已经存在的对象——通常返回&this
。
返回一个对象或指向对象的指针允许将一个重载运算符的结果“发送”到另一个运算符,从而像我们习惯使用内置类型那样构造复杂的表达式。返回void
将使运算符无法在表达式中使用。例如,如果=
运算符被定义为void
类型,那么多重赋值将停止工作:
cpp
Type x, y, z = 1; // 构造函数和特定类的变量初始化
x = y = z; // 赋值,编译错误
赋值链从右到左运行,y = z
将返回空值。
如果对象仅包含内置类型的字段(包括数组),则不需要重新定义来自同一类对象的赋值/复制运算符=
:MQL5 默认提供所有字段的“一对一”复制。赋值/复制运算符不应与复制构造函数和初始化混淆。
现在让我们看第二个示例:处理矩阵(Matrix.mq5
)。
顺便提一下,MQL5 中最近出现了内置对象类型矩阵和向量。是使用内置类型还是自己定义的类型(或者也许将它们结合使用)是每个开发者的选择。内置类型中许多流行方法的现成且快速的实现很方便,并且消除了常规编码。另一方面,自定义类允许根据任务调整算法。这里我们提供Matrix
类作为教程。
在矩阵类中,我们将其元素存储在一维动态数组m
中。对于大小,选择变量rows
和columns
。
cpp
class Matrix
{
protected:
double m[];
int rows;
int columns;
void assign(const int r, const int c, const double v)
{
m[r * columns + c] = v;
}
public:
Matrix(const Matrix &other) : rows(other.rows), columns(other.columns)
{
ArrayCopy(m, other.m);
}
Matrix(const int r, const int c) : rows(r), columns(c)
{
ArrayResize(m, rows * columns);
ArrayInitialize(m, 0);
}
主构造函数接受两个参数(矩阵维度)并为数组分配内存。还有一个从另一个矩阵other
复制的构造函数。这里和下面,大量使用了处理数组的内置函数(特别是ArrayCopy
、ArrayResize
、ArrayInitialize
)——它们将在单独的章节中讨论。
我们通过重载赋值运算符来组织从外部数组填充元素:
cpp
Matrix *operator=(const double &a[])
{
if(ArraySize(a) == ArraySize(m))
{
ArrayCopy(m, a);
}
return &this;
}
为了实现两个矩阵的加法,我们重载+=
和+
运算符:
cpp
Matrix *operator+=(const Matrix &other)
{
for(int i = 0; i < rows * columns; ++i)
{
m[i] += other.m[i];
}
return &this;
}
Matrix operator+(const Matrix &other) const
{
Matrix temp(this);
return temp += other;
}
请注意,+=
运算符在修改当前对象后返回指向当前对象的指针,而+
运算符按值返回一个新实例(将使用复制构造函数),并且+
运算符本身具有const
修饰符,所以它不会改变当前对象。
+
运算符本质上是一个包装器,它将所有工作委托给+=
运算符,在调用+=
运算符之前先创建当前矩阵的临时副本temp
。因此,通过内部调用+=
运算符将temp
加到other
上(temp
被修改),然后作为+
运算符的结果返回。
矩阵乘法的重载类似,有*=
和*
两个运算符。
cpp
Matrix *operator*=(const Matrix &other)
{
// 乘法条件:this.columns == other.rows
// 结果将是一个大小为 this.rows by other.columns 的矩阵
Matrix temp(rows, other.columns);
for(int r = 0; r < temp.rows; ++r)
{
for(int c = 0; c < temp.columns; ++c)
{
double t = 0;
// 我们将当前矩阵第 'r' 行的第 'i' 个元素与矩阵 other 第 'c' 列的第 'i' 个元素的两两乘积相加
for(int i = 0; i < columns; ++i)
{
t += m[r * columns + i] * other.m[i * other.columns + c];
}
temp.assign(r, c, t);
}
}
// 将结果复制到当前矩阵对象 this
this = temp; // 调用重载的赋值运算符
return &this;
}
Matrix operator*(const Matrix &other) const
{
Matrix temp(this);
return temp *= other;
}
现在,我们将矩阵乘以一个数:
cpp
Matrix *operator*=(const double v)
{
for(int i = 0; i < ArraySize(m); ++i)
{
m[i] *= v;
}
return &this;
}
Matrix operator*(const double v) const
{
Matrix temp(this);
return temp *= v;
}
为了比较两个矩阵,我们提供==
和!=
运算符:
cpp
bool operator==(const Matrix &other) const
{
return ArrayCompare(m, other.m) == 0;
}
bool operator!=(const Matrix &other) const
{
return!(this == other);
}
为了调试目的,我们实现将矩阵数组输出到日志。
cpp
void print() const
{
ArrayPrint(m);
}
除了上述重载之外,Matrix
类还重载了[]
运算符:它返回嵌套类MatrixRow
的一个对象,即给定行号的一行。
cpp
MatrixRow operator[](int r)
{
return MatrixRow(this, r);
}
MatrixRow
类本身通过重载相同的[]
运算符提供对矩阵元素的更“深入”访问(也就是说,对于矩阵,可以自然地指定两个索引m[i][j]
)。
cpp
class MatrixRow
{
protected:
const Matrix *owner;
const int row;
public:
class MatrixElement
{
protected:
const MatrixRow *row;
const int column;
public:
MatrixElement(const MatrixRow &mr, const int c) : row(&mr), column(c) { }
MatrixElement(const MatrixElement &other) : row(other.row), column(other.column) { }
double operator~() const
{
return row.owner.m[row.row * row.owner.columns + column];
}
double operator=(const double v)
{
row.owner.m[row.row * row.owner.columns + column] = v;
return v;
}
};
MatrixRow(const Matrix &m, const int r) : owner(&m), row(r) { }
MatrixRow(const MatrixRow &other) : owner(other.owner), row(other.row) { }
MatrixElement operator[](int c)
{
return MatrixElement(this, c);
}
double operator[](uint c)
{
return owner.m[row * owner.columns + c];
}
};
对于int
类型参数的[]
运算符返回MatrixElement
类的一个对象,通过它可以在数组中写入特定元素。为了读取元素,使用uint
类型参数的[]
运算符。这似乎是一个技巧,但这是语言的限制:重载必须在参数类型上有所不同。作为读取元素的替代方法,MatrixElement
类提供了~
运算符的重载。
在处理矩阵时,经常需要单位矩阵,所以让我们为它创建一个派生类:
对象类型转换:dynamic_cast 和 void * 指针
对象类型在源变量类型和目标变量类型不匹配时,有特定的转换规则。内置类型的转换规则已在第 2.6 章“类型转换”中讨论过。结构体类型在复制时的类型转换细节在“结构体布局和继承”部分有描述。
对于结构体和类来说,类型转换可接受的主要条件是它们应该在继承链上存在关联。来自层次结构不同分支的类型或者完全没有关联的类型不能相互转换。
对象(值)和指针的转换规则是不同的。
对象
如果类型 B 有一个接受类型 A 的参数的构造函数(可以是按值、引用或指针传递参数,通常形式为 B(const A &a)
),那么类型 A 的一个对象可以赋值给类型 B 的一个对象。这样的构造函数也被称为转换构造函数。
在没有这样的显式构造函数时,编译器会尝试使用隐式复制运算符,即 B::operator=(const B &b)
,同时类 A 和类 B 必须在同一个继承链上,隐式复制才能实现从 A 到 B 的转换。如果 A 是从 B 继承的(包括间接继承),那么当复制到 B 时,添加到 A 的属性将会消失。如果 B 是从 A 继承的,那么只有 A 中存在的那部分属性会被复制到 B 中。这样的转换通常是不被提倡的。
此外,编译器也不总是会提供隐式复制运算符。特别是,如果类有带 const
修饰符的字段,复制被认为是禁止的(见后文)。
在 ShapesCasting.mq5
脚本中,我们使用图形类层次结构来演示对象类型转换。在 Shape
类中,字段 type
故意被设为常量,所以尝试将一个 Square
对象转换(赋值)为一个 Rectangle
对象会以编译器报错结束,并带有详细解释:
attempting to reference deleted function 'void Rectangle::operator=(const Rectangle&)'
function 'void Rectangle::operator=(const Rectangle&)' was implicitly deleted
because it invokes deleted function 'void Shape::operator=(const Shape&)'
function 'void Shape::operator=(const Shape&)' was implicitly deleted
because member 'type' has 'const' modifier
根据这条消息,Rectangle::operator=(const Rectangle&)
复制方法被编译器隐式移除了(编译器会提供它的默认实现),因为它使用了基类 Shape::operator =(const Shape&)
中的类似方法,而后者又因为存在带 const
修饰符的字段 type
而被移除。这样的字段只能在对象创建时设置,在这样的限制下编译器不知道如何复制对象。
顺便说一下,“删除”方法的效果不仅编译器会用到,应用程序员也可以使用:更多相关内容将在“继承控制:final 和 delete”部分讨论。
这个问题可以通过移除 const
修饰符或者提供自己的赋值运算符实现来解决(在自定义实现中,const
字段不会被涉及,并且会保留类型描述为“Rectangle”的内容):
cpp
Rectangle *operator=(const Rectangle &r)
{
coordinates.x = r.coordinates.x;
coordinates.y = r.coordinates.y;
backgroundColor = r.backgroundColor;
dx = r.dx;
dy = r.dy;
return &this;
}
注意,这个定义返回的是指向当前对象的指针,而编译器生成的默认实现的类型是 void
(从错误消息中可以看出)。这意味着编译器提供的默认赋值运算符不能用于 x = y = z
这样的链式赋值。如果你需要这种能力,显式地重载 operator=
并返回除 void
以外的期望类型。
指针
最实用的是对不同类型对象的指针进行转换。
理论上,对象类型指针的所有转换选项可以归结为三种:
- 从基类到派生类,即向下类型转换(downcast),因为习惯上把类层次结构画成倒置的树状;
- 从派生类到基类,即向上类型转换(upcast);
- 在层次结构不同分支的类之间,甚至是不同类族之间的转换。
最后一种选项是被禁止的(我们会得到编译错误)。编译器允许前两种转换,但如果“向上类型转换”是自然且安全的,那么“向下类型转换”可能会导致运行时错误。
cpp
void OnStart()
{
Rectangle *r = addRandomShape(Shape::SHAPES::RECTANGLE);
Square *s = addRandomShape(Shape::SHAPES::SQUARE);
Circle *c = NULL;
Shape *p;
Rectangle *r2;
// 没问题
p = c; // Circle -> Shape
p = s; // Square -> Shape
p = r; // Rectangle -> Shape
r2 = p; // Shape -> Rectangle
...
};
当然,当使用指向基类对象的指针时,不能在它上面调用派生类的方法和属性,即使指针所指向的是对应的派生类对象。我们会得到一个“未声明的标识符”编译错误。
然而,指针支持显式转换语法(见 C 风格),这允许在表达式中“即时”将指针转换为所需类型并对其进行解引用,而无需创建中间变量。
cpp
Base *b;
Derived d;
b = &d;
((Derived *)b).derivedMethod();
在这里,我们创建了一个派生类对象(Derived
)和一个指向它的基类类型指针(Base *
)。为了访问派生类的 derivedMethod
方法,指针被临时转换为 Derived
类型。
指针类型的星号必须用括号括起来。此外,转换表达式本身,包括变量名,也被另一对括号包围。
在我们的测试中,另一个编译错误(“类型不匹配” - “type mismatch”)是在我们尝试将指向 Rectangle
的指针转换为指向 Circle
的指针时产生的:它们来自不同的继承分支。
cpp
c = r; // 错误: 类型不匹配
当要转换的指针类型与实际对象不匹配时(尽管它们的类型是兼容的,因此程序可以正常编译),情况会更糟。这样的操作会在程序执行阶段以错误结束(也就是说,编译器无法捕获它)。然后程序会被卸载。
例如,在 ShapesCasting.mq5
脚本中,我们描述了一个指向 Square
的指针,并将一个指向 Shape
的指针赋值给它,而这个 Shape
指针包含的是 Rectangle
对象。
cpp
Square *s2;
// 运行时错误
s2 = p; // 错误: 指针的不正确转换
终端返回“指针的不正确转换”错误。更具体类型的 Square
指针无法指向父类对象 Rectangle
。
为了避免运行时的麻烦并防止程序崩溃,MQL5 提供了一个特殊的语言结构 dynamic_cast
。使用这个结构,你可以“仔细”检查是否可以将指针转换为所需类型。如果转换是可能的,那么就会进行转换。如果不行,我们会得到一个空指针(NULL
),并且我们可以以特殊方式处理它(例如,使用 if
语句来进行某种初始化或者中断函数的执行,但不是整个程序的执行)。
dynamic_cast
的语法如下:
cpp
dynamic_cast< Class * >( pointer )
在我们的例子中,只需要这样写:
cpp
s2 = dynamic_cast<Square *>(p); // 尝试转换类型,如果不成功将得到 NULL
Print(s2); // 0
程序将按预期运行。
特别是,我们可以再次尝试将 Rectangle
转换为 Circle
,并确保得到 0
:
cpp
c = dynamic_cast<Circle *>(r); // 尝试转换类型,如果不成功将得到 NULL
Print(c); // 0
在 MQL5 中有一个特殊的指针类型,可以存储任何对象。这个类型的表示如下:void *
。
让我们演示一下 void *
变量与 dynamic_cast
一起使用的情况。
cpp
void *v;
v = s; // 设置为 Square 实例
PRT(dynamic_cast<Shape *>(v));
PRT(dynamic_cast<Rectangle *>(v));
PRT(dynamic_cast<Square *>(v));
PRT(dynamic_cast<Circle *>(v));
PRT(dynamic_cast<Triangle *>(v));
前三行将记录指针的值(同一个对象的描述符),后两行将打印 0
。
现在,回到“指标”部分的前置声明示例(见文件 ThisCallback.mq5
),其中 Manager
类和 Element
类包含相互的指针。
void *
指针类型允许我们摆脱前置声明(ThisCallbackVoid.mq5
)。让我们注释掉包含前置声明的那一行,并将指向 Manager
对象的指针字段 owner
的类型更改为 void *
。在构造函数中,我们也更改参数的类型。
cpp
// class Manager;
class Element
{
void *owner; // 期望与 Manager 类型指针兼容
public:
Element(void *t = NULL): owner(t) { } // 原来是 Element(Manager &t)
void doMath()
{
const int N = 1000000;
// 在运行时获取所需类型
Manager *ptr = dynamic_cast<Manager *>(owner);
// 然后在使用前的任何地方都需要检查 ptr 是否为 NULL
for(int i = 0; i < N; ++i)
{
if(i % (N / 20) == 0)
{
if(ptr != NULL) ptr.progressNotify(&this, i * 100.0f / N);
}
// ... 大量计算
}
if(ptr != NULL) ptr.progressNotify(&this, 100.0f);
}
...
};
这种方法可以提供更大的灵活性,但需要更加小心,因为 dynamic_cast
可能会返回 NULL
。建议在可能的情况下,使用语言提供的带有类型控制的标准分派机制(静态和动态)。
void *
指针通常在特殊情况下才会用到。而带有前置描述的“多余”行并不是这种特殊情况。在这里使用它只是作为 void *
指针通用性的最简单示例。
指针、引用和 const
在学习了内置类型和对象类型,以及引用和指针的概念之后,对所有可用的类型修饰进行比较可能是很有意义的。
在 MQL5 中,引用仅用于描述函数和方法的参数。而且,对象类型的参数必须通过引用传递。
cpp
void function(ClassOrStruct &object) { } // 正确
void function(ClassOrStruct object) { } // 错误
void function(double &value) { } // 正确
void function(double value) { } // 正确
这里 ClassOrStruct
是类或结构体的名称。
仅允许将变量(左值)作为引用类型参数的参数传递,而不能传递常量或作为表达式求值结果得到的临时值。
不能创建引用类型的变量,也不能从函数中返回引用。
cpp
ClassOrStruct &function(void) { return Class(); } // 错误
ClassOrStruct &object; // 错误
double &value; // 错误
在 MQL5 中,指针仅适用于类对象。不支持指向内置类型变量或结构体的指针。
你可以声明一个指向对象的指针类型的变量或函数参数,也可以从函数中返回指向对象的指针。
cpp
ClassOrStruct *pointer; // 正确
void function(ClassOrStruct *object) { } // 正确
ClassOrStruct *function() { return new ClassOrStruct(); } // 正确
然而,不能返回指向局部自动对象的指针,因为当函数退出时,局部自动对象将被释放,指针将变得无效。
如果函数返回了一个用 new
在函数内动态分配的对象的指针,那么调用代码必须“记住”用 delete
释放该指针。
与引用不同,指针可以为 NULL
。指针参数可以有默认值,但引用不能(会出现“引用无法初始化”错误)。
cpp
void function(ClassOrStruct *object = NULL) { } // 正确
void function(ClassOrStruct &object = NULL) { } // 错误
在参数描述中可以将引用和指针结合起来。因此,一个函数可以接受一个指向指针的引用:然后在函数中对指针的更改在调用代码中就会可见。特别是,负责创建对象的工厂函数可以用这种方式实现。
cpp
void createObject(ClassName *&ref)
{
ref = new ClassName();
// 进一步自定义 ref
...
}
诚然,为了从函数中返回单个指针,通常习惯使用 return
语句,所以这个例子有点不自然。然而,在需要将指针数组传递到外部的情况下,参数中对它的引用就成为了首选。例如,在一些用于处理 map
类型(带有 [键,值] 对)容器类的标准库类中(MQL5/Include/Generic/SortedMap.mqh
,MQL5/Include/Generic/HashMap.mqh
),有用于获取包含 CKeyValuePair
元素数组的 CopyTo
方法。
cpp
int CopyTo(CKeyValuePair<TKey,TValue> *&dst_array[], const int dst_start = 0);
参数类型 dst_array
可能看起来不熟悉:它是一个类模板。我们将在下一章学习模板。目前,对我们来说唯一重要的是,这是一个指向指针数组的引用。
const
修饰符对所有类型都施加了特殊的行为。对于内置类型,在“常量变量”部分已经讨论过。对象类型有其自身的特点。
如果一个变量或函数参数被声明为指向对象的指针或引用(引用仅在参数的情况下),那么 const
修饰符的存在将可访问的方法和属性集限制为仅那些也具有 const
修饰符的方法和属性。换句话说,通过常量引用和指针只能访问常量属性。
当尝试调用非 const
方法或更改非 const
字段时,编译器会生成错误:“为常量对象调用非 const 方法” 或 “常量不能被修改”。
非 const
指针参数可以接受任何参数(常量或非常量)。
应该记住,在指针描述中可以设置两个 const
修饰符:一个将应用于对象,另一个将应用于指针:
Class *pointer
是指向对象的指针;对象和指针的使用没有限制;const Class *pointer
是指向const
对象的指针;对于对象,仅可用常量方法和读取属性,但指针可以更改(可以将另一个对象的地址赋给它);const Class * const pointer
是指向const
对象的const
指针;对于对象,仅可用const
方法和读取属性;指针不能更改;Class * const pointer
是指向对象的const
指针;指针不能更改,但对象的属性可以更改。
以以下 Counter
类(CounterConstPtr.mq5
)为例。
cpp
class Counter
{
public:
int counter;
Counter(const int n = 0) : counter(n) { }
void increment()
{
++counter;
}
Counter *clone() const
{
return new Counter(counter);
}
};
人为地将变量 counter
设置为公共的。该类还有两个方法,其中一个是 const
的(clone
),另一个不是(increment
)。回想一下,const
方法无权更改对象的字段。
以下具有 Counter *ptr
类型参数的函数可以调用该类的所有方法并更改其字段。
cpp
void functionVolatile(Counter *ptr)
{
// 正确: 所有操作都可用
ptr->increment();
ptr->counter += 2;
// 立即删除 clone 以避免内存泄漏
// clone 仅用于演示调用 const 方法
delete ptr->clone();
ptr = NULL;
}
以下具有参数 const Counter *ptr
的函数将抛出一些错误。
cpp
void functionConst(const Counter *ptr)
{
// 错误:
ptr->increment(); // 为常量对象调用非 const 方法
ptr->counter = 1; // 常量不能被修改
// 正确: 仅可用 const 方法,可以读取字段
Print(ptr->counter); // 读取 const 对象
Counter *clone = ptr->clone(); // 调用 const 方法
ptr = clone; // 更改非 const 指针 ptr
delete ptr; // 清理内存
}
最后,以下具有参数 const Counter * const ptr
的函数能做的更少。
cpp
void functionConstConst(const Counter * const ptr)
{
// 正确: 仅可用 const 方法,指针 ptr 不能更改
Print(ptr->counter); // 读取 const 对象
delete ptr->clone(); // 调用 const 方法
Counter local(0);
// 错误:
ptr->increment(); // 为常量对象调用非 const 方法
ptr->counter = 1; // 常量不能被修改
ptr = &local; // 常量不能被修改
}
在 OnStart
函数中,我们声明了两个 Counter
对象(一个是 const
的,另一个不是),可以调用这些函数,但有一些例外情况:
cpp
void OnStart()
{
Counter counter;
const Counter constCounter;
counter.increment();
// 错误:
// constCounter.increment(); // 为常量对象调用非 const 方法
Counter *ptr = (Counter *)&constCounter; // 技巧: 去掉 const 的类型转换
ptr->increment();
functionVolatile(&counter);
// 错误: 无法从 const 指针转换...
// functionVolatile(&constCounter); // 到非 const 指针
functionVolatile((Counter *)&constCounter); // 去掉 const 的类型转换
functionConst(&counter);
functionConst(&constCounter);
functionConstConst(&counter);
functionConstConst(&constCounter);
}
首先,请注意,当尝试在非 const
对象上调用 const
方法 increment
时,变量也会产生错误。
其次,constCounter
不能传递给 functionVolatile
函数 —— 我们会得到 “无法从 const 指针转换到非 const 指针” 的错误。
然而,这两个错误都可以通过不带 const
修饰符的显式类型转换来规避。尽管不建议这样做。
继承管理:final 和 delete
MQL5 允许对类和结构体的继承施加一些限制。
关键字 final
通过在类名后面添加 final
关键字,开发者可以禁止从该类进行继承。例如(FinalDelete.mq5
):
cpp
class Base
{
};
class Derived final : public Base
{
};
class Concrete : public Derived // 错误
{
};
编译器将抛出错误 “无法从 'Derived' 继承,因为它已被声明为 'final'”。
遗憾的是,对于使用这种限制的好处和场景并没有达成一致意见。这个关键字让类的使用者知道,类的作者由于某种原因不建议将其作为基类(例如,其当前实现是草案,并且会有很大的变化,这可能会导致潜在的遗留项目无法编译)。
有些人试图通过这种方式鼓励程序设计,即使用对象包含(组合)来代替继承。过度热衷于继承确实会增加类的内聚性(即相互影响),因为所有子类都以某种方式可以更改父类的数据或方法(特别是通过重定义虚函数)。结果,程序工作逻辑的复杂性和出现意外副作用的可能性都会增加。
使用 final
的另一个好处是编译器可以进行代码优化:对于 “final
” 类型的指针,它可以用静态分派代替虚函数的动态分派。
关键字 delete
delete
关键字可以在方法的头部指定,以使该方法在当前类及其子类中不可访问。父类的虚方法不能以这种方式删除(这将违反类的 “契约”,即子类将不再 “是” 同一类的代表)。
cpp
class Base
{
public:
void method() { Print(__FUNCSIG__); }
};
class Derived : public Base
{
public:
void method() = delete;
};
void OnStart()
{
Base *b;
Derived d;
b = &d;
b->method();
// 错误:
// attempting to reference deleted function 'void Derived::method()'
// function 'void Derived::method()' was explicitly deleted
d.method();
}
尝试调用它将导致编译错误。
我们在“对象类型转换”部分看到过类似的错误,因为编译器有一定的智能性,在某些条件下也会 “删除” 方法。
建议将编译器提供隐式实现的以下方法标记为已删除:
- 默认构造函数:
Class(void) = delete;
- 复制构造函数:
Class(const Class &object) = delete;
- 复制/赋值运算符:
void operator=(const Class &object) = delete;
如果你需要这些方法中的任何一个,则必须显式定义它们。否则,放弃隐式实现被认为是一种良好的实践。原因是隐式实现相当简单直接,可能会引发难以定位的问题,特别是在进行对象类型转换时。