调试 MQL5 程序

Mykola Demko | 24 三月, 2014

简介

本文主要针对那些已经学过这种语言、但又没有完全掌握该语言开发的程序员。它重点介绍了每位开发人员在调试程序时都会遇到的关键问题。那么,什么是调试呢?

调试是程序开发过程中的一个阶段,旨在检查并移除程序执行错误。在调试过程中,程序员会对应用程序实施分析,尝试找出其潜在问题。而待分析数据,则是通过观察变量和程序执行(被调用的函数和时机)而来。

有两种互为补充的调试技术:

假设您对 MQL5 的变量、结构等内容有所了解,但却尚未独自开发过程序。那么,您要做的第一件事就是编译。事实是,这是调试的第一个阶段。

1. 编译

编译就是将源代码由一种高级编程语言转换为一种低级编程语言。

MetaEditor 编译器会将程序转换为字节码,而不是本地代码(详情请见链接)。如此则可以开发加密程序。此外,32 位和 64 位两种操作系统中均可启用字节码。

我们再回到调试的第一个阶段-编译上来。按下 F7 或 Compile (编译)按钮后,MetaEditor 5 会报告您在编写该代码时产生的所有错误。"Toolbox" (工具箱)窗口的 "Errors" (错误)选项卡中,包含对于检测到的错误及其位置的描述。用光标高亮描述行,再按 Enter (回车)直接前往错误之处。

通过编译器显示的错误只有两种类型:

语法错误通常由粗心导致。比如说,声明变量时,"," 和 ";" 就很容易混淆:

int a; b; // incorrect declaration

如是这样声明,编译器就会返回一个错误。正确的声明如下所示:

int a, b; // correct declaration

或:  

int a; int b; // correct declaration

警告亦不可忽视(许多程序员在这方面都很粗心)。如果 MetaEditor 5 在编译期间返回了警告,那么也会创建一个程序,但其是否按预期运行就不能保证了。

对于 MQL5 开发人员为系统化程序员常见拼写错误所做的主要工作而言,警告还只是隐藏冰山的一角。

假设您要对比两个变量:

if(a==b) { } // if a is equal to b, then ...

但是,不管是因为拼写错误还是健忘,您用 "=" 替代了 "=="。这种情况下,编译器就会按下述方式阐释代码:

if(a=b) { } // assign b to a, if a is true, then ... (unlike MQL4, it is applicable in MQL5)

可以看出,这种拼写错误可对程序的运行造成重大改变。因此,编译器会显示该行的警告。

我们总结一下: 编译是调试的第一个阶段。编译器警告不得忽视。

图 1. 编译期间调试数据

图 1. 编译期间调试数据

2. 调试程序

调试的第二个阶段是使用调试程序(F5 热键)。调试程序会在仿真模式下启动您的程序,并逐步执行。调试程序是 MetaEditor 5 的一项新功能,MetaEditor 4 中没有该程序。所以说,从 MQL4 转到 MQL5 的程序员对它的使用也无需什么经验。

调试程序界面有三个主按钮、三个辅助按钮:

这就是调试程序的界面。但是,我们该怎么使用它呢?程序调试可从程序员已设定专用 DebugBreak() 调试函数的行开始,或者是从某个通过按 F9 按钮(或单击工具栏上的专用按钮)设置的断点开始。

图 2. 设置断点

图 2. 设置断点

如果没有断点,调试程序就只执行程序,并报告调试成功,但您什么也看不到。利用 DebugBreak,您可以跳过一些您不感兴趣的代码,并从您认为棘手的行开始一步一步地检查程序。

如此一来,我们启动了调试程序,将 DebugBreak 放到了正确的位置,而且正在检查程序的执行。下一步做什么呢?它又怎样帮助我们理解程序发生的相关事情呢?

首先,查看调试程序窗口的左侧。它会显示函数名称,以及您当前所在的行数。其次,查看窗口的右侧。它是空的,但您可以在 Expression (表达式)字段中输入任何变量名称。输入变量名称,以在 Value (值)字段中查看其当前值。

该值亦可利用 [Shift+F9] 热键或从如下的上下文菜单选择并添加:

图 3. 添加调试时监测变量

图 3. 添加调试时监测变量

