请 [注册] 或 [登录]  | 返回主站

量化交易吧 /  量化平台 帖子:3364740 新帖:4

利用MQL进行MQL解析

谎言梦发表于:4 月 17 日 18:27回复(1)

简介

编程本质上是使用通用或专用语言将某些过程形式化和自动化。MetaTrader 交易平台允许使用嵌入式MQL语言应用编程来解决各种交易员的问题。通常,编码过程是根据源代码中指定的规则分析和处理应用程序数据。但是,有时需要分析和处理源代码本身。下面是一些例子。

最一致和最常用的任务之一是在源代码库中进行上下文和语义搜索。当然,您可以像在普通文本中一样在源代码中搜索字符串;但是,所寻找的字符串的语义正在丢失。毕竟,在源代码的情况下,需要区分在每个特定情况下使用子字符串的具体情况。如果程序员想找到在哪里使用某个特定变量,例如“notification”,那么通过其名称进行简单的搜索可以返回远远超过必要的结果,其中字符串出现在其他值中,例如方法名、文本或注释中。

通常,在大型项目中,识别代码结构、依赖关系和类层次结构是一项更复杂和更受欢迎的任务。它与允许执行代码重构/改进和代码生成的元编程紧密相连。例如 MetaEditor 提供了一些生成代码的功能, 特别是使用 向导创建EA交易的的代码或者是根据源代码生成头文件,然而,这项技术的潜力更大。

代码结构分析允许计算各种质量度量和统计,以及查找编译器无法检测到的典型运行时错误源。事实上,编译器本身当然是分析源代码并返回许多类型警告的第一个工具;但是,检查所有潜在错误通常并不是内置的-此任务太大,因此通常分配给单独的程序。

此外,解析源代码也可以用于样式(格式化)和模糊(混淆)。

在工业编程语言中可以使用许多实现上述问题的工具。而对于 MQL, 选择却很有限。我们可以尝试用可用的方法来分析MQL,通过调整将MQL放置在与C++相同的级别上。使用一些工具非常简单,例如 Doxygen, 但需要更深入地适应更强有力的手段, 例如 lint, 因为 MQL 依然并不是 C++。

应当注意的是,本文只处理静态代码分析, 虽然动态分析器允许您在虚拟环境中跟踪内存操作错误、工作流锁、变量值的正确性等等。

可以使用各种方法来执行源代码的静态分析,对于简单的问题,例如搜索MQL程序的输入变量,使用正则表达式库就足够了。一般来说,分析必须基于考虑MQL语法的解析器。我们将在本文中探讨这种方法。我们还将努力把它实际应用。

换句话说,我们将在MQL中编写一个MQL解析器,并获取源代码的元数据。这将使我们能够解决上述问题,并在今后提供一些其他奇妙的挑战。因此,有了一个完全正确的解析器,我们可以依靠它来开发一个MQL解释器,或者将MQL自动转换为其他交易语言,反之亦然 ( 所谓的 transpiling)。然而,我用“奇妙”这个词是有原因的。尽管所有这些技术已经在其他领域广泛使用,但我们必须先深入了解基本原理,然后在 MetaTrader 平台上进行探讨。


技术回顾

有许多不同的解析器,我们不会涉及技术细节-您可以在维基百科上找到介绍性信息,并且已经为高级研究开发了大量资源。

我们应该注意的是,解析器的功能基于所谓的描述语法的语言。描述语法的最常见形式之一是 Backus-Naur (BNF) 的语法。有许多BNF修改版本,但我们不会涉及太多细节,只考虑基本点。

在BNF中,所有的语言结构都由所谓的非终端定义,而不可分割的实体则是终端。这里,“终端”是指解析文本的最后一点,即包含源代码片段“原样”并解释为整体的标记。例如,它可以是逗号、括号或单个单词的字符。我们在语法中自己定义终端列表,即字母表。根据一些规则,程序的所有其他组件都是由终端组成的。

例如,我们可以指定程序由简化的 BNF 符号中的运算符组成,如下所示:

program ::= operator more_operators
more_operators ::= program | {empty}

在这里,据说非终端“program”(程序)可以由一个或多个运算符组成,随后的运算符已经使用到“program”的递归链接进行了描述。字符 '|' (不带有 BNF 中的引号标记) 表示逻辑或(OR) — 选择其中一个选项. 为了完成递归,在上面的单元中使用了特殊终端 {empty},它可以表示为空字符串或者一个跳过规则的选项。

字符 'operator' 也是一个非终端, 需要通过其它的非终端和终端来'扩展', 比如像这样:

operator ::= name '=' expression ';'

这个项目定义了每个操作符应当以变量名称开始, 然后是 '=' 符号, 一个表达式, 然后操作符以字符 ';' 结尾。字符 '=' 和 ';' 是终端。名称包含字母:

name ::= letter more_letters
more_letters ::= name | {empty}
letter ::= [A-Z]

在此,任何从 'A' 到 'Z' 的字符都可以用作字母 (它们的集合以方括号标记),

使得表达式由操作数和算术(运算)组成:

expression ::= operand more_operands
more_operands ::= operation expression | {empty}

在最简单的情况下,表达式只包含一个操作数。但是,可以有更多的(更多的操作数),然后通过操作字符作为子表达式附加到它,让操作数能够引用变量或数字,而操作可以是“+”或“-”:

operand ::= name | number
number ::= digit more_digits
more_digits ::= number | {empty}
digit ::= [0-9]
operation ::= '+' | '-'

因此,我们描述了一种简单语言的语法,在这种语法中,可以使用数字和变量进行计算,例如:

A = 10;
X = A - 5;

当我们开始分析文本时,实际上,我们检查哪些规则有效,哪些规则失败。那些已经工作过的用户迟早会产生“production”,即找到一个与文本中当前位置的内容一致的终端,将光标移动到下一个位置。这个过程被重复,直到整个文本通过片段与非终端片段相关联,形成语法允许的序列。

在上面的示例中,输入字符“a”的解析器将开始查看下面概述的规则:

program(程序)
  operator(操作符)
    name(名称)
      letter(字母)
        'A'

然后找到第一个匹配。然后光标将移动到下一个字符 '='. 因为 'letter' 是一个字母,解析器将返回规则 'name'。因为'name'只能由字母组成,所以选项more_letters不起作用(它被选择为等于{empty}),并且解析器返回到规则'operator',这里是名称后面的终端'='。这将是第二个匹配。然后,扩展规则“expression”,解析器将找到操作数-整数10(作为两位数的序列),最后,分号将完成对第一个字符串的解析。根据它的结果,我们实际上将知道变量名、表达式内容,即它由一个数字组成的事实,以及它的值。第二个字符串以类似的方式解析。

重要的是要注意,一种语言和同一种语言的语法可以以不同的方式记录,后者正式地相互匹配。然而,在某些情况下,严格遵守规则可能会导致某些问题。例如,数字的描述可以表示为:

number ::= number digit | {empty}

此条目格式命名为left recursion:非终端“number”既在左侧,也在右侧,用于确定其“production”的规则,它是字符串中的第一个、左侧(因此命名为“left recursion”)。这是最简单的显式左递归。但是,如果非终端在某些中间规则之后扩展为一个字符串,则它可以是隐式的,该字符串从该非终端开始。

左递归经常出现在编程语言语法的形式BNF符号中。然而,一些类型的解析器,根据它们的实现,可能会被困在具有类似规则的循环中。实际上,如果我们将规则视为操作指南(解析算法),那么这个条目将一次又一次地递归地输入“数字”,而不从输入流中读取任何新的终端,理论上,在扩展非终端“数字”时应该会发生这种情况。

因为我们试图从零开始创建MQL语法,但是在可能的情况下,使用C++语法的BNF标记,必须注意左递归,并且规则应该以另一种方式重写。同时,我们还必须实现对无限循环的保护——正如我们将进一步看到的,C++语言类型或MQL类型语言的语法是如此的分枝,以至于似乎不可能手动检查它们的正确性。

在这里,值得注意的是,编写解析器是一门真正的科学,建议您在“简单到复杂”的基础上逐渐开始掌握这个领域。最简单的是递归下降解析器。它将输入文本作为一个整体,对应于语法的起始非终结点。在上面的示例中,这是非终端“program”。按照每个合适的规则,解析器检查输入字符序列是否匹配终端,并在查找匹配项时沿着文本移动。如果解析器在解析的任何时刻发现不匹配,它将回滚到指定了替代项的规则,从而检查所有可能的语言结构。这个算法完全重复了我们在上面的例子中纯理论上执行的操作。

“回滚”操作称为“回溯”,可以对响应速度产生负面影响。因此,在最坏的情况下,经典的下降解析器在查看文本时会生成成倍增长的选项。解决这个问题有多种选择,例如预测解析器不需要回溯,它的工作时间是线性的。

但是,这只适用于语法,对于语法,可以通过预先定义的后续字符数 k 明确地选择“产生”规则。这样更高级的解析器是基于它们对特殊的转换表的操作,这些转换表是根据语法的所有规则预先计算的。它们包括,但不限于,LL - 解析器和 LR 解析器。

LL代表从左到右,最左边的派生。这意味着文本是从左到右查看的,规则也是从左到右查看的,这相当于一个自上而下的结论(从一般到具体),从这个意义上说,LL是我们的下降解析器的一个相对物。

LR代表从左到右,最右的派生。这意味着文本像以前一样从左到右查看,但是规则从右到左查看,这相当于自下而上形成语言结构,即从单个字符到更大的非终端。此外,LR在左递归方面的问题更少。

