Запись и чтение переменных (бинарные файлы)

Если какая-либо структура содержит поля тех типов, которые запрещены для "простых" структур (строки, динамические массивы, указатели), то записать её в файл или прочитать из файла рассмотренными ранее функциями не получится. То же самое касается и объектов классов. Вместе с тем, такие сущности содержат обычно большую часть данных в программах и также требуют сохранения и восстановления своего состояния.

На примере структуры заголовка в предыдущем разделе было наглядно видно, что без строк (и прочих типов переменной длины) можно, в принципе, обойтись, но при этом приходится изобретать альтернативные, более громоздкие реализации алгоритмов (например, заменять строку на массив символов).

Для записи и чтения данных произвольной сложности MQL5 предоставляет наборы функций нижнего уровня, если так можно выразиться. Они оперируют одним значением конкретного типа: double, float, int/uint, long/ulong или string. Напомним, что все другие встроенные типы MQL5 эквиваленты целым разного размера: char/uchar — 1 байт, short/ushort — 2 байта, color — 4 байта, перечисления — 4 байта, datetime — 8 байтов. Такие функции можно назвать атомарными (т.е. неделимыми), потому что функций чтения и записи в файлы на уровне битов уже не существует.

Разумеется, поэлементная запись или чтение снимает также и ограничение на файловые операции с динамическими массивами.

Что же касается указателей на объекты, в духе парадигмы ООП мы можем поручить сохранение и восстановление объектов им самим: достаточно в каждом классе реализовать некий интерфейс (набор методов), отвечающий за перенос важного содержимого в файлы и обратно, и использующий низкоуровневые функции. Тогда, если в составе объекта нам встретится поле-указатель на другой объект, мы просто делегируем сохранение или чтение ему, а он, в свою очередь, "разберется" со своими полями, среди которых могут быть другие указатели, и делегирование продолжится вглубь, пока не охватит все элементы.

Обратите внимание, что в данном разделе мы рассмотрим атомарные функции для бинарных файлов. Их аналоги для текстовых файлов будут представлены в следующем разделе. Все функции данного раздела возвращают количество записанных байтов или 0 в случае ошибки.

uint FileWriteDouble(int handle, double value)

uint FileWriteFloat(int handle, float value)

uint FileWriteLong(int handle, long value)

Функции записывают в бинарный файл с дескриптором handle значение соответствующего типа, переданное в параметре value (double, float, long).

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

Функция записывает в бинарный файл с дескриптором handle целочисленное значение value. Размер значения в байтах задается параметром size и может быть одной из предопределенных констант: CHAR_VALUE (1), SHORT_VALUE (2), INT_VALUE (4, по умолчанию), что соответствует типам char, short и int (со знаком и без знака).

Функция поддерживает недокументированный режим записи целого числа размером 3 байта. Его использование не рекомендуется.

Файловый указатель перемещается на количество записанных байтов (а не на размер int).

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

Функция записывает в бинарный файл с дескриптором handle строку из параметра value. В параметре length можно указать количество символов для записи. Если оно меньше длины строки, в файл попадет лишь указанная часть строки. Если length равен -1 или не указан, в файл переносится вся строка без терминального нуля. Если length больше длины строки, лишние символы заполняются в файле нулями.

Следует иметь в виду, что при записи в файл, открытый с флагом FILE_UNICODE (либо без флага FILE_ANSI), строка сохраняется в формате Unicode (каждый символ занимает 2 байта). При записи в файл, открытый с флагом FILE_ANSI, каждый символ занимает 1 байт (возможно искажение национальных символов).

Функция FileWriteString может работать и с текстовыми файлами. Такое её применение описано в следующем разделе.

 

double FileReadDouble(int handle)

float FileReadFloat(int handle)

long FileReadLong(int handle)

Функции читают число соответствующего типа — double, float или long — из бинарного файла с указанным дескриптором. При необходимости приведите результат к ulong (если в файле в данной позиции ожидается беззнаковое длинное целое).

int FileReadInteger(int handle, int size = INT_VALUE)

Функция читает из бинарного файла с дескриптором handle целочисленное значение, причем его размер в байтах указывается в параметре size.

Поскольку результат функции — типа int, его необходимо явно приводить к требуемому целевому типу, если он отличается от int (т.е. к uint, или short/ushort, или char/uchar). В противном случае вы, как минимум, получите предупреждение компилятора, а как максимум — потерю знака.

Дело в том, что при чтении CHAR_VALUE или SHORT_VALUE, результат по умолчанию всегда положительный (т.е. соответствует uchar и ushort, которые целиком "умещаются" в int). В этих случаях, если числа действительно типов uchar и ushort, предупреждения компилятора чисто номинальные, так как мы заведомо уверены в том, что внутри значения типа int заполнены лишь 1 или 2 младших байта, и у них нет знака. Это происходит без искажений.