如此一来,您就可以跟踪当前所处的代码行,并查看重要变量的值。完成所有这些分析后,您最终就会明白,程序是否运行正常。

无需担心您感兴趣的变量被声明为局部声明,而您还没有接触到其声明所在的函数。尽管您在该变量的范围之外,但仍有 "Unknown identifier" (未知标识符)值。也就是说,此变量未声明。这不会导致调试程序出错。进入该变量的范围后,您会看到其值及类型。

图 4. 调试过程 - 查看变量值

图 4. 调试过程查看变量值。

上述均为调试程序的主要功能。而调试程序不能做什么,则会在测试程序部分说明。

3. 剖析工具

代码剖析程序是调试程序的一个重要补充。事实上,这是由其优化构成的程序调试过程的最后一个阶段。

剖析程序通过单击 "Start profiling" (开始剖析)按钮、由 MetaEditor 5 菜单调用。与调试程序逐步的程序分析不同,剖析程序是执行该程序。如果程序是指标或 EA 交易,则剖析工具会一直工作,直到该程序卸载。而卸载既可通过移除图表中的指标或 EA 交易来完成,亦可通过单击 "Stop profiling" (停止剖析)实现。

剖析会为我们提供重要的统计资料:每个函数被调用了多少次,其执行花了多长时间。您可能会对百分比形式的统计资料有点困惑。有一点要清楚:统计资料并不考虑嵌套函数。因此,所有百分比值加起来会远远超过 100%。

但尽管如此,剖析程序仍是一款优化程序的强大工具。它允许用户查看哪些函数需要快速优化、哪里可以节省一些内存。

图 5. 剖析程序运行结果

图 5. 剖析程序运行结果

4. 交互性

不管怎样,我都觉得消息显示函数 - Print Comment 是调试的主力工具。首先,它们的使用非常方便。其次,从旧版本转来 MQL5 的程序员也都了解它们。

"Print" 函数将传递来的参数作为一个文本字符串,发送到日志文件和 Experts tool (EA 工具)选项卡。发送时间以及调用函数的程序名称,则会显示于文本左侧。调试期间,此函数用于定义变量中包含哪些值。

除变量值外,有时还有必要了解上述变量的调用顺序。"__FUNCTION__" 和 "__FUNCSIG__" 此时就派上了用场。第一个宏会返回一个带有从其调用的函数的名称的字符串,而第二个宏则会额外显示被调用函数的参数列表。

下面所示即宏的使用:

//+------------------------------------------------------------------+
//| Example of displaying data for debugging                         |
//+------------------------------------------------------------------+
void myfunc(int a)
  {
   Print(__FUNCSIG__); // display data for debugging 
//--- here is some code of the function itself
  }

我更愿意使用 "__FUNCSIG__" 宏,因为它会显示出已重载函数 之间的区别(名称相同但参数不同)。

通常有必要略过一些调用,甚至只专注于某特定的函数调用。为此,可将 Print 函数有条件保护。比如说,1013 次迭代后方可调用显示:

//+------------------------------------------------------------------+
//| Example of data output for debugging                             |
//+------------------------------------------------------------------+
void myfunc(int a)
  {
//--- declare the static counter
   static int cnt=0;
//--- condition for the function call
   if(cnt==1013)
      Print(__FUNCSIG__," a=",a); // data output for debugging
//--- increment the counter
   cnt++;
//--- here is some code of the function itself
  }

针对 Comment 函数亦可采用相同做法,该函数可在图表左上角处显示注释。这是一个超大的优势,因为您在调试期间无需切换到别处。但如果使用此函数,每条新注释都会删除前一条注释。可以将其视为一项缺点(尽管有时也很便利)。

为消除这一缺陷,适用向变量添加编写一个新字符串的方法。首先,string 类型变量是通过空值声明(大多数情况下是全局)和初始化的。然后,利用添加的换行字符将每个新文本字符串放在开头,同时将变量的前一值添加到末尾。

