Appearance
预处理器
到目前为止,我们在学习 MQL5 编程时,一直假定源代码是由编译器处理的,编译器会将其文本表示形式转换为二进制形式(可由终端执行)。然而,第一个读取并在必要时转换源代码的工具是预处理器。这个内置于 MetaEditor 中的实用工具由直接插入源代码中的特殊指令控制。它能够解决程序员在准备源代码时面临的一系列问题。
与 C++ 预处理器类似,MQL5 支持宏替换定义(#define
)、条件编译(#ifdef
)以及包含其他源文件(#include
)。在本章中,我们将探讨这些功能。与 C++ 相比,其中一些功能存在限制。
除了标准指令外,MQL5 预处理器还有其自身特定的指令,特别是一组 MQL 程序属性(#property
)以及从单独的 EX5 和 DLL 文件中导入函数(#import
)。在学习各类 MQL 程序时,我们将在第五、第六和第七部分对这些指令进行探讨。
所有预处理器指令都以井号 #
开头,后面跟着一个关键字和额外的参数,其语法取决于指令的类型。
建议从行首开始编写预处理器指令,或者至少在有空白缩进之后编写(如果指令是嵌套的)。将指令插入到源代码语句内部被认为是一种糟糕的编程风格(与 MQL5 不同,C++ 预处理器根本不允许这样做)。
预处理器指令不是语言语句,不应该以分号 ;
结尾。指令通常延续到当前行的末尾。在某些情况下,它们可以以特殊的方式延续到后续行,这将单独进行讨论。
指令会按照它们在文本中出现的顺序依次执行,并且会考虑到之前指令的处理结果。例如,如果使用 #include
指令将另一个文件包含到当前文件中,并且在被包含的文件中使用 #define
定义了一个替换规则,那么该规则将适用于后续的所有代码行,包括之后包含的头文件。
预处理器不会处理注释。
包含源文件(#include
)
#include
指令用于将另一个文件的内容包含到源代码中。该指令的效果就如同程序员将包含文件中的文本复制到剪贴板,然后粘贴到当前文件中使用该指令的位置一样。
在编写复杂程序时,将源代码拆分成多个文件是一种常见的做法。此类程序基于模块化构建,每个模块或文件包含逻辑相关的代码,用于解决一个或多个相关的任务。
包含文件还用于分发库(即现成算法的集合)。同一个库可以被包含到不同的程序中。在这种情况下,对库(其头文件)的更新将在下次编译所有相关程序时自动应用。
如果 MQL 程序的主文件必须具有 .mq5
扩展名,那么包含文件通常具有 .mqh
扩展名(单词末尾的 h
表示“头文件”)。同时,也允许对其他类型的文本文件使用 #include
指令,例如 .txt
文件(见下文)。无论如何,当包含文件时,由主 .mq5
文件和所有头文件组合而成的最终程序在语法上必须仍然是正确的。例如,包含一个包含二进制信息的文件(如 PNG 图像)会导致编译失败。
#include
语句有两种类型:
plaintext
#include <file_name>
#include "file_name"
在第一种情况中,文件名用尖括号括起来。编译器会在终端数据目录的 MQL5/Include/
子文件夹中搜索此类文件。
在第二种情况中,文件名用引号括起来,编译器会在包含使用 #include
语句的当前文件的同一目录中进行搜索。
在这两种情况下,文件都可以位于搜索目录内的子文件夹中。此时,你应该在指令中的文件名之前指定完整的相对文件夹层次结构。例如,随 MetaTrader 5 一起提供了许多常用的引导文件,其中有一个 DateTime.mqh
文件,它包含一组用于处理日期和时间的方法(这些方法被设计为结构体,我们将在专门讨论面向对象编程的第三部分中讨论这种语言结构)。DateTime.mqh
文件位于 Tools
文件夹中。要将其包含到你的源代码中,你应该使用以下指令:
cpp
#include <Tools/DateTime.mqh>
为了演示如何使用该指令从与源文件相同的文件夹中包含头文件,我们来看一下 Preprocessor.mq5
文件。它包含以下指令:
cpp
#include "Preprocessor.mqh"
该指令引用的 Preprocessor.mqh
文件实际上就位于 Preprocessor.mq5
文件旁边。
一个包含文件反过来也可以包含其他文件。具体来说,在 Preprocessor.mqh
文件内部有以下代码:
cpp
double array[] =
{
#include "Preprocessor.txt"
};
这意味着数组的内容是从给定的文本文件中初始化的。如果我们查看 Preprocessor.txt
文件的内容,会看到符合数组初始化语法规则的文本:
plaintext
1, 2, 3, 4, 5
因此,可以使用自定义组件来组合源代码,甚至可以通过其他程序来生成代码。
需要注意的是,如果指令中指定的文件未找到,编译将会失败。
多个文件的包含顺序决定了其中预处理器指令的处理顺序。
宏替换指令概述
宏替换指令包含 #define
指令的两种形式:
- 简单形式,通常用于定义常量。
- 将宏定义为带参数的伪函数。
此外,还有一个 #undef
指令,用于取消之前任何 #define
定义。如果不使用 #undef
,每个已定义的宏在源代码编译结束前都有效。
宏先进行注册,然后在代码中通过名称使用,遵循标识符的规则。按照惯例,宏名用大写字母书写。宏名可以与变量、函数和源代码的其他元素的名称重叠。有目的地利用这一点可以灵活地动态更改和生成源代码。然而,宏名与程序元素无意的重合会导致错误。
两种形式的宏替换的操作原理是相同的。使用 #define
指令引入一个标识符,该标识符与一段特定的文本(即定义)相关联。如果预处理器在后续源代码中找到该标识符,就会将其替换为与之关联的文本。需要强调的是,宏名只有在注册后才能在编译代码中使用(这类似于变量声明原则,但仅在编译阶段)。
将宏名替换为其定义的过程称为展开。源代码的分析是逐行逐步进行的,但每行中的展开可以像在循环中一样任意次数地执行,只要在结果中能找到宏名。不能在宏定义中包含相同的名称:在替换时,这样的宏会导致“未知标识符”错误。
在本书的第三部分,我们将学习模板,它也允许生成(实际上是复制)源代码,但规则不同。如果源代码中同时存在宏替换指令和模板,会先展开宏,然后从模板生成代码。
在 MetaEditor 中,宏名会以红色突出显示。
#define
的简单形式
#define
指令的简单形式用于注册一个标识符,以及一个字符序列。在该指令之后,直到程序结束,或者在遇到带有相同标识符的 #undef
指令之前,源代码中出现的该标识符都会被这个字符序列所替换。
其语法如下:
plaintext
#define macro_identifier [text]
文本从标识符之后开始,一直延续到当前行的末尾。标识符和文本之间必须用任意数量的空格或制表符分隔。如果所需的字符序列太长,为了提高可读性,可以将其分成几行,方法是在每行的末尾加上反斜杠字符 \
。
plaintext
#define macro_identifier text_beginning \
text_continued \
text_ending
文本可以由任何语言结构组成:常量、运算符、标识符和标点符号。如果在源代码中用 macro_identifier
替换找到的结构,所有这些内容都将包含在编译过程中。
简单形式的 #define
传统上用于以下几个目的:
- 标志声明,随后用于条件编译检查;
- 命名常量声明;
- 常用语句的缩写表示。
第一个目的的特点是,在标识符之后无需指定任何内容——带有该名称的指令的存在就足以使相应的标识符被注册,并可用于条件指令 #ifdef
/#ifndef
中。对于这些指令,重要的是标识符是否存在,即它以标志模式工作:已声明/未声明。例如,以下指令定义了 DEMO
标志:
cpp
#define DEMO
然后可以使用该标志,比如说,构建一个程序的演示版本,其中排除某些函数(请参阅条件编译部分的示例)。
使用简单指令的第二种方式允许将源代码中的“魔法数字”替换为更易理解的名称。“魔法数字”是插入到源文本中的常量,其含义并不总是清晰的(因为数字只是一个数字:最好至少在注释中对其进行解释)。此外,相同的值可能分散在代码的不同部分,如果程序员决定将其更改为另一个值,那么他将不得不在所有地方进行更改(并且希望没有遗漏任何地方)。
使用命名宏可以轻松解决这两个问题。例如,一个脚本可以准备一个包含斐波那契数列的数组,达到一定的最大深度。那么定义一个具有预定义数组大小的宏,并在数组本身的描述中使用它是很有意义的(Preprocessor.mq5
)。
cpp
#define MAX_FIBO 10
int fibo[MAX_FIBO]; // 10
void FillFibo()
{
int prev = 0;
int result = 1;
for(int i = 0; i < MAX_FIBO; ++i) // i < 10
{
int temp = result;
result = result + prev;
fibo[i] = result;
prev = temp;
}
}
如果程序员随后决定需要增加数组的大小,他只需在一个地方进行更改——在 #define
指令中。因此,该指令实际上定义了算法的某个参数,该参数“硬编码”到源代码中,并且用户无法配置。这种需求经常会出现。
可能会出现这样一个问题,通过 #define
定义与在全局上下文中定义常量变量有什么区别。确实,我们可以声明一个具有相同名称和用途的变量,甚至保留大写字母:
cpp
const int MAX_FIBO = 10;
然而,在这种情况下,MQL5 不允许定义具有指定大小的数组,因为方括号中只允许使用常量,即字面量(尽管常量变量名称类似,但它不是常量)。为了解决这个问题,我们可以将数组定义为动态数组(首先不指定大小),然后使用 ArrayResize
函数为其分配内存——在这里将变量作为大小传递并不困难。
定义命名常量的另一种方法是使用枚举(enum
),但仅限于整数值。例如:
cpp
enum
{
MAX_FIBO = 10
};
但是宏可以包含任何类型的值。
cpp
#define TIME_LIMIT D'2023.01.01'
#define MIN_GRID_STEP 0.005
在源文本中搜索要替换的宏名时,会考虑语言的语法,也就是说,不可分割的元素,如变量标识符或字符串字面量,即使它们包含与某个宏匹配的子字符串,也将保持不变。例如,给定下面的宏 XYZ
,变量 XYZAXES
将保持原样,而名称 XYZ
(因为它与宏完全相同)将被更改为 ABC
。
cpp
#define XYZ ABC
int XYZAXES = 3; // int XYZAXES = 3
int XYZ = 0; // int ABC = 0
宏替换允许将自己的代码嵌入到其他程序的源代码中。这种技术通常被作为 .mqh
头文件分发并使用 #include
指令连接到程序的库所采用。
特别是对于脚本,我们可以定义自己的 OnStart
函数的库实现,该实现必须执行一些额外的操作,同时不影响程序的原始功能。
cpp
void OnStart()
{
Print("OnStart wrapper started");
// ... 额外操作
_OnStart();
// ... 额外操作
Print("OnStart wrapper stopped");
}
#define OnStart _OnStart
假设这部分代码在包含的头文件(Preprocessor.mqh
)中。
然后,预处理器会将源代码中原来的 OnStart
函数(在 Preprocessor.mq5
中)重命名为 _OnStart
(可以理解为这个标识符在其他地方不会用于其他目的)。并且头文件中的新版本 OnStart
会调用 _OnStart
,将其“包装”在额外的语句中。
使用简单 #define
的第三种常见方式是缩短语言结构的表示形式。例如,无限循环的标题可以用一个单词 LOOP
表示:
cpp
#define LOOP for( ; !IsStopped() ; )
然后在代码中应用:
cpp
LOOP
{
// ...
Sleep(1000);
}
这种方法也是使用带参数的 #define
指令的主要技术(见下文)。
#define
作为伪函数的形式
带参数形式的 #define
的语法类似于函数。
plaintext
#define macro_identifier(parameter,...) text_with_parameters
这样的宏在括号中有一个或多个参数。参数之间用逗号分隔。每个参数都是一个简单的标识符(通常是单个字母)。此外,一个宏的所有参数必须具有不同的标识符。
重要的是,标识符和左括号之间不能有空格,否则这个宏将被视为简单形式,即替换文本以左括号开头。
在注册此指令后,预处理器将在源代码中搜索以下形式的行:
plaintext
macro_identifier(expression,...)
可以用任意表达式代替参数。参数的数量必须与宏参数的数量匹配。所有找到的宏调用都将被替换为 text_with_parameters
,反过来,其中的参数将被替换为传入的表达式。每个参数可以出现多次,顺序任意。
例如,下面这个宏用于找出两个值中的最大值:
cpp
#define MAX(A,B) ((A) > (B) ? (A) : (B))
如果代码中包含语句:
cpp
int z = MAX(x, y);
预处理器会将其“展开”为:
cpp
int z = ((x) > (y) ? (x) : (y));
宏替换适用于任何数据类型(只要宏内部应用的操作对该数据类型有效)。
然而,替换也可能有副作用。例如,如果实际参数是一个函数调用或修改变量的语句(比如 ++x
),那么相应的操作可能会执行多次(而不是预期的一次)。对于 MAX
宏来说,这种情况会发生两次:一次在比较时,另一次在 ? :
运算符的某一个分支中获取值时。鉴于此,只要有可能,将这类宏转换为函数是有意义的(尤其要考虑到在 MQL5 中函数会自动进行内联)。
参数周围以及整个宏定义周围都有括号。它们用于确保将表达式作为参数进行替换,或者在其他表达式中使用宏本身时,不会因为优先级不同而扭曲计算顺序。假设宏定义了两个参数的乘法(还没有加括号):
cpp
#define MUL(A,B) A * B
那么使用这个宏并传入以下表达式会产生意外的结果:
cpp
int x = MUL(1 + 2, 3 + 4); // 1 + 2 * 3 + 4
我们得到的不是 (1 + 2) * (3 + 4)
(结果为 21)的乘法运算,而是 1 + 2 * 3 + 4
,即 11。合适的宏定义应该是这样的:
cpp
#define MUL(A,B) ((A) * (B))
你可以将另一个宏指定为宏参数。此外,还可以在宏定义中插入其他宏。所有这些宏都会依次被替换。例如:
cpp
#define SQ3(X) (X * X * X)
#define ABS(X) MathAbs(SQ3(X))
#define INC(Y) (++(Y))
那么以下代码将输出 504(MathAbs
是一个内置函数,返回一个数的模,即不带符号的值):
cpp
int x = -10;
Print(ABS(INC(x)));
// -> ABS(++(Y))
// -> MathAbs(SQ3(++(Y)))
// -> MathAbs((++(Y))*(++(Y))*(++(Y)))
// -> MathAbs(-9*-8*-7)
// -> 504
变量 x
中的值将变为 -7(因为进行了三次自增操作)。
宏定义中可以包含不匹配的括号。这种技巧通常用于一对宏中,其中一个宏用于打开某段代码,另一个宏用于关闭它。在这种情况下,它们各自不匹配的括号会变得匹配。特别是在 MetaTrader 5 发行包中的标准库文件,如 Controls/Defines.mqh
中,定义了 EVENT_MAP_BEGIN
和 EVENT_MAP_END
宏。它们用于在图形对象中形成事件处理函数。
预处理器从主 .mq5
文件开始逐行读取程序的整个源文本,并在遇到头文件的地方插入头文件的文本。在读取任何一行代码时,已经定义了一组特定的宏。宏的定义顺序无关紧要:很可能一个宏在其定义中引用了另一个宏,而被引用的宏在文本中既可以在前面定义也可以在后面定义。重要的是,在使用宏名的源代码行中,所有被引用的宏的定义都是已知的。
看一个例子:
cpp
#define NEG(x) (-SQN(x))*TEN
#define SQN(x) ((x)*(x))
#define TEN 10
...
Print(NEG(2)); // -40
这里,NEG
宏使用了在它下面定义的 SQN
和 TEN
宏。并且这并不妨碍我们在这三个 #define
指令之后在代码中成功使用它。
然而,如果我们将行的相对位置更改为以下情况:
cpp
#define NEG(x) (-SQN(x))*TEN
#define SQN(x) ((x)*(x))
...
Print(NEG(2)); // error: 'TEN' - undeclared identifier
...
#define TEN 10
我们会得到一个“未声明标识符”的编译错误。
#define
定义中的特殊运算符 #
和 ##
在宏定义内部,可以使用两个特殊的运算符:
- 在宏参数名称前面的单个哈希符号
#
会将该参数的内容转换为字符串;这只允许在函数宏中使用; - 两个单词(标记)之间的双哈希符号
##
会将它们组合起来,如果标记是一个宏参数,那么会替换其值,但如果标记是一个宏名称,则按原样进行替换,不会展开该宏;如果在“粘合”后得到了另一个宏名称,则会展开它;
在本书的示例中,我们经常使用以下宏:
cpp
#define PRT(A) Print(#A, "=", (A))
它调用 Print
函数,由于 #A
,传入的表达式会以字符串形式显示,并且在“等于”符号之后,会打印出 A
的实际值。
为了演示 ##
运算符,让我们看另一个宏:
cpp
#define COMBINE(A,B,X) A##B(X)
使用这个宏,我们实际上可以生成对上面定义的 SQN
宏的调用:
cpp
Print(COMBINE(SQ,N,2)); // 4
字面值 SQ
和 N
被连接起来,然后 SQN
宏展开为 ((2)*(2))
,并得到结果 4。
下面这个宏允许通过给定宏的参数生成变量名称,从而在代码中创建变量定义:
cpp
#define VAR(TYPE,N) TYPE var##N = N
那么代码行:
cpp
VAR(int, 3);
等同于以下内容:
cpp
int var3 = 3;
标记的连接允许使用宏实现对数组元素的循环简写。
cpp
#define for_each(I, A) for(int I = 0, max_##I = ArraySize(A); I < max_##I; ++I)
// 描述并以某种方式填充数组 x
double x[];
// ...
// 实现对数组的循环
for_each(i, x)
{
x[i] = i * i;
}
取消宏替换(#undef
)
如果使用 #define
注册的宏替换在代码的特定部分之后不再需要,那么可以将其取消。为此,需要使用 #undef
指令。
plaintext
#undef macro_identifier
特别是当你需要在代码的不同部分以不同方式定义同一个宏时,这个指令非常有用。如果 #define
中指定的标识符已经在代码的前几行的某个地方(由另一个 #define
指令)注册过了,那么旧的定义将被新的定义所取代,并且预处理器会生成“宏重定义”警告。使用 #undef
可以避免这个警告,同时明确表明程序员在代码后续部分不再使用特定宏的意图。
#undef
不能取消预定义宏的定义。
预定义的预处理器常量
MQL5 有几个预定义常量,它们相当于简单的宏,但由编译器本身定义。下表列出了其中一些常量的名称和含义。
名称 | 描述 |
---|---|
__COUNTER__ | 计数器(在宏展开期间,每次在文本中提及该常量,其值都会增加 1) |
__DATE__ | 编译日期(日) |
__DATETIME__ | 编译日期和时间 |
__FILE__ | 被编译文件的名称 |
__FUNCSIG__ | 当前函数签名 |
__FUNCTION__ | 当前函数名称 |
__LINE__ | 被编译文件中的行号 |
__MQLBUILD__ , __MQL5BUILD__ | 编译器版本 |
__RANDOM__ | ulong 类型的随机数 |
__PATH__ | 被编译文件的路径 |
_DEBUG | 在调试模式下编译时定义 |
_RELEASE | 在正常模式下编译时定义 |
条件编译(#ifdef
/#ifndef
/#else
/#endif
)
条件编译指令允许在编译过程中包含或排除代码片段。#ifdef
和 #ifndef
指令标记它们所控制的代码片段的开始。该片段以 #endif
指令结束。在最简单的情况下,#ifdef
的语法如下:
plaintext
#ifdef macro_identifier
statements
#endif
如果在代码中上方使用 #define
定义了具有指定标识符的宏,那么这个代码片段将参与编译。否则,它将被排除在外。除了在应用程序代码中定义的宏之外,环境还提供了一组预定义常量,特别是 _RELEASE
和 _DEBUG
标志(请参阅“预定义常量”部分):它们的名称也可以在条件编译指令中进行检查。
扩展形式的 #ifdef
允许指定两段代码:如果宏标识符已定义,则包含第一段代码;如果未定义,则包含第二段代码。为此,在 #ifdef
和 #endif
之间插入片段分隔符 #else
。
plaintext
#ifdef macro_identifier
statesments_true
#else
statements_false
#endif
#ifndef
指令的工作方式类似,但代码片段的包含和排除逻辑相反:如果在指令头部指定的宏未定义,则编译第一段代码;如果已定义,则编译第二段代码。
例如,根据 DEMO
宏替换是否存在,我们可以选择是否调用计算斐波那契数列的函数。
cpp
#ifdef DEMO
Print("Fibo is disabled in the demo");
#else
FillFibo();
#endif
在这种情况下,如果启用了 DEMO
模式,将在日志中显示一条消息,而不是调用函数。但是,由于在 Preprocessor.mq5
脚本以及所有包含的文件中都没有 #define DEMO
的定义,因此编译将按照 #else
分支进行,也就是说,对 FillFibo
函数的调用会被包含在可执行的 ex5
文件中。
这些指令可以嵌套使用。
cpp
#ifdef _DEBUG
Print("Debugging");
#else
#ifdef _RELEASE
Print("Normal run");
#else
Print("Undefined mode!");
#endif
#endif
通用程序属性(#property
)
通过使用 #property
指令,程序员可以设置 MQL 程序的一些属性。其中一些属性是通用的,即适用于任何程序,我们将在此进行探讨。其余属性是 MQL5 特定类型程序所特有的,将在描述 MQL5 API 的第五部分的相关章节中讨论。
#property
指令具有以下格式:
plaintext
#property key value
key
是下表第一列中列出的属性之一。第二列说明了 value
将如何被解释。
属性 | 值 |
---|---|
copyright | 包含版权所有者信息的字符串 |
link | 指向开发者网站的链接字符串 |
version | 包含程序版本号的字符串(对于 MQL5 市场,其格式必须为“X.Y”,其中 X 和 Y 是对应于主版本号和次版本号的整数) |
description | 包含程序描述的行(允许使用多个 #description 指令,其内容将被合并) |
icon | 字符串,指向 ICO 格式的程序徽标文件的路径 |
stacksize | 以字节为单位指定栈大小的整数(默认情况下,根据程序类型和环境,其大小在 4 到 16 MB 之间,1 MB = 1024 * 1024 字节);如有必要,大小可增加至 64 MB(最大值) |
上述所有字符串属性都是程序属性对话框的信息来源,该对话框在程序启动时打开。然而,对于脚本,默认情况下不会显示此对话框。要更改此行为,必须额外指定 #property script_show_inputs
指令。此外,当鼠标光标悬停在 MetaTrader 5 导航器中的程序上时,版权信息会显示在工具提示中。
在本书之前的所有示例中,我们已经见过 copyright
、link
和 version
属性。
栈大小 stacksize
是一个建议值:如果编译器在源代码中发现局部变量(通常是数组)超过了指定的值,在编译期间栈的大小将自动增加,但最多不超过 64 MB。如果超过了限制,程序甚至无法启动:在日志(“日志”选项卡,而不是“专家”选项卡)中会出现错误“Stack size of 64MB exceeded. Reduce the memory occupied by local variables”(栈大小超过 64 MB。请减少局部变量占用的内存)。
请注意,这种分析和启动限制仅考虑程序启动时的固定状态。在递归函数调用的情况下,栈内存的消耗可能会显著增加,并在程序执行阶段导致栈溢出错误。有关栈的更多信息,请参阅“描述数组”中的注释。
#property
指令仅在已编译的 .mq5
文件中起作用,在所有通过 #include
包含的文件中会被忽略。