Skip to content

文件操作

很难找到一个不使用数据输入输出的程序。我们已经知道,MQL程序可以通过输入变量接收设置,并将信息输出到日志中,因为我们几乎在所有测试脚本中都使用了后者。但在大多数情况下,这还不够。

例如,相当一部分程序定制工作涉及到大量数据,这些数据无法通过输入参数来传递。一个程序可能需要与某些外部分析工具集成,也就是说,以标准或专用格式上传市场信息,对其进行处理,然后以新的形式加载到终端中,特别是作为交易信号、一组神经网络权重或决策树系数。此外,为MQL程序维护一个单独的日志也会很方便。

文件子系统为完成这类任务提供了最通用的功能。MQL5 API提供了广泛的文件操作函数,包括创建、删除、搜索、写入和读取文件的函数。我们将在本章中学习所有这些内容。

MQL5中的所有文件操作都被限制在磁盘上的一个特殊区域,这个区域被称为沙盒。这样做是出于安全考虑,以防止任何MQL程序被用于恶意目的,从而损害你的计算机或操作系统。

高级用户可以通过特殊措施来规避这个限制,我们稍后会讨论这些措施。但这只应在特殊情况下进行,同时要注意预防措施并承担所有责任。

对于安装在计算机上的每个终端实例,沙盒的根目录位于<terminal_data_folder>/MQL5/Files/。在MetaEditor中,你可以使用“文件 -> 打开数据文件夹”命令来打开数据文件夹。如果你在计算机上有足够的访问权限,这个目录通常与终端的安装位置相同。如果你没有所需的权限,路径将如下所示:

X:/Users/<user_name>/AppData/Roaming/MetaQuotes/Terminal/<instance_id>/MQL5/Files/

这里X是系统安装所在的盘符,<user_name>是Windows用户登录名,<instance_id>是终端实例的唯一标识符。Users文件夹也有一个别名“Documents and Settings”。

请注意,在通过RDP(远程桌面协议)远程连接到计算机的情况下,即使你拥有管理员权限,终端也将始终使用Roaming目录及其子目录。

我们回想一下,数据目录中的MQL5文件夹是存储所有MQL程序的地方:包括它们的源代码和编译后的ex5文件。每种类型的MQL程序,包括指标、智能交易系统、脚本等,在MQL5文件夹中都有一个专用的子文件夹。所以用于处理文件的Files文件夹就在它们旁边。

除了计算机上每个终端副本的单独沙盒之外,还有一个供所有终端共享的通用沙盒:它们可以通过这个沙盒进行通信。其路径经过Windows用户的主文件夹,并且可能因操作系统版本而异。例如,在Windows 7、8和10的标准安装中,路径如下:

X:/Users/<user_name>/AppData/Roaming/MetaQuotes/Terminal/Common/Files/

同样,你可以通过MetaTrader轻松访问该文件夹:运行“文件 -> 打开共享数据文件夹”命令,你就会进入Common文件夹。

某些类型的MQL程序(智能交易系统和指标)不仅可以在终端中执行,还可以在测试器中执行。在测试器中运行时,共享沙盒仍然可访问,并且会使用测试代理内部的一个文件夹,而不是单个实例沙盒。通常,它看起来像这样:

X:/<terminal_path>/Tester/Agent-IP-port/MQL5/Files/

这在MQL程序本身中可能不可见,也就是说,所有文件函数的工作方式完全相同。然而,从用户的角度来看,可能会觉得存在某种问题。例如,如果程序将其工作结果保存到一个文件中,在运行完成后,该文件将在测试器的代理文件夹中被删除(就好像该文件从未被创建过一样)。这种常规方法是为了防止一个程序的潜在有价值数据泄露到另一个程序中,而这个程序可能在以后的某个时间在同一个代理上进行测试(特别是因为代理可以共享)。我们将在本书的第五部分讨论用于将文件传输到代理并从代理返回结果到终端的其他技术。

要绕过沙盒限制,你可以使用Windows为文件系统对象分配符号链接的功能。在我们的情况下,连接(连接点)最适合重定向对本地计算机上文件夹的访问。可以使用以下命令(在Windows命令行中)创建它们:

mklink /J new_name existing_target

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

为了创建到沙盒外部的外部文件夹的连接,建议在MQL5/Files中创建一个专用文件夹,例如Links。然后,进入该文件夹后,你可以通过选择new_name并将沙盒外部的真实路径替换为existing_target来执行上述命令。例如,以下命令将在Links文件夹中创建一个名为Settings的新链接,该链接将提供对MQL5/Presets文件夹的访问:

mklink /J Settings "....\Presets"

相对路径“....\”假定该命令在指定的MQL5/Files/Links文件夹中执行。两个点“..”的组合表示从当前文件夹切换到父文件夹。指定两次表示向上两级路径层次结构。结果,目标文件夹(existing_target)将被生成为MQL5/Presets。但是在existing_target参数中,你也可以指定绝对路径。

你可以像删除普通文件一样删除符号链接(但当然,你首先应该确保要删除的是左下角带有箭头图标的文件夹,即链接,而不是原始文件夹)。建议一旦你不再需要超出沙盒的限制,就立即删除它们。事实上,创建的虚拟文件夹对所有MQL程序都可用,而不仅仅是你的程序,并且不知道其他人的程序会如何利用这种额外的自由。

本章的许多部分都涉及文件名。它们充当文件系统元素的标识符,并且有类似的规则,包括一些限制。