string com=""; // declare the global variable for storing debugging data
//+------------------------------------------------------------------+
//| Example of data output for debugging                             |
//+------------------------------------------------------------------+
void myfunc(int a)
  {
//--- declare the static counter
   static int cnt=0;
//--- storing debugging data in the global variable
   com=(__FUNCSIG__+" cnt="+(string)cnt+"\n")+com;
   Comment(com); // вывод информации для отладки
//--- increase the counter
   cnt++;
//--- here is some code of the function itself
  }

这样,我们又有了一次详细查看程序内容的机会 - 打印到文件。Print 和 Comment 函数可能并不始终适于大数据量或高速显示。前者有时没有足够的时间来显示变化(因为调用可能在显示之前运行,进而导致混淆);而后者则是因为其运行更慢。此外,注释还不能重读和详细检验。

如您需要检查调用顺序或记录大量的数据,那么打印到文件就是最便利的数据输出方法。但要记住的是,这种打印并非每次迭代都用,而是在文件的末尾;而根据上述原理,数据保存到字符串变量却是每次迭代都用(唯一的区别在于,新数据是另外写入到此变量的末尾)。

string com=""; // declare the global variable for storing debugging data
//+------------------------------------------------------------------+
//| Program shutdown                                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//--- saving data to the file when closing the program
   WriteFile();
  }
//+------------------------------------------------------------------+
//| Example of data output for debugging                             |
//+------------------------------------------------------------------+
void myfunc(int a)
  {
//--- declare the static counter
   static int cnt=0;
//--- storing debugging data in the global variable
   com+=__FUNCSIG__+" cnt="+(string)cnt+"\n";
//--- increment the counter
   cnt++;
//--- here is some code of the function itself
  }
//+------------------------------------------------------------------+
//| Save data to file                                                |
//+------------------------------------------------------------------+
void WriteFile(string name="Отладка")
  {
//--- open the file
   ResetLastError();
   int han=FileOpen(name+".txt",FILE_WRITE|FILE_TXT|FILE_ANSI," ");
//--- check if the file has been opened
   if(han!=INVALID_HANDLE)
     {
      FileWrite(han,com); // печать данных
      FileClose(han);     // закрытие файла
     }
   else
      Print("File open failed "+name+".txt, error",GetLastError());
  }

WriteFile 函数在 OnDeinit 中调用。因此,该程序内发生的所有变化,都会被写入此文件。

注: 如果您的日志过于庞大,最好将其存储于多个变量中。为此,最好的方法就是将文本变量的内容放到字符串类型数组单元格,并将 com 变量归零(为下一阶段的工作做准备)。

每有 100-200 万字符串(非经常性条目),即应执行此操作。首先,您会避免由变量溢出导致的数据丢失(顺便说一下,我想尽办法也没做到这一点,因为开发人员都把功夫下在了字符串类型上)。其次,也是最重要的 - 您将能够分多个文件显示数据,而不是在编辑器中打开一个巨大的文件。

为了不持续地追踪已保存字符串的数量,您可以采用函数分离法将文件分成三部分。第一个部分是打开文件,第二是于每次迭代写入文件,而第三则是关闭文件。

//--- open the file
int han=FileOpen("Debugging.txt",FILE_WRITE|FILE_TXT|FILE_ANSI," ");
//--- print data
if(han!=INVALID_HANDLE) FileWrite(han,com);
if(han!=INVALID_HANDLE) FileWrite(han,com);
if(han!=INVALID_HANDLE) FileWrite(han,com);
if(han!=INVALID_HANDLE) FileWrite(han,com);
//--- close the file
if(han!=INVALID_HANDLE) FileClose(han);

但此法要小心使用。如您程序执行失败(比如说,因为零除),您就可能收到一份难以处理的打开文件,它会干扰操作系统的运行。

而且,我强烈建议不要在每次迭代都使用完整的打开-写入-关闭循环。以我的个人经验,这种情况下,您的硬盘驱动器几个月就得报废。

5. 测试程序

调试 EA 交易时,您通常需要检查某些特定条件是否激活。但是,上述的调试程序仅于实时模式下启动 EA 交易,要这些条件最终都被激活,您可能要等待相当长的时间。

事实上,具体的交易条件可能很少出现。我们确实知道它们一定会发生,但如果为此等上几个月,那可荒谬之极。那我们该如何做呢?

