管理在文件中的位置

我们已知知道,系统将某个指针与每个打开的文件关联:该指针确定下次任何 I/O 函数被调用时在文件中开始写入或读取数据的位置(相对于开头位置的偏移)。函数执行后,指针将偏移写入或读取数据的大小。

在某些情况下,你需要在没有 I/O 操作的情况下更改指针的位置。尤其是我们需要将数据附加到文件末尾时,我们以“混合”模式 FILE_READ | FILE_WRITE 打开文件,此时必须想办法将指针定位在文件末尾(否则我们将从开头开始覆写数据)。虽然我们可以在有内容需要读取的情况下调用读函数(从而偏移指针),但这种方式的效率不高。最好使用专用函数 FileSeek。FileTell 函数允许获取指针的实际值(在文件中的位置)。

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

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

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

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

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

如果你将位置设为超出文件末尾(值大于文件大小),则随后对文件的写入将不是从文件末尾进行,而是从设置的位置进行。在此情况下,未定义的值将被写入到之前的文件末尾与给定位置之间(见下文)。

如果成功,函数返回 true,如果出错,返回 false

ulong FileTell(int handle)

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

bool FileIsEnding(int handle)

该函数返回关于指针是否在 handle 文件末尾的指示。如果是,则结果为 true

bool FileIsLineEnding(int handle)

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

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

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

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

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

以读/写模式新建或打开一个现有 fileraw 文件 ("MQL5Book/cursor.raw")。在打开之后以及在每个操作之后,立即通过调用 FileState 输出文件的当前状态。

void OnStart()
{
   int handle;
   Print("\n * Phase I. Binary file");
   handle = PRTF(FileOpen(filerawFILE_BIN | FILE_WRITE | FILE_READ));
   Print(FileState(handle));
   ...

将指针移动到文件末尾,这将允许我们在每次执行脚本时将数据附加到该文件(而不是从开头覆写文件)。最明显的表示文件末尾的方式:相对于 origin=SEEK_END 的零 offset

   PRTF(FileSeek(handle0SEEK_END));
   Print(FileState(handle));

如果文件不再为空(非新),则我们可以在其任意位置(相对或绝对位置)读取现有数据。尤其是,若 FileSeek 函数的 origin 参数等于 SEEK_CUR,这表明如果 offset 为负,则当前位置将往后(向左)移动对应的字节数,如果为正,则往前移动(向右)。

在本示例中,我们尝试以 int 类型值的大小回退。稍后我们将会看到,在该位置应该有一个以下结构体的 day_of_year 字段(最后字段): MqlDateTime,因为我们在后续指令中将其写入到一个文件,在下次运行时可从文件中读取该数据。读取的值被记录,与之前保存的值进行比较。

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

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

接下来,以数据填充该文件。首先,使用 FileWriteLong 写入计算机的当前本地时间(8 字节的 datetime)。

   datetime now = TimeLocal();
   PRTF(FileWriteLong(handlenow));
   Print(FileState(handle));

然后我们尝试从当前位置回退 4 个字节 (-4) 并读取 long

   PRTF(FileSeek(handle, -4SEEK_CUR));
   long x = PRTF(FileReadLong(handle));
   Print(FileState(handle));

这一尝试将出现错误 5015 (FILE_READERROR),因为此时处于文件末尾,向左偏移 4 个字节后无法从右侧读取 8 个字节(long 大小)。但是,如同我们将会从日志所看到的那样,虽然这一尝试不成功,但指针仍将回移到文件末尾。

如果回退 8 个字节 (-8),则后续对 long 值的读取将会成功,并且两个时间值(包括原始值和从文件接收到的值)必须匹配。

   PRTF(FileSeek(handle, -8SEEK_CUR));
   Print(FileState(handle));
   x = PRTF(FileReadLong(handle));
   PRTF((now == x));

最后,将填充了相同时间的 MqlDateTime 结构体写入到文件。文件中的位置将增加 32(以字节表示的结构体大小)。

   MqlDateTime mdt;
   TimeToStruct(nowmdt);
   StructPrint(mdt); // display the date/time in the log visually
   PRTF(FileWriteStruct(handlemdt)); // 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

根据状态,文件大小初始为零,因为偏移到文件末尾 ("F:true") 之后位置为 "P:0"。每次记录之后(使用 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 文件 filetxt (MQL5Book/cursor.csv)。我们同样将以读写组合模式打开它,但不将指针移动到文件末尾。因此,每次脚本运行将从文件开头开始覆写数据。为方便看到差异,CSV 文件第一列中的数字是随机生成的。在第二列中,相同字符串始终替换为 StringFormat 函数中的模板。

   Print(" * Phase II. Text file");
   srand(GetTickCount());
   // create a new file or open an existing file for writing/overwriting
   // from the very beginning and subsequent reading; inside CSV data (Unicode)
   handle = PRTF(FileOpen(filetxtFILE_CSV | FILE_WRITE | FILE_READ, ','));
   // three rows of data (number,string pair in each), separated by '\n'
   // note that the last element does not end with a newline '\n'
   // this is optional, but allowed
   string content = StringFormat(
      "%02d,abc\n%02d,def\n%02d,ghi"
      rand() % 100rand() % 100rand() % 100);
   // '\n' will be replaced with '\r\n' automatically, thanks to FileWriteString
   PRTF(FileWriteString(handlecontent));

下面是生成数据的示例:

34,abc
20,def
02,ghi

然后我们将回到文件开头,并使用 FileReadString 在循环中读取数据,持续记录状态。

   PRTF(FileSeek(handle0SEEK_SET));
   Print(FileState(handle));
   // count the lines in the file using the FileIsLineEnding feature
   int lineCount = 0;
   while(!FileIsEnding(handle))
   {
      PRTF(FileReadString(handle));
      Print(FileState(handle));
      // FileIsLineEnding also equals true when FileIsEnding equals true,
      // even if there is no trailing '\n' character
      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 文件有两列,在每个我们读取的第二个值之后,我们都会看到有一个 EOL 标记 ("L:true")。

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

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

   Print(" * Phase III. Allocate large file");
   handle = PRTF(FileOpen(file100FILE_BIN | FILE_WRITE));
   PRTF(FileSeek(handle1000000SEEK_END));
   // to change the size, you need to write at least something
   PRTF(FileWriteInteger(handle0xFF1));
   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