Skip to content

预处理器

到目前为止,我们在学习 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 传统上用于以下几个目的:

  1. 标志声明,随后用于条件编译检查;
  2. 命名常量声明;
  3. 常用语句的缩写表示。

第一个目的的特点是,在标识符之后无需指定任何内容——带有该名称的指令的存在就足以使相应的标识符被注册,并可用于条件指令 #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_BEGINEVENT_MAP_END 宏。它们用于在图形对象中形成事件处理函数。

预处理器从主 .mq5 文件开始逐行读取程序的整个源文本,并在遇到头文件的地方插入头文件的文本。在读取任何一行代码时,已经定义了一组特定的宏。宏的定义顺序无关紧要:很可能一个宏在其定义中引用了另一个宏,而被引用的宏在文本中既可以在前面定义也可以在后面定义。重要的是,在使用宏名的源代码行中,所有被引用的宏的定义都是已知的。

看一个例子:

cpp
#define NEG(x) (-SQN(x))*TEN
#define SQN(x) ((x)*(x))
#define TEN 10
...
Print(NEG(2)); // -40

这里,NEG 宏使用了在它下面定义的 SQNTEN 宏。并且这并不妨碍我们在这三个 #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

字面值 SQN 被连接起来,然后 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 导航器中的程序上时,版权信息会显示在工具提示中。

在本书之前的所有示例中,我们已经见过 copyrightlinkversion 属性。

栈大小 stacksize 是一个建议值:如果编译器在源代码中发现局部变量(通常是数组)超过了指定的值,在编译期间栈的大小将自动增加,但最多不超过 64 MB。如果超过了限制,程序甚至无法启动:在日志(“日志”选项卡,而不是“专家”选项卡)中会出现错误“Stack size of 64MB exceeded. Reduce the memory occupied by local variables”(栈大小超过 64 MB。请减少局部变量占用的内存)。

请注意,这种分析和启动限制仅考虑程序启动时的固定状态。在递归函数调用的情况下,栈内存的消耗可能会显著增加,并在程序执行阶段导致栈溢出错误。有关栈的更多信息,请参阅“描述数组”中的注释。

#property 指令仅在已编译的 .mq5 文件中起作用,在所有通过 #include 包含的文件中会被忽略。