这种情况下,策略测试程序就能派上用场了。调试采用的,还是相同的 Print 和 Comment 函数。Comment 始终在评估情况方面首当其冲,而 Print 函数则用于更详细的分析。测试程序会将显示的数据存储于测试程序日志(每个测试程序代理都有单独的目录)中。

为了按正确的间隔启动 EA 交易,我将时间本地化(我觉得问题就在这)、设定了测试程序中的必要日期,并在可视化模式下所有价格变动处将其启动。

我还想提一下,这种调试方法是我借鉴 MetaTrader 4 而来,当时它几乎是在程序执行期间进行调试的唯一方式。

图 6. 利用策略测试程序调试

图 6. 利用策略测试程序调试

6. OOP 中的调试

MQL5 中出现的面向对象编程,已经对调试过程造成了影响。调试流程时,您可以只用函数名称,就能在程序中实现轻松导航。但在 OOP 中,通常却需要了解对象不同方法的调用来源。如果对象为纵向设计(采用继承性),则尤其重要。这种情况下,模板(最近才引入 MQL5)可以派上用场了。

此模板函数允许将指针类型作为一个字符串类型值接收。

template<typename T> string GetTypeName(const T &t) { return(typename(T)); }

我利用这一属性进行下述方式的调试:

//+------------------------------------------------------------------+
//| Base class contains the variable for storing the type            |
//+------------------------------------------------------------------+
class CFirst
  {
public:
   string            m_typename; // variable for storing the type
   //--- filling the variable by the custom type in the constructor
                     CFirst(void) { m_typename=GetTypeName(this); }
                    ~CFirst(void) { }
  };
//+------------------------------------------------------------------+
//| Derived class changes the value of the base class variable       |
//+------------------------------------------------------------------+
class CSecond : public CFirst
  {
public:
   //--- filling the variable by the custom type in the constructor
                     CSecond(void) { m_typename=GetTypeName(this); }
                    ~CSecond(void) {  }
  };

此基类中包含存储其类型的变量(该变量在每个对象的构造函数中进行初始化)。衍生类亦使用该变量的值存储其类型。因此,调用这个宏时,我只是添加 m_typename 变量-不仅接收被调用函数的名称,还有调用此函数的对象的类型。

指针本身则可针对更加准确的对象识别而获得,从而允许用户通过编号区分对象。对象内部,其实现方式如下:

Print((string)this); // print pointer number inside the class

如在外部,则如下所示:

Print((string)GetPointer(pointer)); // print pointer number outside the class

存储对象名称的变量,亦可用于每个类的内部。这种情况下,创建对象时,将对象名称作为某构造函数的参数传递就可能实现了。这就允许您不仅按其编号划分对象,还能了解每个对象代表的内容(因为您将为其命名)。只需类似于填写 m_typename 变量,即可实现此方法。

7. 追踪

上文提到的所有方法,彼此互为补充,对于调试来讲都非常重要。然而,还有另一种不太流行的方法 - 追踪。

因为太过于复杂,此方法很少采用。但当您陷入困境、不知道下一步该怎么做时,追踪就能派上用场了。

此方法允许您了解应用程序的结构-调用的对象和顺序。利用追踪,您就能清楚程序出了什么问题。此外,该方法还提供项目概述。

追踪的执行方式如下。创建两个宏:

//--- opening substitution  
#define zx Print(__FUNCSIG__+"{");
//--- closing substitution
#define xz Print("};");

相应地,分别是打开 zx 和关闭 xz 宏。我们将其放入待追踪的函数体:

//+------------------------------------------------------------------+
//| Example of function tracing                                      |
//+------------------------------------------------------------------+
void myfunc(int a,int b)
  {
   zx
//--- here is some code of the function itself
   if(a!=b) { xz return; } // exit in the middle of the function
//--- here is some code of the function itself
   xz return;
  }

如果此函数中包含依条件退出,那么我们要设置为在每次返回之前关闭保护区内的 xz。如此会防止追踪结构的干扰。

请注意,上述宏仅为简化示例而用。追踪最好还是采用打印到文件。此外,关于打印到文件,我还有一个技巧。为了查看整个追踪结构,我将函数名称都打包到了下述句法结构中:

if() {...}

