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

Для текстовых файлов имеется свой набор функций атомарного (поэлементного) сохранения и чтения данных. Он несколько отличается от набора для бинарных файлов из предыдущего раздела. Также следует отметить отсутствие функций-аналогов для записи/чтения структуры или массива структур в текстовый файл. Если вы попытаетесь использовать какие-либо из этих функций с текстовым файлом, они не произведут никакого эффекта, но взведут внутренний код ошибки 5011 (FILE_NOTBIN).

Как мы уже знаем, у текстовых файлов в MQL5 существует две ипостаси: простой текст, и текст в формате CSV. Соответствующий режим — FILE_TXT или FILE_CSV — задается при открытии файла и не может быть изменен без закрытия и повторного получения дескриптора. Разница между ними проявляется только при чтении файлов. Запись обоих режимов производится одинаково.

В режиме TXT каждый вызов функции чтения (любой функции из тех, что мы рассмотрим в этом разделе) находит в файле следующий перевод строки (символ '\n' или пару '\r\n') и обрабатывает все данные вплоть до него. Суть обработки заключается в преобразовании текста из файла в значение конкретного типа, соответствующего вызванной функции. В простейшем случае, если вызвана функция FileReadString, обработка не производится (строка возвращается "как есть").

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

Иными словами, чтение текста и перевод внутренней позиции внутри файла производится фрагментами от разделителя к разделителю, где под "разделителем" понимается не только символ delimiter в списке параметров FileOpen, но и перевод строки ('\n', '\r\n'), а также начало и конец файла.

На запись текста в файлы FILE_TXT и FILE_CSV дополнительный разделитель влияет одинаково, но только при использовании функции FileWrite: она этот символ автоматически вставляет между записываемыми элементами. Функция FileWriteString разделитель не учитывает.

Приведем формальное описание функций, а затем рассмотрим пример FileTxtCsv.mq5.

uint FileWrite(int handle, ...)

Функция относится к категории функций, которые принимают переменное количество параметров. Такие параметры обозначаются в прототипе функции многоточием. Поддерживаются только встроенные типы данных. Для записи структур или объектов классов необходимо разыменовывать их элементы и передавать по отдельности.

Функция записывает в текстовый файл с дескриптором handle все аргументы, переданные после первого. Аргументы разделяются запятыми, как в обычном списке аргументов. Количество выводимых в файл аргументов не может превышать 63.

При выводе числовые данные преобразуются в текстовый формат по правилам стандартного приведения к (string). Значения типа double выводятся с точностью до 16 значащих цифр: либо в традиционном, либо в научном формате с показателем степени (выбирается более компактный вариант). Данные типа float выводятся с точностью до 7 значащих цифр. Для вывода вещественных чисел с другой точностью или в явно указанном формате необходимо использовать функцию DoubleToString (см. раздел Числа в строки и обратно).

Значения типа datetime выводятся в формате "YYYY.MM.DD hh:mm:ss" (см. раздел Дата и время).

Стандартный цвет (из списка web-цветов) выводится в виде названия, нестандартный — как тройка значений компонентов RGB (см. раздел Цвет), разделенных запятыми (внимание: запятая — наиболее частый символ-разделитель в CSV).

Для перечислений выводится целое число, обозначающее элемент, а не его идентификатор (название). Например, при записи FRIDAY (из ENUM_DAY_OF_WEEK, см. раздел Перечисления) получим в файле число 5.

Значения типа bool выводятся в виде строк "true" или "false".

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

После записи всех аргументов в файл добавляется признак конца строки '\r\n'.

Функция возвращает количество записанных байтов или 0 в случае ошибки.

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

Функция записывает в текстовый файл с дескриптором handle строковый параметр text. Параметр length применим только для бинарных файлов и в данном контексте игнорируется (строка записывается полностью).

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

Любые разделители (между элементами в строке) и переводы строк программист должен вставить/добавить самостоятельно, если они требуются.

Функция возвращает количество записанных байтов (в режиме FILE_UNICODE это будет в 2 раза больше длины строки в символах) или 0 в случае ошибки.

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

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

Полученную строку можно преобразовать в значение требуемого типа по стандартным правилам приведения или с помощью функций конвертации. Альтернативно можно использовать специализированные функции чтения: FileReadBool, FileReadDatetime, FileReadNumber, описанные далее.

В случае ошибки будет возвращена пустая строка. Код ошибки можно узнать через переменную _LastError или функцию GetLastError. В частности, по достижении конца файла код ошибки будет равен 5027 (FILE_ENDOFFILE).

bool FileReadBool(int handle)

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

Слово "true" должно занимать весь прочитанный элемент. Даже если строка начинается на "true", но имеет продолжение (например "True Volume"), получим false.

datetime FileReadDatetime(int handle)

Функция читает из CSV-файла строку одного из форматов: "YYYY.MM.DD hh:mm:ss", "YYYY.MM.DD" или "hh:mm:ss" и преобразует ее в значение типа datetime. Если фрагмент не содержит корректного текстового представления даты и/или времени, функция вернет нулевое или "странное" время, в зависимости от того, какие символы её удастся интерпретировать как фрагменты даты и времени. Для пустых или нечисловых строк получим текущую дату с нулевым временем.