请注意,文件名不能包含在文件系统中起特殊作用的某些字符('<', '>', '/', '\', '"', ':', '|', '* ', '?'),以及任何代码在0到31(包括0和31)之间的字符。

以下文件名也被操作系统保留用于特殊用途,不能使用:CON, PRN, AUX, NUL, COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9, LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9。

应该注意的是,Windows文件系统并不区分字母的大小写,所以像“Name”、“NAME”和“name”这样的名称指的是同一个元素。

Windows允许使用反斜杠'\'和正斜杠'/'作为路径组件(子文件夹和文件)之间的分隔符。然而,在MQL5字符串中,反斜杠需要转义(即实际上要写两次),因为''字符本身是特殊的:它用于构造控制字符序列,如'\r', '\n', '\t'等(请参阅字符类型部分)。例如,以下路径是等效的:"MQL5Book/file.txt" 和 "MQL5Book\file.txt"。

点字符 '.' 用作名称和扩展名之间的分隔符。如果一个文件系统元素的标识符中有多个点,那么扩展名是最右边的点右边的片段,而它左边的所有内容都是名称。名称(点之前)或扩展名(点之后)可以为空。例如,没有扩展名的文件名是“text”,没有名称(只有扩展名)的文件是“.txt”。

在Windows中,路径和文件名的总长度是有限制的。同时,在MQL5中管理文件时,应该考虑到沙盒的路径将被添加到它们的路径和名称中,也就是说,在MQL函数调用中为文件对象名称分配的空间会更少。默认情况下,总长度限制是系统常量MAX_PATH,其值为260。从Windows 10(版本1607)开始,你可以将此限制增加到32767。为此,你需要将以下文本保存到一个.reg文件中,并通过将其添加到Windows注册表中来运行它。

[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem] "LongPathsEnabled"=dword:00000001

对于其他版本的Windows,你可以使用命令行中的解决方法。特别是,你可以使用上述连接来缩短路径(通过创建一个具有短路径的虚拟文件夹)。你也可以使用shell命令 -subst,例如,subst z: c:\very\long\path(有关详细信息,请参阅Windows帮助)。

信息存储方法:文本和二进制

在之前的许多章节中我们已经看到,同样的信息可以用文本和二进制两种形式来表示。例如,int、long和double格式的数字、日期和时间(datetime)以及颜色(color),在内存中是以一定长度的字节序列来存储的。这种方法很紧凑,更便于计算机解读,但对于人类来说,以文本形式分析信息则更加方便,尽管这会花费更多时间。因此,我们非常关注数字与字符串之间的相互转换,以及处理字符串的函数。

在文件层面,数据也同样分为二进制和文本两种表示形式。二进制文件用于以与内存中相同的内部表示形式存储数据。而文本文件则包含字符串表示形式。

文本文件通常用于诸如CSV(逗号分隔值)、JSON(JavaScript对象表示法)、XML(可扩展标记语言)、HTML(超文本标记语言)等标准格式。

当然,对于许多应用程序来说,二进制文件也有标准格式,特别是对于图像(PNG、GIF、JPG、BMP)、声音(WAV、MP3)或压缩档案(ZIP)。然而,二进制格式从一开始就具有更强的保护性,并且是对数据进行底层操作,因此在只看重存储效率和特定程序对数据的可访问性时,更常用于解决内部问题。换句话说,任何应用结构和类的对象都可以轻松地在二进制文件中保存和恢复它们的状态,实际上就像在内存中留下了印记,并且无需担心与任何标准的兼容性问题。

从理论上讲,在写入二进制文件时,我们可以手动将数据转换为字符串,然后在读取文件时再将其从字符串转换回数字(或结构、或数组)。这类似于文本文件模式自动提供的功能,但需要额外的工作量。而文本文件模式则为我们省去了这样的繁琐操作。此外,MQL5文件子系统会隐式地执行一些在处理文本时必要的可选但重要的操作。

首先,文本的概念是基于使用分隔符的一些通用规则。具体来说,我们假定所有文本都由字符串组成。这样,从算法角度来看,读取和分析它们会更加方便。因此,存在一些特殊字符用于分隔不同的字符串。

在这里,我们遇到了第一个困难,那就是不同的操作系统接受不同的字符组合作为分隔符。在Windows系统中,行分隔符是由两个字符组成的序列'\r\n'(既可以表示为十六进制代码:0xD 0xA,也可以用名称CRLF表示,即回车和换行)。在Unix和Linux系统中,单个字符'\n'是标准的行分隔符,但在MacOS系统下的某些版本和程序中可能会使用单个字符'\r'。

尽管MetaTrader 5运行在Windows系统下,但我们无法保证生成的任何文本文件不会使用不常见的分隔符进行保存。如果我们以二进制模式读取该文件,并自行检查分隔符以形成字符串,那么这些差异就需要进行特定的处理。而MQL5中的文件文本操作模式可以解决这个问题:它在读写时会自动对换行符进行规范化处理。

不过,MQL5并不能在所有情况下都修正换行符。特别是,在读取文本文件时,单个字符'\r'不会被解释为'\r\n',而单个'\n'则会被正确地解释为'\r\n'。

其次,字符串在内存中可以有多种存储表示形式。默认情况下,MQL5中的字符串(string类型)由双字节字符组成。这提供了对通用Unicode编码的支持,这很好,因为它包含了所有国家的文字。然而,在许多情况下,并不需要这种通用性(例如,当存储数字或英文消息时),在这种情况下,使用ANSI编码的单字节字符字符串会更高效。MQL5 API函数允许你选择在文本模式下将字符串写入文件的首选方式。但如果我们在MQL程序中控制写入操作,就可以保证从Unicode转换为单字节字符的有效性和可靠性。在这种情况下,当与某些外部软件或网络服务集成时,其文件中的ANSI代码页可以是任意的。在这方面,就产生了以下一点。

第三,由于存在许多不同的语言,我们需要为各种ANSI编码的文本做好准备。如果不能正确解释编码,文本在写入或读取时可能会出现失真,甚至变得无法读取。我们在“处理符号和代码页”这一章节中已经看到了这一点。这就是为什么文件函数已经包含了正确处理字符的方法:只需在参数中指定所需或预期的编码即可。关于编码的选择,我们会在单独的章节中更详细地描述。

最后,文本模式内置了对著名的CSV格式的支持。由于交易经常需要表格数据,CSV格式非常适合这种需求。在CSV模式的文本文件中,MQL5 API函数不仅会处理用于换行的分隔符,还会处理用于分隔列(表格中每行的字段)边界的额外分隔符。这通常是制表符'\t'、逗号','或分号';'。例如,以下是一个包含外汇新闻的CSV文件的样子(显示了一个逗号分隔的片段):

标题,国家,日期,时间,影响程度,预测值,先前值 银行假日,日元,2021年08月09日,上午12:00,节假日,, 消费者物价指数年率,人民币,2021年08月09日,上午1:30,低,0.8%,1.1% 生产者物价指数年率,人民币,2021年08月09日,上午1:30,低,8.6%,8.8% 失业率,瑞士法郎,2021年08月09日,上午5:45,低,3.0%,3.1% 德国贸易差额,欧元,2021年08月09日,上午6:00,低,139亿欧元,126亿欧元 Sentix投资者信心指数,欧元,2021年08月09日,上午8:30,低,29.2,29.8 职位空缺与劳动力流动调查职位空缺数,美元,2021年08月09日,下午2:00,中等,927万个,921万个 美联储理事博斯蒂克讲话,美元,2021年08月09日,下午2:00,中等,, 美联储理事巴尔金讲话,美元,2021年08月09日,下午4:00,中等,, 英国零售联盟零售销售监测年率,英镑,2021年08月09日,晚上11:01,低,4.9%,6.7% 经常账户,日元,2021年08月09日,晚上11:50,低,1.71万亿日元,1.87万亿日元

为了更清晰地展示,以下是以表格形式呈现的内容:

标题国家日期时间影响程度预测值先前值
银行假日日元2021年08月09日上午12:00节假日
消费者物价指数年率人民币2021年08月09日上午1:300.8%1.1%
生产者物价指数年率人民币2021年08月09日上午1:308.6%8.8%
失业率瑞士法郎2021年08月09日上午5:453.0%3.1%
德国贸易差额欧元2021年08月09日上午6:00139亿欧元126亿欧元
Sentix投资者信心指数欧元2021年08月09日上午8:3029.229.8
职位空缺与劳动力流动调查职位空缺数美元2021年08月09日下午2:00中等927万个921万个
美联储理事博斯蒂克讲话美元2021年08月09日下午2:00中等
美联储理事巴尔金讲话美元2021年08月09日下午4:00中等
英国零售联盟零售销售监测年率英镑2021年08月09日晚上11:014.9%6.7%
经常账户日元2021年08月09日晚上11:501.71万亿日元1.87万亿日元

简易模式下的文件读写

在MQL5中用于读写数据的文件函数可以分为两个不对等的组。第一组包含两个函数:FileSave和FileLoad,它们允许在一次函数调用中以二进制模式写入或读取数据。一方面,这种方法具有不可否认的优势,即简单性,但另一方面,它也存在一些局限性(下面会详细介绍)。第二大组中的所有文件函数使用方式不同:需要依次调用几个函数,才能执行一个逻辑完整的读写操作。这看起来更复杂,但它为操作过程提供了灵活性和控制能力。第二组函数使用特殊的整数——文件描述符进行操作,文件描述符应使用FileOpen函数获取(详见下一节)。

我们先来看一下这两个函数的正式说明,然后再看它们的示例(FileSaveLoad.mq5)。

bool FileSave(const string filename, const void &data[], const int flag = 0)

该函数将传入的数据数组的所有元素写入一个名为filename的二进制文件中。filename参数不仅可以包含文件名,还可以包含多层嵌套文件夹的名称:如果指定的文件夹不存在,该函数将创建它们。如果文件已存在,它将被覆盖(除非被另一个程序占用)。

作为data参数,可以传入除字符串外的任何内置类型的数组。它也可以是一个简单结构的数组,该结构包含除字符串、动态数组和指针之外的内置类型的字段。类也不被支持。

如果需要,flag参数可以包含预定义常量FILE_COMMON,这意味着在所有终端的公共数据目录(Common/Files/)中创建并写入文件。如果未指定flag(对应默认值0),则文件将被写入常规数据目录(如果MQL程序在终端中运行)或测试代理目录(如果在测试器中运行)。在最后两种情况下,如本章开头所述,将在目录内使用MQL5/Files/沙盒。

该函数返回操作成功(true)或失败(false)的指示。

long FileLoad(const string filename, void &data[], const int flag = 0)

该函数将二进制文件filename的全部内容读取到指定的数据数组中。文件名可以包含MQL5/Files或Common/Files沙盒内的文件夹层次结构。

数据数组必须是除字符串外的任何内置类型,或者是简单结构类型(见上文)。

flag参数控制文件搜索和打开的目录选择:默认情况下(值为0)是标准沙盒,但如果设置为FILE_COMMON,则是所有终端共享的沙盒。

该函数返回读取的项目数量,如果出错则返回-1。

请注意,文件中的数据是以一个数组元素为块进行读取的。如果文件大小不是元素大小的倍数,那么剩余的数据将被跳过(不读取)。例如,如果文件大小为10字节,将其读取到double类型的数组中(sizeof(double)=8),实际上只会加载8字节,即1个元素(函数将返回1)。文件末尾的剩余2字节将被忽略。

在FileSaveLoad.mq5脚本中,我们定义了两个用于测试的结构。

c
struct Pair
{
   short x, y;
};
  
struct Simple
{
   double d;
   int i;
   datetime t;
   color c;
   uchar a[10]; // 允许固定大小的数组
   bool b;
   Pair p;      // 也允许复合字段(嵌套的简单结构)
   
   // 使用字符串和动态数组会导致编译错误
   // FileSave/FileLoad:不允许包含对象的结构或类
   // string s;
   // uchar a[];
   
   // 指针也不被支持
   // void *ptr;
};

Simple结构包含了大多数允许的类型的字段,以及一个具有Pair结构类型的复合字段。在OnStart函数中,我们填充了一个小型的Simple类型数组。

c
void OnStart()
{
   Simple write[] =
   {
      {+1.0, -1, D'2021.01.01', clrBlue, {'a'}, true, {1000, 16000}},
      {-1.0, -2, D'2021.01.01', clrRed,  {'b'}, true, {1000, 16000}},
   };
   ...

我们将选择与MQL5Book子文件夹一起写入数据的文件,这样我们的实验就不会与你的工作文件混淆:

c
const string filename = "MQL5Book/rawdata";

让我们将一个数组写入文件,再将其读取到另一个数组中,然后进行比较。

c
PRT(FileSave(filename, write/*, FILE_COMMON*/)); // true
   
Simple read[];
PRT(FileLoad(filename, read/*, FILE_COMMON*/)); // 2
   
PRT(ArrayCompare(write, read)); // 0

FileLoad返回了2,即读取了2个元素(2个结构)。如果比较结果为0,则表示数据匹配。你可以在你喜欢的文件管理器中打开MQL5/Files/MQL5Book文件夹,并确保存在“rawdata”文件(不建议使用文本编辑器查看其内容,建议使用支持二进制模式的查看器)。

在脚本的进一步操作中,我们将读取的结构数组转换为字节,并以十六进制代码的形式输出到日志中。这是一种内存转储,它可以让你了解二进制文件是什么。

c
uchar bytes[];
for(int i = 0; i < ArraySize(read); ++i)
{
   uchar temp[];
   PRT(StructToCharArray(read[i], temp));
   ArrayCopy(bytes, temp, ArraySize(bytes));
}
ByteArrayPrint(bytes);

结果:

[00] 00 | 00 | 00 | 00 | 00 | 00 | F0 | 3F | FF | FF | FF | FF | 00 | 66 | EE | 5F | 
[16] 00 | 00 | 00 | 00 | 00 | 00 | FF | 00 | 61 | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 
[32] 00 | 00 | 01 | E8 | 03 | 80 | 3E | 00 | 00 | 00 | 00 | 00 | 00 | F0 | BF | FE | 
[48] FF | FF | FF | 00 | 66 | EE | 5F | 00 | 00 | 00 | 00 | FF | 00 | 00 | 00 | 62 | 
[64] 00 | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 01 | E8 | 03 | 80 | 3E |

因为内置的ArrayPrint函数不能以十六进制格式打印,所以我们不得不开发自己的函数ByteArrayPrint(这里我们不会给出它的源代码,详见附件文件)。

接下来,让我们记住FileLoad能够将数据加载到任何类型的数组中,所以我们将使用它直接将同一个文件读取到一个字节数组中。

c
uchar bytes2[];
PRT(FileLoad(filename, bytes2/*, FILE_COMMON*/)); // 78,  39 * 2
PRT(ArrayCompare(bytes, bytes2)); // 0, 相等

两个字节数组的成功比较表明,FileLoad可以按照指定的任意方式处理来自文件的原始数据(文件中没有信息表明它存储的是Simple结构的数组)。

这里需要重点注意的是,由于字节类型的大小最小(1),它是任何文件大小的倍数。因此,任何文件总是能无剩余地被读取到字节数组中。这里FileLoad函数返回了数字78(元素数量等于字节数)。这就是文件的大小(两个39字节的结构)。

基本上,FileLoad能够为任何类型解释数据,这就要求程序员谨慎处理并进行检查。特别是,在脚本的进一步操作中,我们将同一个文件读取到MqlDateTime结构的数组中。这当然是错误的,但它运行时没有错误。

c
MqlDateTime mdt[];
PRT(sizeof(MqlDateTime)); // 32
PRT(FileLoad(filename, mdt)); // 2
 // 注意:还有14字节未读取
ArrayPrint(mdt);

结果包含了一组无意义的数字:

        [year]      [mon] [day]     [hour]    [min]    [sec] [day_of_week] [day_of_year]
[0]          0 1072693248    -1 1609459200        0 16711680            97             0
[1] -402587648    4096003     0  -20975616 16777215  6286950     -16777216    1644167168

因为MqlDateTime的大小是32,所以在一个78字节的文件中只能容纳两个这样的结构,还剩下14字节多余。剩余字节的存在表明存在问题。但即使没有剩余字节,也不能保证所执行操作的合理性,因为两种不同大小的结构纯粹出于偶然也可能以整数(但不同)次的方式适配文件长度。此外,两个意义不同的结构可能具有相同的大小,但这并不意味着它们应该相互写入和读取。

毫不奇怪,MqlDateTime结构数组的日志显示了奇怪的值,因为实际上它是完全不同的数据类型。

为了使读取更加谨慎,脚本实现了一个类似于FileLoad的函数——MyFileLoad。当我们学习新的文件函数并使用它们来模拟FileSave/FileLoad的内部结构时,我们将在接下来的部分详细分析这个函数及其对应的MyFileSave函数。同时,只需注意在我们的版本中,我们可以检查文件中是否存在未读取的剩余部分并显示警告。

最后,让我们看一下脚本中演示的另外几个潜在错误。

c
/*
// 编译错误,这里不支持字符串类型
string texts[];
FileSave("any", texts); // 不允许参数转换
*/
   
double data[];
PRT(FileLoad("any", data)); // -1
PRT(_LastError); // 5004, ERR_CANNOT_OPEN_FILE

第一个错误发生在编译时(这就是为什么代码块被注释掉了),因为不允许使用字符串数组。

第二个错误是读取一个不存在的文件,这就是为什么FileLoad返回-1。可以使用GetLastError(或_LastError)轻松获取解释性的错误代码。

文件的打开与关闭

要从文件中读写数据,大多数MQL5函数都要求先打开文件。为此,有FileOpen函数。在执行完所需的操作后,应该使用FileClose函数关闭打开的文件。事实上,根据应用的选项,一个打开的文件可能会被其他程序阻止访问。此外,出于性能原因,文件操作会在内存(缓存)中进行缓冲,如果不关闭文件,新数据可能在一段时间内不会实际上传到文件中。如果正在写入的数据在等待外部程序使用(例如,当将MQL程序与其他系统集成时),这一点尤其关键。我们可以从FileFlush函数的说明中了解到将缓冲区刷新到磁盘的另一种方法。

在MQL程序中,一个特殊的整数(称为描述符)与打开的文件相关联。它由FileOpen函数返回。所有与访问或修改文件内部内容相关的操作都需要在相应的API函数中指定这个标识符。那些对整个文件进行操作(复制、删除、移动、检查是否存在)的函数不需要描述符。执行这些操作时,不需要打开文件。

c
int FileOpen(const string filename, int flags, const short delimiter = '\t', uint codepage = CP_ACP)
int FileOpen(const string filename, int flags, const string delimiter, uint codepage = CP_ACP)

该函数以flags参数指定的模式打开指定名称的文件。filename参数在实际文件名之前可能包含子文件夹。在这种情况下,如果以写入模式打开文件且所需的文件夹层次结构尚不存在,它将被创建。

flags参数必须包含描述所需文件操作模式的常量组合。该组合使用按位或操作来完成。以下是可用常量的表格。

标识符描述
FILE_READ1以读取模式打开文件
FILE_WRITE2以写入模式打开文件
FILE_BIN4二进制读写模式,不进行字符串到字符串的数据转换
FILE_CSV8CSV类型的文件;写入的数据将转换为相应类型的文本(Unicode或ANSI,见下文),读取时进行从文本到所需类型(在读取函数中指定)的反向转换;一条CSV记录是一行文本,由换行符分隔(通常是CRLF);在CSV记录内部,元素由分隔符字符(参数delimiter)分隔
FILE_TXT16纯文本文件,类似于CSV模式,但不使用分隔符字符(参数delimiter的值将被忽略)
FILE_ANSI32ANSI类型字符串(单字节字符)
FILE_UNICODE64Unicode类型字符串(双字节字符)
FILE_SHARE_READ128多个程序的共享读访问
FILE_SHARE_WRITE256多个程序的共享写访问
FILE_REWRITE512在FileCopy和FileMove函数中覆盖文件(如果文件已存在)的权限
FILE_COMMON4096文件位于所有客户端终端的共享文件夹/Terminal/Common/Files中(在打开文件(FileOpen)、复制文件(FileCopy、FileMove)和检查文件是否存在(FileIsExist)时使用该标志)

打开文件时,必须指定FILE_WRITE、FILE_READ标志之一或它们的组合。

FILE_SHARE_READ和FILE_SHARE_WRITE标志不能替代或取消指定FILE_READ和FILE_WRITE标志的需求。

MQL程序执行环境总是对文件进行读取缓冲,这相当于隐式添加了FILE_READ标志。因此,为了正确处理共享文件,总是应该使用FILE_SHARE_READ(即使已知另一个进程打开了一个只写文件)。

如果未指定FILE_CSV、FILE_BIN、FILE_TXT标志中的任何一个,则假定FILE_CSV具有最高优先级。如果指定了这三个标志中的多个,则应用传递的最高优先级(它们按优先级降序列在上面)。

对于文本文件,默认模式是FILE_UNICODE。

仅影响CSV的delimiter参数可以是ushort或string类型。在第二种情况下,如果字符串长度大于1,将只使用其第一个字符。

codepage参数仅影响以文本模式(FILE_TXT或FILE_CSV)打开的文件,并且仅在为字符串选择了FILE_ANSI模式时才起作用。如果字符串以Unicode(FILE_UNICODE)存储,则代码页不重要。

如果成功,该函数将返回一个文件描述符,即一个正整数。它仅在特定的MQL程序内是唯一的;与其他程序共享它没有意义。为了进一步处理文件,将描述符传递给其他函数的调用。

如果出错,结果为INVALID_HANDLE (-1)。错误的本质应从GetLastError函数返回的代码中加以澄清。

文件打开时设置的所有操作模式设置在文件打开期间保持不变。如果需要更改模式,则应关闭文件并使用新参数重新打开。

对于每个打开的文件,MQL程序执行环境会维护一个内部指针,即文件内的当前位置。打开文件后,指针立即设置为开头(位置0)。在写入或读取过程中,根据从各种文件函数传输或接收的数据量,位置会相应地移动。也可以直接影响该位置(向后或向前移动)。所有这些功能将在以下部分中讨论。

FILE_READ和FILE_WRITE的各种组合允许实现几种场景:

  • FILE_READ — 仅当文件存在时才打开文件;否则,函数返回错误,并且不会创建新文件。
  • FILE_WRITE — 如果文件尚不存在,则创建一个新文件,或者打开一个现有文件,其内容将被清除,大小将重置为零。
  • FILE_READ|FILE_WRITE — 打开一个现有文件及其所有内容,或者如果文件尚不存在,则创建一个新文件。

如您所见,某些场景仅由于标志而无法实现。特别是,如果文件已经存在,您不能仅以写入模式打开它。这可以通过使用其他函数来实现,例如FileIsExist。此外,对于以读写组合模式打开的文件,无法“自动”重置:在这种情况下,MQL5始终保留其内容。

要向文件追加数据,不仅必须以FILE_READ|FILE_WRITE模式打开文件,还必须通过调用FileSeek将文件内的当前位置移动到文件末尾。

正确描述对文件的共享访问是成功执行文件打开的先决条件。这方面的管理如下:

  • 如果未指定FILE_SHARE_READ和FILE_SHARE_WRITE标志中的任何一个,那么如果当前程序首先打开文件,它将获得对该文件的独占访问权。如果同一个文件之前已被其他人(其他程序或同一个程序)打开,函数调用将失败。
  • 当设置了FILE_SHARE_READ标志时,程序允许后续请求打开同一个文件进行读取。如果在函数调用时该文件已被另一个或同一个程序打开进行读取,并且未设置此标志,函数将失败。
  • 当设置了FILE_SHARE_WRITE标志时,程序允许后续请求打开同一个文件进行写入。如果在函数调用时该文件已被另一个或同一个程序打开进行写入,并且未设置此标志,函数将失败。

访问共享不仅要针对其他MQL程序或MetaTrader 5外部的进程进行检查,而且如果同一个MQL程序重新打开文件,也要针对该程序进行检查。

因此,冲突最少的模式意味着同时指定两个标志,但如果已经有人在没有共享的情况下获得了该文件的描述符,它仍然不能保证文件会被打开。然而,根据计划的读取或写入操作,应该遵循更严格的规则。

例如,当打开一个文件进行读取时,让其他人也有机会读取它是有意义的。此外,如果这是一个正在补充的文件(例如,日志),您可能还可以允许其他人写入它。但是,当打开一个文件进行写入时,几乎不应该让其他人拥有写入访问权限:这将导致不可预测的数据覆盖。

c
void FileClose(int handle)

该函数通过句柄关闭先前打开的文件。

文件关闭后,程序中的句柄将变为无效:尝试对其调用任何文件函数将导致错误。但是,如果重新打开同一个或不同的文件,您可以使用相同的变量存储不同的句柄。

当程序终止时,打开的文件将被强制关闭,如果写入缓冲区不为空,将被写入磁盘。然而,建议显式关闭文件。

在完成对文件的操作后关闭文件是一条需要遵循的重要规则。这不仅是因为正在写入的信息会被缓存,如果不关闭文件,这些信息可能会在一段时间内保留在RAM中而不会保存到磁盘(如上文所述)。此外,一个打开的文件会消耗操作系统的一些内部资源,这里说的不是磁盘空间。同时打开的文件数量是有限的(根据Windows设置,可能是几百个或几千个)。如果许多程序保持大量文件处于打开状态,可能会达到这个限制,并且尝试打开新文件将失败。

在这方面,使用一个包装类来防止描述符可能的丢失是很有必要的。这个包装类在创建对象时打开文件并获取描述符,并且在析构函数中自动释放描述符并关闭文件。

我们将在测试纯FileOpen和FileClose函数之后创建一个包装类。

但在深入研究文件的具体细节之前,让我们准备一个新版本的宏,以说明我们的函数输出到调用日志的情况。需要这个新版本是因为到目前为止,像PRT和PRTS(在前面部分使用)这样的宏在打印时“吸收”了函数返回值。例如,我们这样写:

c
PRT(FileLoad(filename, read));

这里FileLoad调用的结果被发送到日志中,但无法在调用代码行中获取它。说实话,我们之前不需要它。但现在FileOpen函数将返回一个文件描述符,并且应该将其存储在一个变量中,以便进一步操作文件。

旧宏存在两个问题。首先,它们基于Print函数,该函数消耗传递的数据(将其发送到日志),但本身不返回任何内容。其次,对于带有结果的变量,任何值只能从表达式中获取,而Print调用不能成为表达式的一部分,因为它的类型是void。

为了解决这些问题,我们需要一个返回可打印值的打印辅助函数。我们将把它的调用打包到一个新的PRTF宏中:

c
#include <MQL5Book/MqlError.mqh>
  
#define PRTF(A) ResultPrint(#A, (A))
  
template<typename T>
T ResultPrint(const string s, const T retval = 0)
{
   const string err = E2S(_LastError) + "(" + (string)_LastError + ")";
   Print(s, "=", retval, " / ", (_LastError == 0 ? "ok" : err));
 ResetLastError();// 清除下一次调用的错误标志
   return retval;
}

使用'#'神奇字符串转换操作符,我们得到了作为第一个参数传递给ResultPrint的代码片段(表达式A)的详细描述符。表达式本身(宏参数)被求值(如果有函数,将调用它),其结果作为第二个参数传递给ResultPrint。接下来,通常的Print函数开始起作用,最后,相同的结果被返回给调用代码。

为了无需查阅帮助文档来解码错误代码,准备了一个E2S宏,它使用了包含所有MQL5错误的MQL_ERROR枚举。可以在头文件MQL5/Include/MQL5Book/MqlError.mqh中找到它。新的宏和ResultPrint函数在PRTF.mqh文件中定义,位于测试脚本旁边。

在FileOpenClose.mq5脚本中,让我们尝试打开不同的文件,特别是,将并行多次打开同一个文件。在实际程序中通常会避免这种情况。对于大多数任务,程序实例中对特定文件的单个句柄就足够了。

其中一个文件,MQL5Book/rawdata,肯定已经存在,因为它是由“简易模式下的文件读写”部分中的脚本创建的。另一个文件将在测试期间创建。

我们将选择FILE_BIN文件类型。在这个阶段,使用FILE_TXT或FILE_CSV文件类型的操作与此类似。

让我们预留一个文件描述符数组,以便在脚本结束时一次性关闭所有文件。

首先,让我们以不共享访问的读取模式打开MQL5Book/rawdata。假设该文件未被任何第三方应用程序使用,我们可以期望成功获取句柄。

c
void OnStart()
{
   int ha[4] = {}; // 用于测试文件句柄的数组 
   
   // 运行FileSaveLoad.mq5后此文件必须存在
   const string rawdata = "MQL5Book/rawdata";
   ha[0] = PRTF(FileOpen(rawdata, FILE_BIN | FILE_READ)); // 1 / ok

如果我们尝试再次打开同一个文件,我们将遇到错误,因为第一次和第二次调用都不允许共享。

c
ha[1] = PRTF(FileOpen(rawdata, FILE_BIN | FILE_READ)); // -1 / CANNOT_OPEN_FILE(5004)

让我们关闭第一个句柄,再次打开文件,但具有共享读权限,并确保现在重新打开操作有效(尽管它也需要允许共享读取):

c
FileClose(ha[0]);
ha[0] = PRTF(FileOpen(rawdata, FILE_BIN | FILE_READ | FILE_SHARE_READ)); // 1 / ok
ha[1] = PRTF(FileOpen(rawdata, FILE_BIN | FILE_READ | FILE_SHARE_READ)); // 2 / ok

以写入模式(FILE_WRITE)打开文件将不起作用,因为之前的两次FileOpen调用只允许FILE_SHARE_READ。

c
ha[2] = PRTF(FileOpen(rawdata, FILE_BIN | FILE_READ | FILE_WRITE | FILE_SHARE_READ));
// -1 / CANNOT_OPEN_FILE(5004)

现在让我们尝试创建一个新文件MQL5Book/newdata。如果以只读模式打开它,文件将不会被创建。

c
const string newdata = "MQL5Book/newdata";
ha[3] = PRTF(FileOpen(newdata, FILE_BIN | FILE_READ));
// -1 / CANNOT_OPEN_FILE(5004)

要创建一个文件,必须指定FILE_WRITE模式(这里FILE_READ的存在并不关键,但它使调用更通用:如我们所记得的,在这种组合中,指令保证如果旧文件存在,将打开它,否则将创建一个新文件)。

c
ha[3] = PRTF(FileOpen(newdata, FILE_BIN | FILE_READ | FILE_WRITE)); // 3 / ok

让我们尝试使用我们已知的FileSave函数向新文件写入一些内容。它充当一个“外部参与者”,因为它绕过我们的描述符对文件进行操作,这与另一个MQL程序或第三方应用程序的操作方式非常相似。

c
long x[1] = {0x123456789ABCDEF0};
PRTF(FileSave(newdata, x)); // false

这次调用失败,因为句柄是在没有共享权限的情况下打开的。关闭并以最大“权限”重新打开文件。

c
FileClose(ha[3]);
ha[3] = PRTF(FileOpen(newdata, 
      FILE_BIN | FILE_READ | FILE_WRITE | FILE_SHARE_READ | FILE_SHARE_WRITE)); // 3 / ok

这次FileSave按预期工作。

c
PRTF(FileSave(newdata, x)); // true

您可以查看文件夹MQL5/Files/MQL5Book/,并在那里找到长度为8字节的newdata文件。

请注意,在我们关闭文件后,其描述符将返回到空闲描述符池中,并且下次打开文件(可能是另一个文件)时,相同的数字将再次起作用。

为了整齐地关闭程序,我们将显式关闭所有打开的文件。

c
for(int i = 0; i < ArraySize(ha); ++i)
{
   if(ha[i] != INVALID_HANDLE)
   {
     FileClose(ha[i]);
   }
}

}

文件描述符管理

由于我们需要时刻记住打开的文件,并在从函数中任何方式退出时释放局部描述符,将整个常规操作委托给特殊对象会更高效。

这种方法在编程中很常见,被称为资源获取即初始化(RAII)。使用RAII可以更轻松地控制资源并确保它们处于正确状态。特别是,如果打开文件的函数(并为其创建一个所有者对象)从几个不同的地方退出,这种方法会特别有效。

RAII的应用范围并不局限于文件。在“对象类型模板”部分,我们创建了AutoPtr类,它管理指向对象的指针。这是该概念的另一个例子,因为指针也是一种资源(内存),而且很容易丢失它,并且在算法的几个不同分支中释放它也会消耗资源。

文件包装类在另一方面也很有用。文件API没有提供一个函数,允许通过描述符获取文件名(尽管在内部这种关系肯定存在)。同时,在对象内部,我们可以存储这个文件名,并实现我们自己的与描述符的绑定。

在最简单的情况下,我们需要一个存储文件描述符并在析构函数中自动关闭它的类。FileHandle.mqh文件中展示了一个示例实现。

c
class FileHandle
{
   int handle;
public:
   FileHandle(const int h = INVALID_HANDLE) : handle(h)
   {
   }
   
   FileHandle(int &holder, const int h) : handle(h)
   {
      holder = h;
   }
   
   int operator=(const int h)
   {
      handle = h;
      return h;
   }
   ...

两个构造函数以及重载的赋值运算符确保对象与文件(描述符)绑定。第二个构造函数允许传递对局部变量的引用(来自调用代码),该变量将额外获得一个新描述符。这将是同一个描述符的一种外部别名,可以在其他函数调用中以通常的方式使用。

但也可以不使用别名。对于这些情况,类定义了“~”运算符,它返回内部handle变量的值。

c
   int operator~() const
   {
      return handle;
   }

最后,实现这个类最重要的部分是智能析构函数:

c
   ~FileHandle()
   {
      if(handle != INVALID_HANDLE)
      {
         ResetLastError();
         // 如果句柄无效,将设置内部错误代码
         FileGetInteger(handle, FILE_SIZE);
         if(_LastError == 0)
         {
            #ifdef FILE_DEBUG_PRINT
               Print(__FUNCTION__, ": Automatic close for handle: ", handle);
            #endif
            FileClose(handle);
         }
         else
         {
            PrintFormat("%s: handle %d is incorrect, %s(%d)", 
               __FUNCTION__, handle, E2S(_LastError), _LastError);
         }
      }
   }

在进行几次检查后,对受控的handle变量调用FileClose。关键在于,文件可能在程序的其他地方被显式关闭,尽管使用这个类后不再需要这样做。结果是,当算法执行离开定义FileHandle对象的代码块时,在调用析构函数时描述符可能已经无效。为了查明这一点,使用了对FileGetInteger函数的虚拟调用。之所以说是虚拟的,是因为它没有做任何有用的事情。如果调用后内部错误代码仍然为0,则描述符有效。

我们可以省略所有这些检查,简单地写成以下形式:

c
   ~FileHandle()
   {
      if(handle != INVALID_HANDLE)
      {
         FileClose(handle);
      }
   }

如果描述符已损坏,FileClose不会返回任何警告。但我们添加了检查,以便能够输出诊断信息。

让我们试试FileHandle类的实际效果。它的测试脚本名为FileHandle.mq5。

c
const string dummy = "MQL5Book/dummy";
   
void OnStart()
{
   // 创建一个新文件,或者打开一个现有文件并重置它
   FileHandle fh1(PRTF(FileOpen(dummy, 
      FILE_TXT | FILE_WRITE | FILE_SHARE_WRITE | FILE_SHARE_READ))); // 1
   // 通过'='连接描述符的另一种方式
   int h = PRTF(FileOpen(dummy, 
      FILE_TXT | FILE_WRITE | FILE_SHARE_WRITE | FILE_SHARE_READ)); // 2
   FileHandle fh2 = h;
   // 以及另一种支持的语法:
   // int f;
   // FileHandle ff(f, FileOpen(dummy,
   //    FILE_TXT | FILE_WRITE | FILE_SHARE_WRITE | FILE_SHARE_READ));
   
   // 假设在这里写入数据
   // ...
   
   // 手动关闭文件(这不是必需的;只是为了演示FileHandle将检测到这一点,并且不会尝试再次关闭它)
   FileClose(~fh1); // 对对象应用'~'运算符将返回句柄
   
   // 绑定到对象'fh2'的变量'h'中的描述符句柄不会被手动关闭
   // 并且将在析构函数中自动关闭
}

根据日志中的输出,一切按计划进行:

   FileHandle::~FileHandle: Automatic close for handle: 2
   FileHandle::~FileHandle: handle 1 is incorrect, INVALID_FILEHANDLE(5007)

然而,如果有很多文件,为每个文件创建一个跟踪对象副本可能会带来不便。对于这种情况,设计一个在给定上下文中(例如,在一个函数内部)收集所有描述符的单个对象是有意义的。

这样的类在FileHolder.mqh文件中实现,并在FileHolder.mq5脚本中展示。FileHolder本身的一个副本根据请求创建FileOpener类的辅助观察对象,它与FileHandle有共同的特性,特别是析构函数以及handle字段。

要通过FileHolder打开文件,应该使用它的FileOpen方法(其签名与标准FileOpen函数的签名相同)。

c
class FileHolder
{
   static FileOpener *files[];
   int expand()
   {
      return ArrayResize(files, ArraySize(files) + 1) - 1;
   }
public:
   int FileOpen(const string filename, const int flags, 
                const ushort delimiter = '\t', const uint codepage = CP_ACP)
   {
      const int n = expand();
      if(n > -1)
      {
         files[n] = new FileOpener(filename, flags, delimiter, codepage);
         return files[n].handle;
      }
      return INVALID_HANDLE;
   }

所有FileOpener对象都添加到files数组中,以跟踪它们的生命周期。在同一个数组中,零元素标记了创建FileHolder对象的局部上下文(代码块)的注册时刻。FileHolder构造函数负责这一点。

c
   FileHolder()
   {
      const int n = expand();
      if(n > -1)
      {
         files[n] = NULL;
      }
   }

如我们所知,在程序执行期间,它会进入嵌套的代码块(调用函数)。如果它们需要管理局部文件描述符,则应在那里描述FileHolder对象(每个代码块一个或更少)。根据栈的规则(先进后出),所有这些描述都添加到files数组中,然后在程序离开上下文时以相反的顺序释放。在每个这样的时刻都会调用析构函数。

c
   ~FileHolder()
   {
      for(int i = ArraySize(files) - 1; i >= 0; --i)
      {
         if(files[i] == NULL)
         {
            // 减少数组大小并退出
            ArrayResize(files, i);
            return;
         }
         
         delete files[i];
      }
   }

它的任务是删除数组中最后一个FileOpener对象,直到遇到第一个零元素,该零元素表示上下文的边界(数组中进一步的描述符来自另一个外部上下文)。

你可以自己研究整个类。

让我们看看它在测试脚本FileHolder.mq5中的使用。除了OnStart函数外,它还有SubFunc函数。在这两个上下文中都执行文件操作。

c
const string dummy = "MQL5Book/dummy";
   
void SubFunc()
{
   Print(__FUNCTION__, " enter");
   FileHolder holder;
   int h = PRTF(holder.FileOpen(dummy, 
      FILE_BIN | FILE_WRITE | FILE_SHARE_WRITE | FILE_SHARE_READ));
   int f = PRTF(holder.FileOpen(dummy, 
      FILE_BIN | FILE_WRITE | FILE_SHARE_WRITE | FILE_SHARE_READ));
   // 使用h和f
   // ...
   // 无需手动关闭文件并跟踪函数的提前退出
   Print(__FUNCTION__, " exit");
}
 
void OnStart()
{
   Print(__FUNCTION__, " enter");
   
   FileHolder holder;
   int h = PRTF(holder.FileOpen(dummy, 
      FILE_BIN | FILE_WRITE | FILE_SHARE_WRITE | FILE_SHARE_READ));
   // 通过描述符对文件进行写入数据和其他操作
   // ...
   /*
   int a[] = {1, 2, 3};
   FileWriteArray(h, a);
   */
   
   SubFunc();
   SubFunc();
   
 if(rand() >32000) // 模拟条件分支
   {
      // 多亏了holder,我们不需要显式调用
      // FileClose(h);
      Print(__FUNCTION__, " return");
      return; // 函数中可能有很多退出点
   }
   
   /*
     ... 更多代码
   */
   
   // 多亏了holder,我们不需要显式调用
   // FileClose(h);
   Print(__FUNCTION__, " exit");
}

我们没有手动关闭任何句柄,FileHolder的实例将在析构函数中自动关闭它们。

以下是日志输出的一个示例:

OnStart enter
holder.FileOpen(dummy,FILE_BIN|FILE_WRITE|FILE_SHARE_WRITE|FILE_SHARE_READ)=1 / ok
SubFunc enter
holder.FileOpen(dummy,FILE_BIN|FILE_WRITE|FILE_SHARE_WRITE|FILE_SHARE_READ)=2 / ok
holder.FileOpen(dummy,FILE_BIN|FILE_WRITE|FILE_SHARE_WRITE|FILE_SHARE_READ)=3 / ok
SubFunc exit
FileOpener::~FileOpener: Automatic close for handle: 3
FileOpener::~FileOpener: Automatic close for handle: 2
SubFunc enter
holder.FileOpen(dummy,FILE_BIN|FILE_WRITE|FILE_SHARE_WRITE|FILE_SHARE_READ)=2 / ok
holder.FileOpen(dummy,FILE_BIN|FILE_WRITE|FILE_SHARE_WRITE|FILE_SHARE_READ)=3 / ok
SubFunc exit
FileOpener::~FileOpener: Automatic close for handle: 3
FileOpener::~FileOpener: Automatic close for handle: 2
OnStart exit
FileOpener::~FileOpener: Automatic close for handle: 1

文本模式下的编码选择

对于编写的文本文件,应根据文本的特点来选择编码,或者根据生成文件所针对的外部程序的要求进行调整。如果没有外部要求,对于包含数字、英文字母和标点的纯文本,可以遵循始终使用ANSI编码的规则(在“字符串比较”部分给出了128个这样的国际字符的表)。当处理各种语言或特殊字符时,使用UTF-8或Unicode,即分别为:

c
int u8 = FileOpen("utf8.txt", FILE_WRITE | FILE_TXT | FILE_ANSI, 0, CP_UTF8);
int u0 = FileOpen("unicode.txt", FILE_WRITE | FILE_TXT | FILE_UNICODE);

例如,这些设置对于将金融工具的名称保存到文件中很有用,因为它们有时会使用表示货币或交易模式的特殊字符。

读取自己的文件应该不是问题,因为在读取时指定与写入时相同的编码设置就足够了。然而,文本文件可能来自不同的来源。它们的编码可能未知,或者会在没有事先通知的情况下发生变化。因此,就出现了这样一个问题:如果有些文件可能以单字节字符串(ANSI)的形式提供,有些以双字节字符串(Unicode)的形式提供,还有些以UTF-8编码的形式提供,该怎么办。

可以通过程序的输入参数来选择编码。然而,这仅对一个文件有效,并且如果必须打开许多不同的文件,它们的编码可能不匹配。因此,最好指示系统动态地(逐个文件地)做出正确的模式选择。

MQL5不允许100%自动检测和应用正确的编码,但是,有一种最通用的模式可用于读取各种文本文件。为此,需要设置FileOpen函数的以下输入参数:

c
int h = FileOpen(filename, FILE_READ | FILE_TXT | FILE_ANSI, 0, CP_UTF8);

有几个因素在起作用。

首先,UTF-8编码可以透明地跳过任何ANSI编码中提到的128个字符(即它们按“一对一”的方式传输)。

其次,它在互联网协议中是最流行的编码。

第三,MQL5对双字节Unicode的文本格式有额外的内置分析功能,如果有必要,无论指定的参数如何,它都可以自动将文件操作模式切换到FILE_UNICODE。事实上,Unicode格式的文件通常前面会有一对特殊的标识符:0xFFFE,或者反过来是0xFEFF。这个序列被称为字节序标记(BOM)。之所以需要它,是因为我们知道,在不同的平台上,字节在数字内部的存储顺序可能不同(这在“整数中的字节序控制”部分讨论过)。

FILE_UNICODE格式每个字符使用一个2字节的整数(代码),所以与其他编码不同,字节顺序变得很重要。Windows的字节序BOM是0xFFFE。如果MQL5核心在文本文件的开头找到这个标记,它的读取将自动切换到Unicode模式。

让我们看看不同的模式设置如何处理不同编码的文本文件。为此,我们将使用FileText.mq5脚本以及几个内容相同但编码不同的文本文件(括号内标明了字节大小):

  • ansi1252.txt (50):欧洲编码1252(在使用欧洲语言的Windows系统中,它将完整显示且不会失真)
  • unicode1.txt (102):双字节Unicode,开头是固有的Windows BOM 0xFFFE
  • unicode2.txt (100):没有BOM的双字节Unicode(一般来说,BOM是可选的)
  • unicode3.txt (102):双字节Unicode,开头是Unix固有的BOM 0xFEFF
  • utf8.txt (54):UTF-8编码

在OnStart函数中,我们将使用不同设置的FileOpen在循环中读取这些文件。请注意,通过使用FileHandle(在上一节中介绍过),我们不必担心关闭文件的问题:在每次迭代中一切都会自动进行。

c
void OnStart()
{
   Print("=====> UTF-8");
   for(int i = 0; i < ArraySize(texts); ++i)
   {
      FileHandle fh(FileOpen(texts[i], FILE_READ | FILE_TXT | FILE_ANSI, 0, CP_UTF8));
      Print(texts[i], " -> ", FileReadString(~fh));
   }
   
   Print("=====> Unicode");
   for(int i = 0; i < ArraySize(texts); ++i)
   {
      FileHandle fh(FileOpen(texts[i], FILE_READ | FILE_TXT | FILE_UNICODE));
      Print(texts[i], " -> ", FileReadString(~fh));
   }
   
   Print("=====> ANSI/1252");
   for(int i = 0; i < ArraySize(texts); ++i)
   {
      FileHandle fh(FileOpen(texts[i], FILE_READ | FILE_TXT | FILE_ANSI, 0, 1252));
      Print(texts[i], " -> ", FileReadString(~fh));
   }
}

FileReadString函数从文件中读取一个字符串。我们将在关于读写变量的部分介绍它。

以下是脚本执行结果的示例日志:

=====> UTF-8
MQL5Book/ansi1252.txt -> This is a text with special characters: ?? / ? / ?
MQL5Book/unicode1.txt -> This is a text with special characters: ±Σ / £ / ¥
MQL5Book/unicode2.txt -> T
MQL5Book/unicode3.txt -> ??
MQL5Book/utf8.txt -> This is a text with special characters: ±Σ / £ / ¥
=====> Unicode
MQL5Book/ansi1252.txt -> 桔獩椠⁳⁡整瑸眠瑩⁨灳捥慩档牡捡整獲›㾱⼠ꌠ⼠ꔠ
MQL5Book/unicode1.txt -> This is a text with special characters: ±Σ / £ / ¥
MQL5Book/unicode2.txt -> This is a text with special characters: ±Σ / £ / ¥
MQL5Book/unicode3.txt -> 吀栀椀猀 椀猀 愀 琀攀砀琀 眀椀琀栀 猀瀀攀挀椀愀氀 挀栀愀爀愀挀琀攀爀猀㨀 넀
MQL5Book/utf8.txt -> 桔獩椠⁳⁡整瑸眠瑩⁨灳捥慩档牡捡整獲›뇂ꏎ⼠술₣ ꗂ
=====> ANSI/1252
MQL5Book/ansi1252.txt -> This is a text with special characters: ±? / £ / ¥
MQL5Book/unicode1.txt -> This is a text with special characters: ±Σ / £ / ¥
MQL5Book/unicode2.txt -> T
MQL5Book/unicode3.txt -> þÿ
MQL5Book/utf8.txt -> This is a text with special characters: ±Σ / £ / ¥

unicode1.txt文件总是能被正确读取,因为它有BOM 0xFFFE,系统会忽略源代码中的设置。然而,如果缺少这个标记或者是大端字节序,这种自动检测就不起作用。此外,当设置为FILE_UNICODE时,我们就失去了读取单字节文本和UTF-8文本的能力。

因此,前面提到的FILE_ANSI和CP_UTF8的组合应该被认为对格式变化更具适应性。只有在明确需要时,才建议选择特定的国家代码页。

尽管在文本模式下使用文件时,API为程序员提供了很大的帮助,但如果有必要,我们可以避免使用FILE_TXT或FILE_CSV模式,而以二进制模式FILE_BINARY打开文本文件。这将把解析文本和确定编码的所有复杂性都交给程序员,但这将使他们能够支持其他非标准格式。但这里的重点是,可以从以二进制模式打开的文件中读取和写入文本。然而,一般情况下,反过来是不可能的。以文本模式打开的包含任意数据的二进制文件(这意味着,它不专门包含字符串)很可能会被解释为“乱码”文本。如果需要将二进制数据写入文本文件,首先要使用CryptEncode函数和CRYPT_BASE64编码。

数组的读写

MQL5中有两个函数用于读写数组:FileWriteArray和FileReadArray。对于二进制文件,它们允许处理除字符串外的任何内置类型的数组,以及不包含字符串字段、对象、指针和动态数组的简单结构数组。这些限制与读写过程的优化有关,由于排除了具有可变长度的类型,才使得这种优化成为可能。字符串、对象和动态数组就是属于可变长度的类型。

同时,在处理文本文件时,这些函数能够处理字符串类型的数组(在FILE_TXT/FILE_CSV模式的文件中,这些函数不允许处理其他类型的数组)。这样的数组以以下格式存储在文件中:每行一个元素。

如果需要在文件中存储没有类型限制的结构或类,请使用特定类型的函数,这些函数每次调用处理一个值。在关于读写内置类型变量的两个部分中(针对二进制文件和文本文件)对它们进行了描述。

此外,通过对信息存储的内部优化,可以实现对包含字符串的结构的支持。例如,您可以使用整数字段代替字符串字段,这些整数字段将包含在一个单独的字符串数组中相应字符串的索引。考虑到使用面向对象编程(OOP)工具重新定义许多操作(特别是赋值操作)以及按数字获取数组结构元素的可能性,算法的外观实际上不会改变。但是在写入时,您可以首先以二进制模式打开文件,并对具有简化结构类型的数组调用FileWriteArray,然后以文本模式重新打开文件,并使用第二次FileWriteArray调用将所有字符串的数组添加到其中。要读取这样的文件,应该在文件开头提供一个头,其中包含数组中的元素数量,以便将其作为count参数传递给FileReadArray(请参阅后文)。

如果需要保存或读取的不是结构数组,而是单个结构,请使用下一节中描述的FileWriteStruct和FileReadStruct函数。

让我们研究一下函数签名,然后看一个通用示例(FileArray.mq5)。

c
uint FileWriteArray(int handle, const void &array[], int start = 0, int count = WHOLE_ARRAY)

该函数将数组array写入具有句柄描述符的文件中。数组可以是多维的。start和count参数允许设置元素的范围;默认情况下,它等于整个数组。对于多维数组,起始索引start和元素数量count是指所有维度的连续编号,而不是数组的第一维度。例如,如果数组的配置为[][5],那么start值等于7将指向索引为[1][2]的元素,count = 2将把索引为[1][3]的元素添加到其中。

该函数返回写入的元素数量。如果出错,返回值将为0。

如果句柄是在二进制模式下获取的,数组可以是除字符串外的任何内置类型,或者简单结构类型。如果句柄是以任何文本模式打开的,数组必须是字符串类型。

c
uint FileReadArray(int handle, const void &array[], int start = 0, int count = WHOLE_ARRAY)

该函数从具有句柄描述符的文件中读取数据到数组中。数组可以是多维的和动态的。对于多维数组,start和count参数基于上述所有维度的元素连续编号来工作。如果需要,动态数组会自动增加大小以适应正在读取的数据。如果start大于数组的原始长度,在内存分配后,这些中间元素将包含随机数据(请参阅示例)。

请注意,该函数无法控制写入文件时使用的数组配置与读取时接收数组的配置是否匹配。基本上,不能保证正在读取的文件是使用FileWriteArray写入的。

为了检查数据结构的有效性,通常会使用文件内部一些预定义格式的初始头或其他描述符。这些函数本身将读取文件大小范围内的任何内容,并将其放置在指定的数组中。

如果句柄是在二进制模式下获取的,数组可以是任何内置的非字符串类型或简单结构类型。如果句柄是以文本模式打开的,数组必须是字符串类型。

让我们使用FileArray.mq5脚本检查在二进制和文本模式下的工作情况。为此,我们将预留两个文件名。

c
const string raw = "MQL5Book/array.raw";
const string txt = "MQL5Book/array.txt";

在OnStart函数中描述了三个long类型的数组和两个string类型的数组。每种类型中只有第一个数组被填充了数据,其余所有数组将在文件写入后检查读取情况。

c
void OnStart()
{
   long numbers1[][2] = {{1, 4}, {2, 5}, {3, 6}};
   long numbers2[][2];
   long numbers3[][2];
   
   string text1[][2] = {{"1.0", "abc"}, {"2.0", "def"}, {"3.0", "ghi"}};
   string text2[][2];
   ...

此外,为了测试对结构的操作,定义了以下3种类型:

c
struct TT
{
   string s1;
   string s2;
};
  
struct B
{
private:
   int b;
public:
   void setB(const int v) { b = v; }
};
  
struct XYZ : public B
{
   color x, y, z;
};

我们将无法在上述函数中使用TT类型的结构,因为它包含字符串字段。它用于在注释语句中演示潜在的编译错误(请参阅后文)。结构B和XYZ之间的继承关系,以及私有字段的存在,对FileWriteArray和FileReadArray函数来说都不是障碍。

这些结构用于声明一对数组:

c
TT tt[]; // 为空,因为数据不重要
   XYZ xyz[1];
   xyz[0].setB(-1);
   xyz[0].x = xyz[0].y = xyz[0].z = clrRed;

让我们从二进制模式开始。创建一个新文件或打开一个现有文件,清空其内容。然后,通过三次调用FileWriteArray,我们将尝试写入三个数组:numbers1、text1和xyz。

c
   int writer = PRTF(FileOpen(raw, FILE_BIN | FILE_WRITE)); // 1 / ok
   PRTF(FileWriteArray(writer, numbers1)); // 6 / ok
   PRTF(FileWriteArray(writer, text1)); // 0 / FILE_NOTTXT(5012)
   PRTF(FileWriteArray(writer, xyz)); // 1 / ok
   FileClose(writer);
   ArrayPrint(numbers1);

数组numbers1和xyz写入成功,写入的元素数量表明了这一点。text1数组写入失败,错误为FILE_NOTTXT(5012),因为字符串数组要求文件以文本模式打开。因此,xyz的内容将紧跟在numbers1的所有元素之后存储在文件中。

请注意,每个写入(或读取)函数从文件内的当前位置开始写入(或读取)数据,并根据写入或读取数据的大小移动指针。如果在写入操作之前此指针位于文件末尾,则文件大小会增加。如果在读取时到达文件末尾,指针不再移动,系统会引发特殊的内部错误代码5027(FILE_ENDOFFILE)。在大小为零的新文件中,开头和结尾是相同的。

从数组text1中写入了0个元素,所以文件中没有任何内容表明在两次成功的FileWriteArray调用之间有一次失败。

在测试脚本中,我们只是将函数的结果和状态(错误代码)输出到日志中,但在实际程序中,我们应该即时分析问题并采取一些行动:修改参数、文件设置中的某些内容,或者向用户发送消息中断进程。

让我们将文件读取到numbers2数组中。

c
   int reader = PRTF(FileOpen(raw, FILE_BIN | FILE_READ)); // 1 / ok
   PRTF(FileReadArray(reader, numbers2)); // 8 / ok
   ArrayPrint(numbers2);

由于向文件中写入了两个不同的数组(不仅有numbers1,还有xyz),所以有8个元素被读取到接收数组中(即读取了整个文件到末尾,因为没有使用参数指定其他情况)。

实际上,结构XYZ的大小是16字节(4个字段,每个字段4字节:一个int和三个color),这对应于数组numbers2中的一行(2个long类型的元素)。在这种情况下,这只是一个巧合。如前所述,这些函数不知道原始数据的配置和大小,可以将任何内容读取到任何数组中:程序员必须监控操作的有效性。

让我们比较初始状态和接收状态。源数组numbers1:

       [,0][,1]
   [0,]   1   4
   [1,]   2   5
   [2,]   3   6

结果数组numbers2:

                 [,0]          [,1]
   [0,]             1             4
   [1,]             2             5
   [2,]             3             6
   [3,] 1099511627775 1095216660735

numbers2数组的开头与原始的numbers1数组完全匹配,即通过文件进行的写入和读取操作正常工作。

最后一行完全被单个结构XYZ占据(值是正确的,但表示为两个long类型的数字是不正确的)。

现在我们回到文件开头(使用FileSeek函数,我们将在“文件内的位置控制”部分中讨论它),并调用FileReadArray指定元素的数量和数量,即我们进行部分读取。

c
   PRTF(FileSeek(reader, 0, SEEK_SET)); // true
   PRTF(FileReadArray(reader, numbers3, 10, 3));
   FileClose(reader);
   ArrayPrint(numbers3);

从文件中读取了三个元素,并从索引10开始放置到接收数组numbers3中。由于是从文件开头读取的,这些元素是值1、4、2。并且由于二维数组的配置为[][2],通过索引10指向元素[5,0]。这是它在内存中的样子:

       [,0][,1]
   [0,]   1   4
   [1,]   1   4
   [2,]   2   6
   [3,]   0   0
   [4,]   0   0
   [5,]   1   4
   [6,]   2   0

用黄色标记的元素是随机的(对于不同的脚本运行可能会改变)。它们可能全为零,但不能保证。numbers3数组最初为空,FileReadArray调用启动了在偏移量10处接收3个元素所需的内存分配(总共13个)。所选的块没有填充任何内容,并且仅从文件中读取了3个数字。因此,通过索引从0到9(即前5行)的元素,以及最后一个索引为13的元素,包含无效数据。

多维数组沿着第一维度进行扩展,因此增加1个数字意味着沿着更高维度添加整个配置。在这种情况下,分布涉及一系列两个数字([][2])。换句话说,请求的大小13向上舍入为2的倍数,即14。

最后,让我们测试这些函数如何处理字符串数组。创建一个新文件或打开一个现有文件,清空其内容。然后,通过两次调用FileWriteArray,我们将写入text1和numbers1数组。

c
   writer = PRTF(FileOpen(txt, FILE_TXT | FILE_ANSI | FILE_WRITE)); // 1 / ok
   PRTF(FileWriteArray(writer, text1)); // 6 / ok
   PRTF(FileWriteArray(writer, numbers1)); // 0 / FILE_NOTBIN(5011)
   FileClose(writer);

字符串数组成功保存。数字数组因错误FILE_NOTBIN(5011)而被忽略,因为它必须以二进制模式打开文件。

当尝试写入结构tt的数组时,我们得到一个冗长的编译错误消息“不允许包含对象的结构或类”。编译器实际上的意思是它不喜欢像string这样的字段(假定字符串和动态数组具有某些服务对象的内部表示)。因此,尽管文件是以文本模式打开的,并且结构中只有文本字段,但在MQL5中不支持这种组合。

c
   // 编译错误:不允许包含对象的结构或类
   FileWriteArray(writer, tt);

字符串字段的存在使结构变得“复杂”,不适合在任何模式下与FileWriteArray/FileReadArray函数一起使用。

运行脚本后,您可以切换到目录MQL5/Files/MQL5Book并检查生成文件的内容。

早些时候,在“简易模式下的文件读写”部分中,我们讨论了FileSave和FileLoad函数。在测试脚本(FileSaveLoad.mq5)中,我们使用FileWriteArray和FileReadArray实现了这些函数的等效版本。但我们还没有详细查看它们。由于我们现在熟悉了这些新函数,我们可以检查源代码:

c
template<typename T>
bool MyFileSave(const string name, const T &array[], const int flags = 0)
{
   const int h = FileOpen(name, FILE_BIN | FILE_WRITE | flags);
   if(h == INVALID_HANDLE) return false;
   FileWriteArray(h, array);
   FileClose(h);
   return true;
}
   
template<typename T>
long MyFileLoad(const string name, T &array[], const int flags = 0)
{
   const int h = FileOpen(name, FILE_BIN | FILE_READ | flags);
   if(h == INVALID_HANDLE) return -1;
   const uint n = FileReadArray(h, array, 0, (int)(FileSize(h) / sizeof(T)));
   // 与标准的FileLoad相比,此版本添加了以下检查:
   // 如果文件大小不是结构大小的倍数,则打印警告
   const ulong leftover = FileSize(h) - FileTell(h);
   if(leftover != 0)
   {
      PrintFormat("Warning from %s: Some data left unread: %d bytes", 
         __FUNCTION__, leftover);
      SetUserError((ushort)leftover);
   }
   FileClose(h);
   return n;
}

MyFileSave基于对FileWriteArray的单个调用,MyFileLoad基于对FileReadArray的调用,在一对FileOpen/FileClose调用之间。在这两种情况下,所有可用数据都被写入和读取。由于使用了模板,我们的函数也能够接受任意类型的数组。但是,如果推导出任何不受支持的类型(例如类)作为元参数T,则会发生编译错误,就像错误地访问内置函数时一样。

结构的读写(二进制文件)

在上一节中,我们学习了如何对结构数组执行输入/输出操作。当读写操作涉及单个结构时,使用FileWriteStruct和FileReadStruct这对函数会更加方便。

c
uint FileWriteStruct(int handle, const void &data, int size = -1)

该函数将简单数据结构的内容写入具有句柄描述符的二进制文件中。如我们所知,这样的结构只能包含内置非字符串类型的字段以及嵌套的简单结构。

该函数的主要特点是size参数。它有助于设置要写入的字节数,这使我们能够舍弃结构的某些部分(结构的尾部)。默认情况下,该参数的值为-1,这意味着保存整个结构。如果size大于结构的大小,超出的部分将被忽略,即仅写入结构本身,也就是sizeof(data)字节。

如果成功,该函数返回写入的字节数;如果出错,返回0。

c
uint FileReadStruct(int handle, void &data, int size = -1)

该函数从具有句柄描述符的二进制文件中读取内容到数据结构中。size参数指定要读取的字节数。如果未指定该参数或该参数超过结构的大小,则使用指定结构的确切大小。

如果成功,该函数返回读取的字节数;如果出错,返回0。

只有FileWriteStruct和FileReadStruct函数具有截断结构尾部的选项。因此,在循环中使用它们是保存和读取经过修剪的结构数组的最合适的替代方法:FileWriteArray和FileReadArray函数不具备此功能,并且逐个字段进行读写可能会更耗费资源(我们将在后续部分查看相应的函数)。

需要注意的是,为了使用此功能,您设计结构时应确保所有不应该保存的临时和中间计算字段都位于结构的末尾。

让我们看一下在脚本FileStruct.mq5中使用这两个函数的示例。

假设我们希望不时地对最新的报价进行存档,以便能够在将来检查它们的不变性,或者与其他提供商的类似时间段进行比较。基本上,这可以通过MetaTrader 5中的“符号”对话框(在“柱线”选项卡中)手动完成。但这需要额外的努力并且要遵循一定的时间表。从程序中自动完成会容易得多。此外,手动导出的报价是以CSV文本格式完成的,而我们可能需要将文件发送到外部服务器。因此,最好以紧凑的二进制形式保存它们。除此之外,假设我们对有关报价点、点差和实际交易量的信息不感兴趣(对于外汇符号,这些信息总是为空)。

在“数组中的比较、排序和搜索”部分中,我们考虑了MqlRates结构和CopyRates函数。稍后将对它们进行详细描述,而现在我们将再次使用它们作为文件操作的测试基础。

使用FileWriteStruct中的size参数,我们可以仅保存MqlRates结构的一部分,不包括最后几个字段。

在脚本的开头,我们定义宏和测试文件名。

c
#define BARLIMIT 10 // 要写入的柱线数量
#define HEADSIZE 10 // 我们格式的头部大小 
const string filename = "MQL5Book/struct.raw";

HEADSIZE常量特别值得关注。如前所述,文件函数本身并不负责文件中数据的一致性以及读取这些数据的结构类型。程序员必须在他们的代码中提供这样的控制。因此,通常会在文件开头写入某个头部信息,借助它,首先可以确保这是所需格式的文件,其次可以在其中保存正确读取所需的元信息。

特别是,头部可能指示记录的数量。严格来说,后者并不总是必要的,因为我们可以逐渐读取文件直到结束。然而,根据头部中的计数器一次性为所有预期的记录分配内存会更有效率。

为了我们的目的,我们开发了一个简单的结构FileHeader。

c
struct FileHeader
{
   uchar signature[HEADSIZE];
   int n;
   FileHeader(const int size = 0) : n(size)
   {
      static uchar s[HEADSIZE] = {'C','A','N','D','L','E','S','1','.','0'};
      ArrayCopy(signature, s);
   }
};

它以文本签名“CANDLES”(在signature字段中)、版本号“1.0”(在相同位置)以及记录数量(n字段)开头。由于我们不能对签名使用字符串字段(否则该结构将不再是简单结构,无法满足文件函数的要求),实际上文本被打包到固定大小为HEADSIZE的uchar数组中。实例中的初始化由构造函数基于局部静态副本完成。

在OnStart函数中,我们请求最后BARLIMIT个柱线的数据,以FILE_WRITE模式打开文件,并将头部信息以及经过截断形式的结果报价写入文件。

c
void OnStart()
{
   MqlRates rates[], candles[];
   int n = PRTF(CopyRates(_Symbol, _Period, 0, BARLIMIT, rates)); // 10 / ok
   if(n < 1) return;
  
   // 创建一个新文件或从头覆盖旧文件
   int handle = PRTF(FileOpen(filename, FILE_BIN | FILE_WRITE)); // 1 / ok
  
 FileHeaderfh(n);// 包含实际记录数量的头部
  
   // 首先写入头部
   PRTF(FileWriteStruct(handle, fh)); // 14 / ok
  
   // 然后写入数据
   for(int i = 0; i < n; ++i)
   {
      FileWriteStruct(handle, rates[i], offsetof(MqlRates, tick_volume));
   }
   FileClose(handle);
   ArrayPrint(rates);
   ...

作为FileWriteStruct函数中size参数的值,我们使用了一个带有熟悉的offsetof操作符的表达式:offsetof(MqlRates, tick_volume),即写入文件时,从tick_volume字段开始的所有字段都将被舍弃。

为了测试数据读取,让我们以FILE_READ模式打开同一个文件并读取FileHeader结构。

c
   handle = PRTF(FileOpen(filename, FILE_BIN | FILE_READ)); // 1 / ok
   FileHeader reference, reader;
   PRTF(FileReadStruct(handle, reader)); // 14 / ok
   // 如果头部不匹配,则不是我们的数据
   if(ArrayCompare(reader.signature, reference.signature))
   {
      Print("Wrong file format; 'CANDLES' header is missing");
      return;
   }

reference结构包含未更改的默认头部(签名)。reader结构从文件中获取了14字节。如果两个签名匹配,我们可以继续处理,因为文件格式正确,并且reader.n字段包含从文件中读取的记录数量。我们为接收数组candles分配并清零所需大小的内存,然后将所有记录读取到其中。

c
   PrintFormat("Reading %d candles...", reader.n);
 ArrayResize(candles, reader.n);// 提前为预期数据分配内存
   ZeroMemory(candles);
   
   for(int i = 0; i < reader.n; ++i)
   {
      FileReadStruct(handle, candles[i], offsetof(MqlRates, tick_volume));
   }
   FileClose(handle);
   ArrayPrint(candles);
}

清零操作是必要的,因为MqlRates结构是部分读取的,如果不清零,剩余的字段将包含无效数据。

以下是显示XAUUSD,H1初始数据(完整)的日志。

                 [time]  [open]  [high]   [low] [close] [tick_volume] [spread] [real_volume]

[0] 2021.08.16 03:00:00 1778.86 1780.58 1778.12 1780.56          3049        5             0

[1] 2021.08.16 04:00:00 1780.61 1782.58 1777.10 1777.13          4633        5             0

[2] 2021.08.16 05:00:00 1777.13 1780.25 1776.99 1779.21          3592        5             0

[3] 2021.08.16 06:00:00 1779.26 1779.26 1776.67 1776.79          2535        5             0

[4] 2021.08.16 07:00:00 1776.79 1777.59 1775.50 1777.05          2052        6             0

[5] 2021.08.16 08:00:00 1777.03 1777.19 1772.93 1774.35          3213        5             0

[6] 2021.08.16 09:00:00 1774.38 1775.41 1771.84 1773.33          4527        5             0

[7] 2021.08.16 10:00:00 1773.26 1777.42 1772.84 1774.57          4514        5             0

[8] 2021.08.16 11:00:00 1774.61 1776.67 1773.69 1775.95          3500        5             0

[9] 2021.08.16 12:00:00 1775.96 1776.12 1773.68 1774.44          2425        5             0

现在让我们看看读取到了什么。

                 [time]  [open]  [high]   [low] [close] [tick_volume] [spread] [real_volume]

[0] 2021.08.16 03:00:00 1778.86 1780.58 1778.12 1780.56             0        0             0

[1] 2021.08.16 04:00:00 1780.61 1782.58 1777.10 1777.13             0        0             0

[2] 2021.08.16 05:00:00 1777.13 1780.25 1776.99 1779.21             0        0             0

[3] 2021.08.16 06:00:00 1779.26 1779.26 1776.67 1776.79             0        0             0

[4] 2021.08.16 07:00:00 1776.79 1777.59 1775.50 1777.05             0        0             0

[5] 2021.08.16 08:00:00 1777.03 1777.19 1772.93 1774.35             0        0             0

[6] 2021.08.16 09:00:00 1774.38 1775.41 1771.84 1773.33             0        0             0

[7] 2021.08.16 10:00:00 1773.26 1777.42 1772.84 1774.57             0        0             0

[8] 2021.08.16 11:00:00 1774.61 1776.67 1773.69 1775.95             0        0             0

[9] 2021.08.16 12:00:00 1775.96 1776.12 1773.68 1774.44             0        0             0

报价数据匹配,但每个结构中的最后三个字段为空。

您可以打开MQL5/Files/MQL5Book文件夹并检查struct.raw文件的内部表示(使用支持二进制模式的查看器;示例如下)。

在外部查看器中呈现带有报价存档的二进制文件的选项

在外部查看器中呈现带有报价存档的二进制文件的选项

这是显示二进制文件的典型方式:左列显示地址(相对于文件开头的偏移量),中间列是字节码,右列显示相应字节的符号表示。第一列和第二列使用十六进制表示数字。右列中的字符可能会因所选的ANSI代码页而异。只有在已知存在文本的片段中才值得关注它们。在我们的例子中,签名“CANDLES1.0”在开头清晰可见。应该通过中间列分析数字。例如,在签名之后,您可以看到4字节的值0x0A000000,即倒置形式的0x0000000A(记得“整数中的字节序控制”部分):这是10,即写入的结构数量。

变量的读写(二进制)

如果一个结构体包含的字段类型对于简单结构体来说是不允许的(如字符串、动态数组、指针),那么就无法使用之前讨论过的函数将其写入文件或从文件中读取。类对象的情况也是如此。然而,这类实体通常包含程序中的大部分数据,并且也需要保存和恢复它们的状态。

通过上一节中头部结构体的示例可以清楚地看到,字符串(以及其他可变长度类型)是可以避免使用的,但在这种情况下,就必须设计出更繁琐的替代算法实现方式(例如,用字符数组来代替字符串)。

为了读写任意复杂程度的数据,MQL5提供了一组较低级别的函数,这些函数操作特定类型的单个值:双精度浮点数(double)、单精度浮点数(float)、整型/无符号整型(int/uint)、长整型/无符号长整型(long/ulong)或字符串(string)。所有其他内置的MQL5类型都等同于不同大小的整数:字符型/无符号字符型(char/uchar)为1字节,短整型/无符号短整型(short/ushort)为2字节,颜色(color)为4字节,枚举(enumerations)为4字节,日期时间(datetime)为8字节。这样的函数可以称为原子函数(即不可分割的),因为在比特级别进行文件读写的函数已经不存在了。

当然,逐个元素地写入或读取也消除了对动态数组进行文件操作的限制。

至于对象指针,按照面向对象编程(OOP)范式的思想,我们可以允许它们保存和恢复对象:只需在每个类中实现一个接口(一组方法),该接口负责将重要内容传输到文件中以及从文件中读取回来,并使用低级函数即可。然后,如果在对象中遇到指向另一个对象的指针字段,我们只需将保存或读取操作委托给它,反过来,它将处理自身的字段,其中可能还存在其他指针,并且这种委托会不断深入,直到涵盖所有元素。

请注意,在本节中我们将介绍用于二进制文件的原子函数。它们对应于文本文件的函数将在下一节中介绍。本节中的所有函数都返回写入的字节数,如果出错则返回0。

c
uint FileWriteDouble(int handle, double value)
c
uint FileWriteFloat(int handle, float value)
c
uint FileWriteLong(int handle, long value)

这些函数将参数value中传入的相应类型(双精度浮点数、单精度浮点数、长整型)的值写入具有句柄描述符的二进制文件中。

c
uint FileWriteInteger(int handle, int value, int size = INT_VALUE)

该函数将整数值写入具有句柄描述符的二进制文件中。值的字节大小由size参数设置,可以是以下预定义常量之一:CHAR_VALUE(1)、SHORT_VALUE(2)、INT_VALUE(4,默认值),分别对应于charshortint(有符号和无符号)类型。

该函数支持一种未记录的3字节整数写入模式。不建议使用这种模式。

文件指针会移动写入的字节数(而不是int类型的大小)。

c
uint FileWriteString(int handle, const string value, int length = -1)

该函数将value参数中的字符串写入具有句柄描述符的二进制文件中。可以通过length参数指定要写入的字符数。如果length小于字符串的长度,则只有字符串的指定部分会被写入文件。如果length为-1或未指定,则整个字符串会被写入文件,且不包含终止空字符。如果length大于字符串的长度,则多余的字符会用零填充。

请注意,当写入以FILE_UNICODE标志打开的文件(或未使用FILE_ANSI标志打开的文件)时,字符串会以Unicode格式保存(每个字符占用2字节)。当写入以FILE_ANSI标志打开的文件时,每个字符占用1字节(外语字符可能会失真)。

FileWriteString函数也可以用于文本文件。其在这方面的应用将在下一节中介绍。

c
double FileReadDouble(int handle)
c
float FileReadFloat(int handle)
c
long FileReadLong(int handle)

这些函数从具有指定描述符的二进制文件中读取相应类型(双精度浮点数、单精度浮点数或长整型)的数字。如果需要,会将结果转换为ulong(如果在文件的该位置预期是一个无符号长整型)。

c
int FileReadInteger(int handle, int size = INT_VALUE)

该函数从具有句柄描述符的二进制文件中读取整数值。值的字节大小在size参数中指定。

由于该函数的结果是int类型,如果目标类型与int不同(即uintshort/ushortchar/uchar),则必须显式地将其转换为所需的目标类型。否则,至少会得到一个编译器警告,最严重的情况下可能会导致符号丢失。

实际上,当读取CHAR_VALUESHORT_VALUE时,默认结果总是正数(即对应于ucharushort,它们完全可以“容纳”在int中)。在这些情况下,如果数字实际上是ucharushort类型,编译器警告只是名义上的,因为我们已经确定在int类型的值内部只有1或2个低字节被填充,并且它们是无符号的。这样不会产生失真。

然而,当在文件中存储有符号值(charshort类型)时,转换就变得必要了,因为如果不进行转换,负数将变成具有相同位表示的反相正数(请参阅算术类型转换部分中的“有符号和无符号整数”部分)。

无论如何,最好通过显式类型转换来避免警告。

该函数支持3字节整数读取模式。不建议使用这种模式。

文件指针会移动读取的字节数(而不是size所表示的int大小)。

c
string FileReadString(int handle, int size = -1)

该函数从具有句柄描述符的文件中读取指定字符数大小的字符串。在处理二进制文件时必须设置size参数(默认值仅适用于使用分隔字符的文本文件)。否则,不会读取字符串(函数返回空字符串),并且内部错误代码_LastError为5016(FILE_BINSTRINGSIZE)。

因此,即使在将字符串写入二进制文件的阶段,也需要考虑如何读取该字符串。主要有三种选择:

  1. 写入末尾带有空终止字符的字符串。在这种情况下,必须在循环中逐个字符地分析,并将字符组合成字符串,直到遇到0为止。
  2. 始终写入固定(预定义)长度的字符串。长度应该在大多数情况下留有一定余量,或者根据规范(需求说明、协议等)来选择,但这样不经济,并且不能100%保证某些罕见的字符串在写入文件时不会被截断。
  3. 在字符串之前写入长度作为整数。

FileReadString函数也可以用于文本文件。其在这方面的应用将在下一节中介绍。

还请注意,如果size参数为0(在某些计算过程中可能会出现这种情况),则函数不会进行读取:文件指针保持在原来的位置,函数返回一个空字符串。

作为本节的一个示例,我们将改进上一节中的FileStruct.mq5脚本。新的程序名称为FileAtomic.mq5

任务仍然是:将给定数量的带有报价的截断MqlRates结构体保存到一个二进制文件中。但现在FileHeader结构体将变成一个类(并且格式签名将存储在一个字符串中,而不是字符数组中)。这种类型的头部和一个报价数组将是另一个控制类Candles的一部分,并且这两个类都将从Persistent接口继承,以便将任意对象写入文件和从文件中读取。

以下是接口:

c
interface Persistent
{
   bool write(int handle);
   bool read(int handle);
};

FileHeader类中,我们将实现格式签名(将其更改为“CANDLES/1.1”)以及当前符号名称和图表时间框架名称(关于_Symbol_Period的更多信息)的保存和检查。

写入操作在从接口继承的write方法中实现。

c
class FileHeader : public Persistent
{
   const string signature;
public:
   FileHeader() : signature("CANDLES/1.1") { }
   bool write(int handle) override
   {
      PRTF(FileWriteString(handle, signature, StringLen(signature)));
      PRTF(FileWriteInteger(handle, StringLen(_Symbol), CHAR_VALUE));
      PRTF(FileWriteString(handle, _Symbol));
      PRTF(FileWriteString(handle, PeriodToString(), 3));
      return true;
   }

签名会按照其确切长度写入,因为样本存储在对象中,并且在读取时也会设置相同的长度。

对于当前图表的交易品种,我们首先将其名称长度保存到文件中(对于长度不超过255的名称,1字节就足够了),然后才保存字符串本身。

时间框架名称如果不包括常量前缀“PERIOD_”,则永远不会超过3个字符,因此为该字符串选择了固定长度。去除前缀后的时间框架名称是在辅助函数PeriodToString中获得的:它在一个单独的头文件Periods.mqh中(在“符号和时间框架”部分将更详细地讨论)。

读取操作在read方法中以相反的顺序执行(当然,假设读取将在一个不同的新对象中进行)。

c
   bool read(int handle) override
   {
      const string sig = PRTF(FileReadString(handle, StringLen(signature)));
      if(sig != signature)
      {
         PrintFormat("Wrong file format, header is missing: want=%s vs got %s", 
            signature, sig);
         return false;
      }
      const int len = PRTF(FileReadInteger(handle, CHAR_VALUE));
      const string sym = PRTF(FileReadString(handle, len));
      if(_Symbol != sym)
      {
         PrintFormat("Wrong symbol: file=%s vs chart=%s", sym, _Symbol);
         return false;
      }
      const string stf = PRTF(FileReadString(handle, 3));
      if(_Period != StringToPeriod(stf))
      {
         PrintFormat("Wrong timeframe: file=%s(%s) vs chart=%s", 
            stf, EnumToString(StringToPeriod(stf)), EnumToString(_Period));
         return false;
      }
      return true;
   }

如果文件中的任何属性(签名、符号、时间框架)与当前图表上的不匹配,函数将返回false以指示错误。

将时间框架名称反向转换为ENUM_TIMEFRAMES枚举的操作是由函数StringToPeriod完成的,该函数也在文件Periods.mqh中。

用于请求、保存和读取报价存档的主要Candles类如下:

c
class Candles : public Persistent
{
   FileHeader header;
   int limit;
   MqlRates rates[];
public:
   Candles(const int size = 0) : limit(size)
   {
      if(size == 0) return;
      int n = PRTF(CopyRates(_Symbol, _Period, 0, limit, rates));
      if(n < 1)
      {
 limit =0; // 初始化失败
      }
 limit =n; // 可能小于请求的数量
   }

这些字段包括FileHeader类型的头部、请求的柱线数量limit,以及一个从MetaTrader 5接收MqlRates结构体的数组。数组在构造函数中填充。如果发生错误,limit字段将重置为零。

由于Candles类派生自Persistent接口,因此需要实现writeread方法。在write方法中,我们首先指示头部对象保存自身,然后将报价数量、日期范围(供参考)以及数组本身追加到文件中。

c
   bool write(int handle) override
   {
      if(!limit) return false; // 没有数据
      if(!header.write(handle)) return false;
      PRTF(FileWriteInteger(handle, limit));
      PRTF(FileWriteLong(handle, rates[0].time));
      PRTF(FileWriteLong(handle, rates[limit - 1].time));
      for(int i = 0; i < limit; ++i)
      {
         FileWriteStruct(handle, rates[i], offsetof(MqlRates, tick_volume));
      }
      return true;
   }

读取操作以相反的顺序进行:

c
   bool read(int handle) override
   {
      if(!header.read(handle))
      {
         return false;
      }
      limit = PRTF(FileReadInteger(handle));
      ArrayResize(rates, limit);
      ZeroMemory(rates);
      // 需要读取日期:它们不被使用,但这会移动文件中的位置;
      // 本可以显式地更改位置,但这个函数还没有学习过
      datetime dt0 = (datetime)PRTF(FileReadLong(handle));
      datetime dt1 = (datetime)PRTF(FileReadLong(handle));
      for(int i = 0; i < limit; ++i)
      {
         FileReadStruct(handle, rates[i], offsetof(MqlRates, tick_volume));
      }
      return true;
   }

在一个用于存档报价的实际程序中,日期范围的存在将允许通过文件头部在较长的历史记录中构建它们的正确顺序,并且在一定程度上可以防止文件被随意重命名。

有一个简单的打印方法来控制这个过程:

c
   void print() const
   {
      ArrayPrint(rates);
   }

在脚本的主函数中,我们创建两个Candles对象,并且使用其中一个对象,首先保存报价存档,然后使用另一个对象将其恢复。文件由我们已经知道的包装器FileHandle管理(请参阅“文件描述符管理”部分)。

c
const string filename = "MQL5Book/atomic.raw";
  
void OnStart()
{
   // 创建一个新文件并覆盖旧文件
   FileHandle handle(PRTF(FileOpen(filename, 
      FILE_BIN | FILE_WRITE | FILE_ANSI | FILE_SHARE_READ)));
   // 生成数据
   Candles output(BARLIMIT);
   // 将数据写入文件
   if(!output.write(~handle))
   {
      Print("Can't write file");
      return;
   }
   output.print();
  
   // 打开新创建的文件进行检查
   handle = PRTF(FileOpen(filename, 
      FILE_BIN | FILE_READ | FILE_ANSI | FILE_SHARE_READ | FILE_SHARE_WRITE));
   // 创建一个空对象来接收报价
   Candles inputs;
   // 从文件中读取数据到对象中
   if(!inputs.read(~handle))
   {
      Print("Can't read file");
   }
   else
   {
      inputs.print();
   }

以下是XAUUSD,H1的初始数据日志示例:

FileOpen(filename,FILE_BIN|FILE_WRITE|FILE_ANSI|FILE_SHARE_READ)=1 / 成功

CopyRates(_Symbol,_Period,0,limit,rates)=10 / 成功

FileWriteString(handle,signature,StringLen(signature))=11 / 成功

FileWriteInteger(handle,StringLen(_Symbol),CHAR_VALUE)=1 / 成功

FileWriteString(handle,_Symbol)=6 / 成功

FileWriteString(handle,PeriodToString(),3)=3 / 成功

FileWriteInteger(handle,limit)=4 / 成功

FileWriteLong(handle,rates[0].time)=8 / 成功

FileWriteLong(handle,rates[limit-1].time)=8 / 成功

                 [time]  [open]  [high]   [low] [close] [tick_volume] [spread] [real_volume]

[0] 2021.08.17 15:00:00 1791.40 1794.57 1788.04 1789.46          8157        5             0

[1] 2021.08.17 16:00:00 1789.46 1792.99 1786.69 1789.69          9285        5             0

[2] 2021.08.17 17:00:00 1789.76 1790.45 1780.95 1783.30          8165        5             0

[3] 2021.08.17 18:00:00 1783.30 1783.98 1780.53 1782.73          5114        5             0

[4] 2021.08.17 19:00:00 1782.69 1784.16 1782.09 1782.49          3586        6             0

[5] 2021.08.17 20:00:00 1782.49 1786.23 1782.17 1784.23          3515        5             0
[6] 2021.08.17 20:00:00 1782.49 1786.23 1782.17 1784.23          3515        5             0

[7] 2021.08.17 21:00:00 1784.20 1784.85 1782.73 1783.12          2627        6             0

[8] 2021.08.17 22:00:00 1783.10 1785.52 1782.37 1785.16          2114        5             0

[9] 2021.08.17 23:00:00 1785.11 1785.84 1784.71 1785.80           922        5             0

[10] 2021.08.18 01:00:00 1786.30 1786.34 1786.18 1786.20            13        5             0

以下是恢复数据的示例(回想一下,根据我们假设的技术任务,这些结构体是以截断形式保存的):

FileOpen(filename,FILE_BIN|FILE_READ|FILE_ANSI|FILE_SHARE_READ|FILE_SHARE_WRITE)=2 / 成功

FileReadString(handle,StringLen(signature))=CANDLES/1.1 / 成功

FileReadInteger(handle,CHAR_VALUE)=6 / 成功

FileReadString(handle,len)=XAUUSD / 成功

FileReadString(handle,3)=H1 / 成功

FileReadInteger(handle)=10 / 成功

FileReadLong(handle)=1629212400 / 成功

FileReadLong(handle)=1629248400 / 成功

             [time]  [open]  [high]   [low] [close] [tick_volume] [spread] [real_volume]

[0] 2021.08.17 15:00:00 1791.40 1794.57 1788.04 1789.46 0 0 0

[1] 2021.08.17 16:00:00 1789.46 1792.99 1786.69 1789.69 0 0 0

[2] 2021.08.17 17:00:00 1789.76 1790.45 1780.95 1783.30 0 0 0

[3] 2021.08.17 18:00:00 1783.30 1783.98 1780.53 1782.73 0 0 0

[4] 2021.08.17 19:00:00 1782.69 1784.16 1782.09 1782.49 0 0 0

[5] 2021.08.17 20:00:00 1782.49 1786.23 1782.17 1784.23 0 0 0

[6] 2021.08.17 21:00:00 1784.20 1784.85 1782.73 1783.12 0 0 0

[7] 2021.08.17 22:00:00 1783.10 1785.52 1782.37 1785.16 0 0 0

[8] 2021.08.17 23:00:00 1785.11 1785.84 1784.71 1785.80 0 0 0

[9] 2021.08.18 01:00:00 1786.30 1786.34 1786.18 1786.20 0 0 0

很容易确认数据被正确地存储和读取了。现在让我们看看文件内部的数据是什么样的:

使用外部程序查看包含报价存档的二进制文件的内部结构

使用外部程序查看包含报价存档的二进制文件的内部结构

在这里,我们头部的各种字段用颜色进行了标记:签名、符号名称长度、符号名称、时间框架名称等等。

变量的读写(文本文件)

文本文件有其自己的一组用于原子(逐个元素)保存和读取数据的函数。这与上一节中介绍的二进制文件的函数略有不同。还应该注意的是,没有用于将结构体或结构体数组写入文本文件或从文本文件中读取的类似函数。如果你尝试对文本文件使用这些函数中的任何一个,它们将不会产生任何效果,并且会引发内部错误代码5011(FILE_NOTBIN)。

我们已经知道,MQL5中的文本文件有两种形式:纯文本和CSV格式的文本。在打开文件时设置相应的模式,即FILE_TXT或FILE_CSV,并且在不关闭并重新获取句柄的情况下无法更改该模式。它们之间的差异仅在读取文件时才会显现。两种模式的写入方式是相同的。

在TXT模式下,每次调用读取函数(我们将在本节中介绍的任何一个函数)都会在文件中找到下一个换行符(一个‘\n’字符或一对‘\r\n’),并处理直到该换行符之前的所有内容。处理的重点是将文件中的文本转换为与调用函数相对应的特定类型的值。在最简单的情况下,如果调用了FileReadString函数,则不会执行任何处理(字符串将按原样返回)。

在CSV模式下,每次调用读取函数时,文件中的文本在逻辑上不仅会按换行符进行分割,还会按打开文件时指定的附加分隔符进行分割。从文件当前位置到最近分隔符的片段的其余处理过程是类似的。

换句话说,读取文本并在文件内移动内部位置是按从一个分隔符到下一个分隔符的片段进行的,这里的分隔符不仅指FileOpen参数列表中的分隔符字符,还包括换行符(‘\n’,‘\r\n’)以及文件的开头和结尾。

附加分隔符对向FILE_TXT和FILE_CSV文件写入文本具有相同的影响,但仅在使用FileWrite函数时才会体现:它会自动在记录的元素之间插入该字符。FileWriteString函数的分隔符将被忽略。

让我们先查看这些函数的正式描述,然后看一下FileTxtCsv.mq5中的示例。

c
uint FileWrite(int handle,...)

该函数属于接受可变数量参数的函数类别。在函数原型中,此类参数用省略号表示。仅支持内置数据类型。要写入结构体或类对象,必须解引用它们的元素并单独传递。

该函数将第一个参数之后传递的所有参数写入具有句柄描述符的文本文件中。参数之间用逗号分隔,就像在普通参数列表中一样。输出到文件的参数数量不能超过63个。

在输出时,数值数据将根据转换为(string)的标准规则转换为文本格式。double类型的值将以16位有效数字输出,采用传统格式或科学指数格式(选择更紧凑的选项)。float类型的数据将以7位有效数字的精度显示。要以不同的精度或以显式指定的格式显示实数,请使用DoubleToString函数(请参阅“数字转换为字符串及反之”)。

datetime类型的值将以“YYYY.MM.DD hh:mm:ss”格式输出(请参阅“日期和时间”)。

标准颜色(来自网页颜色列表)将显示为名称,非标准颜色将显示为RGB分量值的三元组(请参阅“颜色”),并用逗号分隔(注意:逗号是CSV中最常见的分隔符字符)。

对于枚举类型,将显示表示元素的整数,而不是其标识符(名称)。例如,当写入FRIDAY(来自ENUM_DAY_OF_WEEK,请参阅“枚举”)时,我们在文件中得到的数字是5。

bool类型的值将以字符串“true”或“false”输出。

如果在打开文件时指定了非0的分隔符字符,则该字符将插入到由相应参数转换得到的两个相邻行之间。

一旦所有参数都写入文件,将添加一个行终止符‘\r\n’。

该函数返回写入的字节数,如果出错则返回0。

c
uint FileWriteString(int handle, const string text, int length = -1)

该函数将文本字符串参数写入具有句柄描述符的文本文件中。length参数仅适用于二进制文件,在此上下文中将被忽略(整行将被完整写入)。

FileWriteString函数也可以用于二进制文件。该函数在这方面的应用已在上一节中介绍。

任何分隔符(行中元素之间的分隔符)和换行符都必须由程序员插入/添加。

该函数返回写入的字节数(在FILE_UNICODE模式下,这将是字符串字符长度的2倍),如果出错则返回0。

c
string FileReadString(int handle, int length = -1)

该函数从具有句柄描述符的文件中读取直到下一个分隔符的字符串(CSV文件中的分隔符字符、任何文件中的换行符,或者直到文件结束)。length参数仅适用于二进制文件,在此上下文中将被忽略。

得到的字符串可以使用标准的类型转换规则或使用转换函数转换为所需类型的值。或者,也可以使用专门的读取函数:下面将介绍FileReadBool、FileReadDatetime、FileReadNumber。

如果出错,将返回一个空字符串。可以通过变量_LastError或函数GetLastError找到错误代码。特别是,当到达文件末尾时,错误代码将是5027(FILE_ENDOFFILE)。

c
bool FileReadBool(int handle)

该函数读取CSV文件中直到下一个分隔符的片段,或者直到行尾,并将其转换为bool类型的值。如果片段包含文本“true”(任何大小写形式,包括混合大小写,例如“True”),或者一个非零数字,则返回true。在其他情况下,返回false。

单词“true”必须占据整个读取的元素。即使字符串以“true”开头,但有后续内容(例如“True Volume”),也将返回false。

c
datetime FileReadDatetime(int handle)

该函数从CSV文件中读取以下格式之一的字符串:“YYYY.MM.DD hh:mm:ss”、“YYYY.MM.DD”或“hh:mm:ss”,并将其转换为datetime类型的值。如果片段不包含有效的日期和/或时间的文本表示形式,该函数将返回零或“奇怪”的时间,具体取决于它可以将哪些字符解释为日期和时间片段。对于空字符串或非数字字符串,将得到当前日期且时间为零。

通过组合两个函数StringToTime(FileReadString(handle)),可以实现更灵活的日期和时间读取(支持更多格式)。有关StringToTime的更多详细信息,请参阅“日期和时间”。

c
double FileReadNumber(int handle)

该函数从CSV文件中读取直到下一个分隔符或直到行尾的片段,并根据标准类型转换规则将其转换为double类型的值。

请注意,double类型可能会丢失非常大的值的精度,这可能会影响long/ulong类型的大数字的读取(double内部整数失真的值是9007199254740992:在“联合类型”部分给出了这种现象的一个示例)。

上一节中讨论的函数,包括FileReadDouble、FileReadFloat、FileReadInteger、FileReadLong和FileReadStruct,不能应用于文本文件。

FileTxtCsv.mq5脚本演示了如何处理文本文件。上次我们将报价上传到了二进制文件中。现在让我们以TXT和CSV格式来做这件事。

基本上,MetaTrader 5允许从“符号”对话框中以CSV格式导出和导入报价。但出于教学目的,我们将重现这个过程。此外,软件实现允许偏离默认生成的确切格式。下面显示了以标准方式导出的XAUUSD H1历史数据的一个片段。

<DATE> » <TIME> » <OPEN> » <HIGH> » <LOW> » <CLOSE> » <TICKVOL> » <VOL> » <SPREAD>
2021.01.04 » 01:00:00 » 1909.07 » 1914.93 » 1907.72 » 1913.10 » 4230 » 0 » 5
2021.01.04 » 02:00:00 » 1913.04 » 1913.64 » 1909.90 » 1913.41 » 2694 » 0 » 5
2021.01.04 » 03:00:00 » 1913.41 » 1918.71 » 1912.16 » 1916.61 » 6520 » 0 » 5
2021.01.04 » 04:00:00 » 1916.60 » 1921.89 » 1915.49 » 1921.79 » 3944 » 0 » 5
2021.01.04 » 05:00:00 » 1921.79 » 1925.26 » 1920.82 » 1923.19 » 3293 » 0 » 5
2021.01.04 » 06:00:00 » 1923.20 » 1923.71 » 1920.24 » 1922.67 » 2146 » 0 » 5
2021.01.04 » 07:00:00 » 1922.66 » 1922.99 » 1918.93 » 1921.66 » 3141 » 0 » 5
2021.01.04 » 08:00:00 » 1921.66 » 1925.60 » 1921.47 » 1922.99 » 3752 » 0 » 5
2021.01.04 » 09:00:00 » 1922.99 » 1925.54 » 1922.47 » 1924.80 » 2895 » 0 » 5
2021.01.04 » 10:00:00 » 1924.85 » 1935.16 » 1924.59 » 1932.07 » 6132 » 0 » 5

特别是在这里,我们可能对默认的分隔符字符(制表符,表示为‘"’)、列的顺序,或者日期和时间被分成两个字段的情况不满意。

在我们的脚本中,我们将选择逗号作为分隔符,并且我们将按照MqlRates结构体的字段顺序生成列。将在FILE_TXT和FILE_CSV模式下进行导出和后续的测试读取。

c
const string txtfile = "MQL5Book/atomic.txt";
const string csvfile = "MQL5Book/atomic.csv";
const short delimiter = ',';

在OnStart函数的开头,将以标准方式请求报价:

c
void OnStart()
{
   MqlRates rates[];   
   int n = PRTF(CopyRates(_Symbol, _Period, 0, 10, rates)); // 10

我们将分别在数组中指定列的名称,并且还将使用辅助函数StringCombine将它们组合起来。需要单独的标题,因为我们使用可选择的分隔符字符将它们组合成一个通用标题(另一种解决方案可以基于StringReplace)。我们鼓励你独立研究StringCombine的源代码:它执行与内置的StringSplit相反的操作。

c
   const string columns[] = {"DateTime", "Open", "High", "Low", "Close", 
                             "Ticks", "Spread", "True"};
   const string caption = StringCombine(columns, delimiter) + "\r\n";

最后一列本应命名为“Volume”,但我们将使用它的示例来检查FileReadBool函数的性能。你可以假设当前名称意味着“True Volume”(但这样的字符串不会被解释为true)。

接下来,让我们以FILE_TXT和FILE_CSV模式打开两个文件,并将准备好的标题写入其中。

c
   int fh1 = PRTF(FileOpen(txtfile, FILE_TXT | FILE_ANSI | FILE_WRITE, delimiter));//1
   int fh2 = PRTF(FileOpen(csvfile, FILE_CSV | FILE_ANSI | FILE_WRITE, delimiter));//2
  
   PRTF(FileWriteString(fh1, caption)); // 48
   PRTF(FileWriteString(fh2, caption)); // 48

由于FileWriteString函数不会自动添加换行符,我们在caption变量中添加了‘\r\n’。

c
   for(int i = 0; i < n; ++i)
   {
      FileWrite(fh1, rates[i].time, 
         rates[i].open, rates[i].high, rates[i].low, rates[i].close, 
         rates[i].tick_volume, rates[i].spread, rates[i].real_volume);
      FileWrite(fh2, rates[i].time, 
         rates[i].open, rates[i].high, rates[i].low, rates[i].close, 
         rates[i].tick_volume, rates[i].spread, rates[i].real_volume);
   }
   
   FileClose(fh1);
   FileClose(fh2);

从rates数组中写入结构体字段的方式相同,即通过在循环中为两个文件分别调用FileWrite函数来实现。回想一下,FileWrite函数会自动在参数之间插入分隔符字符,并在字符串末尾添加‘\r\n’。当然,也可以将所有输出值独立转换为字符串,并使用FileWriteString将它们发送到文件中,但那样我们就必须自己处理分隔符和换行符。在某些情况下,它们是不需要的,例如,如果你以紧凑形式(基本上在一整行中)写入JSON格式的数据。

因此,在写入阶段,两个文件的处理方式相同,并且内容也相同。以下是XAUUSD,H1的文件内容示例(你的结果可能会有所不同):

DateTime,Open,High,Low,Close,Ticks,Spread,True
2021.08.19 12:00:00,1785.3,1789.76,1784.75,1789.06,4831,5,0
2021.08.19 13:00:00,1789.06,1790.02,1787.61,1789.06,3393,5,0
2021.08.19 14:00:00,1789.08,1789.95,1786.78,1786.89,3536,5,0
2021.08.19 15:00:00,1786.78,1789.86,1783.73,1788.82,6840,5,0
2021.08.19 16:00:00,1788.82,1792.44,1782.04,1784.02,9514,5,0
2021.08.19 17:00:00,1784.04,1784.27,1777.14,1780.57,8526,5,0
2021.08.19 18:00:00,1780.55,1784.02,1780.05,1783.07,5271,6,0
2021.08.19 19:00:00,1783.06,1783.15,1780.73,1782.59,3571,7,0
2021.08.19 20:00:00,1782.61,1782.96,1780.16,1780.78,3236,10,0
2021.08.19 21:00:00,1780.79,1780.9,1778.54,1778.65,1017,13,0

在读取阶段,处理这些文件的差异将开始显现。

让我们打开一个文本文件进行读取,并在循环中使用FileReadString函数“扫描”它,直到它返回一个空字符串(即直到文件结束)。

c
   string read;
   fh1 = PRTF(FileOpen(txtfile, FILE_TXT | FILE_ANSI | FILE_READ, delimiter)); // 1
   Print("===== Reading TXT");
   do
   {
      read = PRTF(FileReadString(fh1));
   }
   while(StringLen(read) > 0);

日志将显示类似以下内容:

===== Reading TXT
FileReadString(fh1)=DateTime,Open,High,Low,Close,Ticks,Spread,True / ok
FileReadString(fh1)=2021.08.19 12:00:00,1785.3,1789.76,1784.75,1789.06,4831,5,0 / ok
FileReadString(fh1)=2021.08.19 13:00:00,1789.06,1790.02,1787.61,1789.06,3393,5,0 / ok
FileReadString(fh1)=2021.08.19 14:00:00,1789.08,1789.95,1786.78,1786.89,3536,5,0 / ok
FileReadString(fh1)=2021.08.19 15:00:00,1786.78,1789.86,1783.73,1788.82,6840,5,0 / ok
FileReadString(fh1)=2021.08.19 16:00:00,1788.82,1792.44,1782.04,1784.02,9514,5,0 / ok
FileReadString(fh1)=2021.08.19 17:00:00,1784.04,1784.27,1777.14,1780.57,8526,5,0 / ok
FileReadString(fh1)=2021.08.19 18:00:00,1780.55,1784.02,1780.05,1783.07,5271,6,0 / ok
FileReadString(fh1)=2021.08.19 19:00:00,1783.06,1783.15,1780.73,1782.59,3571,7,0 / ok
FileReadString(fh1)=2021.08.19 20:00:00,1782.61,1782.96,1780.16,1780.78,3236,10,0 / ok
FileReadString(fh1)=2021.08.19 21:00:00,1780.79,1780.9,1778.54,1778.65,1017,13,0 / ok
FileReadString(fh1)= / FILE_ENDOFFILE(5027)

在FILE_TXT模式下,每次调用FileReadString函数都会读取整行内容(直到‘\r\n’)。为了将其拆分为各个元素,我们应该实现额外的处理。或者,我们可以使用FILE_CSV模式。

让我们对CSV文件进行同样的操作。

c
   fh2 = PRTF(FileOpen(csvfile, FILE_CSV | FILE_ANSI | FILE_READ, delimiter)); // 2
   Print("===== Reading CSV");
   do
   {
      read = PRTF(FileReadString(fh2));
   }
   while(StringLen(read) > 0);

这次日志中的记录会多很多:

===== Reading CSV
FileReadString(fh2)=DateTime / ok
FileReadString(fh2)=Open / ok
FileReadString(fh2)=High / ok
FileReadString(fh2)=Low / ok
FileReadString(fh2)=Close / ok
FileReadString(fh2)=Ticks / ok
FileReadString(fh2)=Spread / ok
FileReadString(fh2)=True / ok
FileReadString(fh2)=2021.08.19 12:00:00 / ok
FileReadString(fh2)=1785.3 / ok
FileReadString(fh2)=1789.76 / ok
FileReadString(fh2)=1784.75 / ok
FileReadString(fh2)=1789.06 / ok
FileReadString(fh2)=4831 / ok
FileReadString(fh2)=5 / ok
FileReadString(fh2)=0 / ok
...
FileReadString(fh2)=2021.08.19 21:00:00 / ok
FileReadString(fh2)=1780.79 / ok
FileReadString(fh2)=1780.9 / ok
FileReadString(fh2)=1778.54 / ok
FileReadString(fh2)=1778.65 / ok
FileReadString(fh2)=1017 / ok
FileReadString(fh2)=13 / ok
FileReadString(fh2)=0 / ok
FileReadString(fh2)= / FILE_ENDOFFILE(5027)

关键在于,在FILE_CSV模式下,FileReadString函数会考虑分隔符字符,并将字符串拆分为各个元素。每次调用FileReadString函数都会从CSV表格中返回一个单一的值(单元格)。显然,得到的字符串随后需要转换为相应的类型。

这个问题可以通过使用专门的函数FileReadDatetime、FileReadNumber、FileReadBool以通用的形式来解决。然而,无论如何,开发人员必须跟踪当前可读列的编号,并确定其实际意义。在测试的第三步中给出了这样一个算法的示例。它使用相同的CSV文件(为简单起见,我们在每一步结束时关闭它,并在下一步开始时打开它)。

为了通过列编号更轻松地为MqlRates结构体中的下一个字段赋值,我们创建了一个子结构体MqlRates,它包含一个模板方法集:

c
struct MqlRatesM : public MqlRates
{
   template<typename T>
   void set(int field, T v)
   {
      switch(field)
      {
         case 0: this.time = (datetime)v; break;
         case 1: this.open = (double)v; break;
         case 2: this.high = (double)v; break;
         case 3: this.low = (double)v; break;
         case 4: this.close = (double)v; break;
         case 5: this.tick_volume = (long)v; break;
         case 6: this.spread = (int)v; break;
         case 7: this.real_volume = (long)v; break;
      }
   }
};

在OnStart函数中,我们描述了一个这样的结构体数组,我们将在其中添加传入的值。需要这个数组是为了使用ArrayPrint简化日志记录(在MQL5中没有现成的函数可以单独打印一个结构体)。

c
   Print("===== Reading CSV (alternative)");
   MqlRatesM r[1];
   int count = 0;
   int column = 0;
   const int maxColumn = ArraySize(columns);

用于记录记录数的count变量不仅用于统计,还作为跳过第一行的一种方式,因为第一行包含标题而不是数据。当前列编号在column变量中进行跟踪。其最大值不应超过列数maxColumn。

现在我们只需要打开文件,并在循环中使用各种函数从文件中读取元素,直到发生错误,特别是像5027(FILE_ENDOFFILE)这样的预期错误,即到达文件末尾。

当列编号为0时,我们应用FileReadDatetime函数。对于其他列,使用FileReadNumber函数。例外情况是包含标题的第一行:对于这一行,我们调用FileReadBool函数,以演示它对故意添加到最后一列的“True”标题的反应。

c
   fh2 = PRTF(FileOpen(csvfile, FILE_CSV | FILE_ANSI | FILE_READ, delimiter)); // 1
   do
   {
      if(column)
      {
         if(count == 1) // 在包含标题的第一条记录上演示FileReadBool函数
         {
            r[0].set(column, PRTF(FileReadBool(fh2)));
         }
         else
         {
            r[0].set(column, FileReadNumber(fh2));
         }
      }
      else // 第0列是日期和时间
      {
         ++count;
         if(count >1) // 上一行的结构体已准备好
         {
            ArrayPrint(r, _Digits, NULL, 0, 1, 0);
         }
         r[0].time = FileReadDatetime(fh2);
      }
      column = (column + 1) % maxColumn;
   }
   while(_LastError == 0); // 当到达文件末尾(5027,FILE_ENDOFFILE)时退出
   
   // 打印最后一个结构体
   if(column == maxColumn - 1)
   {
      ArrayPrint(r, _Digits, NULL, 0, 1, 0);
   }

以下是记录的日志内容:

===== Reading CSV (alternative)
FileOpen(csvfile,FILE_CSV|FILE_ANSI|FILE_READ,delimiter)=1 / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=false / ok
FileReadBool(fh2)=true / ok
2021.08.19 00:00:00   0.00   0.00  0.00    0.00          0     0       1
2021.08.19 12:00:00 1785.30 1789.76 1784.75 1789.06       4831     5       0
2021.08.19 13:00:00 1789.06 1790.02 1787.61 1789.06       3393     5       0
2021.08.19 14:00:00 1789.08 1789.95 1786.78 1786.89       3536     5       0
2021.08.19 15:00:00 1786.78 1789.86 1783.73 1788.82       6840     5       0
2021.08.19 16:00:00 1788.82 1792.44 1782.04 1784.02       9514     5       0
2021.08.19 17:00:00 1784.04 1784.27 1777.14 1780.57       8526     5       0
2021.08.19 18:00:00 1780.55 1784.02 1780.05 1783.07       5271     6       0
2021.08.19 19:00:00 1783.06 1783.15 1780.73 1782.59       3571     7       0
2021.08.19 20:00:00 1782.61 1782.96 1780.16 1780.78       3236    10       0
2021.08.19 21:00:00 1780.79 1780.90 1778.54 1778.65       1017    13       0

如你所见,在所有标题中,只有最后一个被转换为true值,而之前的所有标题都为false。

读取的结构体内容与原始数据相同。

文件中的位置管理

我们已经知道,系统会为每个打开的文件关联一个特定的指针:它确定了文件中的位置(从文件开头的偏移量),下次调用任何输入/输出函数时,将在此处写入或读取数据。函数执行后,指针会根据写入或读取的数据大小进行移动。

在某些情况下,您希望在不进行输入/输出操作的情况下更改指针的位置。特别是,当我们需要将数据追加到文件末尾时,我们以“混合”模式FILE_READ | FILE_WRITE打开文件,然后必须以某种方式将指针移动到文件末尾(否则我们将从文件开头覆盖数据)。我们可以在有数据可读时调用读取函数(从而移动指针),但这样效率不高。最好使用特殊函数FileSeek。而FileTell函数则允许获取指针的实际值(文件中的位置)。

在本节中,我们将探讨这些以及其他几个与文件中当前位置相关的函数。其中一些函数在文本模式和二进制模式下对文件的工作方式相同,而另一些则有所不同。

c
bool FileSeek(int handle, long offset, ENUM_FILE_POSITION origin)

该函数使用origin作为参考点,将文件指针移动offset个字节,originENUM_FILE_POSITION枚举中描述的预定义位置之一。offset可以是正数(向文件末尾及更远的方向移动)或负数(向文件开头移动)。ENUM_FILE_POSITION有以下成员:

  • SEEK_SET:表示文件开头
  • SEEK_CUR:表示当前位置
  • SEEK_END:表示文件末尾

如果相对于锚点计算的新位置为负值(即请求向文件开头左侧的偏移量),则文件指针将设置为文件开头。

如果将位置设置在文件末尾之后(该值大于文件大小),则后续对文件的写入将不是从文件末尾开始,而是从设置的位置开始。在这种情况下,将在文件先前的末尾和给定位置之间写入未定义的值(见下文)。

函数成功时返回true,出错时返回false

c
ulong FileTell(int handle)

对于使用句柄描述符打开的文件,该函数返回内部指针的当前位置(相对于文件开头的偏移量)。如果出错,将返回ULONG_MAX ((ulong)-1)。错误代码可在_LastError变量中获取,或通过GetLastError函数获取。

c
bool FileIsEnding(int handle)

该函数返回一个指示,表明指针是否位于句柄文件的末尾。如果是,则结果为true

c
bool FileIsLineEnding(int handle)

对于具有句柄描述符的文本文件,该函数返回一个指示,表明文件指针是否位于行尾(紧跟在换行符\n\r\n之后)。换句话说,返回值为true表示当前位置在下一行的开头(或在文件末尾)。对于二进制文件,结果始终为false

用于测试上述函数的脚本名为FileCursor.mq5。它处理三个文件:两个二进制文件和一个文本文件。

c
const string fileraw = "MQL5Book/cursor.raw";
const string filetxt = "MQL5Book/cursor.csv";
const string file100 = "MQL5Book/k100.raw";

为了简化对当前位置、文件结束(End-Of-File,EOF)和行结束(End-Of-Line,EOL)标志的日志记录,我们创建了一个辅助函数FileState

c
string FileState(int handle)
{
   return StringFormat("P:%I64d, F:%s, L:%s", 
      FileTell(handle),
      (string)FileIsEnding(handle),
      (string)FileIsLineEnding(handle));
}

在二进制文件上测试这些函数的场景包括以下步骤:

  1. 以读写模式创建一个新的或打开一个现有的fileraw文件("MQL5Book/cursor.raw")。打开后,以及在每次操作之后,我们通过调用FileState输出文件的当前状态。
c
void OnStart()
{
   int handle;
   Print("\n * Phase I. Binary file");
   handle = PRTF(FileOpen(fileraw, FILE_BIN | FILE_WRITE | FILE_READ));
   Print(FileState(handle));
   ...
  1. 将指针移动到文件末尾,这将使我们每次执行脚本时都能向该文件追加数据(而不是从开头覆盖数据)。引用文件末尾最明显的方法是:相对于origin=SEEK_END的零偏移量。
c
   PRTF(FileSeek(handle, 0, SEEK_END));
   Print(FileState(handle));
  1. 如果文件不再为空(不是新文件),我们可以在其任意位置(相对或绝对)读取现有数据。特别是,如果FileSeek函数的origin参数等于SEEK_CUR,这意味着负偏移量将使当前位置向后移动相应的字节数(向左),正偏移量将使当前位置向前移动(向右)。

在这个例子中,我们尝试向后移动一个int类型值的大小。稍后我们会看到,在这个位置应该是MqlDateTime结构体的day_of_year字段(最后一个字段),因为我们在后续指令中将其写入文件,并且在下次运行时可以从文件中获取这些数据。读取的值将被记录下来,以便与之前保存的值进行比较。

c
   if(PRTF(FileSeek(handle, -1 * sizeof(int), SEEK_CUR)))
   {
      Print(FileState(handle));
      PRTF(FileReadInteger(handle));
   }

在一个新的空文件中,FileSeek调用将以错误4003 (INVALID_PARAMETER)结束,并且if语句块将不会执行。

  1. 接下来,用数据填充文件。首先,使用FileWriteLong写入计算机的当前本地时间(datetime类型的8个字节)。
c
   datetime now = TimeLocal();
   PRTF(FileWriteLong(handle, now));
   Print(FileState(handle));
  1. 然后,我们尝试从当前位置向后移动4个字节(-4)并读取long类型数据。
c
   PRTF(FileSeek(handle, -4, SEEK_CUR));
   long x = PRTF(FileReadLong(handle));
   Print(FileState(handle));

这次尝试将以错误5015 (FILE_READERROR)结束,因为我们在文件末尾,向左移动4个字节后,无法从右侧读取8个字节(long类型的大小)。然而,正如我们将从日志中看到的,由于这次不成功的尝试,指针仍然会移动回文件末尾。

如果向后移动8个字节(-8),随后对long类型值的读取将成功,并且两个时间值,包括原始值和从文件中读取的值,必须匹配。

c
   PRTF(FileSeek(handle, -8, SEEK_CUR));
   Print(FileState(handle));
   x = PRTF(FileReadLong(handle));
   PRTF((now == x));
  1. 最后,将填充了相同时间的MqlDateTime结构体写入文件。文件中的位置将增加32(结构体的字节大小)。
c
   MqlDateTime mdt;
   TimeToStruct(now, mdt);
   StructPrint(mdt); // 在日志中直观显示日期/时间
   PRTF(FileWriteStruct(handle, mdt)); // 32 = sizeof(MqlDateTime)
   Print(FileState(handle));
   FileClose(handle);

在首次运行针对fileraw文件(MQL5Book/cursor.raw)的脚本场景后,我们会得到类似以下的结果(时间会有所不同):

first run 
 * Phase I. Binary file
FileOpen(fileraw,FILE_BIN|FILE_WRITE|FILE_READ)=1 / ok
P:0, F:true, L:false
FileSeek(handle,0,SEEK_END)=true / ok
P:0, F:true, L:false
FileSeek(handle,-1*sizeof(int),SEEK_CUR)=false / INVALID_PARAMETER(4003)
FileWriteLong(handle,now)=8 / ok
P:8, F:true, L:false
FileSeek(handle,-4,SEEK_CUR)=true / ok
FileReadLong(handle)=0 / FILE_READERROR(5015)
P:8, F:true, L:false
FileSeek(handle,-8,SEEK_CUR)=true / ok
P:0, F:false, L:false
FileReadLong(handle)=1629683392 / ok
(now==x)=true / ok
  2021     8    23      1    49    52             1           234
FileWriteStruct(handle,mdt)=32 / ok
P:40, F:true, L:false

根据状态,文件大小最初为零,因为在移动到文件末尾后位置为"P:0""F:true")。每次写入(使用FileWriteLongFileWriteStruct)后,位置P会根据写入数据的大小增加。

在第二次运行脚本后,您可以在日志中注意到一些变化:

second run
 * Phase I. Binary file
FileOpen(fileraw,FILE_BIN|FILE_WRITE|FILE_READ)=1 / ok
P:0, F:false, L:false
FileSeek(handle,0,SEEK_END)=true / ok
P:40, F:true, L:false
FileSeek(handle,-1*sizeof(int),SEEK_CUR)=true / ok
P:36, F:false, L:false
FileReadInteger(handle)=234 / ok
FileWriteLong(handle,now)=8 / ok
P:48, F:true, L:false
FileSeek(handle,-4,SEEK_CUR)=true / ok
FileReadLong(handle)=0 / FILE_READERROR(5015)
P:48, F:true, L:false
FileSeek(handle,-8,SEEK_CUR)=true / ok
P:40, F:false, L:false
FileReadLong(handle)=1629683397 / ok
(now==x)=true / ok
  2021     8    23      1    49    57             1           234
FileWriteStruct(handle,mdt)=32 / ok
P:80, F:true, L:false

首先,打开后文件的大小为40(根据移动到文件末尾后的位置"P:40")。每次运行脚本时,文件将增长40个字节。

其次,由于文件不为空,因此可以在其中导航并读取“旧”数据。特别是,从当前位置(也是文件末尾)向后移动-1*sizeof(int)后,我们成功读取了值234,这是MqlDateTime结构体的最后一个字段(它是一年中的第几天,您的情况很可能不同)。

第二个测试场景处理文本CSV文件filetxtMQL5Book/cursor.csv)。我们也将以读写组合模式打开它,但不会将指针移动到文件末尾。因此,每次运行脚本都会从文件开头覆盖数据。为了便于发现差异,CSV第一列中的数字是随机生成的。在第二列中,总是从StringFormat函数的模板中替换相同的字符串。

c
   Print(" * Phase II. Text file");
   srand(GetTickCount());
   // 创建一个新文件或打开一个现有文件以进行写入/覆盖
   // 从开头开始并进行后续读取;内部为CSV数据(Unicode)
   handle = PRTF(FileOpen(filetxt, FILE_CSV | FILE_WRITE | FILE_READ, ','));
   // 三行数据(每行一对数字和字符串),用'\n'分隔
   // 注意,最后一个元素不以换行符'\n'结尾
   // 这是可选的,但允许
   string content = StringFormat(
      "%02d,abc\n%02d,def\n%02d,ghi", 
      rand() % 100, rand() % 100, rand() % 100);
   // 由于FileWriteString,'\n'将自动替换为'\r\n'
   PRTF(FileWriteString(handle, content));

以下是生成数据的一个示例:

34,abc
20,def
02,ghi

然后,我们返回到文件开头,并使用FileReadString在循环中读取文件,同时不断记录状态。

c
   PRTF(FileSeek(handle, 0, SEEK_SET));
   Print(FileState(handle));
   // 使用FileIsLineEnding特性计算文件中的行数
   int lineCount = 0;
   while(!FileIsEnding(handle))
   {
      PRTF(FileReadString(handle));
      Print(FileState(handle));
      // 当FileIsEnding等于true时,FileIsLineEnding也等于true,
      // 即使没有尾随的'\n'字符
      if(FileIsLineEnding(handle)) lineCount++;
   }
   FileClose(handle);
   PRTF(lineCount);

以下是脚本第一次和第二次运行后filetxt文件的日志。首先是第一次运行的日志:

first run
 * Phase II. Text file
FileOpen(filetxt,FILE_CSV|FILE_WRITE|FILE_READ,',')=1 / ok
FileWriteString(handle,content)=44 / ok
FileSeek(handle,0,SEEK_SET)=true / ok
P:0, F:false, L:false
FileReadString(handle)=08 / ok
P:8, F:false, L:false
FileReadString(handle)=abc / ok
P:18, F:false, L:true
FileReadString(handle)=37 / ok
P:24, F:false, L:false
FileReadString(handle)=def / ok
P:34, F:false, L:true
FileReadString(handle)=96 / ok
P:40, F:false, L:false
FileReadString(handle)=ghi / ok
P:46, F:true, L:true
lineCount=3 / ok

这是第二次运行的日志:

second run
 * Phase II. Text file
FileOpen(filetxt,FILE_CSV|FILE_WRITE|FILE_READ,',')=1 / ok
FileWriteString(handle,content)=44 / ok
FileSeek(handle,0,SEEK_SET)=true / ok
P:0, F:false, L:false
FileReadString(handle)=34 / ok
P:8, F:false, L:false
FileReadString(handle)=abc / ok
P:18, F:false, L:true
FileReadString(handle)=20 / ok
P:24, F:false, L:false
FileReadString(handle)=def / ok
P:34, F:false, L:true
FileReadString(handle)=02 / ok
P:40, F:false, L:false
FileReadString(handle)=ghi / ok
P:46, F:true, L:true
lineCount=3 / ok

如您所见,文件大小没有变化,但在相同的偏移量处写入了不同的数字。因为这个CSV文件有两列,所以在我们读取的每第二个值之后,我们会看到一个行结束标志("L:true")被触发。

检测到的行数为3,尽管文件中只有2个换行符:最后(第三)行在文件结尾处结束。

最后,最后一个测试场景使用文件file100MQL5Book/k100.raw)将指针移动到文件末尾之后(到1000000字节的标记处),从而增加其大小(为未来可能的写入操作预留磁盘空间)。

c
   Print(" * Phase III. Allocate large file");
   handle = PRTF(FileOpen(file100, FILE_BIN | FILE_WRITE));
   PRTF(FileSeek(handle, 1000000, SEEK_END));
   // 要更改大小,至少需要写入一些内容
   PRTF(FileWriteInteger(handle, 0xFF, 1));
   PRTF(FileTell(handle));
   FileClose(handle);

此脚本的日志输出在每次运行时不会改变,但是,最终出现在为文件分配的空间中的随机数据可能会有所不同(此处不显示其内容:请使用外部二进制查看器)。

 * Phase III. Allocate large file
FileOpen(file100,FILE_BIN|FILE_WRITE)=1 / ok
FileSeek(handle,1000000,SEEK_END)=true / ok
FileWriteInteger(handle,0xFF,1)=1 / ok
FileTell(handle)=1000001 / ok

获取文件属性

在处理文件的过程中,除了直接读写数据外,通常还需要分析文件的属性。其中一个主要属性——文件大小,可以使用 FileSize 函数获取。此外,还有一些其他特性可以使用 FileGetInteger 函数来查询。

请注意,FileSize 函数需要一个已打开的文件句柄。而 FileGetInteger 函数有一些属性(包括文件大小)可以通过文件名识别,无需事先打开文件。

plaintext
ulong FileSize(int handle)

该函数通过文件描述符返回已打开文件的大小。若出现错误,返回结果为 0,而 0 也是函数正常执行时可能的有效大小,因此你应该始终使用 _LastError(或 GetLastError)来分析潜在的错误。

文件大小也可以通过将文件指针移动到文件末尾 FileSeek(handle, 0, SEEK_END) 并调用 FileTell(handle) 来获取。这两个函数在上一节中有描述。

plaintext
long FileGetInteger(int handle, ENUM_FILE_PROPERTY_INTEGER property)
long FileGetInteger(const string filename, ENUM_FILE_PROPERTY_INTEGER property, bool common = false)

该函数有两种使用方式:一种是通过已打开的文件描述符操作,另一种是通过文件名(包括未打开的文件)操作。

该函数返回 property 参数指定的文件属性之一。每种使用方式的有效属性列表不同(见下文)。尽管返回值类型为 long,但根据所请求的属性,它不仅可以包含整数,还可以包含日期时间或布尔值:需要显式进行所需的类型转换。

当通过文件名请求属性时,你可以额外使用 common 参数来指定在哪个文件夹中搜索文件:当前终端文件夹 MQL5/Filesfalse,默认值)或公共文件夹 Users/<user_name>...MetaQuotes/Terminal/Common/Filestrue)。如果 MQL 程序在测试器中运行,工作目录位于测试代理文件夹内(Tester/<agent>/MQL5/Files),请参阅“文件操作”章节的介绍。

以下表格列出了 ENUM_FILE_PROPERTY_INTEGER 的所有成员。

属性描述
FILE_EXISTS *检查文件是否存在(类似于 FileIsExist
FILE_CREATE_DATE *创建日期
FILE_MODIFY_DATE *最后修改日期
FILE_ACCESS_DATE *最后访问日期
FILE_SIZE *文件大小(以字节为单位,类似于 FileSize
FILE_POSITION文件指针位置(类似于 FileTell
FILE_END文件末尾位置(类似于 FileIsEnding
FILE_LINE_END行末尾位置(类似于 FileIsLineEnding
FILE_IS_COMMON文件是否在终端共享文件夹中打开(FILE_COMMON
FILE_IS_TEXT文件是否以文本模式打开(FILE_TXT
FILE_IS_BINARY文件是否以二进制模式打开(FILE_BIN
FILE_IS_CSV文件是否以 CSV 模式打开(FILE_CSV
FILE_IS_ANSI文件是否以 ANSI 模式打开(FILE_ANSI
FILE_IS_READABLE文件是否以只读模式打开(FILE_READ
FILE_IS_WRITABLE文件是否以可写模式打开(FILE_WRITE

允许通过文件名使用的属性标有星号。如果你尝试获取其他属性,函数的第二个版本将返回错误 4003(INVALID_PARAMETER)。

某些属性在处理已打开的文件时可能会发生变化:FILE_MODIFY_DATEFILE_ACCESS_DATEFILE_SIZEFILE_POSITIONFILE_ENDFILE_LINE_END(仅适用于文本文件)。

若出现错误,函数调用结果为 -1。

函数的第二个版本允许你检查指定的名称是文件还是目录。如果在通过名称获取属性时指定了目录,函数将设置一个特殊的内部错误代码 5018(ERR_MQL_FILE_IS_DIRECTORY),而返回值将是正确的。

我们将使用脚本 FileProperties.mq5 来测试本节中的函数。该脚本将处理一个预定义名称的文件。

plaintext
const string fileprop = "MQL5Book/fileprop";

OnStart 函数开始时,让我们尝试通过一个错误的描述符(未通过 FileOpen 调用获取)来请求文件大小。调用 FileSize 后,需要检查 _LastError 变量,而 FileGetInteger 会立即返回一个特殊值,即错误指示符(-1)。

plaintext
void OnStart()
{
   int handle = 0;
   ulong size = FileSize(handle);
   if(_LastError)
   {
      Print("FileSize error=", E2S(_LastError) + "(" + (string)_LastError + ")");
      // 我们将得到:FileSize 0, error=WRONG_FILEHANDLE(5008)
   }
   
   PRTF(FileGetInteger(handle, FILE_SIZE)); // -1 / WRONG_FILEHANDLE(5008)

接下来,我们创建一个新文件或打开一个现有文件并将其重置,然后写入测试文本。

plaintext
   handle = PRTF(FileOpen(fileprop, FILE_TXT | FILE_WRITE | FILE_ANSI)); // 1
   PRTF(FileWriteString(handle, "Test Text\n")); // 11

我们有选择地请求一些属性。

plaintext
   PRTF(FileGetInteger(fileprop, FILE_SIZE)); // 0,尚未写入磁盘
   PRTF(FileGetInteger(handle, FILE_SIZE)); // 11
   PRTF(FileSize(handle)); // 11
   PRTF(FileGetInteger(handle, FILE_MODIFY_DATE)); // 1629730884,自 1970 年以来的秒数
   PRTF(FileGetInteger(handle, FILE_IS_TEXT)); // 1,布尔值 true
   PRTF(FileGetInteger(handle, FILE_IS_BINARY)); // 0,布尔值 false

通过文件描述符获取的文件长度信息会考虑当前的缓存缓冲区,而通过文件名获取时,只有在文件关闭后或调用 FileFlush 函数(见“强制将缓存写入磁盘”部分),实际长度才可用。

该函数以自 1970 年 1 月 1 日标准纪元以来的秒数形式返回日期和时间,这对应于 datetime 类型,可以进行类型转换。

对于使用描述符的函数版本,请求文件打开标志(即文件模式)是成功的,特别是我们得到响应表明文件是文本文件而不是二进制文件。然而,接下来对文件名进行的类似请求将失败,因为该属性仅在传递有效句柄时才支持。即使文件名指向的是我们已打开的同一个文件,也会出现这种情况。

plaintext
   PRTF(FileGetInteger(fileprop, FILE_IS_TEXT)); // -1 / INVALID_PARAMETER(4003)

让我们等待一秒钟,关闭文件,然后再次检查修改日期(这次通过文件名,因为描述符已不再有效)。

plaintext
   Sleep(1000);
   FileClose(handle);
   PRTF(FileGetInteger(fileprop, FILE_MODIFY_DATE)); // 1629730885 / 正常

在这里你可以清楚地看到时间增加了 1 秒。

最后,确保目录(文件夹)的属性是可用的。

plaintext
   PRTF((datetime)FileGetInteger("MQL5Book", FILE_CREATE_DATE));
   // 我们将得到:2021.08.09 22:38:00 / FILE_IS_DIRECTORY(5018)

由于本书的所有示例都位于 "MQL5Book" 文件夹中,该文件夹必须已经存在。不过,你实际的创建时间会有所不同。在这种情况下,FILE_IS_DIRECTORY 错误代码由 PRTF 宏显示。在实际工作的程序中,函数调用不应使用宏,然后应从 _LastError 中读取错误代码。

强制将缓存写入磁盘

在MQL5中,文件的写入和读取是经过缓存处理的。这意味着会为数据维护一个特定的内存缓冲区,由此提高了工作效率。因此,在写入时通过函数调用传输的数据会进入输出缓冲区,只有在缓冲区满了之后,才会真正地将数据写入磁盘。而在读取时则相反,会从磁盘读取比程序通过函数请求的更多的数据(如果不是文件末尾的话),并且后续很可能进行的读取操作会更快。

缓存是一种在大多数应用程序以及操作系统本身层面都使用的标准技术。然而,除了优点之外,缓存也有其缺点。

特别是当文件被用作程序之间的数据交换手段时,延迟写入会显著降低通信速度,并且使通信的可预测性变差,因为缓冲区的大小可能相当大,而且将缓冲区数据 “转储” 到磁盘的频率可能会根据某些算法进行调整。

例如,在MetaTrader 5中,有一整类的MQL程序用于将交易信号从一个终端实例复制到另一个终端实例。它们倾向于使用文件来传输信息,对于它们来说,缓存不会拖慢速度是非常重要的。对于这种情况,MQL5提供了FileFlush函数。

c
void FileFlush(int handle)

该函数会强制将具有句柄描述符的文件的I/O文件缓冲区中剩余的所有数据刷新到磁盘上。

如果不使用这个函数,那么在最坏的情况下,从程序 “发送” 的部分数据可能只有在文件关闭时才会写入磁盘。

这个功能在发生意外事件(如操作系统或程序挂起)时,为有价值的数据安全提供了更大的保障。然而,另一方面,在大量写入操作期间不建议频繁调用FileFlush函数,因为这可能会对性能产生不利影响。

如果文件是以混合模式打开的,即同时用于写入和读取,那么必须在对文件进行读取和写入操作之间调用FileFlush函数。

作为一个示例,考虑脚本FileFlush.mq5,在这个脚本中,我们实现了两种模式来模拟交易复制器的操作。我们需要在不同的图表上运行该脚本的两个实例,其中一个作为数据发送方,另一个作为数据接收方。

该脚本有两个输入参数:EnableFlashing允许我们比较使用FileFlush函数和不使用该函数时程序的行为,UseCommonFolder表示是否需要创建一个用作数据传输手段的文件,可选择在当前终端实例的文件夹中或在共享文件夹中(在后一种情况下,可以测试不同终端之间的数据传输)。

c
#property script_show_inputs
input bool EnableFlashing = false;
input bool UseCommonFolder = false;

回想一下,为了在脚本启动时出现带有输入变量的对话框,必须额外设置script_show_inputs属性。

中转文件的名称在dataport变量中指定。UseCommonFolder选项控制添加到File Open函数中打开文件的模式开关集中的FILE_COMMON标志。

c
const string dataport = "MQL5Book/dataport";
const int flag = UseCommonFolder ? FILE_COMMON : 0;

主要的OnStart函数实际上由两部分组成:打开文件的设置以及一个定期发送或接收数据的循环。

我们需要运行该脚本的两个实例,并且每个实例都有自己的文件描述符,指向磁盘上的同一个文件,但以不同的模式打开。

c
void OnStart()
{
   bool modeWriter = true; // 默认情况下,脚本应该写入数据
   int count = 0;          // 进行的写入/读取次数
   // 以写入模式创建一个新文件或重置旧文件,作为 “发送方”
   int handle = PRTF(FileOpen(dataport, 
      FILE_BIN | FILE_WRITE | FILE_SHARE_READ | flag));
   // 如果无法写入,很可能另一个脚本实例已经在向该文件写入数据,
   // 所以我们尝试以读取模式打开它
   if(handle == INVALID_HANDLE)
   {
      // 如果可以以读取模式打开文件,我们将继续作为 “接收方” 工作
      handle = PRTF(FileOpen(dataport, 
         FILE_BIN | FILE_READ | FILE_SHARE_WRITE | FILE_SHARE_READ | flag));
      if(handle == INVALID_HANDLE)
      {
         Print("Can't open file"); // 出问题了
         return;
      }
      modeWriter = false; // 切换模式/角色
   }

一开始,我们尝试以FILE_WRITE模式打开文件,不共享写入权限(FILE_SHARE_WRITE),所以运行的脚本的第一个实例将占用该文件,并阻止第二个实例以写入模式工作。第二个实例在第一次调用FileOpen后将得到一个错误并返回INVALID_HANDLE,然后会尝试使用FILE_SHARE_WRITE并行写入标志,通过第二次调用FileOpen以读取模式(FILE_READ)打开文件。理想情况下,这应该是可行的。然后,modeWriter变量将被设置为false,以指示脚本的实际角色。

主要的操作循环具有以下结构:

c
   while(!IsStopped())
   {
      if(modeWriter)
      {
         // ...写入测试数据
      }
      else
      {
         // ...读取测试数据
      }
      Sleep(5000);
   }

该循环会一直执行,直到用户手动从图表中删除该脚本:这将由IsStopped函数发出信号。在循环内部,通过调用Sleep函数,每5秒触发一次操作,Sleep函数会使程序 “冻结” 指定的毫秒数(在这种情况下是5000毫秒)。这样做是为了更容易分析正在进行的变化,并且避免过于频繁地记录状态。在没有详细日志的实际程序中,可以每100毫秒甚至更频繁地发送数据。

传输的数据将包括当前时间(一个datetime值,8个字节)。在if(modeWriter)指令的第一个分支中,即写入文件的地方,我们调用FileWriteLong函数写入最后一次的计数(从TimeLocal函数获得),将操作计数器加1(count++),并将当前状态输出到日志中。

c
         long temp = TimeLocal(); // 获取当前本地时间的datetime
         FileWriteLong(handle, temp); // (每5秒)将其追加到文件中
         count++;
         if(EnableFlashing)
         {
            FileFlush(handle);
         }
         Print(StringFormat("Written[%d]: %I64d", count, temp));

需要注意的是,只有当输入参数EnableFlashing设置为true时,才会在每次写入后调用FileFlush函数。

if操作符的第二个分支中,即我们读取数据的地方,我们首先通过调用ResetLastError重置内部错误标志。这是必要的,因为我们要从文件中读取数据,直到没有数据为止。一旦没有更多的数据可读,程序将得到一个特定的错误代码5015(ERR_FILE_READERROR)。

由于内置的MQL5定时器,包括Sleep函数,精度有限(大约10毫秒),我们不能排除在两次连续尝试读取文件之间发生两次连续写入的情况。例如,一次读取发生在10:00:00'200,第二次读取发生在10:00:05'210(以 “小时:分钟:秒'毫秒” 的格式表示)。在这种情况下,同时发生了两次写入:一次在10:00:00'205,另一次在10:00:05'205,并且这两次写入都落在上述时间段内。这种情况不太可能发生,但却是有可能的。即使时间间隔绝对精确,如果运行的程序总数很大,并且没有足够的处理器核心来处理所有程序,MQL5运行时系统可能会被迫在两个正在运行的脚本之间做出选择(哪个脚本先调用)。

MQL5提供了高精度定时器(精度可达微秒),但对于当前任务来说这并不关键。

嵌套循环还有另一个原因。在脚本作为数据 “接收方” 启动后,它必须处理自 “发送方” 启动以来在文件中积累的所有记录(两个脚本几乎不可能同时启动)。可能有人会更喜欢不同的算法:跳过所有 “旧” 记录,只跟踪新记录。这是可以做到的,但这里实现的是 “无损” 选项。

c
         ResetLastError();
         while(true)// 只要有数据且没有问题就循环
         {
            bool reportedEndBeforeRead = FileIsEnding(handle);
            ulong reportedTellBeforeRead = FileTell(handle);
  
            temp = FileReadLong(handle);
            // 如果没有更多数据,我们将得到错误5015(ERR_FILE_READERROR)
            if(_LastError)break; // 遇到任何错误就退出循环
  
            // 这里数据在没有错误的情况下被接收
            count++;
            Print(StringFormat("Read[%d]: %I64d\t"
               "(size=%I64d, before=%I64d(%s), after=%I64d)", 
               count, temp, 
               FileSize(handle), reportedTellBeforeRead, 
               (string)reportedEndBeforeRead, FileTell(handle)));
         }

请注意以下几点。对于为读取而打开的文件的元数据,例如由FileSize函数返回的文件大小(请参阅 “获取文件属性”),在文件打开后不会改变。如果另一个程序后来向我们打开用于读取的文件中添加了内容,即使我们对读取描述符调用FileFlash函数,其 “可检测到的” 长度也不会更新。可以关闭并重新打开文件(在每次读取之前,但这样效率不高):然后新的描述符将显示新的长度。但我们将通过另一个技巧来避免这种情况。

这个技巧是只要读取函数(在我们的例子中是FileReadLong)返回的数据没有错误,就继续使用这些读取函数读取数据。重要的是不要使用其他操作元数据的函数。特别是,由于只读的文件结束位置保持不变,使用FileIsEnding函数进行检查(请参阅 “文件内的位置控制”)在旧位置将返回true,尽管可能有另一个进程向文件中添加了内容。此外,尝试将内部文件指针移动到文件末尾(FileSeek(handle, 0, SEEK_END);,关于FileSeek函数请参阅同一部分)不会跳到实际的数据末尾,而是跳到打开文件时文件末尾所在的过时位置。

FileTell函数(请参阅同一部分)会告诉我们文件内部的实际位置。随着另一个脚本实例向文件中添加信息并在这个循环中读取信息,指针会越来越向右移动,尽管很奇怪,它会超过FileSize。为了直观地演示指针如何超过文件大小,让我们保存调用FileReadLong之前和之后的指针值,然后将这些值与文件大小一起输出到日志中。

一旦FileReadLong的读取操作产生任何错误,内部循环就会中断。正常的循环退出意味着错误5015(ERR_FILE_READERROR)。特别是当在文件的当前位置没有数据可供读取时,就会发生这种情况。

最后成功读取的数据将输出到日志中,并且很容易将其与发送方脚本输出的数据进行比较。

让我们运行新脚本两次。为了区分脚本的副本,我们将在不同交易品种的图表上运行它。

在运行两个脚本时,重要的是要确保UseCommonFolder参数的值相同。在我们的测试中,将其保留为false,因为我们将在一个终端中完成所有操作。建议在UseCommonFolder设置为true时,独立测试不同终端之间的数据传输。

首先,我们在EURUSD,H1图表上运行第一个实例,保留所有默认设置,包括EnableFlashing=false。然后,我们在XAUUSD,H1图表上运行第二个实例(同样使用默认设置)。日志将如下所示(你的时间会不同):

(EURUSD,H1) *

(EURUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(EURUSD,H1) Written[1]: 1629652995

(XAUUSD,H1) *

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=-1 / CANNOT_OPEN_FILE(5004)

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_READ|FILE_SHARE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(EURUSD,H1) Written[2]: 1629653000

(EURUSD,H1) Written[3]: 1629653005

(EURUSD,H1) Written[4]: 1629653010

(EURUSD,H1) Written[5]: 1629653015

发送方成功打开文件进行写入,并开始每5秒发送一次数据,从带有 “Written” 字样的行以及不断增加的值可以看出这一点。在发送方启动后不到5秒,接收方也启动了。它给出了一个错误消息,因为它无法打开文件进行写入。但随后它成功地打开了文件进行读取。然而,没有记录表明它能够在文件中找到传输的数据。数据仍然 “挂” 在发送方的缓存中。

让我们停止两个脚本,然后按照相同的顺序再次运行它们:首先在EURUSD上运行发送方,然后在XAUUSD上运行接收方。但这次我们将为发送方指定EnableFlashing=true

以下是日志中的情况:

(EURUSD,H1) *

(EURUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(EURUSD,H1) Written[1]: 1629653638

(XAUUSD,H1) *

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=-1 / CANNOT_OPEN_FILE(5004)

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_READ|FILE_SHARE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(XAUUSD,H1) Read[1]: 1629653638 (size=8, before=0(false), after=8)

(EURUSD,H1) Written[2]: 1629653643

(XAUUSD,H1) Read[2]: 1629653643 (size=8, before=8(true), after=16)

(EURUSD,H1) Written[3]: 1629653648

(XAUUSD,H1) Read[3]: 1629653648 (size=8, before=16(true), after=24)

(EURUSD,H1) Written[4]: 1629653653

(XAUUSD,H1) Read[4]: 1629653653 (size=8, before=24(true), after=32)

(EURUSD,H1) Written[5]: 1629653658

同样的文件在两个脚本中再次以不同的模式成功打开,但这次写入的值会被接收方定期读取。

有趣的是,除了第一次之外,在每次下一次数据读取之前,FileIsEnding函数都会返回true(显示在与接收到的数据相同的行中,在 “before” 字符串后面的括号内)。因此,有一个迹象表明我们处于文件末尾,但随后FileReadLong成功地读取了一个据推测超出文件限制的值,并将位置向右移动。例如,“size=8, before=8(true), after=16” 这一记录意味着报告给MQL程序的文件大小为8,调用FileReadLong之前的当前指针也等于8,并且文件结束标志已启用。在成功调用FileReadLong之后,指针移动到了16。然而,在接下来的所有其他迭代中,我们再次看到 “size=8”,并且指针逐渐越来越超出文件范围。

由于发送方的写入和接收方的读取每5秒发生一次,根据它们的循环偏移阶段,我们可以观察到这两个操作之间存在不同的延迟,在最坏的情况下可能长达近5秒。然而,这并不意味着缓存刷新速度如此之慢。实际上,这几乎是一个瞬间的过程。为了确保更快速地检测到变化,可以减少循环中的睡眠周期(请注意,如果延迟太短,这个测试会很快填满日志 —— 与实际程序不同,这里总是生成新数据,因为这是发送方最接近秒的当前时间)。

顺便说一下,与只能有一个发送方不同,你可以运行多个接收方。下面的日志显示了在EURUSD上的发送方以及在XAUUSD和USDRUB图表上的两个接收方的操作情况。

(EURUSD,H1) *

(EURUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(EURUSD,H1) Written[1]: 1629671658

(XAUUSD,H1) *

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=-1 / CANNOT_OPEN_FILE(5004)

(XAUUSD,H1) FileOpen(dataport,FILE_BIN|FILE_READ|FILE_SHARE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(XAUUSD,H1) Read[1]: 1629671658 (size=8, before=0(false), after=8)

(EURUSD,H1) Written[2]: 1629671663

(USDRUB,H1) *

(USDRUB,H1) FileOpen(dataport,FILE_BIN|FILE_WRITE|FILE_SHARE_READ|flag)=-1 / CANNOT_OPEN_FILE(5004)

(USDRUB,H1) FileOpen(dataport,FILE_BIN|FILE_READ|FILE_SHARE_WRITE|FILE_SHARE_READ|flag)=1 / ok

(USDRUB,H1) Read[1]: 1629671658 (size=16, before=0(false), after=8)

(USDRUB,H1) Read[2]: 1629671663 (size=16, before=8(false), after=16)

(XAUUSD,H1) Read[2]: 1629671663 (size=8, before=8(true), after=16)

(EURUSD,H1) Written[3]: 1629671668

(USDRUB,H1) Read[3]: 1629671668 (size=16, before=16(true), after=24)

(XAUUSD,H1) Read[3]: 1629671668 (size=8, before=16(true), after=24)

(EURUSD,H1) Written[4]: 1629671673

(USDRUB,H1) Read[4]: 1629671673 (size=16, before=24(true), after=32)

(XAUUSD,H1) Read[4]: 1629671673 (size=8, before=24(true), after=32)

(EURUSD,H1) Written[5]: 1629671678

当在USDRUB上运行第三个脚本时,文件中已经有两条8字节的记录,所以内部循环立即对FileReadLong执行了两次迭代,并且文件大小 “看起来” 等于16。

删除文件并检查其是否存在

检查文件是否存在以及删除文件是与文件系统相关的关键操作,也就是说,这些操作与文件所处的外部环境有关。到目前为止,我们研究的是操作文件内部内容的函数。从本节开始,重点将转向那些把文件作为不可分割的单元进行管理的函数。

c
bool FileIsExist(const string filename, int flag = 0)

该函数检查名为filename的文件是否存在,如果存在则返回true。使用flag参数选择搜索目录:如果它的值为0(默认值),则在当前终端实例的目录(MQL5/Files)中搜索文件;如果flag等于FILE_COMMON,则检查所有终端的公共目录Users/<user>...MetaQuotes/Terminal/Common/Files。如果MQL程序在测试器中运行,工作目录位于测试器代理文件夹内(Tester/<agent>/MQL5/Files),请参阅 “文件操作” 章节的介绍部分。

指定的名称可能不是文件的名称,而是目录的名称。在这种情况下,FileIsExist函数将返回false,并且会在_LastError变量中记录一个伪错误5018(FILE_IS_DIRECTORY)。

c
bool FileDelete(const string filename, int flag = 0)

该函数删除指定名称为filename的文件。flag参数指定文件的位置。使用默认值时,删除操作在当前终端实例的工作目录(MQL5/Files)中执行,如果程序在测试器中运行,则在测试器代理的工作目录(Tester/<agent>/MQL5/Files)中执行。如果flag等于FILE_COMMON,则文件必须位于所有终端的公共文件夹(/Terminal/Common/Files)中。

该函数返回操作成功的标志(true)或错误标志(false)。

此函数不允许删除目录。要删除目录,请使用FolderDelete函数(请参阅 “文件夹操作”)。

为了了解上述函数的工作方式,我们将使用脚本FileExist.mq5。我们将对一个临时文件进行一些操作。

c
const string filetemp = "MQL5Book/temp";
void OnStart()
{
   PRTF(FileIsExist(filetemp)); // false / FILE_NOT_EXIST(5019)
   PRTF(FileDelete(filetemp));  // false / FILE_NOT_EXIST(5019)
   
   int handle = PRTF(FileOpen(filetemp, FILE_TXT | FILE_WRITE | FILE_ANSI)); // 1
   
   PRTF(FileIsExist(filetemp)); // true
   PRTF(FileDelete(filetemp));  // false / CANNOT_DELETE_FILE(5006)
   
   FileClose(handle);
   
   PRTF(FileIsExist(filetemp)); // true
   PRTF(FileDelete(filetemp));  // true
   PRTF(FileIsExist(filetemp)); // false / FILE_NOT_EXIST(5019)
   
   PRTF(FileIsExist("MQL5Book")); // false / FILE_IS_DIRECTORY(5018)
   PRTF(FileDelete("MQL5Book"));  // false / FILE_IS_DIRECTORY(5018)
}

文件最初不存在,所以FileIsExistFileDelet函数都返回false,错误代码为5019(FILE_NOT_EXIST)。

然后我们创建一个文件,FileIsExist函数报告该文件已存在。然而,此时无法删除该文件,因为它已被打开且被我们的进程占用(错误代码5006,CANNOT_DELETE_FILE)。

一旦文件关闭,就可以删除它了。

在脚本的最后,检查了“MQL5Book”目录并尝试删除它。FileIsExist返回false,因为它不是一个文件,不过错误代码5018(FILE_IS_DIRECTORY)表明它是一个目录。

文件复制与移动

在文件系统层面,对文件的主要操作是复制和移动。为此,MQL5实现了两个原型相同的函数。

c
bool FileCopy(const string source, int flag, const string destination, int mode)

该函数将源文件复制到目标文件。上述两个参数既可以只包含文件名,也可以包含MQL5沙盒中的文件名及其前缀路径(文件夹层级结构)。flagmode参数决定了在哪个工作文件夹中查找源文件以及目标工作文件夹是哪个:值为0表示是当前终端本地实例的文件夹(如果程序在测试器中运行,则是测试器代理的文件夹),值为FILE_COMMON表示是所有终端的公共文件夹。

此外,在mode参数中,你可以选择性地指定FILE_REWRITE常量(如果需要将FILE_REWRITEFILE_COMMON组合使用,可以使用按位或运算符(|)来实现)。如果没有指定FILE_REWRITE,则禁止覆盖已存在的文件。换句话说,如果destination参数中指定路径和名称的文件已经存在,你必须通过设置FILE_REWRITE来确认你要覆盖它的意图。如果不这样做,函数调用将会失败。

函数在成功完成时返回true,出现错误时返回false

如果源文件或目标文件被其他进程占用(已打开),复制操作可能会失败。

复制文件时,通常会保留其元数据(创建时间、访问权限、替代数据流)。如果你只需要对文件的数据进行 “纯粹” 的复制,可以连续调用FileLoadFileSave函数,详见 “简化模式下的文件读写”。

c
bool FileMove(const string source, int flag, const string destination, int mode)

该函数用于移动或重命名文件。源文件的路径和名称在source参数中指定,目标文件的路径和名称在destination参数中指定。

参数列表及其操作原理与FileCopy函数相同。简单来说,FileMove函数的操作和FileCopy函数类似,但在成功复制后会额外删除原始文件。

下面我们通过脚本FileCopy.mq5来学习如何实际使用这些函数。该脚本有两个包含文件名的变量。脚本运行时,这两个文件都不存在。

c
const string source = "MQL5Book/source";
const string destination = "MQL5Book/destination";

OnStart函数中,我们按照一个简单的场景执行一系列操作。首先,我们尝试将源文件从本地工作文件夹复制到公共文件夹中的目标文件。不出所料,操作返回false_LastError中的错误代码为5019(FILE_NOT_EXIST)。

c
void OnStart()
{
   PRTF(FileCopy(source, 0, destination, FILE_COMMON)); // false / FILE_NOT_EXIST(5019)
   ...

因此,我们将以常规方式创建一个源文件,写入一些数据并将其刷新到磁盘。

c
   int handle = PRTF(FileOpen(source, FILE_TXT | FILE_WRITE)); // 1
   PRTF(FileWriteString(handle, "Test Text\n")); // 22
   FileFlush(handle);

由于文件处于打开状态,并且在打开时没有指定FILE_SHARE_READ权限,因此通过其他方式(绕过句柄)访问该文件仍然会被阻止。所以,下一次复制尝试仍将失败。

c
   PRTF(FileCopy(source, 0, destination, FILE_COMMON)); // false / CANNOT_OPEN_FILE(5004)

我们关闭文件并再次尝试。但首先,我们将生成文件的属性(创建时间和修改时间)输出到日志。这两个属性都会包含你计算机的当前时间戳。

c
   FileClose(handle);
   PRTF(FileGetInteger(source, FILE_CREATE_DATE)); // 例如:1629757115
   PRTF(FileGetInteger(source, FILE_MODIFY_DATE)); // 例如:1629757115

在调用FileCopy之前,我们等待3秒。这样可以让你看到原始文件和其副本在属性上的差异。这个暂停与之前文件被锁定无关:如果启用了FILE_SHARE_READ选项,我们可以在关闭文件后立即进行复制,甚至在写入文件时也可以进行复制。

c
   Sleep(3000);

现在我们进行文件复制。这次操作成功了。让我们查看一下副本的属性。

c
   PRTF(FileCopy(source, 0, destination, FILE_COMMON)); // true
   PRTF(FileGetInteger(destination, FILE_CREATE_DATE, true)); // 例如:1629757118,比原始文件晚3秒
   PRTF(FileGetInteger(destination, FILE_MODIFY_DATE, true)); // 例如:1629757115

每个文件都有自己的创建时间(副本的创建时间比原始文件晚3秒),但修改时间相同(副本继承了原始文件的属性)。

现在,我们尝试将副本移回本地文件夹。如果不使用FILE_REWRITE选项,操作将无法完成,因为没有权限覆盖原始文件。

c
   PRTF(FileMove(destination, FILE_COMMON, source, 0)); // false / FILE_CANNOT_REWRITE(5020)

通过更改参数值,我们可以成功完成文件移动。

c
   PRTF(FileMove(destination, FILE_COMMON, source, FILE_REWRITE)); // true

最后,我们还会删除原始文件,以便为使用该脚本进行新的实验提供一个干净的环境。

c
   ...
   FileDelete(source);
}

文件和文件夹的搜索

MQL5允许你在终端沙盒、测试器代理以及所有终端的公共沙盒中搜索文件和文件夹(有关沙盒的更多详细信息,请参阅 “文件操作” 章节的介绍部分)。如果你确切知道所需文件/目录的名称和位置,请使用FileIsExist函数。

c
long FileFindFirst(const string filter, string &found, int flag = 0)

该函数根据传入的筛选条件开始搜索文件和文件夹。筛选条件可以包含沙盒内由子文件夹组成的路径,并且必须包含所搜索的文件系统元素的确切名称或名称模式。filter参数不能为空。

模式是一个包含一个或多个通配符的字符串。有两种类型的通配符:星号(*)可以替代任意数量的任意字符(包括零个字符),问号(?)最多可以替代一个任意字符。例如,筛选条件*将找到所有文件和文件夹,而???.*将只找到名称长度不超过3个字符的文件和文件夹,并且可以有扩展名也可以没有。可以使用*.csv筛选条件找到扩展名为csv的文件(但请注意,文件夹也可能有扩展名)。筛选条件*.可以找到没有扩展名的元素,而.*可以找到没有名称的元素。不过,这里需要记住以下几点。

在许多版本的Windows中,会为文件系统元素生成两种名称:长名称(默认情况下,最长可达260个字符)和短名称(采用从MS-DOS继承的8.3格式)。如果长名称超过8个字符或者扩展名长度超过3个字符,系统会自动从长名称生成短名称。如果没有软件使用短名称,系统可以禁用短名称的生成,但通常情况下短名称生成是启用的。

搜索会同时在这两种名称中进行,这就是为什么返回的列表中可能会包含一些乍一看出乎意料的元素。特别是,如果系统从长名称生成了短名称,那么短名称在点号之前总是包含一个最长为8个字符的初始部分。它可能会意外地与所需的模式匹配。

如果你需要查找具有多种扩展名的文件,或者名称中具有不同片段且无法用一种模式概括的文件,你将不得不使用不同的设置重复搜索过程几次。

搜索仅在特定文件夹中执行(如果筛选条件中没有路径,则在沙盒的根文件夹中搜索;如果筛选条件中包含路径,则在指定的子文件夹中搜索),并且不会深入到子目录中。

搜索不区分大小写。例如,对*.txt文件的搜索请求也会返回扩展名为TXTTxt等的文件。

如果找到匹配名称的文件或文件夹,该名称将被放置在输出参数found中(需要一个变量,因为结果是通过引用传递的),并且函数会返回一个搜索句柄:如果有多个匹配项,需要将此句柄传递给FileFindNext函数,以便继续迭代匹配的项目。

found参数中,只返回名称和扩展名,不包含可能在筛选条件中指定的路径(文件夹层级结构)。

如果找到的项目是一个文件夹,会在其名称右侧附加一个反斜杠(\)字符。

flag参数允许在当前终端副本的本地工作文件夹(值为0)或所有终端的公共文件夹(值为FILE_COMMON)之间选择搜索区域。当MQL程序在测试器中执行时,其本地沙盒(值为0)位于测试器代理目录中。

在搜索过程完成后,应通过调用FileFindClose函数释放接收到的句柄(详见下文)。

c
bool FileFindNext(long handle, string &found)

该函数继续搜索由FileFindFirst函数启动的合适的文件系统元素。第一个参数是从FileFindFirst函数接收到的描述符,通过它应用所有先前的搜索条件。

如果找到下一个元素,其名称将通过参数found传递给调用代码,并且函数返回true

如果没有更多元素,函数返回false

c
void FileFindClose(long handle)

该函数关闭作为调用FileFindFirst函数结果而接收到的搜索描述符。

在搜索过程完成后,必须调用此函数以释放系统资源。

作为一个示例,让我们看一下脚本FileFind.mq5。在前面的章节中,我们测试了许多其他脚本,这些脚本在目录MQL5/Files/MQL5Book中创建了文件。请求获取所有此类文件的列表。

c
void OnStart()
{
   string found; // 接收变量
   // 开始搜索并获取描述符 
   long handle = PRTF(FileFindFirst("MQL5Book/*", found));
   if(handle != INVALID_HANDLE)
   {
      do
      {
         Print(found);
      }
      while(FileFindNext(handle, found));
      FileFindClose(handle);
   }
}

即使你已经清空了该目录,你也可以将本书附带的各种编码的示例文件复制到其中。因此,脚本FileFind.mq5至少应该输出以下列表(枚举顺序可能会有所不同):

ansi1252.txt
unicode1.txt
unicode2.txt
unicode3.txt
utf8.txt

为了简化搜索过程,该脚本有一个辅助函数DirList。它包含对内置函数的所有必要调用,以及一个用于构建包含与筛选条件匹配的元素列表的字符串数组的循环。

c
bool DirList(const string filter, string &result[], bool common = false)
{
   string found[1];
   long handle = FileFindFirst(filter, found[0]);
   if(handle == INVALID_HANDLE) return false;
   do
   {
      if(ArrayCopy(result, found, ArraySize(result)) != 1) break;
   }
   while(FileFindNext(handle, found[0]));
   FileFindClose(handle);
   
   return true;
}

借助这个函数,我们将请求本地沙盒中的目录列表。为此,我们使用这样一个假设,即目录通常没有扩展名(理论上,情况并非总是如此,因此那些希望更严格地请求子文件夹列表的人应该以不同的方式实现)。对于没有扩展名的元素的筛选条件是*.(你可以在Windows shell中使用命令dir *.*进行检查)。然而,这个模式在MQL5函数中会导致错误5002(WRONG_FILENAME)。因此,我们将指定一个更 “模糊” 的模式*.?:它表示没有扩展名或扩展名长度为1个字符的元素。

c
void OnStart()
{
   ...
   string list[];
   // 尝试请求没有扩展名的元素
   // (在Windows命令行中有效)
   PRTF(DirList("*.", list)); // false / WRONG_FILENAME(5002)
   
   // 扩展条件:扩展名必须不超过1个字符
   if(DirList("*.?", list))
   {
      ArrayPrint(list);
      // 例如:"MQL5Book\" "Tester\"
   }
}

在我的MetaTrader 5实例中,该脚本找到了两个文件夹MQL5Book\Tester\。如果你运行了前面的测试脚本,你也应该有第一个文件夹。

文件夹操作

很难想象一个文件系统不具备通过任意目录层级结构(用于存放逻辑相关文件集合的容器)来组织存储信息的能力。在MQL5层面,同样支持这一功能。如果有需要,我们可以使用内置函数FolderCreateFolderCleanFolderDelete来创建、清理和删除文件夹。

早些时候,我们已经见过一种创建文件夹的方法,甚至可能不止一种,而是可以一次性创建出所需的整个子文件夹层级结构。为此,在使用FileOpen创建(打开)文件时,或者在复制文件(FileCopyFileMove)时,不应只指定文件名,而应在文件名前加上所需的路径。例如:

c
   FileCopy("MQL5Book/unicode1.txt", 0, "ABC/DEF/code.txt", 0);

这条语句将在沙盒中创建“ABC”文件夹,在“ABC”文件夹中创建“DEF”文件夹,并将文件以新名称复制到该文件夹中(源文件必须存在)。

如果你不想预先创建源文件,也可以在操作过程中创建一个虚拟文件:

c
   uchar dummy[];
   FileSave("ABC/DEF/empty", dummy);

这样,我们将得到与前面示例相同的文件夹层级结构,但其中有一个大小为零的“empty”文件。

通过这些方法,文件夹的创建成为了文件操作的某种副产品。然而,有时需要将文件夹作为独立的实体进行操作,且不产生副作用,特别是只创建一个空文件夹。FolderCreate函数就提供了这样的功能。

c
bool FolderCreate(const string folder, int flag = 0)

该函数创建名为folder的文件夹,folder可以包含路径(多个顶级文件夹名称)。无论哪种情况,都会在由flag参数定义的沙盒中创建单个文件夹或文件夹层级结构。默认情况下,当flag为0时,使用终端或测试器代理(如果程序在测试器中运行)的本地工作文件夹MQL5/Files。如果flag等于FILE_COMMON,则使用所有终端的共享文件夹。

函数在成功创建文件夹或文件夹已存在时返回true。出现错误时,结果为false

c
bool FolderClean(const string folder, int flag = 0)

该函数删除指定文件夹目录中任意嵌套级别的所有文件和文件夹(连同其所有内容)。flag参数指定操作发生的沙盒(本地或全局)。

使用此功能时要谨慎,因为所有文件和子文件夹(及其文件)都会被永久删除。

c
bool FolderDelete(const string folder, int flag = 0)

该函数删除指定的文件夹(folder)。在调用该函数之前,文件夹必须为空,否则无法删除。

脚本FileFolder.mq5展示了使用这三个函数的方法。你可以在调试模式下逐步(逐语句)执行此脚本,并在文件管理器中观察文件夹和文件的出现与消失。不过请注意,在执行下一条指令之前,你应该使用文件管理器退出已创建的文件夹,直到“MQL5Book”层级,因为否则文件夹可能会被文件管理器占用,这将导致脚本运行出现问题。

我们首先通过向几个子文件夹中写入一个空的虚拟文件来创建这些子文件夹,将其作为文件操作的副产品。

c
void OnStart()
{
   const string filename = "MQL5Book/ABC/DEF/dummy";
   uchar dummy[];
   PRTF(FileSave(filename, dummy)); // true

接下来,我们使用FolderCreate函数在最低嵌套层级创建另一个文件夹:这次文件夹是独立创建的,没有借助辅助文件。

c
   PRTF(FolderCreate("MQL5Book/ABC/GHI")); // true

如果你尝试删除“DEF”文件夹,操作将会失败,因为它不是空的(里面有一个文件)。

c
   PRTF(FolderDelete("MQL5Book/ABC/DEF")); // false / CANNOT_DELETE_DIRECTORY(5024)

为了删除它,必须先清空该文件夹,最简单的方法是使用FolderClean函数。但我们将尝试模拟一种常见情况,即正在清理的文件夹中的某些文件可能会被其他MQL程序、外部应用程序或终端本身锁定。我们打开文件进行读取,然后调用FolderClean函数。

c
   int handle = PRTF(FileOpen(filename, FILE_READ)); // 1
   PRTF(FolderClean("MQL5Book/ABC")); // false / CANNOT_CLEAN_DIRECTORY(5025)

该函数返回false,并显示错误代码5025(CANNOT_CLEAN_DIRECTORY)。在我们关闭文件后,清理并删除整个文件夹层级结构的操作就成功了。

c
   FileClose(handle);
   PRTF(FolderClean("MQL5Book/ABC")); // true
   PRTF(FolderDelete("MQL5Book/ABC")); // true
}

当使用共享的终端目录时,更有可能出现潜在的锁定情况,因为在这种情况下,同一个文件或文件夹可能会被不同的程序实例“占用”。但即使在本地沙盒中,也不应忘记可能出现的冲突(例如,如果一个csv文件在Excel中被打开)。为处理文件夹的代码部分实现详细的诊断和错误输出,以便用户能够注意到并解决问题。

文件或文件夹选择对话框

在用于处理文件和文件夹的函数组中,有一个函数允许以交互方式向用户请求文件或文件夹的名称,以及一组文件的名称,以便将这些信息传递给MQL程序。调用FileSelectDialog函数会使终端中弹出一个标准的Windows文件和文件夹选择窗口。

由于在对话框关闭之前,它会中断MQL程序的执行,因此该函数仅允许在两种在单独线程中执行的MQL程序中调用:智能交易系统(EA)和脚本(请参阅“MQL程序的类型”)。在指标和服务中禁止使用此函数:指标在终端的界面线程中执行(停止指标会导致相应交易品种图表的更新冻结),而服务在后台执行,无法访问用户界面。

该函数所处理的文件系统的所有元素都位于沙盒内部,即在当前终端副本或测试代理(如果程序在测试器中运行)的目录下的MQL5/Files子文件夹中。

如果flags参数中存在FSD_COMMON_FOLDER标志(详见下文),则会使用所有终端的公共沙盒Users/<user>...MetaQuotes/Terminal/Common/Files

对话框的外观取决于Windows操作系统。下面展示了一种可能的界面选项。

Windows文件和文件夹选择对话框

Windows文件和文件夹选择对话框

c
int FileSelectDialog(const string caption, const string initDir, const string filter,
    uint flags, string &filenames[], const string defaultName)

该函数显示一个标准的Windows对话框,用于打开或创建文件,或者选择文件夹。对话框的标题在caption参数中指定。如果该值为NULL,则使用标准标题:根据flags参数中的标志,读取文件时为“打开”,写入文件时为“另存为”,或者选择文件夹时为“选择文件夹”。

initDir参数允许设置对话框打开时的初始文件夹。如果设置为NULL,将显示MQL5/Files文件夹的内容。如果在initDir中指定了一个不存在的路径,也会使用该文件夹。

使用filter参数,可以限制对话框中显示的文件扩展名集合。其他格式的文件将被隐藏。NULL表示没有限制。

筛选字符串的格式如下:

"<描述1>|<扩展名1>|<描述2>|<扩展名2>..."

描述可以是任何字符串。你可以编写任何筛选条件,其中的通配符*?的用法与我们在“查找文件和文件夹”部分中讨论的作为扩展名时的用法相同。符号|是分隔符。

由于相邻的描述和扩展名构成了一个逻辑相关的对,所以字符串中的元素总数必须是偶数,分隔符的数量必须是奇数。

每个描述和扩展名的组合都会在对话框的下拉列表中生成一个单独的选项。描述会显示给用户,而扩展名用于筛选。

例如,"Text documents (*.txt)|*.txt|All files (*.*)|*.*",并且第一个扩展名"Text documents (*.txt)|*.txt"将被选为默认文件类型。

flags参数中,可以使用|运算符指定一个位掩码来设置操作模式。为其定义了以下常量:

  • FSD_WRITE_FILE — 文件写入模式(“另存为”)。如果不存在此标志,默认使用读取模式(“打开”)。如果存在此标志,无论FSD_FILE_MUST_EXIST标志如何,始终允许输入任意新名称。
  • FSD_SELECT_FOLDER — 文件夹选择模式(只能选择一个且必须是已存在的文件夹)。使用此标志时,除FSD_COMMON_FOLDER标志外的所有其他标志将被忽略或导致错误。无法显式请求创建文件夹,但可以在对话框中以交互方式创建一个文件夹并立即选择它。
  • FSD_ALLOW_MULTISELECT — 在读取模式下选择多个文件的权限。如果指定了FSD_WRITE_FILEFSD_SELECT_FOLDER,则此标志将被忽略。
  • FSD_FILE_MUST_EXIST — 所选文件必须存在。如果用户尝试指定一个任意名称,对话框将显示警告并保持打开状态。如果指定了FSD_WRITE_FILE模式,则此标志将被忽略。
  • FSD_COMMON_FOLDER — 为所有客户端终端的公共沙盒打开对话框。

该函数将用所选文件或文件夹的名称填充字符串数组filenames。如果数组是动态的,其大小会根据实际数据量进行调整,特别是如果没有选择任何内容,数组大小将扩展或截断为0。如果数组是固定大小的,它必须足够大以容纳预期的数据。否则,将发生错误4007(ARRAY_RESIZE_ERROR)。

defaultName参数指定默认的文件/文件夹名称,该名称将在对话框打开后立即替换到相应的输入字段中。如果该参数为NULL,则该字段最初将为空。

如果设置了defaultName参数,那么在对MQL程序进行非可视化测试时,FileSelectDialog调用将返回1,并且defaultName的值将被复制到filenames数组中。

该函数返回所选项目的数量(如果用户未选择任何内容,则为0),如果出现错误则返回-1。

考虑一下在脚本FileSelect.mq5中该函数的工作示例。在OnStart函数中,我们将使用不同的设置依次调用FileSelectDialog。只要用户选择了某些内容(没有点击对话框中的“取消”按钮),测试就会一直进行到最后一步(即使函数执行时带有错误代码)。

c
void OnStart()
{
 string filenames[]; // 适合任何调用的动态数组
 string fixed[1]; // 如果文件数量超过1个,这个固定数组就太小了
 const stringfilter = // 筛选条件示例
      "Text documents (*.txt)|*.txt"
      "|Files with short names|????.*"
      "|All files (*.*)|*.*";

首先,我们将要求用户从“MQL5Book”文件夹中选择一个文件。你可以选择一个已存在的文件,也可以输入一个新名称(因为没有FSD_FILE_MUST_EXIST标志)。

c
   Print("Open a file");
   if(PRTF(FileSelectDialog(NULL, "MQL5book", filter, 
      0, filenames, NULL)) == 0) return;             // 1
   ArrayPrint(filenames);                            // "MQL5Book\utf8.txt"

假设该文件夹中至少有5个本书附带的文件,这里会选择其中一个。

然后,我们将以“用于写入”模式(带有FSD_WRITE_FILE标志)进行类似的请求。

c
   Print("Save as a file");
   if(PRTF(FileSelectDialog(NULL, "MQL5book", NULL, 
      FSD_WRITE_FILE, filenames, NULL)) == 0) return;// 1 
   ArrayPrint(filenames);                            // "MQL5Book\newfile"

在这里,用户也将能够选择一个已存在的文件或输入一个新名称。程序员必须检查用户是否要覆盖一个已存在的文件(对话框不会生成警告)。

现在,让我们检查在动态数组中选择多个文件(FSD_ALLOW_MULTISELECT)的情况。

c
   if(PRTF(FileSelectDialog(NULL, "MQL5book", NULL, 
     FSD_FILE_MUST_EXIST | FSD_ALLOW_MULTISELECT, filenames, NULL)) == 0) return; // 5
   ArrayPrint(filenames);
   // "MQL5Book\ansi1252.txt" "MQL5Book\unicode1.txt" "MQL5Book\unicode2.txt"
   // "MQL5Book\unicode3.txt" "MQL5Book\utf8.txt"

FSD_FILE_MUST_EXIST标志的存在意味着如果尝试输入一个新名称,对话框将显示警告并保持打开状态。

如果我们尝试以类似的方式在固定大小的数组中选择多个文件,将会得到一个错误。

c
   Print("Open multiple files (fixed, choose more than 1 file for error)");
   if(PRTF(FileSelectDialog(NULL, "MQL5book", NULL, 
      FSD_FILE_MUST_EXIST | FSD_ALLOW_MULTISELECT, fixed, NULL)) == 0) return;
   // -1 / ARRAY_RESIZE_ERROR(4007)
   ArrayPrint(fixed); // null

最后,让我们检查文件夹操作(FSD_SELECT_FOLDER)。

c
   Print("Select a folder");
   if(PRTF(FileSelectDialog(NULL, "MQL5book/nonexistent", NULL, 
      FSD_SELECT_FOLDER, filenames, NULL)) == 0) return; // 1
   ArrayPrint(filenames); // "MQL5Book"

在这种情况下,指定了一个不存在的子文件夹“nonexistent”作为起始路径,因此对话框将在沙盒MQL5/Files的根目录中打开。我们在那里选择了“MQL5book”。

如果我们组合了无效的标志,将得到另一个错误。

c
   if(PRTF(FileSelectDialog(NULL, "MQL5book", NULL, 
      FSD_SELECT_FOLDER | FSD_WRITE_FILE, filenames, NULL)) == 0) return;
   // -1 / INTERNAL_ERROR(4001)
   ArrayPrint(filenames); // "MQL5Book"
}

由于出现错误,该函数没有修改传递的数组,并且旧的“MQL5Book”元素仍然存在于其中。

在这个测试中,我们故意只检查结果是否为0,以便展示所有选项,而不管是否存在错误。在实际程序中,应考虑错误情况来检查函数的结果,即针对三种结果设置条件:已做出选择(>0)、未做出选择(==0)和出现错误(<0)。