产生的文件有 ".mqh" 扩展名,允许在 MetaEditor 中将其打开,并利用 风格化工具 [Ctrl+,] 显示追踪结构。

下面是完整的追踪代码:

string com=""; // declare global variable for storing debugging data
//--- opening substitution
#define zx com+="if("+__FUNCSIG__+"){\n";
//--- closing substitution
#define xz com+="};\n"; 
//+------------------------------------------------------------------+
//| Program shutdown                                                 |
//+------------------------------------------------------------------+
void OnDeinit(const int reason)
  {
//--- //--- saving data to the file when closing the program
   WriteFile();
  }
//+------------------------------------------------------------------+
//| Example of the function tracing                                  |
//+------------------------------------------------------------------+
void myfunc(int a,int b)
  {
   zx
//--- here is some code of the function itself
   if(a!=b) { xz return; } // exit in the middle of the function
//--- here is some code of the function itself
   xz return;
  }
//+------------------------------------------------------------------+
//| Save data to file                                                |
//+------------------------------------------------------------------+
void WriteFile(string name="Tracing")
  {
//--- open the file
   ResetLastError();
   int han=FileOpen(name+".mqh",FILE_WRITE|FILE_TXT|FILE_ANSI," ");
//--- check if the file has opened
   if(han!=INVALID_HANDLE)
     {
      FileWrite(han,com); // print data
      FileClose(han);     // close the file
     }
   else
      Print("File open failed "+name+".mqh, error",GetLastError());
  }

要从特定位置开始追踪,则要在宏中加入条件:

bool trace=0; // variable for protecting tracing by condition
//--- opening substitution
#define zx if(trace) com+="if("+__FUNCSIG__+"){\n";
//--- closing substitution
#define xz if(trace) com+="};\n";

这种情况下,您就可以通过将 "trace" 变量设置为 "true" 或 "false" 值,来启用或禁用某特定事件之后或某特定位置中的追踪。

如果目前尚不需要追踪(尽管稍后可能需要),或者现在没有足够的时间清除源,那么可将宏值改为零,即可将其禁用:

//--- substitute empty values
#define zx
#define xz

带有标准 EA 交易(其中包含追踪变更内容)的文件,随附如下。在图表上启动 EA 交易后,追踪结果即可在 Files 目录下看到(已创建 tracing.mqh 文件)。下面是生成文件文本中的一小段:

if(int OnInit()){
};
if(void OnTick()){
if(void CheckForOpen()){
};
};
if(void OnTick()){
if(void CheckForOpen()){
};
};
if(void OnTick()){
if(void CheckForOpen()){
};
};
//--- ...

请注意,嵌套调用的结构,最开始在新创建的文件中不能明确定义,但是,待使用代码风格化工具后,就能看到其整体结构了。下面即使用此风格化工具之后生成文件的文本:

if(int OnInit())
  {
  };
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
if(void OnTick())
  {
   if(void CheckForOpen())
     {
     };
  };
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
if(void OnTick())
  {
   if(void CheckForOpen())
     {
     };
  };
//+------------------------------------------------------------------+
//|                                                                  |
//+------------------------------------------------------------------+
if(void OnTick())
  {
   if(void CheckForOpen())
     {
     };
  };
//--- ...

这只是我的一个技巧,而并非追踪执行所应采取的方式。每个人都有按自己方式追踪的自由。重要的是,追踪揭示了函数调用的结构。

有关调试的重要提示

如在调试期间向您的代码实施变更,请使用 MQL5 函数的直接调用包。下面即实现方式:

//+------------------------------------------------------------------+
//| Example of wrapping a standard function in a shell function      |
//+------------------------------------------------------------------+
void DebugPrint(string text) { Print(text); }

这样一来,您就可以在调试结束后,轻松清除代码了:

用于调试用到的变量也同样适用。因此,试试采用全局声明的变量和函数。如此一来,您即可免于搜索埋藏于应用程序深处的结构了。

总结

调试是程序员工作中的一个重要环节。如果不具备执行程序调试的能力,即不能称之为程序员。但主要的调试,却始终都要靠您的头脑来完成。本文只是介绍了调试过程中用到的几种方法。但是,如果不了解应用程序的运行原理,这些方法将毫无用处。

希望您顺利完成调试!