Однако в случае хранения в файле значений со знаками (типов char и short) приведение становится необходимым, потому что без него отрицательные величины превратятся в "обратные" положительные с тем же битовым представлением (см. врезку "Знаковые и беззнаковые целые" в разделе Арифметические преобразования типов).

Но в любом случае от предупреждений лучше избавиться путем явного приведения типа.

Функция поддерживает режим чтения целого числа размером 3 байта. Его использование не рекомендуется.

Файловый указатель перемещается на количество прочитанных байтов (а не на размер int).

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

Функция читает из файла с дескриптором handle строку указанного размера в символах. Параметр size обязательно должен быть задан при работе с бинарным файлом (значение по умолчанию подходит только для текстовых файлов, в которых используются символы-разделители). В противном случае строка не читается (функция возвращает пустую строку), а внутренний код ошибки _LastError равен 5016 (FILE_BINSTRINGSIZE).

Таким образом, еще на стадии записи строки в бинарный файл необходимо подумать, каким образом строка будет читаться. Существует 3 основных варианта:

  • записывать строки с нулевым терминальным символом на конце; в этом случае их придется анализировать посимвольно в цикле и объединять символы в строку, пока не встретится 0;
  • всегда записывать строку фиксированной (предопределенной) длины; длину следует выбрать с запасом для большинства сценариев или по спецификации (техзадания, протокола, и т.д.), но это неэкономно и не дает 100% гарантии, что какая-нибудь редкая строка не окажется укороченной при записи в файл;
  • записывать перед строкой её длину как целое число.

Функция FileReadString может работать и с текстовыми файлами. Такое её применение описано в следующем разделе.

Также обратите внимание, что если параметр size равен 0 (что может получиться в ходе каких-либо вычислений), то функция не производит чтение: указатель файла остается на прежнем месте и функция возвращает пустую строку.

В качестве примера для данного раздела мы усовершенствуем скрипт FileStruct.mq5 из предыдущего раздела. Новое название программы FileAtomic.mq5.

Задача останется прежней: сохранить в двоичный файл заданное количество урезанных структур MqlRates с котировками. Но теперь структура FileHeader станет классом (причем сигнатура формата будет храниться в строке, а не в массиве символов), заголовок этого типа и массив котировок войдут в состав другого управляющего класса Candles, и оба класса будут унаследованы от интерфейса Persistent для записи произвольных объектов в файл и чтения из файла.

А вот и сам интерфейс:

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

Для большего приближения к реальности предусмотрим в классе FileHeader сохранение и проверку при чтении не только сигнатуры формата (поменяем её на "CANDLES/1.1"), но и названия текущего инструмента и таймфрейма графика (подробнее про _Symbol и _Period).

Запись производится в реализации метода write, унаследованного от интерфейса.