解析器 LL(k)和 LR(k)的名称中,通常将k的字符数指定为lookahead,最多可以向前查看文本。在大多数情况下,选择 k = 1 就足够了,不过,这一充分性还不充分。问题是,许多现代编程语言,包括C++和MQL,都不是具有上下文无关语法的语言。换句话说,根据上下文的不同,可以对文本的相同片段进行不同的解释。在这种情况下,为了决定所写内容的消息,通常一个甚至任何数量的字符都是不够的,因为您必须将解析器与其他工具(如预处理器或符号表(已识别的标识符列表及其含义)联系在一起。

对于C++语言,有一个典型的歧义情况(它也适用于MQL),下面的表达式是什么意思?

x * y;

这可能是变量x和y的乘积;但是,它可能是变量y作为x类型指针的描述。不要让你觉得难堪的是,乘法的乘积,如果是乘法,就不会被保存在任何地方,因为乘法的操作可能会超载并产生副作用。

另一个问题,即过去大多数C++编译器所遭受的问题,在解释两个连续的字符 '>' 时是含糊不清的。问题是,在引入模板后,以下类型的结构开始出现在源代码中:

vector<pair<int,int>> v;

而序列“>>”最初被定义为移位运算符。有一段时间,在为这种特定情况引入更精细的过程之前,我们必须用空格编写类似的表达式:

vector<pair<int,int> > v;

在解析器中,我们还必须绕过这个问题。

一般来说,即使是这个简短的介绍也清楚地表明,高级解析器的描述和实现需要更多的努力,包括概括范围和掌握它们所需的时间。因此,在本文中,我们将把自己局限于最简单的解析器——递归下降解析器。

计划

因此,解析器的任务是读取输入的文本,将其分解为不可分割的片段(标记)流,然后将它们与使用 BNF 表示法或者接近 MQL 语法描述的允许的语言结构进行比较。

首先,我们需要一个读取文件的类——我们将它命名为 FileReader。由于MQL项目可以由使用指令 #include 的主文件中包含的多个文件组成,因此可能需要有多个FileReader实例,因此我们在开发中还将考虑一个类,即 FileReaderController。

一般来说,要处理的文件中的文本代表一个标准字符串。但是,我们需要在不同的类之间传输它,而不幸的是,MQL不允许使用字符串指针(我记得这些引用,但是它们不能用于声明类成员,而唯一的替代方法是通过输入将引用传递给所有方法,这是很难处理的)。因此,我们将创建一个单独的类 Source,表示一个字符串包装器,它将执行另一个重要功能。

问题是,由于连接“includes”(因此,从依赖项递归读取头文件),我们将从控制器输出的所有文件中获取合并文本。为了检测错误,我们必须使用合并源代码中的移位来获取原始文件的名称和字符串,从中提取文本。Source 类还支持并存储文件中源代码位置的“映射”。

这里出现了一个相关的问题:不可能不合并源代码,而是单独处理每个文件吗?是的,可能会更正确。但是,在这种情况下,需要为每个文件创建一个解析器实例,然后以某种方式交叉链接解析器在输出时生成的语法树。因此,我决定合并源代码并将它们提供给一个解析器。如果你愿意的话,你可以尝试其他的方法。

为了让FileReaderController能够找到 #include指令,不仅需要从文件中读取文本,还需要在搜索这些指令时执行预览。因此,需要一种预处理器。在MQL中,它可以做其他的好工作。特别是,它允许识别宏,然后用实际表达式替换它们(此外,它还考虑了从宏调用宏的潜在递归)。但最好不要在我们的第一个MQL解析项目中过于分散自己。因此,我们不会在预处理器中处理宏——这不仅需要额外描述宏的语法,还需要在运行中对宏进行解释,以便在源代码中替换正确的表达式。你还记得我们在导言中对解释器所说的话吗?现在,它在这里会很有用,并且稍后会清楚为什么它很重要。这是你第二个独立实验的区域。

预处理器将在类 Preprocessor 中实现,在这个层面上,一个相当有争议的过程正在发生。当读取文件并搜索其中的 #include 指令时,文本中的解析和移动是在最低级别上逐个字符执行的。但是,预处理器“透明地”通过本身传递所有不是指令的内容,并在输出处使用最大的块进行操作-整个文件或指令之间的文件片段。然后解析将在一个中间级别上继续,为了描述哪个,我们必须引入几个术语。

首先,它是词汇单位(lexical unit)——词汇分析的抽象最小单位,非零长度的子字符串。另一个术语通常与IT结合使用——令牌(token)是另一个分析单元,它不是抽象的,而是具体的。两者都代表文本的一个片段,例如单个字符、单词,甚至一块注释。它们之间的细微区别在于,我们在标记级别用含义标记片段。例如,如果文本中出现单词“int”,则它是 MQL 的词汇单位,我们将用 令牌 INT 表示它—— MQL 语言中所有允许的标记枚举中的元素。换言之,词法单位集应指与标记类型对应的字符串字典。

令牌的一个优点是它们允许将文本分割成大于字符的片段。因此,文本分为两个过程进行解析:首先,从字母流中形成高级标记,然后基于这些标记解析语言结构。这可以大大简化语言语法和解析器操作。

特别的 Scanner 类将在文本中突出显示令牌。它可以被看作是一个低级的解析器,通过预先定义和硬连线的语法来处理文本。下面将探讨所需的令牌的确切类型。如果有人开始实验1(将每个文件加载到一个专用的解析器中),那么我们就可以在那里将预处理器与扫描器结合起来,一旦找到标记“#include<something>”,就可以创建一个新的FileReader、一个新的 Scanner 和一个新的 Parser,并将控制权传递给它们。

所有关键(保留)字以及标点符号和操作符号都将是MQL的标记。MQL 关键字的完整列表附加在文件reserved.txt中,并包含在扫描程序的源代码中。

标识符、数字、字符串、文本和其他常量(如日期)也将是独立的标记。

将文本解析为标记时,所有空格、换行符和制表都将被抑制(忽略)。特殊处理形式的唯一例外情况应该是换行,因为计算换行符将允许指出包含错误的行(如果有)。

因此,在给扫描器输入一个合并的文本之后,我们将在输出端获得一个令牌列表。这是我们在类 Parser 中实现的解析器将处理的令牌列表。

要使用MQL规则解释令牌,必须以BNF表示法将语法传递给解析器。为了描述语法,让我们尝试以简化形式重复解析器boost::spirit使用的方法。本质上,由于某些运算符过载,应使用MQL表达式的表达式描述语法规则。

为此,让我们介绍类Terminal、NonTerminal 及其派生的层次结构。Terminal 将是默认情况下与单元令牌对应的基类。正如理论部分所说,终端是解析规则的一个有限不可分割的元素:如果在文本的当前位置找到一个与终端标记一致的字符,则表示该字符与语法相对应。我们可以阅读并继续前进。

我们将在复杂的结构中使用 NonTerminal,其中终端和其他非终端可用于各种组合。这可以通过一个例子来显示。

假设我们必须描述一个简单的语法来计算表达式,其中只能使用整数和操作“加”(“+”)和“乘”(“*”)。为简单起见,我们将把自己局限在只有两个操作数的场景中,例如10+1或5*6。

基于此任务,首先需要识别与整数对应的终端。这个终端将与表达式中的任何有效操作数进行比较。考虑到每次扫描程序在文本中找到一个整数时,它都会生成相关的标记CONST_INTEGER,让我们定义引用该标记的终端类对象。在伪代码中,这将是:

Terminal value = CONST_INTEGER;

此条目意味着我们已经创建了附加到标记“integer”的类终端的“value”对象。

操作符号也是带有相关标记的终端,加号和星号,由扫描仪为单个字符“+”和“*”生成:

Terminal plus = PLUS;
Terminal star = STAR;

为了能够在表达式中使用它们中的任何一个,让我们引入一个非终端,通过或(OR)组合两个操作:

NonTerminal operation = plus | star;

这就是重载操作符的作用:在类终端(及其所有子代)中,操作符必须创建从父对象(在本例中为“operation”)到子对象(“plus”和“star”)的引用,并用它们标记操作类型-逻辑或。

当解析器开始检查非终端“operation”是否与光标下的文本匹配时,它将把进一步的检查(“深度”)委托给对象“operation”,并且该对象将在循环中调用子元素“plus”和“star”(直到第一个匹配,因为它是或)。因为它们是终端,所以它们将把它们的标记返回给解析器,而后者将发现文本中的字符是否匹配其中一个操作。

表达式可以由多个值和其中的操作组成。因此,表达式也是非终端的,必须通过终端和非终端进行“扩展”。

NonTerminal expression = value + operation + value;

这里,我们重载operator+,这意味着操作数必须按照指定的顺序彼此跟随。同样,函数的实现意味着父非终端“expression”必须保存对后代对象“value”、“operation”和另一个“value”的引用,操作类型为逻辑与。实际上,在这种情况下,只有在所有组件都可用的情况下,才应遵循该规则。

用解析器检查文本是否与正确的表达式对应,首先调用“expression”引用数组中的检查,然后调用对象“value”和“operation”(后者将递归地引用“plus”和“minus”),最后再次调用“value”。在任何阶段,如果检查下降到终端级别,则将标记的值与文本中的当前字符进行比较,如果匹配,则光标移动到下一个标记;如果不匹配,则应搜索替代标记。例如,在这种情况下,如果检查操作“plus”失败,将继续检查“star”。如果所有的选择都用尽了,并且找不到匹配项,则意味着违反了语法规则。

运算符“|”和“+”并不是类中要重载的所有运算符,我们将在实现部分提供它们的完整描述。

声明类终端及其派生对象(包含对其他越来越小的对象的引用)构成了预定义语法的抽象语法树(AST)。它是抽象的,因为它与输入文本中的特定标记没有关联,也就是说,理论上,语法描述了一组无限的有效字符串——在我们的例子中是MQL代码。

因此,我们通常考虑项目的主要类。为了看到整幅场景,让我们以类的 UML 图表方式总结它们。


MQL解析类的UML图

MQL解析类的UML图

有些类,例如 TreeNode, 还没有考虑在内。分析程序将在分析输入文本时使用其对象来保存找到的所有匹配的“terminal=token”。因此,我们将在输出端获得所谓的具体语法树(concrete syntax tree,CST),其中所有令牌在某种程度上都包含在语法的终端和非终端中。

原则上,创建树是可选的,因为它对于真正的源代码来说可能太大了。我们将提供回调接口-Callback,而不是以树的形式获取解析输出。在创建了实现这个接口的对象之后,我们将把它传递给解析器,并能够接收有关所生成的每个“production”的通知,即每个有效的语法规则。因此,我们将能够“随时随地”分析语法和语义,而无需等待完整的树。

具有“Hidden”前缀的非终端类将被用于自动隐式创建语法对象的中间组,我们将在下一节中详细介绍这些中间组。


实现

读取文件

Source

Source 类首先是包含要处理的文本的字符串的存储。基本上,它看起来如下:

#define SOURCE_LENGTH 100000

class Source
{
  private:
    string source;

  public:
    Source(const uint length = SOURCE_LENGTH)
    {
      StringInit(source, length);
    }

    Source *operator+=(const string &x)
    {
      source += x;
      return &this;
    }

    Source *operator+=(const ushort x)
    {
      source += ShortToString(x);
      return &this;
    }
    
    ushort operator[](uint i) const
    {
      return source[i];
    }
    
    string get(uint start = 0, uint length = -1) const
    {
      return StringSubstr(source, start, length);
    }
    
    uint length() const
    {
      return StringLen(source);
    }
};

该类具有文本的变量“source”,并为使用字符串的最频繁操作重写了运算符。到目前为止,让我们把这个类的第二个角色留在幕后,即维护文件列表,从中组装存储字符串。对于输入文本有这样的“包装”,我们可以尝试从一个文件中填充它。FileReader 类就是用于这项任务的。


FileReader

在开始编程之前,应该定义打开和读取文件的方法。因为我们正在处理文本,所以选择文件_TXT模式是合乎逻辑的。这将使我们摆脱对换行符的手动控制,除此之外,在不同的编辑器中可以对换行符进行不同的编码(通常,它是一对符号,CR-LF;但是,在公开可用的 MQL 源代码中,您可以看到替代项,如CR-only或LF-only)。应注意,在文本模式下,文件是逐字符串读取的。

另一个需要考虑的问题是支持不同编码的文本。因为我们要读取几个不同的文件,其中一部分可能被保存为单字节字符串(ANSI),另一部分可能被保存为更宽的双字节字符串(UNICODE),所以最好让系统在移动中选择正确的模式,即从一个文件到另一个文件。此外,文件还可以以UTF-8编码保存。

如果为函数 FileOpen 设置了以下输入,那么MQL能够以正确的编码自动读取各种文本文件:

FileOpen(filename, FILE_READ | FILE_TXT | FILE_ANSI, 0, CP_UTF8);

然后,我们将使用这个组合,在默认情况下添加标志 FILE_SHARE_READ | FILE_SHARE_WRITE.。

在类FileReader中,我们将提供用于存储文件名(“filename”)、打开文件描述符(“handle”)和当前文本行(“line”)的成员。

class FileReader
{
  protected:
    const string filename;
    int handle;
    string line;

此外,我们将跟踪当前的行号和光标在行(column)中的位置。

    int linenumber;
    int cursor;

我们将在 Source 对象的实例中保存读取行。

    Source *text;

我们将把用于接收数据的文件名、标志和就绪的源对象传递给构造函数。

  public:
    FileReader(const string _filename, Source *container = NULL, const int flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint codepage = CP_UTF8): filename(_filename)
    {
      handle = FileOpen(filename, flags, 0, codepage);
      if(handle == INVALID_HANDLE)
      {
        Print("FileOpen failed ", _filename, " ", GetLastError());
      }
      line = NULL;
      cursor = 0;
      linenumber = 0;
      text = container;
    }

    string pathname() const
    {
      return filename;
    }

让我们检查文件是否已成功打开,并查看有关关闭析构函数中的描述符的信息。

    bool isOK()
    {
      return (handle > 0);
    }
    
    ~FileReader()
    {
      FileClose(handle);
    }

通过getChar方法可以确保从文件中按字符读取数据。

    ushort getChar(const bool autonextline = true)
    {
      if(cursor >= StringLen(line))
      {
        if(autonextline)
        {
          if(!scanLine()) return 0;
          cursor = 0;
        }
        else
        {
          return 0;
        }
      }
      return StringGetCharacter(line, cursor++);
    }

当包含文本“line”的字符串为空或从末尾读取时,此方法尝试使用方法scanLine读取下一个字符串。如果“line”字符串包含一些未处理的字符,getChar只返回光标下的字符,然后将光标移动到下一个位置。

scanLine 方法的定义很明显:

    bool scanLine()
    {
      if(!FileIsEnding(handle))
      {
        line = FileReadString(handle);
        linenumber++;
        cursor = 0;
        if(text != NULL)
        {
          text += line;
          text += '\n';
        }
        return true;
      }
      
      return false;
    }

注意,由于文件是以文本模式打开的,所以我们不会得到返回的换行符;但是,我们需要它们计算行数,并作为某些语言结构的最终符号,例如单行注释。所以我们要添加符号'\n'。

除了从文件中读取数据之外,类FileReader还必须能够将光标下的输入数据与词汇单位进行比较。为此,让我们添加以下方法。

    bool probe(const string lexeme) const
    {
      return StringFind(line, lexeme, cursor) == cursor;
    }

    bool match(const string lexeme) const
    {
      ushort c = StringGetCharacter(line, cursor + StringLen(lexeme));
      return probe(lexeme) && (c == ' ' || c == '\t' || c == 0);
    }
    
    bool consume(const string lexeme)
    {
      if(match(lexeme))
      {
        advance(StringLen(lexeme));
        return true;
      }
      return false;
    }

    void advance(const int next)
    {
      cursor += next;
      if(cursor > StringLen(line))
      {
        error(StringFormat("line is out of bounds [%d+%d]", cursor, next));
      }
    }

方法“probe”将文本与传递的词法单位进行比较。方法“match”的作用几乎相同,但它还检查词汇单位是否作为一个单词被提到,也就是说,它后面必须跟一个分隔符,如空格、制表或行尾。方法'consume'“gobbles”传递的词汇单位/单词,即,它验证输入文本是否与预定义文本匹配,如果成功,则将光标移动到词汇单位的末尾。如果失败,则不会移动光标,而方法返回“false”。方法“advance”只需将光标向前移动预先定义的字符数。

后,让我们考虑一个返回文件结束符号的小方法。

    bool isEOF()
    {
      return FileIsEnding(handle) && cursor >= StringLen(line);
    }

还有其他帮助方法可以读取此类中的字段;您可以在附加的源代码中找到它们。

必须在某处创建类FileReader的对象,让我们将其委托给类FileReaderController。

FileReaderController

在类FileReaderController中,必须维护包含文件(“includes”)的堆栈、已包含文件的映射(“files”)、指向正在处理的当前文件(“current”)的指针以及输入文本(“source”)。

class FileReaderController
{
  protected:
    Stack<FileReader *> includes;
    Map<string, FileReader *> files;
    FileReader *current;
    const int flags;
    const uint codepage;
    
    ushort lastChar;
    Source *source;

源代码中出现的列表、堆栈、数组(如basearray)和映射(“map”)包含在支持的头文件中,这些头文件在这里不作描述,因为我在以前的文章中已经使用过它们。当然,完整的档案也附在这里。

控制器在其构造函数中创建空的“source”对象:

  public:
    FileReaderController(const int _flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint _codepage = CP_UTF8, const uint _length = SOURCE_LENGTH): flags(_flags), codepage(_codepage)
    {
      current = NULL;
      lastChar = 0;
      source = new Source(_length);
    }

source”以及FileReader的从属对象从析构函数中的映射中释放:

#define CLEAR(P) if(CheckPointer(P) == POINTER_DYNAMIC) delete P;

    ~FileReaderController()
    {
      for(int i = 0; i < files.getSize(); i++)
      {
        CLEAR(files[i]);
      }
      delete source;
    }

为了将一个或另一个文件包括到处理中,包括扩展名为mq5的第一个项目文件,让我们提供方法“include”。

    bool include(const string _filename)
    {
      Print((current != NULL ? "Including " : "Processing "), _filename);
      
      if(files.containsKey(_filename)) return true;
      
      if(current != NULL)
      {
        includes.push(current);
      }
      
      current = new FileReader(_filename, source, flags, codepage);
      source.mark(source.length(), current.pathname());
      
      files.put(_filename, current);
      
      return current.isOK();
    }

它检查“files”图是否已处理预定义文件,如果文件可用,则立即返回“true”。否则,过程将继续。如果这是第一个文件,我们将创建对象文件阅读器,使其成为当前文件,并保存在文件图中。如果这不是第一个文件,即此时正在处理其他文件,那么我们应该将其保存在堆栈“includes”中。一旦包含的文件被完全处理,我们将返回到处理当前文件,从包含其他文件的点开始。

此方法中的一行“include”尚未编译:

      source.mark(source.length(), current.pathname());

类“source”尚未包含方法“mark”。从上下文中应该可以清楚地看到,此时我们从一个文件切换到另一个文件,因此,我们应该在某个地方标记源文件及其在组合文本中的移动。这就是“mark”方法的作用。在任何时候,输入文本的当前长度都是将添加新文件数据的点。让我们回到类 Source 并添加文件图:

class Source
{
  private:
    Map<uint,string> files;

  public:
    void mark(const uint offset, const string file)
    {
      files.put(offset, file);
    }

从类 FileReaderController 中的文件中读取字符的主要任务由方法getChar执行,该方法将部分工作委托给当前的 FileReader 对象。

    ushort getChar(const bool autonextline = true)
    {
      if(current == NULL) return 0;
      
      if(!current.isEOF())
      {
        lastChar = current.getChar(autonextline);
        return lastChar;
      }
      else
      {
        while(includes.size() > 0)
        {
          current = includes.pop();
          source.mark(source.length(), current.pathname());
          if(!current.isEOF())
          {
            lastChar = current.getChar();
            return lastChar;
          }
        }
      }
      return 0;
    }

如果有一个当前文件,直到最后才被读取,我们将调用它的方法getChar并返回获得的字符。如果当前文件一直读到最后,那么我们将检查是否有在堆栈“includes”中包含任何其他文件的指令。如果当前文件一直读到最后,那么我们将检查堆栈“includes”中是否有包含任何其他文件的指令。如果有文件,我们将提取上面的文件,使其成为“current”,并继续从中读取字符。此外,我们应该记住在对象“source”中注意到数据源已切换到旧文件。

类FileReaderController还可以返回完成读取的符号。

    bool isAtEnd()
    {
      return current == NULL || (current.isEOF() && includes.size() == 0);
    }

另外,让我们提供两种方法来获取当前文件和文本。

    const Source *text() const
    {
      return source;
    }
    
    FileReader *reader()
    {
      return current;
    }

现在一切就绪,可以对文件进行预处理了。


预处理器

预处理器将控制类 FileReaderController(controller)的唯一实例,并决定是否需要加载头文件(标志“loadcincludes”):

class Preprocessor
{
  protected:
    FileReaderController *controller;
    const string includes;
    bool loadIncludes;

问题是我们可能希望处理一些没有依赖关系的文件——例如,为了调试或减少工作时间。我们将在字符串变量“includes”中保存头文件的默认文件夹。

构造函数从用户接收所有这些值和初始文件的名称(及其路径),创建一个控制器,并为文件调用方法“include”。

  public:
    Preprocessor(const string _filename, const string _includes, const bool _loadIncludes = false, const int _flags = FILE_READ | FILE_TXT | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE, const uint _codepage = CP_UTF8, const uint _length = SOURCE_LENGTH): includes(_includes)
    {
      controller = new FileReaderController(_flags, _codepage, _length);
      controller.include(_filename);
      loadIncludes = _loadIncludes;
    }

现在,让我们编写由客户机直接调用的方法“run”,以开始处理一个或多个文件。

    bool run()
    {
      while(!controller.isAtEnd())
      {
        if(!scanLexeme()) return false;
      }
      return true;
    }

我们将读取词汇单元,直到控制器碰到数据的末尾。

这里是 'scanLexeme' 方法:

    bool scanLexeme()
    {
      ushort c = controller.getChar();
      
      switch(c)
      {
        case '#':
          if(controller.reader().consume("include"))
          {
            if(!include())
            {
              controller.reader().error("bad include");
              return false;
            }
          }
          break;
          ...
      }
      return true; // symbol consumed
    }

如果程序看到字符“#”,那么它将尝试“吸收”下一个单词“include”。如果不存在,则跳过单个字符“”(getChar将光标进一步移动一个位置)。如果找到单词“include”,则必须处理该指令,该指令由方法“include”完成。

    bool include()
    {
      ushort c = skipWhitespace();
      
      if(c == '"' || c == '<')
      {
        ushort q = c;
        if(q == '<') q = '>';
        
        int start = controller.reader().column();
        
        do
        {
          c = controller.getChar();
        }
        while(c != q && c != 0);
        
        if(c == q)
        {
          if(loadIncludes)
          {
            Print(controller.reader().source());

            int stop = controller.reader().column();
  
            string name = StringSubstr(controller.reader().source(), start, stop - start - 1);
            string path = "";
  
            if(q == '"')
            {
              path = controller.reader().pathname();
              StringReplace(path, "\\", "/");
              string parts[];
              int n = StringSplit(path, '/', parts);
              if(n > 0)
              {
                ArrayResize(parts, n - 1);
              }
              else
              {
                Print("路径为空: ", path);
                return false;
              }
              
              int upfolder = 0;
              while(StringFind(name, "../") == 0)
              {
                name = StringSubstr(name, 3);
                upfolder++;
              }
              
              if(upfolder > 0 && upfolder < ArraySize(parts))
              {
                ArrayResize(parts, ArraySize(parts) - upfolder);
              }
              
              path = StringImplodeExt(parts, CharToString('/')) + "/";
            }
            else // '<' '>'
            {
              path = includes; // 文件夹;
            }
            
            return controller.include(path + name);
          }
          else
          {
            return true;
          }
        }
        else
        {
          Print("include 不完整");
        }
      }
      return false;
    }

此方法使用“skipWhitespace”(此处未介绍)跳过单词“include”后的所有空格,找到左引号或字符“<”,然后通过右引号或右引号“>”扫描文本,最后提取包含头文件路径和名称的字符串。然后处理这些选项以从同一文件夹或头文件的标准文件夹加载文件。这样就形成了一个新的加载路径和一个新的加载名称,然后分配控制器来处理该文件。

在处理指令 #include 的同时,我们必须跳过注释块和字符串,以便不将它们解释为指令,如果是词法单位 ‘#include’要在其中。因此,让我们将相关选项添加到方法“scanLexem”中的运算符“switch”中。

        case '/':
          if(controller.reader().probe("*"))
          {
            controller.reader().advance(1);
            if(!blockcomment())
            {
              controller.reader().error("bad block comment");
              return false;
            }
          }
          else
          if(controller.reader().probe("/"))
          {
            controller.reader().advance(1);
            linecomment();
          }
          break;
        case '"':
          if(!literal())
          {
            controller.reader().error("unterminated string");
            return false;
          }
          break;

例如,这是跳过注释块的方式:

    bool blockcomment()
    {
      ushort c = 0, c_;
      
      do
      {
        c_ = c;
        c = controller.getChar();
        if(c == '/' && c_ == '*') return true;
      }
      while(!controller.reader().isEOF());
      
      return false;
    }

其他助手方法也同样实现。

因此,理论上,有了类“Preprocessor”和其他类,我们就可以从工作文件中加载文本,类似于这样。

#property script_show_inputs

input string SourceFile = "filename.txt";
input string IncludesFolder = "";
input bool LoadIncludes = false;

void OnStart()
{
  Preprocessor loader(SourceFile, IncludesFolder, LoadIncludes);
  
  if(!loader.run())
  {
    Print("Loader failed");
    return;
  }

  // 从一个或多个文件组装时输出整个数据
  int handle = FileOpen("dump.txt", FILE_WRITE | FILE_TXT | FILE_ANSI, 0, CP_UTF8);
  FileWriteString(handle, loader.text().get());
  FileClose(handle);
}

为什么“理论上”?问题是,MetaTrader只允许使用“sandbox”文件,即目录 MQL5/Files。但是,我们的目标是处理文件夹 MQL5/Include、MQL5/Scripts、MQL5/Experts 和 MQL5/Indicators 中包含的源代码。

为了避免这种限制,让我们使用Windows的功能将符号链接分配给文件系统对象。在我们的例子中,所谓的“连接”最适合转发对本地计算机上文件夹的访问。它们是使用以下命令创建的:

mklink /J new_name existing_target

参数new_name是新虚拟“文件夹”的名称,它将指向实际文件夹 existing_target。

要创建到包含源代码的指定文件夹的连接,让我们打开文件夹 MQL5/Files,在其中创建子文件夹源,然后转到该子文件夹。然后我们将复制随附的makelink.bat文件。此命令脚本实际上包含一个字符串:

mklink /J %1 "..\..\%1\"

它接受一个输入%1—来自MQL5内的应用程序文件夹的名称,例如“Include”。对路径“...\..\”表示命令文件在上面的MQL5/Files/Sources文件夹中,然后目标文件夹(existing_target)将形成为 MQL5/%1。例如,如果在文件夹 Source 中,我们执行命令

makelink Include

然后在文件夹源中,将出现一个虚拟文件夹Include,进入其中,我们将进入MQL5/Include。类似地,我们可以为文件夹 Expert、Script 等创建“双胞胎”。在下面的图片中,将显示资源管理器,其中打开的 MQL5/Include/Expert文件夹的标准头文件位于MQL5/Files/Sources文件夹中。

用于MQL5源代码文件夹的Windows符号链接

用于MQL5源代码文件夹的Windows符号链接

如果需要,我们可以删除符号链接作为普通文件(当然,我们应该首先确保删除的是左下角有一个小箭头的文件夹,而不是原始文件夹)。

我们可以直接在MQL5的根工作文件夹上创建一个连接,但我更喜欢并建议偶尔打开访问:所有MQL程序都可以使用链接读取源代码,包括登录、密码和绝密交易系统,前提是它们存储在那里。
创建链接后,上述脚本的参数“IncludesFolder”将真正起作用:Sources/Include 指向实际文件夹 MQL5/Include处。在参数 'SourceFile' 中, 我们可以通过脚本的源代码来举例说明分析,例如 Sources/Scripts/test.mq5。

令牌化

必须在MQL中区分的令牌类型组合在同名头文件(附加)的枚举“tokentype”中。我们不会在这篇文章中阐述这一点。我们只需注意,其中有单个字符标记,例如各种括号和大括号(“(”,“[”,或“”)、相等符号“=”,加“+”,或减“-”,以及两个字符标记,例如“=”,'!=,等等。此外,单独的标记将是数字、字符串、日期(即受支持类型的常量)、在MQL中保留的所有单词,如运算符、类型、this、修饰符(如“input”、“const”等)以及标识符(其他单词)。此外,还有标记EOF来表示输入数据的结束。


令牌

在查看文本时,扫描器将通过一个特殊的算法(如下所述)识别每个后续令牌的类型,并创建一个 Token 类对象。这是一个非常简单的类。

class Token
{
  private:
    TokenType type;
    int line;
    int offset;
    int length;

  public:
    Token(const TokenType _type, const int _line, const int _offset, const int _length = 0)
    {
      type = _type;
      line = _line;
      offset = _offset;
      length = _length;
    }
    
    TokenType getType() const
    {
      return type;
    }
    
    int getLine() const
    {
      return line;
    }
    ...

    string content(const Source *source) const
    {
      return source.get(offset, length);
    }
};

对象存储令牌的类型、文本中的移位和长度。如果我们需要一个标记的字符串值,我们将一个指向字符串“source”的指针传递给方法“content”,然后从中切掉相关的片段。

现在是时候去看扫描器了,它也被称为“令牌器”。


扫描器 (令牌器)

在 Scanner 类中,我们将使用 MQL 关键字描述静态数组:

class Scanner
{
  private:
    static string reserved[];

然后通过包含文本文件在源代码中初始化它:

static string Scanner::reserved[] =
{
#include "reserved.txt"
};

让我们在这个数组中添加字符串表示形式和每个标记类型之间的静态对应关系图。

    static Map<string, TokenType> keywords;

让我们在构造器中填写映射(见下文)。

在扫描器中,我们还需要一个指向输入、生成的令牌列表和多个计数器的指针。

    const Source *source; // wrapped string
    List<Token *> *tokens;
    int start;
    int current;
    int line;

变量“start”始终指向要处理的下一个标记的开头。变量“current”是在文本中移动的光标。当检查当前字符是否与令牌对应时,它将始终从“start”向前“run”,一旦找到匹配项,则从“start”到“current”的子字符串将属于新令牌。变量'line'是总文本中当前行的编号。

Scanner 类的构造函数:

  public:
    Scanner(const Source *_source): line(0), current(0)
    {
      tokens = new List<Token *>();
      if(keywords.getSize() == 0)
      {
        for(int i = 0; i < ArraySize(reserved); i++)
        {
          keywords.put(reserved[i], TokenType(BREAK + i));
        }
      }
      source = _source;
    }

这里 BREAK 是按字母顺序排列的第一个保留字的标记类型标识符。文件reserved.txt中的字符串和枚举TokenType中的标识符的顺序必须匹配。例如,枚举中的元素 BREAK 显然与 'break' 对应。

方法“scanTokes”在类中占据中心位置。

    List<Token *> *scanTokens()
    {
      while(!isAtEnd())
      {
        // 我们正处在下一个词的开头
        start = current;
        scanToken();
      }
  
      start = current;
      addToken(EOF);
      return tokens;
    }

在其循环中生成了越来越多的新令牌。方法 ‘isAtEnd’ 和 ‘addToken’ 比较简单:

    bool isAtEnd() const
    {
      return (uint)current >= source.length();
    }

    void addToken(TokenType type)
    {
      tokens.add(new Token(type, line, start, current - start));
    }

所有的困难工作都是通过“scanToken”方法完成的,但是,在演示之前,我们应该先了解一些简单的助手方法——它们与我们在“预处理器”类中已经看到的方法类似,这就是为什么它们的目的似乎不需要任何解释的原因。

    bool match(ushort expected)
    {
      if(isAtEnd()) return false;
      if(source[current] != expected) return false;
  
      current++;
      return true;
    }
    
    ushort previous() const
    {
      if(current > 0) return source[current - 1];
      return 0;
    }
    
    ushort peek() const
    {
      if(isAtEnd()) return '\0';
      return source[current];
    }
    
    ushort peekNext() const
    {
      if((uint)(current + 1) >= source.length()) return '\0';
      return source[current + 1];
    }

    ushort advance()
    {
      current++;
      return source[current - 1];
    }

现在,回到 'scanToken' 方法。

    void scanToken()
    {
      ushort c = advance();
      switch(c)
      {
        case '(': addToken(LEFT_PAREN); break;
        case ')': addToken(RIGHT_PAREN); break;
        ...

它读取下一个字符,并根据其代码创建一个令牌。我们不会在这里提供所有的一个字符标记,因为它们的创建方式类似。

如果一个令牌建议为两个字符的令牌,则处理过程将复杂化:

        case '-': addToken(match('-') ? DEC : (match('=') ? MINUS_EQUAL : MINUS)); break;
        case '+': addToken(match('+') ? INC : (match('=') ? PLUS_EQUAL : PLUS)); break;
        ...

为词汇 '--', '-=', '++', and '+=' 构建令牌的过程显示如下,

当前版本的扫描器会跳过注释:

        case '/':
          if(match('/'))
          {
            // 注释一直到行尾
            while(peek() != '\n' && !isAtEnd()) advance();
          }

如果愿意,您可以将它们保存在特殊的令牌中。

块结构(如字符串、文本和预处理器指令)是在分配的助手方法中处理的,我们不会详细考虑它们:

        case '"': _string(); break;
        case '\'': literal(); break;
        case '#': preprocessor(); break;

这是一个如何扫描字符串的示例:

    void _string()
    {
      while(!(peek() == '"' && previous() != '\\') && !isAtEnd())
      {
        if(peek() == '\n')
        {
          line++;
        }
        advance();
      }
  
      if(isAtEnd())
      {
        error("Unterminated string");
        return;
      }
  
      advance(); // The closing "
  
      addToken(CONST_STRING);
    }

如果没有触发任何令牌类型,则执行默认测试,检查数字、标识符和关键字。

        default:
        
          if(isDigit(c))
          {
            number();
          }
          else if(isAlpha(c))
          {
            identifier();
          }
          else
          {
            error("Unexpected character `" + ShortToString(c) + "` 0x" + StringFormat("%X", c) + " @ " + (string)current + ":" + source.get(MathMax(current - 10, 0), 20));
          }
          break;

isDigit 和 isAlpha 的实现很明显,这里,只显示 'identifier' 方法。

    void identifier()
    {
      while(isAlphaNumeric(peek())) advance();

      // 查看标识符是否为保留字
      string text = source.get(start, current - start);
  
      TokenType type = keywords.get(text);
      if(type == null) type = IDENTIFIER;
      
      addToken(type);
    }

所有方法的完整实现都可以在所附的源代码中找到。当然,为了不重新发明轮子,我从Crafting Interpreters一书中提取了一部分代码,并做了一些更正。

基本上,这就是整个扫描器。如果没有错误,方法“scanTokes”将向用户返回一个令牌列表,该列表可以传递给解析器。但是,解析令牌列表时,解析器必须具有要引用的语法。因此,在继续分析之前,我们必须考虑语法描述。我们从类终端及其导数的对象中形成它。

语法描述

让我们首先设想一下,我们必须描述的不是MQL语法,而是用于计算算术表达式(即计算器)的某种简单语言的语法。这是允许的计算公式:

(10 + 1) * 2

让我们只允许整数和操作“+”、“-”、“*”和“/”,而不进行优先级排序:我们将使用“(”和“)”作为优先级。

语法的入口点必须是描述整个表达式的非终结点。假设这就足够写:

NonTerminal expression;

表达式由操作数(即整数值)和运算符符号组成。以上都是终端,也就是说,它们可以基于扫描器支持的令牌创建。

假设我们将其描述如下:

Terminal plus(PLUS), star(STAR), minus(MINUS), slash(SLASH);
Terminal value(CONST_INTEGER);

如我们所见,终端的构造函数必须允许将令牌类型作为参数传递。

最简单的表达式就是一个数字,可以合理地表示如下:

expression = value;

这将重新启动分配运算符。在它中,我们必须将指向对象“value”(让我们将其命名为“equivalence”中的“eq”)的链接保存在“expression”中的一个变量中。一旦分配给解析器检查“表达式”是否与输入的字符串匹配,它就会将检查委托给非终端。后者将“看到“与'value'的链接,并请求解析器检查‘value’,并且检查会最终达到匹配令牌所在的终端,即在终端和在输入流中的令牌。

但是,表达式可能还有一个操作和第二个操作数;因此,有必要扩展规则“expression”。为此,我们将对新的非终端进行初步描述:

NonTerminal operation;
operation = (plus | star | minus | slash) + value;

在这里,许多有趣的事情发生在幕后。类中必须重载运算符“|”,以确保按或对元素进行逻辑分组。但是,对终端(即简单字符)调用运算符,而我们需要一组元素。因此,组的第一个元素(在本例中,对于该元素,执行环境将调用运算符(“plus”))必须检查它是否是组的成员,如果仍然没有组,则动态地将其创建为hiddennominalor类的对象。然后,重载运算符的实现必须将“this”和相邻的右终端“star”(作为参数传递给运算符函数)添加到新创建的组中。该运算符返回指向该组的链接,以便后续(链接的)运算符“|”现在被调用为 HiddenNonTerminalOR。

为了维护包含组成员的数组,我们当然会在类中提供数组“next”。它的名称意味着语法元素的下一个详细级别。对于我们添加到这个子节点数组中的每个元素,我们应该为父节点设置一个反向链接,我们将把它命名为“parent”。非零“parent”的可用性正好意味着组中的成员身份。由于在括号内执行代码,我们将获得一个包含所有4个操作符号的数组的HiddenNoTerminalOR。

然后,重载的运算符“+”开始起作用。它必须与运算符“”类似,也就是说,要创建一个隐式元素组;但这次是HiddenNonTerminalAND类的;并且必须通过逻辑规则和解析阶段对它们进行检查。

请注意,我们有一个终端和非终端的依赖层次结构-在这种情况下,对象HiddenNonTerminalAND将包含两个子元素:新创建的组HiddenNonTerminalOR和“value”。HiddenNonTerminalAND 反过来依赖于非终端“operation”。

操作“|"和“+”的优先级是,在没有括号的情况下,先处理,然后处理。这就是为什么我们必须把“operation”中所有字符的版本放在括号中的原因。

通过对非终端“operation”的描述,我们可以纠正表达式的语法:

expression = value + operation;

据称,它们描述了表示为a@b的表达式,其中a和b是整数,而@是操作但这里有一个症结,

我们已经有两个涉及对象“value”的规则。这意味着到第一个规则中父集的链接将在第二个规则中重新写入。为了不发生这种情况,不必在规则中插入对象,而是插入它们的副本。

为此,我们将为两个运算符提供重载:“~”和“^”。第一个是一元数,放在操作数之前。在接收到相关运算符函数调用的对象中,我们将动态创建一个代理对象并将其返回到调用代码。第二个运算符是二进制的,与对象一起,我们将把语法源代码中的当前字符串编号传递给它,即由MQL编译器预定义的常量 __LINE__。因此,我们将能够通过创建对象实例的行数来区分隐式定义的对象实例。这将有助于调试复杂语法。把它另一种方式,操作符‘~’、‘^’为执行相同的工作,但一个是在第一个模式的第二个版本,而在调试模式。

代理实例表示HiddenNonTerminal类的对象,其中上述变量“eq”引用原始对象。

因此,我们将重新编写表达式语法,考虑创建代理对象。

operation = (plus | star | minus | slash) + ~value;
expression = ~value + operation;

因为“operation”只使用一次,所以我们不需要为它制作副本。展开表达式时,每个逻辑引用将递归增加一个。但是,为了避免大型语法中的错误,我们建议在任何地方引用。如果一个非终端现在只使用了一次,它可以在另一部分的语法上继续使用。我们将为源代码提供检查父节点覆盖的功能,以显示错误消息。

现在,我们的语法可以处理“10+1”。但它已经失去了读取单独数字的能力。实际上,非终端“operation”必须是可选的。为此,让我们实现一个重载的运算符“*”。如果一个语法元素被乘以0,那么我们可以在执行检查时省略它,因为它的缺失不会导致错误。

expression = ~value + operation*0;

重载乘法运算符将允许我们实现另一件重要的事情-尽可能多地重复元素。在这种情况下,我们将元素乘以1。在终端类中,该属性(即多重性或可选性)存储在变量“mult”中。一个元素都是可选的并且可以重复多次的情况很容易通过两个链接实现:第一个元素必须是可选的(可选的*0),另一个元素是多个(可选的=element*1)。

计算器目前的语法还有一个弱点,它不适用于具有多个操作的长表达式,例如1+2+3+4+5。要纠正这个问题,我们应该改变非终端的“operation”。

operation = (plus | star | minus | slash) + ~expression;

我们将把“value”替换为“expression”本身,允许对越来越多的表达式的新结尾进行循环分析。

画龙点睛是支持用括号括起来的表达。不难猜测它们与单位值(“value”)具有相同的作用。因此,让我们从两个选项中将其重新定义为可选选项:整数和括号中的子表达式。整个语法将显示如下:

NonTerminal expression;
NonTerminal value;
NonTerminal operation;

Terminal number(CONST_INTEGER);
Terminal left(LEFT_PAREN);
Terminal right(RIGHT_PAREN);
Terminal plus(PLUS), star(STAR), minus(MINUS), slash(SLASH);

value = number | left + expression^__LINE__ + right;
operation = (plus | star | minus | slash) + expression^__LINE__;
expression = value + operation*0;

让我们仔细看看上面的类是如何在内部排列的。


Terminal

在类 Terminal 中,让我们描述令牌类型(“me”)、多重性属性(“mult”)、可选名称(“name”,用于标识日志中的非终端)、到生产的链接(“eq”)、到父元素(“parent”)和子元素(数组“next”)的字段。

class Terminal
{
  protected:
    TokenType me;
    int mult;
    string name;
    Terminal *eq;
    BaseArray<Terminal *> next;
    Terminal *parent;

这些字段在构造函数和setter方法中填写,并使用getter方法读取,我们在这里不讨论这些方法是为了简洁起见。

我们将按照以下原则重载操作符:

    virtual Terminal *operator|(Terminal &t)
    {
      Terminal *p = &t;
      if(dynamic_cast<HiddenNonTerminalOR *>(p.parent) != NULL)
      {
        p = p.parent;
      }

      if(dynamic_cast<HiddenNonTerminalOR *>(parent) != NULL)
      {
        parent.next << p;
        p.setParent(parent);
      }
      else
      {
        if(parent != NULL)
        {
          Print("Bad OR parent: ", parent.toString(), " in ", toString());

          ... error
        }
        else
        {
          parent = new HiddenNonTerminalOR("hiddenOR");

          p.setParent(parent);
          parent.next << &this;
          parent.next << p;
        }
      }
      return parent;
    }

这里显示依据OR分组,一切都与 AND 类似。

设置多重性的特征在运算符“*”中:

    virtual Terminal *operator*(const int times)
    {
      mult = times;
      return &this;
    }

在析构函数中,我们将注意正确删除创建的实例。

    ~Terminal()
    {
      Terminal *p = dynamic_cast<HiddenNonTerminal *>(parent);
      while(CheckPointer(p) != POINTER_INVALID)
      {
        Terminal *d = p;
        if(CheckPointer(p.parent) == POINTER_DYNAMIC)
        {
          p = dynamic_cast<HiddenNonTerminal *>(p.parent);
        }
        else
        {
          p = NULL;
        }
        CLEAR(d);
      }
    }

最后,给出了类 Terminal 的主要方法,负责解析。

    virtual bool parse(Parser *parser)
    {
      Token *token = parser.getToken();

      bool eqResult = true;

这里,我们已经收到了对解析器的引用,并从中读取了当前令牌(解析器类将在下面讨论)。

      if(token.getType() == EOF && mult == 0) return true;

如果令牌EOF和当前元素是可选的,它意味着一个正确的结局或文本已被发现。

然后,如果我们在副本中,我们将检查是否存在从重载运算符“=”到元素原始实例的引用。如果有引用,我们将把它发送到解析器的方法“match”中进行检查。

      if(eq != NULL) // redirect
      {
        eqResult = parser.match(eq);
        
        bool lastResult = eqResult;
        
        // 如果需要多个令牌,并且在成功使用下一个令牌时
        while(eqResult && eq.mult == 1 && parser.getToken() != token && parser.getToken().getType() != EOF)
        {
          token = parser.getToken();
          eqResult = parser.match(eq);
        }
        
        eqResult = lastResult || (mult == 0);
        
        return eqResult; // redirect was fulfilled
      }

此外,这里处理的情况是一个元素可能重复(“mult”=1):在方法“match”返回成功时,一次又一次地调用解析器。

成功标记-“true”或“false”-在此分支和其他情况下(例如对于终端)从方法“parse”返回:

      if(token.getType() == me) // 令牌匹配
      {
        parser.advance(parent);
        return true;
      }

对于终端,我们只需将其标记“me”与输入中的当前标记进行比较,如果存在匹配,则使用方法“advance”分配解析器将光标移动到下一个输入标记。在相同的方法中,解析器通知客户机程序结果是在非终端“parent”中生成的。

对于一组元素来说,一切都有点复杂。让我们考虑逻辑与;或的版本相似。使用虚拟方法hasAnd(在类终端中,它返回“false”,而在子代中被重写),我们会发现是否已填充具有子元素的数组以供和检查。

      else
      if(hasAnd()) // check AND-ed conditions
      {
        parser.pushState();
        for(int i = 0; i < getNext().size(); i++)
        {
          if(!parser.match(getNext()[i]))
          {
            if(mult == 0)
            {
              parser.popState();
              return true;
            }
            else
            {
              parser.popState();
              return false;
            }
          }
        }

        parser.commitState(parent);
        return true;
      }

由于如果所有组件都与语法匹配,那么这个非终端将被认为是正确的,因此我们将在循环中为所有组件调用解析器的“match”方法。如果至少出现一个负结果,整个检查将失败。但是,有一个例外:如果非终端是可选的,则语法规则仍将遵循,即使从方法“match”返回“false”。

注意,我们在解析器中保存循环之前的当前状态(pushState),在早期退出时恢复它(popState),如果检查完全成功完成,则确认新状态(commitState)。这对于延迟新“production”上客户端代码的通知是必要的,直到整个语法规则完全工作为止。“state”一词仅指输入令牌流中的当前光标位置。

如果token和子元素组都没有在方法“parse”内触发,则只剩下检查当前对象的可选性:

      else
      if(mult == 0) // last chance
      {
        // parser.advance();-不要使用令牌并继续下一个结果
        return true;
      }

否则,我们将“陷入”表示错误的方法端,即文本与语法不对应。

      if(dynamic_cast<HiddenNonTerminal *>(&this) == NULL)
      {
        parser.trackError(&this);
      }
      
      return false;
    }

现在让我们描述从类 Terminal 派生的类。



Non-terminals, hidden 和 explicit

HiddenNonTerminal 类的主要任务是创建对象的动态实例并收集垃圾。

class HiddenNonTerminal: public Terminal
{
  private:
    static List<Terminal *> dynamic; // 垃圾收集器

  public:
    HiddenNonTerminal(const string id = NULL): Terminal(id)
    {
    }

    HiddenNonTerminal(HiddenNonTerminal &ref)
    {
      eq = &ref;
    }

    virtual HiddenNonTerminal *operator~()
    {
      HiddenNonTerminal *p = new HiddenNonTerminal(this);
      dynamic.add(p);
      return p;
    }
    ...
};

类 HiddenNonTerminalOR确保重载运算符“|”(比类终端中的更简单,因为HiddenNonTerminalOR本身是一个“容器”,即一组从属语法元素的所有者)。

class HiddenNonTerminalOR: public HiddenNonTerminal
{
  public:
    virtual Terminal *operator|(Terminal &t) override
    {
      Terminal *p = &t;
      next << p;
      p.setParent(&this);
      return &this;
    }
    ...
};

类 HiddenNonTerminalAND 以类似方式实现。

类 NonTerminal 确保重载运算符“=”(规则中的“production”)。

class NonTerminal: public HiddenNonTerminal
{
  public:
    NonTerminal(const string id = NULL): HiddenNonTerminal(id)
    {
    }

    virtual Terminal *operator=(Terminal &t)
    {
      Terminal *p = &t;
      while(p.getParent() != NULL)
      {
        p = p.getParent();
        if(p == &t)
        {
          Print("Cyclic dependency in assignment: ", toString(), " <<== ", t.toString());
          p = &t;
          break;
        }
      }
    
      if(dynamic_cast<HiddenNonTerminal *>(p) != NULL)
      {
        eq = p;
      }
      else
      {
        eq = &t;
      }
      eq.setParent(this);
      return &this;
    }
};

最后还有一个类 Rule — NonTerminal 的继承类. 但是,它的整个角色是在描述语法时将一些规则标记为主要规则(如果它们生成对象规则)或次要规则(如果它们导致非终端规则)。

为了方便描述非终端,创建了以下宏:

// 调试版
#define R(Y) (Y^__LINE__)

// 发布版
#define R(Y) (~Y)

#define _DECLARE(Cls) NonTerminal Cls(#Cls); Cls
#define DECLARE(Cls) Rule Cls(#Cls); Cls
#define _FORWARD(Cls) NonTerminal Cls(#Cls);
#define FORWARD(Cls) Rule Cls(#Cls);

唯一的名称行号被指定为宏的参数。如果非终端相互引用,则需要进行正向声明——我们已经在计算器的语法中看到了这一点。

此外,为了用令牌生成终端,实现了支持收集垃圾的特殊类关键字。

class Keywords
{
  private:
    static List<Terminal *> keywords;

  public:
    static Terminal *get(const TokenType t, const string value = NULL)
    {
      Terminal *p = new Terminal(t, value);
      keywords.add(p);
      return p;
    }
};

要在描述语法时使用它,已创建以下宏:

#define T(X) Keywords::get(X)
#define TC(X,Y) Keywords::get(X,Y)

让我们看看如何使用实现的程序接口描述上面考虑的计算器语法。

  FORWARD(expression);
  _DECLARE(value) = T(CONST_INTEGER) | T(LEFT_PAREN) + R(expression) + T(RIGHT_PAREN);
  _DECLARE(operation) = (T(PLUS) | T(STAR) | T(MINUS) | T(SLASH)) + R(expression);
  expression = R(value) + R(operation)*0;

最后,我们准备学习类 Parser。


Parser

类 Parser 的成员存储令牌(“token”)的输入列表、其中的当前位置(“cursor”)、已知的最远位置(“maxcursor”,用于错误诊断)、调用嵌套元素组之前的位置堆栈(“state”),用于回滚,记住“backtracking”),以及到输入文本的链接(“source“,用于打印日志和其他用途)。

class Parser
{
  private:
    BaseArray<Token *> *tokens; // 输入流
    int cursor;                 // 当前令牌
    int maxcursor;
    BaseArray<int> states;
    const Source *source;

此外,解析器使用“stack”跟踪语法元素调用的整个层次结构。此模板中使用的类 TreeNode是一对(终端、令牌)的简单容器,其源代码可以在附加的存档中找到。错误在另一个堆栈中累积用于诊断-“errors”。

    // 当前堆栈,语法如何展开
    Stack<TreeNode *> stack;

    // 在每个有问题的点上保留当前堆栈
    Stack<Stack<TreeNode *> *> errors;

解析器构造函数接收令牌列表、源文本以及在解析期间启用形成语法树的可选标志。

  public:
    Parser(BaseArray<Token *> *_tokens, const Source *text, const bool _buildTree = false)

如果启用了树模式,则所有作为树节点对象进入堆栈的成功“production”都将串珠到树根变量“tree”上:

    TreeNode *tree;   // 具体语法树(可选结果)

为此,类 TreeNode支持子节点数组。解析程序完成工作后,如果启用了树,则可以使用以下方法获取树:

    const TreeNode *getCST() const
    {
      return tree;
    }

解析器的主要方法“match”以其简化形式显示如下。

    bool match(Terminal *p)
    {
      TreeNode *node = new TreeNode(p, getToken());
      stack.push(node);
      int previous = cursor;
      bool result = p.parse(&this);
      stack.pop();
      
      if(result) // success
      {
        if(stack.size() > 0) // 有一个持有者要绑定到
        {
          if(cursor > previous) // 令牌已被使用
          {
            stack.top().bind(node);
          }
          else
          {
            delete node;
          }
        }
      }
      else
      {
        delete node;
      }

      return result;
    }

方法“advance”和“CommitState”(我们在熟悉终端类时看到的)是这样实现的(跳过了一些细节)。

    void advance(const Terminal *p)
    {
      production(p, cursor, tokens[cursor], stack.size());
      if(cursor < tokens.size() - 1) cursor++;
      
      if(cursor > maxcursor)
      {
        maxcursor = cursor;
        errors.clear();
      }
    }

    void commitState(const Terminal *p)
    {
      int x = states.pop();
      for(int i = x; i < cursor; i++)
      {
        production(p, i, tokens[i], stack.size());
      }
    }

advance”将光标沿标记列表移动。如果位置超过最大值,我们可以消除累积的错误,因为每次检查不成功时都会记录这些错误。

方法“production”使用回调接口通知解析器用户有关“production”的信息,我们将在测试中进一步使用它。

    void production(const Terminal *p, const int index, const Token *t, const int level)
    {
      if(callback) callback.produce(&this, p, index, t, source, level);
    }

接口定义如下:

interface Callback
{
  void produce(const Parser *parser, const Terminal *, const int index, const Token *, const Source *context, const int level);
  void backtrack(const int index);
};

在客户机端实现此接口的对象可以使用setCallback方法连接到解析器,然后在每个“production”中调用它。或者,由于运算符[Callback *]重载,此类对象可以单独连接到任何终端。 在特定语法点放置断点对于调试很有用。

让我们在实践中试用解析器。

实践,第1部分:计算器

我们已经有了计算器语法,让我们为它创建一个调试脚本。之后,我们还将为使用MQL语法的测试补充它。

#property script_show_inputs

enum TEST_GRAMMAR {Expression, MQL};

input TEST_GRAMMAR TestMode = Expression;;
input string SourceFile = "Sources/calc.txt";;
input string IncludesFolder = "Sources/Include/";;
input bool LoadIncludes = false;
input bool PrintCST = false;

#include <mql5/scanner.mqh>
#include <mql5/prsr.mqh>

void OnStart()
{
  Preprocessor loader(SourceFile, IncludesFolder, LoadIncludes);
  if(!loader.run())
  {
    Print("Loader failed");
    return;
  }

  Scanner scanner(loader.text());
  List<Token *> *tokens = scanner.scanTokens();
  
  if(!scanner.isSuccess())
  {
    Print("Tokenizer failed");
    delete tokens;
    return;
  }

  Parser parser(tokens, loader.text(), PrintCST);

  if(TestMode == Expression)
  {
    testExpressionGrammar(&parser);
  }
  else
  {
    //...
  }
  
  delete tokens;
}

void testExpressionGrammar(Parser *p)
{
  _FORWARD(expression);
  _DECLARE(value) = T(CONST_INTEGER) | T(LEFT_PAREN) + R(expression) + T(RIGHT_PAREN);
  _DECLARE(operation) = (T(PLUS) | T(STAR) | T(MINUS) | T(SLASH)) + R(expression);
  expression = R(value) + R(operation)*0;

  while(p.match(&expression) && !p.isAtEnd())
  {
    Print("", "Unexpected end");
    break;
  }

  if(p.isAtEnd())
  {
    Print("Success");
  }
  else
  {
    p.printState();
  }

  if(PrintCST)
  {
    Print("Concrete Syntax Tree:");
    TreePrinter printer(p);
    printer.printTree();
  }

  Comment("");
}

该脚本的目的是读取预处理器中传递的文件,使用扫描器将其转换为令牌流,并与解析器一起检查指定的语法。通过调用方法“match”来执行检查,并将根语法规则“expression”传递到该方法中。

作为选项(PrintCST),我们可以使用实用程序类TreePrinter在日志中显示已处理表达式的语法树。

注意!对于真正的程序,树将非常大。只有在调试语法的小片段或整个语法不太大的情况下(例如计算器),才建议使用此选项。

如果对表达式为“(10+1)*2”的文件运行测试脚本,我们将获得以下树(记住选择TestMode=Expression和PrintCST=true):

Concrete Syntax Tree:
|  |  |Terminal LEFT_PAREN @ (
|  |   |  | |Terminal CONST_INTEGER @ 10
|  |   |  |NonTerminal value
|  |   |  |  |Terminal PLUS @ +
|  |   |  |  |  | |Terminal CONST_INTEGER @ 1
|  |   |  |  |  |NonTerminal value
|  |   |  |  |NonTerminal expression
|  |   |  |NonTerminal operation
|  |   |NonTerminal expression
|  |  |Terminal RIGHT_PAREN @ )
|  |NonTerminal value
|  |  |Terminal STAR @ *
|  |  |  | |Terminal CONST_INTEGER @ 2
|  |  |  |NonTerminal value
|  |  |NonTerminal expression
|  |NonTerminal operation
|NonTerminal expression

竖线表示处理语法中明确描述的非终端(即命名终端)的级别。空间与级别相对应,其中隐式创建的HiddenXYZ类的非终端是“展开的”-默认情况下,所有此类节点都不会显示在日志中;但在类TreePrinter中,有一个选项可以启用它们。

注意,选项printcst函数基于一个特殊的元数据结构——一个TreeNode 对象树。我们的解析器可以选择在分析后生成它,作为对调用方法getCST的响应。回想一下,包括树排列模式是由解析器构造函数的第三个参数设置的。

您可以尝试其他表达式,包括那些不正确的表达式,以确保存在错误处理。例如,如果我们破坏了使其成为“10+”的表达式,我们将获得通知:

Failed
First 2 tokens read out of 3
Source: EOF (file:Sources/Include/Layouts/calc.txt; line:1; offset:4) ``
Expected:
CONST_INTEGER in expression;operation;expression;value;
LEFT_PAREN in expression;operation;expression;value;

好吧,所有的类都可以工作了,现在我们可以转到主要的实际部分—MQL解析。


实践,第2部分:MQL语法

在工程方面,一切都准备好编写MQL语法。然而,它比一个小计算器要复杂得多。从头开始创建它将是一项艰巨的任务。为了解决这个问题,让我们使用一个事实,即MQL与C++类似,

对于C++,许多现成的语法描述是公开可访问的。其中一份作为文件cppgrmr.htm附于本文件之后。将它完全转换为语法也是一个挑战。首先,许多结构在MQL中无论如何都不受支持。其次,符号中经常有左递归,因此规则必须更改。最后,第三,限制语法的大小是可取的,因为它可能会对处理速度产生负面影响:保留一些很少使用的特性,由真正需要它们的人随意添加是合理的。

自从第一个触发版本截取了随后的检查之后,提到替代项或事项的顺序。如果在某些情况下,由于跳过某些可选元素,版本可能部分重合,那么我们必须重新排列它们,或者先指定更长更具体的结构,然后指定更短更通用的结构。

让我们演示如何将来自HTM文件的一些符号转换为规则和终端的语法。

在C++文法中:

assignment-expression:
  conditional-expression 
  unary-expression assignment-operator assignment-expression

assignment-operator: one of
  = *= /= %= += –= >= <= &= ^= |=

在MQL文法中:

_FORWARD(assignment_expression);
_FORWARD(unary_expression);

...

assignment_expression =
    R(unary_expression) + R(assignment_operator) + R(assignment_expression)
  | R(conditional_expression);

_DECLARE(assignment_operator) =
    T(EQUAL) | T(STAR_EQUAL) | T(SLASH_EQUAL) | T(DIV_EQUAL)
  | T(PLUS_EQUAL) | T(MINUS_EQUAL) | T(GREATER_EQUAL) | T(LESS_EQUAL)
  | T(BIT_AND_EQUAL) | T(BIT_XOR_EQUAL) | T(BIT_OR_EQUAL);

在C++文法中:

unary-expression:
  postfix-expression 
  ++ unary-expression 
  –– unary-expression 
  unary-operator cast-expression 
  sizeof unary-expression 
  sizeof ( type-name ) 
  allocation-expression 
  deallocation-expression

在MQL文法中:

unary_expression =
    R(postfix_expression)
  | T(INC) + R(unary_expression) | T(DEC) + R(unary_expression)
  | R(unary_operator) + R(cast_expression)
  | T(SIZEOF) + T(LEFT_PAREN) + R(type) + T(RIGHT_PAREN)
  | R(allocation_expression) | R(deallocation_expression);

在C++文法中:

statement:
  labeled-statement 
  expression-statement 
  compound-statement 
  selection-statement 
  iteration-statement 
  jump-statement 
  declaration-statement
  asm-statement
  try-except-statement
  try-finally-statement

在MQL文法中:

statement =
    R(expression_statement) | R(codeblock) | R(selection_statement)
  | R(labeled_statement) | R(iteration_statement) | R(jump_statement);

在MQL语法中,声明语句也有一个规则,但它被转移了。许多规则以比C++更简单的方式记录下来。原则上,这种语法是一个活的有机体,或者用英语的说法是“进行中的工作”,在解释源代码中的特定结构时,很可能发生错误。

对于MQL语法,入口点是规则“program”,由1个或多个“elements”组成:

  _DECLARE(element) =
     R(class_decl)
   | R(declaration_statement) | R(function) | R(sharp) | R(macro);

  _DECLARE(program) = R(element)*1;

在我们的测试脚本中,函数testMQLgramar中描述了所呈现的MQL语法:

void testMQLgrammar(Parser *p)
{
  // 所有语法规则优先
  // ...
  _DECLARE(program) = R(element)*1;

解析就是在这里启动的(类似于计算器):

  while(p.match(&program) && !p.isAtEnd())
  ...

如果出现错误,问题元素应通过日志进行本地化,并且必须在文本的单独输入片段上调试特定语法规则(建议使用最多包含5-6个标记的片段)。换句话说,应该为特定的规则调用解析器的“match”方法,并且应该将具有单独语言结构的文件发送到输入。要将分析器的跟踪输出到日志中,需要取消对脚本中的指令的注释:

//#define PRINTX Print

注意!要显示的信息量非常大,

在调试之前,建议将规则的不同元素放在不同的行中,因为这将用源行的唯一编号标记对象的匿名实例。
然而,创建解析器并不是为了检查文本是否符合MQL语法,而是为了提取语义数据,让我们试试看。

实践,第3部分:列出类的方法和类的层次结构

作为基于MQL解析的第一个应用程序任务,让我们列出类的所有方法。为此,让我们定义一个实现接口回调并记录相关“productions”的类。

原则上,基于语法树进行解析更符合逻辑,但是,这将使存储树的内存过载,并使用单独的算法在该树上迭代。但是,事实上,在解析文本时,解析器本身已经以相同的顺序对其进行了迭代(因为它是这个序列,如果启用了相关模式,那么将在该序列中构建树)。因此,在移动中更容易解析。

MQL语法具有以下规则:

  _DECLARE(method) = R(template_decl)*0 + R(method_specifiers)*0 + R(type) + R(name_with_arg_list) + R(modifiers)*0;

它由许多其他非终端组成,这些非终端又通过其他非终端显示出来,因此该方法的语法树具有高度的分支。在生产处理器中,我们将截获与非终端“方法”相关的所有片段,并将它们放入一个公共字符串中。当下一个生产结果显示为另一个非终端时,这意味着方法描述结束,结果可以显示在日志中。

class MyCallback: public Callback
{
    virtual void produce(const Parser *parser, const Terminal *p, const int index, const Token *t, const Source *context, const int level) override
    {
      static string method = "";
      
      // 从'method`非终端收集所有标记
      if(p.getName() == "method")
      {
        method += t.content(context) + " ";
      }
      // 一旦检测到其他[非]终端并填充字符串,签名就准备好了
      else if(method != "")
      {
        Print(method);
        method = "";
      }
    }

要将处理器连接到解析器,让我们以以下方式(在OnStart中)扩展测试脚本:

  MyCallback myc;
  Parser parser(tokens, loader.text(), PrintCST);
  parser.setCallback(&myc);

除了方法列表之外,让我们收集关于类声明的信息——特别需要确定定义方法的上下文,但我们也可以构建派生层次结构。

要存储有关随机类的元数据,让我们准备名为“Class” 的类 ;-)。

  class Class
  {
    private:
      BaseArray<Class *> subclasses;
      Class *superclass;
      string name;

    public:
      Class(const string n): name(n), superclass(NULL)
      {
      }
      
      ~Class()
      {
        subclasses.clear();
      }
      
      void addSubclass(Class *derived)
      {
        derived.superclass = &this;
        subclasses.add(derived);
      }
      
      bool hasParent() const
      {
        return superclass != NULL;
      }
      
      Class *operator[](int i) const
      {
        return subclasses[i];
      }
      
      int size() const
      {
        return subclasses.size();
      }
      ...
   };

它有一个子类数组和一个到超类的链接,方法 addSubclass 负责填充这些相关字段。我们将把 Class 对象的实例放入一个映射中,字符串键表示为类名:

  Map<string,Class *> map;

map 在 MyCallback 的同一对象中。

现在我们可以从接口回调扩展方法“product”。为了收集与类声明相关的令牌,我们需要拦截更多的规则,因为我们不仅需要完整的声明,还需要突出显示特定属性的声明:新类的名称、其模板的类型(如果有)、基类的名称以及其模板的类型(如果有)。

让我们添加相关变量来收集数据(注意!MQL中的类可以是嵌套类,虽然不常见,但为了简单起见,我们不考虑这样做!同时,我们的MQL语法也支持)。

      static string templName = "";
      static string templBaseName = "";
      static string className = "";
      static string baseName = "";

在非终端“template_decl”的上下文中,标识符是模板类型:

      if(p.getName() == "template_decl" && t.getType() == IDENTIFIER)
      {
        if(templName != "") templName += ",";
        templName += t.content(context);
      }

“template”decl以及下面使用的对象的相关语法规则可以在所附的源代码中进行研究。

在非终端'class_name'的上下文中,identifier是类名;如果到那时templname不是空字符串,则这些是应添加到描述中的模板类型:

      if(p.getName() == "class_name" && t.getType() == IDENTIFIER)
      {
        className = t.content(context);
        if(templName != "")
        {
          className += "<" + templName + ">";
          templName = "";
        }
      }

“derived_clause”上下文中的第一个标识符(如果有)是基类的名称。

      if(p.getName() == "derived_clause" && t.getType() == IDENTIFIER)
      {
        if(baseName == "") baseName = t.content(context);
        else
        {
          if(templBaseName != "") templBaseName += ",";
          templBaseName += t.content(context);
        }
      }

所有后续的标识符都是基类的模板类型。

一旦类声明完成,语法中的规则“class-decl”就会触发。到那时,所有的数据都已经收集好了,可以添加到类的映射中。

      if(p.getName() == "class_decl") // 完成
      {
        if(className != "")
        {
          if(map[className] == NULL)
          {
            map.put(className, new Class(className));
          }
          else
          {
            // 类已定义,可能是转发声明
          }
        }
      
        if(baseName != "")
        {
          if(templBaseName != "")
          {
            baseName += "<" + templBaseName + ">";
          }
          Class *base = map[baseName];
          if(base == NULL)
          {
            // 未知类,可能不包括在内,但奇怪
            base = new Class(baseName);
            map.put(baseName, base);
          }
          
          if(map[className] == NULL)
          {
            Print("Error: base name `", baseName, "` resolved before declaration of the class: ", className);
          }
          else
          {
            base.addSubclass(map[className]);
          }
          
          baseName = "";
        }
        className = "";
        templName = "";
        templBaseName = "";
      }

最后,我们清除所有字符串并等待下一个声明出现。

成功解析程序文本后,它仍然可以以任何方便的方式显示类的层次结构。在测试脚本中,类 MyCallback提供函数“print”以显示在日志中。反过来,它在类“Class”的对象中使用方法“print”。我们将把这些辅助算法留给独立阅读,而且,对于那些希望尝试自己优势的人来说,这也是一个小的编程问题(这样的竞争通常会自发地出现在mql5.com的论坛上)。现有的实现是纯实用的,并不假装是最佳的。它简单地确保了目标:在日志中尽可能清晰地显示类类型对象的树结构层次结构。但是,这可以以更有效的方式完成。

让我们检查测试脚本如何工作来分析一些MQL项目。下面,让我们设置参数 TestMode = MQL。

例如,对于标准EA交易'macd sample.mq5',设置 SourceFile = Sources/Experts/Examples/MACD/MACD Sample.mq5 和 LoadIncludes = true,也就是说,对于所有依赖项,我们将得到以下结果(方法列表大大缩短):

处理 Sources/Experts/Examples/MACD/MACD Sample.mq5
扫描中...
#include <Trade\Trade.mqh>
Including Sources/Include/Trade\Trade.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "StdLibErr.mqh"
Including Sources/Include/StdLibErr.mqh
#include "OrderInfo.mqh"
Including Sources/Include/Trade/OrderInfo.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "SymbolInfo.mqh"
Including Sources/Include/Trade/SymbolInfo.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "PositionInfo.mqh"
Including Sources/Include/Trade/PositionInfo.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "SymbolInfo.mqh"
Including Sources/Include/Trade/SymbolInfo.mqh
#include <Trade\PositionInfo.mqh>
Including Sources/Include/Trade\PositionInfo.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "SymbolInfo.mqh"
Including Sources/Include/Trade/SymbolInfo.mqh
处理的文件数: 8
源代码长度: 175860
文件图:
Sources/Experts/Examples/MACD/MACD Sample.mq5 0
Sources/Include/Trade\Trade.mqh 900
Sources/Include/Object.mqh 1277
Sources/Include/StdLibErr.mqh 1657
Sources/Include/Object.mqh 2330
Sources/Include/Trade\Trade.mqh 3953
Sources/Include/Trade/OrderInfo.mqh 4004
Sources/Include/Trade/SymbolInfo.mqh 4407
Sources/Include/Trade/OrderInfo.mqh 38837
Sources/Include/Trade\Trade.mqh 59925
Sources/Include/Trade/PositionInfo.mqh 59985
Sources/Include/Trade\Trade.mqh 75648
Sources/Experts/Examples/MACD/MACD Sample.mq5 143025
Sources/Include/Trade\PositionInfo.mqh 143091
Sources/Experts/Examples/MACD/MACD Sample.mq5 158754
Lines: 4170
Tokens: 18005
定义语法...
解析中...
CObject :: CObject * Prev ( void ) const 
CObject :: void Prev ( CObject * node ) 
CObject :: CObject * Next ( void ) const 
CObject :: void Next ( CObject * node ) 
CObject :: virtual bool Save ( const int file_handle ) 
CObject :: virtual bool Load ( const int file_handle ) 
CObject :: virtual int Type ( void ) const 
CObject :: virtual int Compare ( const CObject * node , const int mode = 0 ) const 
CSymbolInfo :: string Name ( void ) const 
CSymbolInfo :: bool Name ( const string name ) 
CSymbolInfo :: bool Refresh ( void ) 
CSymbolInfo :: bool RefreshRates ( void ) 

...

CSampleExpert :: bool Init ( void ) 
CSampleExpert :: void Deinit ( void ) 
CSampleExpert :: bool Processing ( void ) 
CSampleExpert :: bool InitCheckParameters ( const int digits_adjust ) 
CSampleExpert :: bool InitIndicators ( void ) 
CSampleExpert :: bool LongClosed ( void ) 
CSampleExpert :: bool ShortClosed ( void ) 
CSampleExpert :: bool LongModified ( void ) 
CSampleExpert :: bool ShortModified ( void ) 
CSampleExpert :: bool LongOpened ( void ) 
CSampleExpert :: bool ShortOpened ( void ) 
成功
类继承:

CObject
  ^
  +--CSymbolInfo
  +--COrderInfo
  +--CPositionInfo
  +--CTrade
  +--CPositionInfo

CSampleExpert

现在,让我们试试第三方项目。我从这篇文章中选择了 EA 'SlidingPuzzle2' ,我把它放在: "SourceFile = Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5". 包含了所有头文件 (LoadIncludes = true), 我们将会取得如下的结果 (已缩短):

处理 Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5
扫描中...
#include "SlidingPuzzle2.mqh"
Including Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh
#include <Layouts\GridTk.mqh>
Including Sources/Include/Layouts\GridTk.mqh
#include "Grid.mqh"
Including Sources/Include/Layouts/Grid.mqh
#include "Box.mqh"
Including Sources/Include/Layouts/Box.mqh
#include <Controls\WndClient.mqh>
Including Sources/Include/Controls\WndClient.mqh
#include "WndContainer.mqh"
Including Sources/Include/Controls/WndContainer.mqh
#include "Wnd.mqh"
Including Sources/Include/Controls/Wnd.mqh
#include "Rect.mqh"
Including Sources/Include/Controls/Rect.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
#include "StdLibErr.mqh"
Including Sources/Include/StdLibErr.mqh
#include "Scrolls.mqh"
Including Sources/Include/Controls/Scrolls.mqh
#include "WndContainer.mqh"
Including Sources/Include/Controls/WndContainer.mqh
#include "Panel.mqh"
Including Sources/Include/Controls/Panel.mqh
#include "WndObj.mqh"
Including Sources/Include/Controls/WndObj.mqh
#include "Wnd.mqh"
Including Sources/Include/Controls/Wnd.mqh
#include <Controls\Edit.mqh>
Including Sources/Include/Controls\Edit.mqh
#include "WndObj.mqh"
Including Sources/Include/Controls/WndObj.mqh
#include <ChartObjects\ChartObjectsTxtControls.mqh>
Including Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh
#include "ChartObject.mqh"
Including Sources/Include/ChartObjects/ChartObject.mqh
#include <Object.mqh>
Including Sources/Include/Object.mqh
Files processed: 17
Source length: 243134
文件图:
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5 0
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 493
Sources/Include/Layouts\GridTk.mqh 957
Sources/Include/Layouts/Grid.mqh 1430
Sources/Include/Layouts/Box.mqh 1900
Sources/Include/Controls\WndClient.mqh 2377
Sources/Include/Controls/WndContainer.mqh 2760
Sources/Include/Controls/Wnd.mqh 3134
Sources/Include/Controls/Rect.mqh 3509
Sources/Include/Controls/Wnd.mqh 14312
Sources/Include/Object.mqh 14357
Sources/Include/StdLibErr.mqh 14737
Sources/Include/Object.mqh 15410
Sources/Include/Controls/Wnd.mqh 17033
Sources/Include/Controls/WndContainer.mqh 46214
Sources/Include/Controls\WndClient.mqh 61689
Sources/Include/Controls/Scrolls.mqh 61733
Sources/Include/Controls/Panel.mqh 62137
Sources/Include/Controls/WndObj.mqh 62514
Sources/Include/Controls/Panel.mqh 72881
Sources/Include/Controls/Scrolls.mqh 78251
Sources/Include/Controls\WndClient.mqh 103907
Sources/Include/Layouts/Box.mqh 115349
Sources/Include/Layouts/Grid.mqh 126741
Sources/Include/Layouts\GridTk.mqh 131057
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 136066
Sources/Include/Controls\Edit.mqh 136126
Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh 136555
Sources/Include/ChartObjects/ChartObject.mqh 137079
Sources/Include/ChartObjects\ChartObjectsTxtControls.mqh 177423
Sources/Include/Controls\Edit.mqh 213551
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mqh 221772
Sources/Experts/Examples/Layouts/SlidingPuzzle2.mq5 241539
Lines: 6102
Tokens: 27248
定义语法...
解析中...
CRect :: CPoint LeftTop ( void ) const 
CRect :: void LeftTop ( const int x , const int y ) 
CRect :: void LeftTop ( const CPoint & point ) 

...

CSlidingPuzzleDialog :: virtual bool Create ( const long chart , const string name , const int subwin , const int x1 , const int y1 , const int x2 , const int y2 ) 
CSlidingPuzzleDialog :: virtual bool OnEvent ( const int id , const long & lparam , const double & dparam , const string & sparam ) 
CSlidingPuzzleDialog :: void Difficulty ( int d ) 
CSlidingPuzzleDialog :: virtual bool CreateMain ( const long chart , const string name , const int subwin ) 
CSlidingPuzzleDialog :: virtual bool CreateButton ( const int button_id , const long chart , const string name , const int subwin ) 
CSlidingPuzzleDialog :: virtual bool CreateButtonNew ( const long chart , const string name , const int subwin ) 
CSlidingPuzzleDialog :: virtual bool CreateLabel ( const long chart , const string name , const int subwin ) 
CSlidingPuzzleDialog :: virtual bool IsMovable ( CButton * button ) 
CSlidingPuzzleDialog :: virtual bool HasNorth ( CButton * button , int id , bool shuffle = false ) 
CSlidingPuzzleDialog :: virtual bool HasSouth ( CButton * button , int id , bool shuffle = false ) 
CSlidingPuzzleDialog :: virtual bool HasEast ( CButton * button , int id , bool shuffle = false ) 
CSlidingPuzzleDialog :: virtual bool HasWest ( CButton * button , int id , bool shuffle = false ) 
CSlidingPuzzleDialog :: virtual void Swap ( CButton * source ) 
成功
类继承:

CPoint

CSize

CRect

CObject
  ^
  +--CWnd
  |    ^
  |    +--CDragWnd
  |    +--CWndContainer
  |    |    ^
  |    |    +--CScroll
  |    |    |    ^
  |    |    |    +--CScrollV
  |    |    |    +--CScrollH
  |    |    +--CWndClient
  |    |         ^
  |    |         +--CBox
  |    |              ^
  |    |              +--CGrid
  |    |                   ^
  |    |                   +--CGridTk
  |    +--CWndObj
  |         ^
  |         +--CPanel
  |         +--CEdit
  +--CGridConstraints
  +--CChartObject
       ^
       +--CChartObjectText
            ^
            +--CChartObjectLabel
                 ^
                 +--CChartObjectEdit
                 |    ^
                 |    +--CChartObjectButton
                 +--CChartObjectRectLabel

CAppDialog
  ^
  +--CSlidingPuzzleDialog

在这里,类的层次结构更有趣。

虽然我已经在不同的项目上测试了解析器,但在某些程序上它更可能是“存根脚趾”。其中一个尚未解决的问题与处理宏有关。正如上面已经提到的,正确的分析建议动态地解释和扩展宏,在分析开始之前将结果替换为源代码。

在当前的MQL语法中,我们试图将调用宏定义为对函数的较不严格的调用。然而,它并不总是有效的。

例如,在TypeToBytes库中,宏的参数用于生成元类型。这是其中一种情况:

#define _C(A, B) CASTING<A>::Casting(B)

此外,该宏的用法如下:

Res = _C(STRUCT_TYPE<T1>, Tmp);

如果我们试图在这段代码上运行解析器,它将无法“摘要”STRUCT_TYPE<T1>,因为实际上,这个参数表示一个模板化类型,而解析器则意味着一个值,或者更广泛地说,一个表达式(尤其是将字符“<”和“>”解释为比较器)。现在,通过调用宏也会产生类似的问题,之后就没有分号了。但是,我们可以绕过它,将“;”插入正在处理的源代码中。

那些想要它的人可以执行第3个实验(本文开头提到了前两个实验),这将包括搜索迭代算法,用这样的宏规则修改当前语法,从而使您能够成功地解析这样复杂的案例。

结论

我们已经探讨了一般和技术上最简单的数据解析方法,包括分析用MQL编写的源代码。为此,本文介绍了MQL语言的语法以及标准工具(即解析器和扫描程序)的实现。使用它们来获取源代码结构允许计算它们的统计信息、标识质量指标、显示依赖项以及自动更改格式。

同时,这里介绍的实现需要一些改进,以实现与复杂的MQL项目的100%兼容性,特别是在支持宏扩展方面。

在进行更深入的准备的情况下,例如保存名称表中实体的信息,这种方法还允许执行代码生成、控制典型错误以及执行其他更复杂的任务。


全部回复

0/140

量化课程

    移动端课程