Более гибкого чтения даты и времени (с большим числом поддерживаемых форматов) можно добиться, скомбинировав две функции: StringToTime(FileReadString(handle)). Про StringToTime см. раздел Дата и время.

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.

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

Котировки запросим в начале функции OnStart привычным способом:

void OnStart()
{
   MqlRates rates[];   
   int n = PRTF(CopyRates(_Symbol_Period010rates)); // 10

Названия колонок укажем в массиве по отдельности, а также "склеим" их вместе с помощью вспомогательной функции StringCombine. Раздельные названия требуются, поскольку мы объединяем их в общий заголовок, используя выбираемый символ-разделитель (альтернативное решение могло бы строиться на StringReplace). С исходным кодом StringCombine предлагается ознакомиться самостоятельно: она делает обратную операцию по отношению к встроенной StringSplit.

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

Последняя колонка должна была бы называться "Volume", но мы на её примере проверим работу функции FileReadBool. Вы можете считать, что текущее название подразумевает "True Volume" (но такая строка не интерпретировалась бы как true).

Далее открываем два файла в режимах FILE_TXT и FILE_CSV, и записываем в них подготовленный заголовок.

   int fh1 = PRTF(FileOpen(txtfileFILE_TXT | FILE_ANSI | FILE_WRITEdelimiter));//1
   int fh2 = PRTF(FileOpen(csvfileFILE_CSV | FILE_ANSI | FILE_WRITEdelimiter));//2
  
   PRTF(FileWriteString(fh1caption)); // 48
   PRTF(FileWriteString(fh2caption)); // 48

Поскольку функция FileWriteString не добавляет автоматически перевод строки, мы предварительно приплюсовали "\r\n" к переменной caption.

   for(int i = 0i < n; ++i)
   {
      FileWrite(fh1rates[i].time,
         rates[i].openrates[i].highrates[i].lowrates[i].close,
         rates[i].tick_volumerates[i].spreadrates[i].real_volume);
      FileWrite(fh2rates[i].time,
         rates[i].openrates[i].highrates[i].lowrates[i].close,
         rates[i].tick_volumerates[i].spreadrates[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 в цикле, пока она не вернет пустую строку (т.е. до конца файла).

   string read;
   fh1 = PRTF(FileOpen(txtfileFILE_TXT | FILE_ANSI | FILE_READdelimiter)); // 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)

Каждый вызов FileReadString читает в режиме FILE_TXT всю строку целиком (вплоть до '\r\n'). Чтобы разделить её на элементы, следовало бы позаботиться о дополнительной обработке. Или выбрать режим FILE_CSV.

Сделаем то же самое для CSV-файла.

   fh2 = PRTF(FileOpen(csvfileFILE_CSV | FILE_ANSI | FILE_READdelimiter)); // 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)

Все дело в том, что функция FileReadString в режиме FILE_CSV учитывает символ-разделитель и дробит строки на элементы. Каждый вызов FileReadString возвращает отдельное значение (ячейку) из таблицы CSV. Очевидно, что полученные строки нужно впоследствии преобразовать к соответствующим типам.

Эту задачу можно в обобщенном виде решить с помощью специализированных функций FileReadDatetime, FileReadNumber, FileReadBool. Однако разработчик в любом случае должен сам отслеживать номер текущей читаемой колонки и определять её прикладной смысл. Пример такого алгоритма приведен на третьем шаге теста. Он использует тот же CSV-файл (для простоты мы его закрываем в конце каждого шага и открываем в начале следующего).

Чтобы упростить присвоение очередного поля в структуре MqlRates по номеру колонки, была создана структура-наследник MqlRates, содержащая один шаблонный метод set:

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

В функции OnStart мы описали массив из одной такой структуры, куда будем складывать поступающие значения. Массив потребовался, чтобы упростить вывод в журнал с помощью ArrayPrint (для печати структуры самой по себе — готовой функции в MQL5 нет).

   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", который был специально сделан у последней колонки.

   fh2 = PRTF(FileOpen(csvfileFILE_CSV | FILE_ANSI | FILE_READdelimiter)); // 1
   do
   {
      if(column)
      {
         if(count == 1// демо для FileReadBool на 1-ой записи с заголовками
         {
            r[0].set(columnPRTF(FileReadBool(fh2)));
         }
         else
         {
            r[0].set(columnFileReadNumber(fh2));
         }
      }
      else // 0-ая колонка это дата и время
      {
         ++count;
         if(count > 1// структура из предыдущей строки готова
         {
            ArrayPrint(r_DigitsNULL010);
         }
         r[0].time = FileReadDatetime(fh2);
      }
      column = (column + 1) % maxColumn;
   }
   while(_LastError == 0); // выход по достижении конца файла 5027 (FILE_ENDOFFILE)
   
   // печать последней структуры
   if(column == maxColumn - 1)
   {
      ArrayPrint(r_DigitsNULL010);
   }

Вот что выводится в журнал:

===== 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.

Содержимое прочитанных структур совпадает с исходными данными.