
掌握 MQL5:从入门到精通(第三部分)复杂数据类型和包含文件
概述
本文是初学者系列文章的延续。在这里,我假设读者已经理解了前两篇文章的内容。
第一篇文章是简介。它假设读者之前没有编程经验,并介绍了程序员所需的工具,描述了主要的程序类型,介绍了一些基本概念,特别是“函数”的概念。
第二篇文章描述了数据操作,它介绍了“文字”、“变量”、“数据类型”、“运算符”等概念,并检查了主要的数据修改运算符:算术、逻辑、位运算符等。
在本文中,我将描述程序员如何创建复杂的数据类型:
- 结构
- 联合
- 类(初学者水平)
- 允许将变量名用作函数的类型。这允许将函数作为参数传递给其他函数。
本文还介绍了如何使用 #include 预处理器指令包含外部文本文件,以确保我们的程序模块化和灵活性。让我提醒你,数据可以用不同的方式组织,但编译器必须始终知道我们的程序需要多少内存,因此在使用数据之前,必须通过指定其类型来描述它。
第二篇文章中描述了诸如 double 、 enum 、 string 等简单数据类型。它详细介绍了变量(操作期间发生变化的数据)和常量。然而,在编程时,经常会出现从简单数据创建更复杂类型更方便的情况。我们在本文的第一部分要讨论的正是这些“构造”。
程序的结构越模块化,开发和维护就越容易。在团队中工作时,这一点变得尤为重要。对于“独立开发人员”来说,不在冗长的代码中而是在其小片段中查找错误也要容易得多。特别是如果您在很长一段时间后返回代码,以便为程序添加一些功能或修复一些不明显的逻辑错误的时候。
如果你提供适当的数据结构,分离方便的函数,而不是使用冗长的条件和循环列表,并在不同的文件中分布不同的逻辑相关代码块,那么进行修改将更容易。
结构
结构描述了一组可以方便地存储在单个变量中的复杂数据。例如,有关日内交易执行时间的信息应包含小时、分钟和秒。
当然,您可以为每个组件创建变量,一共三个,并根据需要访问每个变量。但是,由于这些变量是单个描述的一部分,并且通常一起使用,因此为此类数据描述单独的类型会很方便。结构还可以包含其他类型的附加数据,例如时区或程序员需要的任何其他数据。
最简单的情况下,结构描述如下:
struct IntradayTime { int hours; int minutes; int seconds; string timeCodeString; }; // note the semicolon after the curly brace
例 1 .描述交易时间的示例结构。
此代码创建了一个新的数据类型 IntradayTime 。在这个声明的花括号之间,你可以看到我们想要组合的所有变量。这样,所有 IntradayTime 类型的变量都将包含小时、分钟和秒。
每个变量内结构的每个部分都可以通过句点 “.” 访问。
IntradayTime dealEnterTime; dealEnterTime.hours = 8; dealEnterTime.minutes = 15; dealEnterTime.timeCodeString = "GMT+2";
例 2 .使用结构类型变量。
当我们描述一个结构时,它的“内部”变量(通常称为字段)可以具有任何有效的数据类型,包括使用其他结构。例如:
// Nested structure struct TradeParameters { double stopLoss; double takeProfit; int magicNumber; }; // Main structure struct TradeSignal { string symbol; // Symbol name ENUM_ORDER_TYPE orderType; // Order type (BUY or SELL) double volume; // Order volume TradeParameters params; // Nested structure as parameter type }; // Using the structure void OnStart() { // Variable description for the structure TradeSignal signal; // Initializing structure fields signal.symbol = Symbol(); signal.orderType = ORDER_TYPE_BUY; signal.volume = 0.1; signal.params.stopLoss = 20; signal.params.takeProfit = 40; signal.params.magicNumber = 12345; // Using data in an expression Print("Symbol: ", signal.symbol); Print("Order type: ", signal.orderType); Print("Volume: ", signal.volume); Print("Stop Loss: ", signal.params.stopLoss); Print("Take Profit: ", signal.params.takeProfit); Print("Magic Number: ", signal.params.magicNumber); }
例 3 .使用一个结构来描述另一个结构的字段类型。
如果使用常量而不是表达式作为结构的初始值,则可以使用简写符号进行初始化。在这里,你应该使用花括号。例如,前面示例中的初始化块可以重写为:
TradeSignal signal = { "EURUSD", ORDER_TYPE_BUY, 0.1, {20.0, 40.0, 12345} };
例 4 .使用常量初始化结构。
常量的顺序必须与描述中的字段顺序相匹配。您还可以通过列出初始字段的值来仅初始化结构的一部分。在这种情况下,所有其它字段将被初始化为零。
MQL5 提供了一组预定义结构,例如 MqlDateTime、MqlTradeRequest、MqlTick 等。一般来说,它们的使用并不比本节描述的更复杂。语言参考中详细描述了这些和许多其他结构的字段列表。
此外,当您创建所需类型的变量,然后键入其名称并按键盘上的句点(“.”)时,任何结构(和其他复杂类型)的此列表在 MetaEditor 中都是可见的。
图 01.MetaEditor 中的结构字段列表。
默认情况下,我们程序的所有函数都可以使用该结构的所有字段。
关于 MQL5 结构:对于那些知道如何使用外部 dll 的人来说,我有一些话要说
默认情况下,MQL5结构中的数据以打包形式存在,即直接一个接一个地存在,因此,如果你想让结构占用一定数量的字节,你可能需要添加额外的元素。
在这种情况下,最好先放置最大的数据,然后放置较小的数据。这样你就可以避免很多问题。然而,MQL5 结构还可以使用特殊的操作符 pack 来“对齐”数据:
struct pack(sizeof(long)) MyStruct1 { // structure members will be aligned on an 8-byte boundary }; // or struct MyStruct2 pack(sizeof(long)) { // structure members will be aligned on an 8-byte boundary };
例 5 .对齐结构。
在 pack 的括号内,只能使用数字 1、2、4、8、16。
特殊命令 offsetof 将允许您获取结构中任何字段相对于开头的偏移量(以字节为单位)。例如,如果我们采用示例 3 中的 TradeParameters 结构,则可以使用以下代码来获取 stopLoss 字段偏移量:
Print (offsetof(TradeParameters, stopLoss)); // Result: 0
例 6 .使用运算符 offsetof。
没有包含字符串,类对象,指针和动态数组对象的结构被称为简单结构。简单结构的变量以及由此类元素组成的数组可以自由传递给从外部 DLL 库导入的函数。
也可以使用赋值运算符将简单结构相互复制,但仅限于两种情况:
- 变量要么是相同类型;
- 或者变量类型通过直接继承相关。
这意味着如果我们已经定义了结构“植物”和“树木”,那么“植物”的任何变量都可以复制到基于“树木”创建的任何变量,反之亦然。但是,如果我们也有“灌木丛”,那么您只能逐个元素地从“灌木丛”复制到“树木”(反之亦然)。
在所有其他情况下,即使具有相同字段的结构也必须逐个元素地复制。
相同的规则适用于类型转换:您不能将“灌木”直接转换为“树”,即使它们具有相同的字段,但您可以将“植物”转换为“灌木”。
好吧,如果你真的需要将“灌木”类型转换为“树”,你可以使用联合(union)。但是,您应该注意本文相关部分中描述对联合的限制。简而言之,任何数字字段都可以轻松转换。
//--- enum ENUM_LEAVES { rounded, oblong, pinnate }; //--- struct Tree { int trunks; ENUM_LEAVES leaves; }; //--- struct Bush { int trunks; ENUM_LEAVES leaves; }; //--- union Plant { Bush bush; Tree tree; }; //--- void OnStart() { Tree tree = {1, rounded}; Bush bush; Plant plant; // bush = tree; // Error! // bush = (Bush) tree; // Error! plant.tree = tree; bush = plant.bush; // No problem... Print(EnumToString(bush.leaves)); } //+------------------------------------------------------------------+
例 7 .使用联合体转换结构。
目前就是这样,结构的所有<1 功能的完整描述包含了比本文中描述的更多的细节和细微差别。您可能想要将 MQL5 结构与其他语言进行比较或了解更多详细信息。在这种情况下,请再次查看语言参考。
但对于初学者来说,在我看来,关于结构的材料已经足够了,所以我继续下一节。
联合(union)
对于某些任务,您可能需要将一个存储单元中的数据解释为不同类型的变量。大多数情况下,在转换结构类型时会遇到这样的问题。在加密过程中也可能出现类似的要求。
对这些数据的描述与对简单结构的描述几乎没有什么不同:
// Creating a type union AnyNumber { long integerSigned; // Any valid data types (see further) ulong integerUnsigned; double doubleValue; }; // Using AnyNumber myVariable; myVariable.integerSigned = -345; Print(myVariable.integerUnsigned); Print(myVariable.doubleValue);
例 8 .使用联合。
为了避免联合中的错误,建议使用占用相同内存空间的数据类型(尽管这对于某些转换来说可能是不必要的,甚至是有害的)。
以下类型不能成为联合中的成员:
- 动态数组
- 字符串
- 对象指针和函数
- 类对象
- 具有构造函数或析构函数的结构对象
- 具有以上 1 到 5 点元素的结构对象
不存在其他限制。
请记住:如果您的结构使用任何字符串字段,编译器将给出错误。一定要考虑到这一点。
对面向对象编程的基本理解
面向对象编程是许多编程语言的基础编程范式。在这种方法中,程序中发生的所有事情都被分解为单独的块。每个这样的块都描述了一个特定的“实体”:一个文件、一条线、一个窗口、一个价格表等。
每个块的目的是在一个地方收集数据和处理数据所需的操作。如果块构造正确,这种结构具有许多优点:
- 允许多次重复使用代码
- 便于 IDE 操作,提供快速替换与特定对象相关的变量和函数名称的能力
- 更容易发现错误,并减少添加新错误的机会
- 为处理代码不同部分的不同人员(甚至团队)提供并行操作
- 使更改代码更容易,即使已经过去了很长时间
- 所有这些最终都会使程序开发更快、可靠性更高和编码更容易。
一般来说,这样的布局是自然的,因为它遵循日常生活原则。我们总是对各种对象进行分类:“这个东西属于动物类,那个东西属于植物类,还有一个是家具”等等。反过来,家具可以是橱柜或软垫。等等。
所有这些分类都使用了对象的某些特征及其描述。例如,植物有树干和根,动物有可移动的四肢。所以,每个类都有一些特征属性。在编程中情况也是一样。
如果你设定了一个目标来创建一个处理线的库,那么你需要清楚地了解每条线可以做什么以及它有什么。例如,任何线都有起点、终点、粗细和颜色。
这些是线类的属性或特性或字段。对于动作,您可以使用动词“绘制”,“移动”,“以一定偏移复制”,“以一定角度旋转”等。
如果一个线对象可以独立完成所有这些动作,那么程序员就会讨论这个对象的方法。
属性和方法一起称为类的成员(元素)。
因此,为了使用此方法创建一条线,您首先需要创建此行的类(描述) - 以及程序中的所有其他线 - 然后通知编译器:“这些变量是线,这个函数使用它们。”
类是一种变量类型,包含属于该类的对象的属性和方法的描述。
就描述方式而言,类与结构非常相似。主要区别在于,默认情况下,一个类的所有成员只能在该类内访问。在结构中,它的所有成员都可以访问我们程序的所有函数。下面是创建所需类的总体方案:
// class (variable type) description class TestClass { // Create a type private: // Describe private variables and functions // They will only be accessible to functions within the class // Description of data (class "properties" or "fields") double m_privateField; // Description of functions (class "methods") bool Private_Method(){return false;} public: // Description of public variables and functions // They will be available to all functions that use objects of this class // Description of data (class "properties", "fields", or "members") int m_publicField; // Description of functions (class "methods") void Public_Method(void) { Print("Value of `testElement` is ", testElement ); } };
例 9.类结构描述
关键字 public: 和 private: 定义类成员的可见性区域。
public: 以下的所有内容都可以在类的“外部”使用,也就是说,对于我们程序的其他函数来说,甚至是那些不属于此类的函数来说。
此部分之上(以及 private: 一词之下)的所有内容都将被“隐藏”,并且只有同一类的函数才可以访问这些元素。
一个类可以包含任意数量的 public: 和 private: 部分。
但是,尽管有建议,最好每个范围只使用一个块(一个 private: 和一个 public: ),以便所有具有相同访问级别的数据或功能都彼此靠近。一些有经验的开发人员仍然喜欢创建四个部分 - 两个(私有和公共)用于函数,两个用于变量。现在由你来决定。
基本上,可以省略单词 private:,因为类中所有未声明为 public: 的成员默认都是私有的(与结构不同)。但不建议这样做,因为这样的代码会变得不方便阅读。
重要的是要记住,通常,所描述类中至少有一个函数必须是“公共的”,否则该类在大多数情况下都是无用的。有例外,但很少见。
为了保护数据,将仅函数(非变量)放置在 public:部分中被认为是一种良好的编程实践。这允许仅通过该类的方法修改类变量。这种方法增加了程序代码的可靠性。
要使用所描述的类,需要在程序的所需位置创建所需类型的变量。变量以通常的方式创建。对每个这样的变量的方法和属性的访问通常是通过句点符号完成的,就像在结构中一样:// Description of the variable of the required type TestClass myTestClassVariable; // Using the capabilities of this variable myTestClassVariable.testElement = 5; myTestClassVariable.PrintTestElement();
例 10.使用类。
为了说明公共和私有属性是如何工作的,请尝试将示例 11 中的代码粘贴到脚本的 OnStart 函数定义中并编译文件。编译应该会成功。
然后尝试取消注释行“myVariable.a = 5;”并再次编译代码。在这种情况下,您将收到一个编译错误,表明您正在尝试访问类的私有成员。编译器的这一特性有助于消除程序员在使用其他方法时可能出现的一些微妙错误。
class PrivateAndPublic { private: int a; public: int b; }; PrivateAndPublic myVariable; // myVariable.a = 5; // Compiler error! myVariable.b = 10; // Success
例 11.使用类的公共和私有属性。
如果我们必须自己编写所有类,这种方法与其他方法没有什么不同,也没有什么意义。
幸运的是,许多标准类已经在 MQL5\Include 目录中可用。此外,还可以在代码库中找到许多有用的库。在很多情况下,我们只需要包含适当的文件(如下所述)即可利用其他人的开发成果。这对于程序员来说有很大的帮助。
大量的书籍都致力于 OOP,它绝对值得一篇单独的文章。然而,本文的目的只是让初学者了解如何在程序中使用复杂的数据类型。既然你知道如何定义一个基本类以及如何使用其他人的类,我就继续下一节。
函数数据类型(typedef 运算符)
警告 - 这一节对初学者来说可能很难,所以你可以在第一次阅读这篇文章时跳过它。
理解本节中的材料不会影响您对其余材料的理解,甚至可能不会影响您编程旅程的其余部分。大多数问题可以有多种解决方案,函数类型可以很容易地避免。
然而,将某些函数分配给变量的能力(因此,在某些情况下,将它们用作其他函数的参数)可能很方便,我认为了解这种能力是值得的,至少可以阅读别人的代码。
有时,创建“函数式”类型的变量是有用的,例如将它们作为参数传递给另一个函数。
例如,在交易情况下,买入和卖出订单非常相似,只有一个参数不同。然而,买入价始终是Ask ,而卖出价始终是Bid 。
程序员通常会编写自己的买卖(Buy,Sell)函数,其中会考虑到特定订单的所有细微差别。然后,他们编写一个类似Trade(交易)的函数,将这两种可能性结合起来,并且在“上涨”交易和“下跌”交易时看起来都一样。它很方便,因为 Trade 本身会根据计算出的价格变动方向替代对书面 Buy 或 Sell 函数的调用,程序员可以专注于其他事情。
你可以想到很多例子来表达这样的意思:“机器人,做吧!”并让函数决定在特定情况下应该调用哪个选项。在计算止盈时,应该从价格中增加还是减少点数?何时计算止损?当在极值下订单时,它应该寻找最大值还是最小值?等等。
在这种情况下,有时会使用下面描述的方法。
像往常一样,您首先需要描述所需变量的类型。在这种情况下,使用以下模板描述此类型:
typedef function_result_type (*Function_type_name)(input_parameter1_type,input_parameter1_type ...);
例 12.用于描述函数类型的模板。
其中:
- function_result_type 是返回值的类型(有效类型,例如 int 、 double等)。
- Function_type_name 是我们在创建变量时将使用的类型的名称。
- input_parameter1_type 是第一个参数的类型。参数列表遵循普通函数列表的规则。
请注意类型名称前的星号 (*)。这很重要,没有它,一切都不会奏效。
这意味着这种类型的变量将不包含结果或数字,而是函数本身,它具有一整套功能,因此,这个变量将结合其他变量和函数固有的功能。
这种在描述数据类型时使用对象本身(函数,某个类的对象等),而不是对象数据的副本或其操作结果的构造称为指针。
我们将在以后的文章中更多地讨论指针。让我们看一个使用 typedef 运算符的工作示例。
让我们有函数 Diff 和 Add ,我们想要将它们分配给某个变量。两个函数都返回整数值并接受两个整数参数。它们的实现很简单:
//--- int Add (int a,int b) { return (a+b); } //--- int Diff (int a,int b) { return (a-b); }
例 13.用于函数类型测试的求和与求差函数。
typedef int (*TFunc) (int, int);
例 14.可以存储 Add 和 Diff 函数的变量的类型声明。
现在让我们检查一下这个描述如何工作:
void OnStart() { TFunc operate; //As usual, we declare a variable of the described type operate = Add; // Write a value to a variable (in this case, assign a function) Print(operate(3, 5)); // Use the variable as a normal function // Function output: 8 operate=Diff; Print(operate(3, 5)); // Function output: -2 }
例 15.使用函数类型的变量。
我想指出的是,typedef 运算符只适用于自定义函数
你不能直接使用像 MathMin 或类似的标准函数,但你可以为它们制作一个“包装器”。例如:
//--- double MyMin(double a, double b){ return (MathMin(a,b)); } //--- double MyMax(double a, double b){ return (MathMax(a,b)); } //--- typedef double (*TCompare) (double, double); //--- void OnStart() { TCompare extremumOfTwo; compare= MyMin; Print(extremumOfTwo(5, 7));// 5 compare= MyMax; Print(extremumOfTwo(5, 7));// 7 }
例 16.使用包装器来处理标准函数。
包含外部文件(#include 预处理器指令)
任何程序都可以分为几个模块。
如果你正在处理大型项目,那么划分它们是必不可少的。程序的模块化同时解决了几个问题。
- 首先,模块化代码更容易导航。
- 其次,如果你在一个团队中工作,每个模块都可以由不同的人来描述,这大大加快了开发过程。
- 第三,创建的模块可以重复使用。
最明显的“模块”是一个函数。此外,您可以为所有常量、一些复杂的数据类型描述、几个相关函数的组合(例如,用于更改对象外观的函数或数学函数)等创建模块。
在大型项目中,将这些代码块放置在单独的文件中,然后将这些文件包含在当前程序中,这非常方便。
要在程序中包含额外的文本文件,我们使用 #include 预处理器指令:
#include <SomeFile.mqh> // Angle brackets specify search relative to MQL5\Include directory #include "AnyOtherPath.mqh" // Quotes specify search relative to current file
例 17.#include 的两种形式。
如果编译器在你的代码的任何地方遇到 #include 指令,它就会尝试将指定文件的内容插入到该指令的位置,但每个程序只能插入一次。如果该文件已经被使用,则不能再次包含。
您可以使用下一节中描述的脚本测试此语句。
最常见的是,包含的文件被赋予扩展名 *.mqh,因为这很方便,但一般来说,扩展名可以是任何扩展名。
测试 #include 指令操作的脚本
当遇到此预处理器指令时,测试编译器的操作,我们需要创建两个文件。
首先,我们在脚本目录 ( MQL5\Scripts ) 中创建一个名为“1.mqh”的文件。此文件的内容非常简单:
Print("This is include with number "+i);
例 18.最简单的包含文件可以只包含一个命令。
我希望你能清楚地了解这段代码的作用。假设在某处声明了变量 i,则此代码通过将变量的值附加到消息中为用户创建消息,然后将该消息打印到日志中。
变量 i 将是一个标记,指示该指令在脚本的哪个位置被调用。这个文件中不应写入任何其他内容。现在,在同一个目录中(文件“1.mqh”所在的位置),我们创建一个包含以下代码的脚本:
//+------------------------------------------------------------------+ //| Script program start function | //+------------------------------------------------------------------+ void OnStart() { //--- int i=1; #include "1.mqh" i=2; #include "1.mqh" } //+------------------------------------------------------------------+ // Script output: // // This is include with number 1 // // The second attempt to use the same file will be ignored //+------------------------------------------------------------------+
例 19.测试文件的重复包含。
在这段代码中,我们试图使用文件“1.mqh”两次来接收两条触发消息。
当我们在终端中运行此脚本时,我们将看到第一条消息按预期工作,在消息中显示数字 1,但第二条消息没有出现。
为什么要实施这一限制?为什么文件不能被多次使用?
这是一个重要的原则,因为包含文件通常包含变量和函数的声明。您已经知道,在全局级别(所有函数之外)的一个程序中,应该只有一个具有特定名称的变量。
例如,如果变量 int a;声明过了,则不能在该级别再次声明完全相同的变量。您只能使用现有的那个。至于函数,情况有点困难,但想法是一样的:每个函数在我们的程序中都必须是唯一的。现在想象一下,程序使用两个独立的模块,但每个模块都包含位于文件 <Arrays\List.mqh> 中的相同标准类(图 2)。
图 2.两个模块使用同一个类。
如果没有这个限制,编译器将返回错误消息,因为禁止两次声明同一个类。但在这种情况下,这种构造是非常可行的,因为在 FieldOf_Module1 字段的描述之后,CList 描述已经包含在编译器列表中,因此只对模块 2 使用此描述。
理解了这个原则,您甚至可以创建“多层”嵌套,例如当某些类元素“循环”相互依赖时,如图 3 所示。
你甚至可以在一个类中描述同一类的变量。
所有这些都是可接受的构造,因为 #include 对一个文件只起作用一次。
图 3.循环依赖:每个类都包含依赖于其他类的元素。
在本节的结论中,我想再次提醒您,您可以包含到代码中的 MetaTrader 5 标准库文件位于MQL5\Include 目录中。要在资源管理器中打开此目录,请在 MetaTrader 终端中选择菜单“文件”->“打开数据目录”(图 4)。
图 4.如何打开数据目录。
如果您想在 MetaEditor 中打开此目录中的文件,请在导航面板中找到 Include 文件夹。您可以在同一目录中(最好在单独的文件夹中)创建自己的包含文件,也可以使用程序的目录及其子目录(参见示例 17 中的注释)。通常, #include 指令在文件的开头使用,在所有其他操作开始之前。然而,这条规则并不严格,一切都取决于具体的任务。
结论
让我再次简要地说出本文中讨论的主题。
我希望现在本文中描述的数据类型对您来说只是在结构上“复杂”,而不是在应用程序上。
与语言中内置的简单类型不同,“复杂”类型必须首先声明,然后才能创建变量。然而,处理此类数据与处理“简单”类型基本没有什么不同:您创建变量并调用这些变量的组件(成员)(如果有的话),或者如果您创建了函数类型,则使用变量名作为函数名。
使用结构创建的变量的初始化可以使用花括号完成。
我希望您现在明白,创建自己的复杂类型并将程序分解为存储在外部文件中的模块的能力使程序开发变得灵活方便。
本文由MetaQuotes Ltd译自俄文
原文地址: https://www.mql5.com/ru/articles/14354


