调试 MQL5 程序
简介
本文主要针对那些已经学过这种语言、但又没有完全掌握该语言开发的程序员。它重点介绍了每位开发人员在调试程序时都会遇到的关键问题。那么,什么是调试呢?
调试是程序开发过程中的一个阶段,旨在检查并移除程序执行错误。在调试过程中,程序员会对应用程序实施分析,尝试找出其潜在问题。而待分析数据,则是通过观察变量和程序执行(被调用的函数和时机)而来。
有两种互为补充的调试技术:
- 采用调试程序 - 呈现所开发程序逐步执行的实用工具。
- “状态和函数”调用变量在屏幕、日志或文件中的交互显示。
假设您对 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. 编译期间调试数据
2. 调试程序
调试的第二个阶段是使用调试程序(F5 热键)。调试程序会在仿真模式下启动您的程序,并逐步执行。调试程序是 MetaEditor 5 的一项新功能,MetaEditor 4 中没有该程序。所以说,从 MQL4 转到 MQL5 的程序员对它的使用也无需什么经验。
调试程序界面有三个主按钮、三个辅助按钮:
- Start [F5] - 启动调试。
- Pause [Break] - 暂停调试。
- Stop [Shift+F5] - 停止调试。
- Step into [F11] - 用户在此行调用的函数内移动。
- Step over [F10] - 调试程序忽略此字符串中调用的函数体,并移往下一行。
- Step out [Shift+F11] - 用户退出其当前所在的函数体。
这就是调试程序的界面。但是,我们该怎么使用它呢?程序调试可从程序员已设定专用 DebugBreak() 调试函数的行开始,或者是从某个通过按 F9 按钮(或单击工具栏上的专用按钮)设置的断点开始。
图 2. 设置断点
如果没有断点,调试程序就只执行程序,并报告调试成功,但您什么也看不到。利用 DebugBreak,您可以跳过一些您不感兴趣的代码,并从您认为棘手的行开始一步一步地检查程序。
如此一来,我们启动了调试程序,将 DebugBreak 放到了正确的位置,而且正在检查程序的执行。下一步做什么呢?它又怎样帮助我们理解程序发生的相关事情呢?
首先,查看调试程序窗口的左侧。它会显示函数名称,以及您当前所在的行数。其次,查看窗口的右侧。它是空的,但您可以在 Expression (表达式)字段中输入任何变量名称。输入变量名称,以在 Value (值)字段中查看其当前值。
该值亦可利用 [Shift+F9] 热键或从如下的上下文菜单选择并添加:
图 3. 添加调试时监测变量
如此一来,您就可以跟踪当前所处的代码行,并查看重要变量的值。完成所有这些分析后,您最终就会明白,程序是否运行正常。
无需担心您感兴趣的变量被声明为局部声明,而您还没有接触到其声明所在的函数。尽管您在该变量的范围之外,但仍有 "Unknown identifier" (未知标识符)值。也就是说,此变量未声明。这不会导致调试程序出错。进入该变量的范围后,您会看到其值及类型。
图 4. 调试过程查看变量值。
上述均为调试程序的主要功能。而调试程序不能做什么,则会在测试程序部分说明。
3. 剖析工具
代码剖析程序是调试程序的一个重要补充。事实上,这是由其优化构成的程序调试过程的最后一个阶段。
剖析程序通过单击 "Start profiling" (开始剖析)按钮、由 MetaEditor 5 菜单调用。与调试程序逐步的程序分析不同,剖析程序是执行该程序。如果程序是指标或 EA 交易,则剖析工具会一直工作,直到该程序卸载。而卸载既可通过移除图表中的指标或 EA 交易来完成,亦可通过单击 "Stop profiling" (停止剖析)实现。
剖析会为我们提供重要的统计资料:每个函数被调用了多少次,其执行花了多长时间。您可能会对百分比形式的统计资料有点困惑。有一点要清楚:统计资料并不考虑嵌套函数。因此,所有百分比值加起来会远远超过 100%。
但尽管如此,剖析程序仍是一款优化程序的强大工具。它允许用户查看哪些函数需要快速优化、哪里可以节省一些内存。
图 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. 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); }
这样一来,您就可以在调试结束后,轻松清除代码了:
- 移除 "DebugPrint" 函数调用,
- 然后编译
- 并删除 MetaEditor 警告有编译错误的代码行中的函数调用。
用于调试用到的变量也同样适用。因此,试试采用全局声明的变量和函数。如此一来,您即可免于搜索埋藏于应用程序深处的结构了。
总结
调试是程序员工作中的一个重要环节。如果不具备执行程序调试的能力,即不能称之为程序员。但主要的调试,却始终都要靠您的头脑来完成。本文只是介绍了调试过程中用到的几种方法。但是,如果不了解应用程序的运行原理,这些方法将毫无用处。
希望您顺利完成调试!
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/654
新文章 调试 MQL5 程序已发布:
作者:Nikolay Demko