class FileHeader : public Persistent
{
   const string signature;
public:
   FileHeader() : signature("CANDLES/1.1") { }
   bool write(int handleoverride
   {
      PRTF(FileWriteString(handlesignatureStringLen(signature)));
      PRTF(FileWriteInteger(handleStringLen(_Symbol), CHAR_VALUE));
      PRTF(FileWriteString(handle_Symbol));
      PRTF(FileWriteString(handlePeriodToString(), 3));
      return true;
   }

Сигнатуру мы записываем точно по её длине, поскольку образец хранится в объекте и при чтении будет задана та же длина.

Для инструмента текущего графика мы предварительно сохраняем в файле длину его имени (достаточно 1 байта для длин вплоть до 255), и только после неё — саму строку.

Название таймфрейма всегда не превышает 3 символа, если из него исключить постоянный префикс "PERIOD_", поэтому для этой строки выбрана фиксированная длина. Получение имени таймфрейма без префикса поручено вспомогательной функции PeriodToString: она находится в отдельном заголовочном файле Periods.mqh (он будет более подробно рассмотрен в разделе Символы и таймфреймы).

Чтение выполняется в методе read в обратном порядке (разумеется, подразумевается, что чтение будет выполняться в другой, новый объект).

   bool read(int handleoverride
   {
      const string sig = PRTF(FileReadString(handleStringLen(signature)));
      if(sig != signature)
      {
         PrintFormat("Wrong file format, header is missing: want=%s vs got %s",
            signaturesig);
         return false;
      }
      const int len = PRTF(FileReadInteger(handleCHAR_VALUE));
      const string sym = PRTF(FileReadString(handlelen));
      if(_Symbol != sym)
      {
         PrintFormat("Wrong symbol: file=%s vs chart=%s"sym_Symbol);
         return false;
      }
      const string stf = PRTF(FileReadString(handle3));
      if(_Period != StringToPeriod(stf))
      {
         PrintFormat("Wrong timeframe: file=%s(%s) vs chart=%s",
            stfEnumToString(StringToPeriod(stf)), EnumToString(_Period));
         return false;
      }
      return true;
   }

Если какое-либо из свойств (сигнатура, символ, таймфрейм) не совпадает в файле и на текущем графике, функция возвращает признак ошибки: false.

Обратное преобразование имени таймфрейма в перечисление ENUM_TIMEFRAMES делает функция StringToPeriod, также из файла Periods.mqh.

Основной класс для запроса, сохранения и чтения архива котировок Candles выглядит следующим образом.

class Candles : public Persistent
{
   FileHeader header;
   int limit;
   MqlRates rates[];
public:
   Candles(const int size = 0) : limit(size)
   {
      if(size == 0return;
      int n = PRTF(CopyRates(_Symbol_Period0limitrates));
      if(n < 1)
      {
         limit = 0// инициализация не удалась
      }
      limit = n// может быть меньше запрошенного
   }

Полями являются заголовок типа FileHeader, запрошенное количество баров limit и массив, принимающий структуры MqlRates от MetaTrader 5. Заполнение массива производится в конструкторе. В случае ошибки поле limit сбрасывается в ноль.

Будучи производным от интерфейса Persistent, класс Candles требует реализации методов write и read. В методе write мы сначала поручаем объекту заголовка сохранить себя, а затем дописываем в файл количество котировок, диапазон дат (для справки) и сам массив.

   bool write(int handleoverride
   {
      if(!limitreturn false// нет данных
      if(!header.write(handle)) return false;
      PRTF(FileWriteInteger(handlelimit));
      PRTF(FileWriteLong(handlerates[0].time));
      PRTF(FileWriteLong(handlerates[limit - 1].time));
      for(int i = 0i < limit; ++i)
      {
         FileWriteStruct(handlerates[i], offsetof(MqlRatestick_volume));
      }
      return true;
   }

Чтение производится в обратном порядке:

   bool read(int handleoverride
   {
      if(!header.read(handle))
      {
         return false;
      }
      limit = PRTF(FileReadInteger(handle));
      ArrayResize(rateslimit);
      ZeroMemory(rates);
      // даты нужно прочитать: они не используются, но это сдвигает позицию в файле;
      // можно было явно изменить позицию, но эта функция нами пока не изучалась
      datetime dt0 = (datetime)PRTF(FileReadLong(handle));
      datetime dt1 = (datetime)PRTF(FileReadLong(handle));
      for(int i = 0i < limit; ++i)
      {
         FileReadStruct(handlerates[i], offsetof(MqlRatestick_volume));
      }
      return true;
   }

В реальной программе архивирования котировок наличие диапазона дат позволило бы по заголовкам файлов выстроить их правильную последовательность на продолжительной истории и в некоторой степени защитило бы от произвольного переименования файлов.

Для контроля процесса имеется простой метод print:

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

В главной функции скрипта мы создаем два объекта Candles, и с помощью одного сначала сохраняем архив котировок, а с помощью другого затем восстанавливаем. Файлы управляются уже известной нам оберткой FileHandle (см. раздел Управление дескрипторами файлов).

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 / ok

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

FileWriteString(handle,signature,StringLen(signature))=11 / ok

FileWriteInteger(handle,StringLen(_Symbol),CHAR_VALUE)=1 / ok

FileWriteString(handle,_Symbol)=6 / ok

FileWriteString(handle,PeriodToString(),3)=3 / ok

FileWriteInteger(handle,limit)=4 / ok

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

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

                 [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 21:00:00 1784.20 1784.85 1782.73 1783.12          2627        6             0

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

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

[9] 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 / ok

FileReadString(handle,StringLen(signature))=CANDLES/1.1 / ok

FileReadInteger(handle,CHAR_VALUE)=6 / ok

FileReadString(handle,len)=XAUUSD / ok

FileReadString(handle,3)=H1 / ok

FileReadInteger(handle)=10 / ok

FileReadLong(handle)=1629212400 / ok

FileReadLong(handle)=1629248400 / ok

                 [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

Легко убедиться, что данные сохранены и прочитаны правильно. А теперь посмотрим, как они выглядят внутри файла:

Просмотр внутреннего устройства бинарного файла с архивом котировок во внешней программе

Просмотр внутреннего устройства бинарного файла с архивом котировок во внешней программе

Здесь цветом подсвечены различные поля нашего заголовка: сигнатура, длина названия символа, название символа, название таймфейма и т.д.