Как стать автором
Обновить

Файловый ввод, сделанный по-человечески

Время на прочтение21 мин
Количество просмотров16K

Поводом к написанию данной статьи и к разработке соответствующей мини-библиотеки ffh стало одно из практических заданий по дисциплине ‘Языки программирования’, которую я веду. В этом задании необходимо прочитать все строки из текстового файла для последующей обработки. Так вот, у студентов, выбравших для выполнения этого задания язык C++ [язык программирования выбирается студентом для каждого задания, но чаще всего выбирают C++ или Python], почему-то иногда читалась из файла лишняя пустая строка. В прошлые годы я не придавал этому большого значения, но в последний раз решил таки разобраться в чём проблема. В тексте задания у меня было написано примерно следующее:
Чтобы прочитать строку из файла на языке C++ используйте такой код:
std::string s;
std::getline(f, s);
Читать строки файла можно в цикле while с проверкой на конец файла f.eof().

Несмотря на прошлый многолетний опыт программирования на C++, функции feof() и ifstream::eof() как-то обошли меня стороной: мне приходилось читать или двоичные файлы, имеющие чёткую структуру, или небольшие текстовые файлы, для эффективного парсинга которых всё равно приходилось читать файл в память целиком (т.к. посимвольное чтение файла и неудобнее и значительно медленнее). И только недавно я узнал, что функция feof()/eof() в C/C++ работает мягко говоря не совсем так, как предполагает программист, не читавший документацию по этой функции:
feof | Microsoft Learn:

The feof routine determines whether the end of stream has been passed. ...

For example, if a file contains 10 bytes and you read 10 bytes from the file, feof will return 0 because, even though the file pointer is at the end of the file, you haven't attempted to read beyond the end. Only after you try to read an 11th byte will feof return a nonzero value.

Теперь понятно, почему в случае когда цикл чтения был организован как while (!f.eof()) {...std::getline(f, s);...} из файла иногда читалась лишняя пустая строка:
  • если текстовый файл оканчивался на символ новой строки, то функция getline() натыкалась на него и прекращала чтение файла, признак конца файла при этом не устанавливался и следующий вызов eof() возвращал false, из-за чего выполнялась лишняя итерация цикла, на которой "читалась" пустая строка;
  • если же текстовый файл не оканчивался на символ новой строки, то функция getline() при чтении последней строки файла в поисках символа новой строки натыкалась на конец файла и перед выходом из функции устанавливала признак конца файла, благодаря чему следующий вызов eof() возвращал true и цикл прекращался.

Тут встал вопрос, как же мне переформулировать текст задания так, чтобы текстовый файл во всех случаях [т.е. независимо от того, оканчивается он на символ новой строки или нет] читался корректно. Но для этого необходимо сначала написать корректный C++ код. В данном случае можно читать файл по строкам так:
std::string s;
while (std::getline(f, s)) {
    ...
}
В тексте задания я поправил строчку, содержащую «с проверкой на конец файла f.eof()», таким образом [полностью убрав упоминание об eof()]:
Читать строки файла следует в цикле до тех пор, пока std::getline() возвращает true.

Но вскоре после этого один из студентов обратился ко мне с вопросом, почему у него не работает такой код:
while (std::getline(f, s) == true) ...

Как вы, наверное, уже знаете, std::getline(f, s) не возвращает булево значение, а возвращает ссылку на объект потока f, у которого определен оператор преобразования к bool. Причём оператор этот объявлен как explicit, т.е. нельзя просто так взять и сравнить объект потока с true.
И вот тут возник конфуз: несмотря на то, что программный код while (std::getline(f, s)) короче, чем while (std::getline(f, s) == true), объяснить его словами на естественном языке гораздо более затруднительно.
Читать строки файла следует в цикле до тех пор, пока std::getline() возвращает… что возвращает? какую-то ссылку на объект потока, у которого определен оператор явного преобразования к bool...
Но ведь нужно дать пояснение не того, что возвращает std::getline(), а того, как записать условие продолжения цикла чтения файла. Т.е. что-то вроде такого:
Читать строки файла следует в цикле до тех пор, пока явное преобразование к bool объекта потока, ссылку на который возвращает std::getline(), даёт true.

Эта формулировка просто ужасна, и если следовать такому описанию, то получится такой код:
while (bool(std::getline(f, s)) == true) ...

Да, этот код работает, но… что за...??? [Ну, вы поняли.]

Чтобы всё-таки получился код while (std::getline(f, s)), можно попытаться добавить ещё, что явный\explicit оператор преобразования к bool в языке C++ является, как бы это сказать, не совсем явным (в отличие от явных операторов преобразования к любому другому типу).
На Stack Overflow есть вопрос ‘Когда не требуется приведение типа при использовании явного оператора преобразования к bool?’
Вот мой [неполный/]выборочный перевод его на русский:
В моём классе определено явное преобразование к bool:
struct T {
    explicit operator bool() const { return true; }
};
И у меня есть объект-экземпляр этого класса:
T t;
Чтобы присвоить его переменной типа bool, необходимо использовать явное приведение типов:
bool b = bool(t);
Я знаю, что я могу использовать объекты моего класса в условиях без явного приведения типов, несмотря на спецификатор explicit:
if (t)
    /* statement */;
А где ещё я могу использовать t как bool без явного приведения типов?

Ответ


В стандарте упоминаются места, где значение может быть "контекстно преобразовано в bool". Они делятся на четыре основные группы:

Утверждения
  • if (t) /* statement */;
  • for (;t;) /* statement */;
  • while (t) /* statement */;
  • do { /* block */ } while (t);

Выражения
  • !t
  • t && t2
  • t || t2
  • t ? "true" : "false"

Проверки времени компиляции
  • static_assert(t);
  • noexcept(t)
  • explicit(t)
  • if constexpr (t)
Для них оператор преобразования должен быть constexpr.

Алгоритмы и концепты
  • NullablePointer T
    
    Везде, где стандарт требует тип, удовлетворяющий этому понятию (например, тип указателя std::unique_ptr), он может быть контекстно преобразован. Кроме того, возвращаемое значение операторов равенства и неравенства NullablePointer должно быть контекстно преобразуемым в bool.
  • std::remove_if(first, last, [&](auto){ return t; });
    
    В любом алгоритме с параметром шаблона Predicate или BinaryPredicate аргумент предикат может возвращать T.
  • std::sort(first, last, [&](auto){ return t; });
    
    В любом алгоритме с параметром шаблона Compare аргумент comparator может возвращать значение T.

[Примечание автора статьи: и это, судя по всему, не полный список (для составления полного списка придётся хорошенько поковырять стандарт). Чувствуете, как пахнет "духом" современного C++? "Отличный" вопрос для собеседования, не правда ли? :)(:
Невольно возникает такая мысль: а стоило ли вообще городить всё это ради пол чайной ложки синтаксического сахара? (Т.е. чтобы можно было писать while (stream) вместо while (stream.ok()) и if (p) вместо if (p != nullptr).)
]

Для чего в C++ явный оператор преобразования к bool был сделан именно так? Для того, чтобы его можно было использовать как замену "safe bool idiom" [и чтобы можно было отказаться от этой идиомы].
Что такое "safe bool idiom"?
Ох, ну это такая штука, которую приходилось использовать вместо operator bool() в C++ до версии C++11, потому что operator bool() без спецификатора explicit… ну в общем он бесполезный "неполноценный", и использовать его в production code не принято.
Если в двух словах, то operator bool() без explicit может быть вызван компилятором неявно для приведения объекта к числу (как целому, так и с плавающей точкой) или даже при сравнении двух различных объектов [у которых определён operator bool() без explicit]. А если не в двух словах, то "safe bool idiom" можно посвятить целую лекцию (: а то и не одну :). Если кому интересно, можете почитать вот это.

В итоге, я решил, что кроличья нора слишком глубока не стоит забивать голову студентам такими подробностями [языка программирования, который они всё равно, скорее всего, использовать после окончания института не будут], и опираясь на такой факт, что std::basic_ios::operator bool() возвращает !fail(), остановился на такой формулировке в тексте задания:
Читать строки файла следует в цикле до тех пор, пока std::getline().fail() возвращает false.
Хотя такую формулировку и соответствующий ей код [while (!std::getline(f, s).fail())] не назвать очень красивыми, но здесь хотя бы понятно, что происходит.

Тем не менее, вернёмся к eof(). Почему эта функция в C++ работает именно так?
Возможно, причина в простоте реализации. В Unix-системах реализация fread() использует системную функцию read(), которая возвращает 0 в случае достижения конца файла. При этом устанавливается индикатор конца файла, который и возвращает функция feof()/eof().
Более интуитивное [и гораздо более практичное!] поведение функции eof() — когда возвращается признак того, что текущая позиция чтения достигла конца файла — реализовать сложнее. Особенно, если размер файла не известен (что возможно когда в качестве файла выступает стандартный поток ввода [stdin]). Но ведь эффективная реализация чтения файла должна использовать буферизацию, благодаря которой реализовать такое поведение eof() (назовём эту функцию at_eof()) становится очень просто: достаточно сравнить текущую позицию в буфере с размером буфера. Если позиция меньше размера, то at_eof() сразу возвращает false. Т.к. такое происходит в >99% случаев вызова at_eof() при чтении файла маленькими порциями, то работать эта функция будет очень быстро. Кроме того, можно вообще избежать накладных расходов на вызов функции [at_eof()], если реализовать эту функцию следующим образом:
    bool at_eof()
    {
        if (buffer_pos < buffer_size)
            return false;
        return has_no_data_left();
    }

private:
#define NOINLINE __attribute__((noinline)) // or __declspec(noinline)
    NOINLINE bool has_no_data_left()
    {
        if (is_eof_reached) {
            ...
            return true;
        }

        if (buffer == nullptr)
            allocate_buffer();
        fill_buffer();
        buffer_pos = 0;
        return buffer_size == 0;
    }

Здесь часть реализации функции at_eof() вынесена в отдельную функцию [has_no_data_left()] с пометкой NOINLINE, которая запрещает компилятору встраивать эту функцию. Это даёт то, что скомпилированная версия функции at_eof() будет содержать самый минимум машинного кода, покрывающий подавляющее большинство "вызовов" этой функции, при которых buffer_pos < buffer_size. "Вызовов" взято в кавычки, т.к. функция at_eof() является встраиваемой несмотря на отсутствие спецификатора inline, т.к. она определятся внутри определения класса.

Аналогичный трюк используется в libc++: часть реализации std::vector::emplace_back() вынесена во вспомогательный метод __emplace_back_slow_path(), который больше нигде [кроме emplace_back()] не вызывается.

Я так акцентирую внимание на таком простом трюке, т.к. оказалось, что не для каждого программиста он очевиден — в форке моего аллокатора памяти ltalloc кто-то сделал такой коммит (там же я подробно объяснил, почему этот коммит делать не следовало).

Такая, казалось бы, незначительная мелочь, как максимально эффективный метод at_eof() [я делаю акцент именно на эффективности, а не просто на наличии метода с таким функционалом, т.к. at_eof() вполне можно сэмулировать стандартными возможностями Си, как это сделано в Nim парой вызовов fgetc() и ungetc(), но работать это будет очень медленно] делает возможной более простую парадигму чтения файлов:
while (!f.at_eof()) {
    // Читаем очередной фрагмент файла f
    // ...
}
Здесь фрагментом файла может быть просто одиночная строка, завершающаяся символом \n. Или это может быть запись в csv-файле (каждое поле внутри одной записи может состоять из нескольких строк). Или десериализуемый в объект блок двоичных данных, как в этом посте.

И как показывает беглый взгляд на Stack Overflow (1, 2, 3), данная парадигма является гораздо более естественной для программистов (и не случайно функция eof() работает именно так в языках Pascal и Nim, правда, гораздо более медленно, чем в представленной реализации).

Мне очень "нравится" логика автора статьи ‘Why it's bad to use feof() to control a loop’:
When reading in a file, and processing it line by line, it's logical to think of the code loop as "while not at the end of the file, read and process data". ...
...
… The problem stems from the method feof() uses to determine if EOF has actually been reached. Let's have a look at the C standard:
...
Do you see the problem yet? ...
To correct the problem, always follow this rule: use the return code from the read function to determine when you've hit EOF.

Начало статьи хорошее: автор верно замечает, что «it's logical to think of the code loop as "while not at the end of the file, read and process data"».

Но дальше он говорит, что проблема заключается в неправильном мышлении программиста, и для исправления проблемы необходимо писать код по-другому.

Мне же очевидно, что проблема тут в другом. А именно, в самом стандарте языка Си.

(Выражаясь словами одного из участников дискуссии на Hacker News: ‘‘Practically everyone is still learning the wrong default’’.)

Один из пользователей Stack Overflow написал, что за 25 лет программирования на Си он воспользовался функцией feof() лишь 2 раза, и в обоих случаях лишь для определения того, что явилось причиной неудачного чтения (достижение конца файла или ошибка). Из этого можно сделать вывод, что такая функция вообще не нужна. А нужна функция, определяющая осталось ли что-то в читаемом файле или нет. Такой функцией и является at_eof().

Оказывается, в Rust тоже есть такая функция, только называется она has_data_left(), и возвращаемое значение этой функции противоположно возвращаемому значению at_eof().
Вот только функция чтения строки файла read_line() в Rust очень неудобна [хоть и некоторые товарищи со Stack Overflow выражают восхищение таким дизайном]:
  1. Требуется очищать буфер, в который читается строка, перед каждым вызовом read_line(). [Не могу себе представить реальной задачи, в которой было бы необходимо добавлять прочитанную строку в непустой буфер.]
  2. Признаком невозможности прочтения строки по причине достижения конца файла является возврат Ok(0). Логичнее было бы возвращать ошибку, как это делает функция read_exact().
  3. Возвращаемое значение — количество прочитанных байт — по сути бесполезно (т.к. если передавать ссылку на пустой буфер, то количество прочитанных байт определяется просто по размеру буфера после успешного выполнения данной функции).
  4. Ошибки чтения необходимо обрабатывать явно. При использовании простой добавки ? теряется информация о месте ошибки [строке кода, в которой возникла ошибка чтения]. Эта информация была бы доступна, если бы использовались традиционные исключения. Хотя это уже претензия скорее к Rust в целом, чем к данной функции.
  5. Функция read_line(), в отличие от lines(), не понимает завершение строк в стиле Windows ("\r\n"), и символ \r в прочитанной строке необходимо убирать вручную.

И, к слову, в Python функция readline() тоже не очень удобна:
  1. Возвращаемая строка (если она не последняя в файле) содержит символ перевода строки "\n", который на практике только мешает и его приходится убирать вручную (например вызовом .rstrip()), за исключением случаев, когда это необязательно, например: int(line), float(line), line.split().
  2. В случае достижения конца файла возвращается пустая строка. Логичнее было бы порождать исключение EOFError, которое порождает функция input(), если на момент вызова input() уже достигнут конец файла (в случае, когда поток стандартного ввода был перенаправлен, т.е. когда ввод читается из файла, например). [Принятое решение можно объяснить тем, что в Python нет метода at_eof().]

Удивительно, но простое наличие эффективного метода at_eof() позволяет перепроектировать все функции чтения так, чтобы они были очень легки для понимания программистом и вместе с тем очень эффективно исполнялись компьютером:
  • read_line() просто читает из файла и возвращает одну строку;
  • read_char() просто читает из файла и возвращает один символ;
  • read_byte() просто читает из файла и возвращает один байт;
  • read_bytes(10) читает из файла 10 байт и возвращает массив из этих 10 байт (в виде std::vector<uint8_t>, а для возврата std::array<uint8_t, 10> можно использовать read_bytes<10>());
  • read_struct(h) читает из файла структуру (например, заголовок bmp-файла) и записывает её по ссылке h, ничего при этом не возвращая.

Никаких дополнительных аргументов, никаких дополнительных возвращаемых значений или магических возвращаемых значений, которые обозначают признак конца файла (как например функция fgetc() вместо кода прочитанного символа возвращает -1 в случае достижения конца файла, а fgetwc() возвращает 0xFFFF).

Все функции чтения просто возвращают то, что попросил программист.

Ну, или не возвращают… :)(:
А что тогда они делают в этом случае? Как можно догадаться, порождают исключение. UnexpectedEOF в случае достижения неожидаемого конца файла. И IOError в случае ошибки.

Так работает функция read_exact() в Rust [только вместо порождения исключения возвращается соответствующий ErrorKind]. Но я считаю, что такое поведение должно быть у всех функций чтения, т.к. это позволяет более просто писать надёжный код — программист может быть уверен, что если код, расположенный после вызова функции чтения, выполняется, то запрошенные данные гарантированно были получены и можно просто с ними работать. Схожую позицию разделяет Реймонд Чен, объясняя почему функция ReadFile() из WinAPI никогда не возвращает меньше байт, чем было запрошено, при условии, что конец файла не был достигнут. [Вот только он умолчал о том, что каждый вызов ReadFile() осуществляет переход в ядро и обратно (порядка 3000 тактов) и никто в здравом уме не будет вызывать ReadFile() для чтения каждых 4-х байт из файла. (По моим замерам каждый вызов ReadFile() для чтения всего одного байта обходится более чем в 8000 тактов даже при условии что читаемый файл уже содержится в кэше операционной системы.)]

[Впрочем, если вам так хочется стандартное поведение read() как в C++ или Rust, то в ffh предусмотрен метод read_at_most(). А поведению eof() из файловых потоков C++ соответствует метод eof_passed().]

Другой важной особенностью библиотеки ffh является максимально эффективный метод read_byte(). Конечно, читать файл лучше, по возможности, с использованием более эффективных функций чтения, таких как read_until() например (на котором основан метод read_line()). Но эффективный read_byte() тоже не будет лишним, т.к. код с его использованием получается проще, а в некоторых случаях без него и вовсе не обойтись. [Хотя замечу, что файлы небольшого размера всегда выгоднее читать в память целиком — парсить этот блок памяти будет и быстрее, и проще.]
Реализация read_byte() в ffh выглядит так:
    uint8_t read_byte()
    {
        if (at_eof())
            throw UnexpectedEOF();
        return buffer[buffer_pos++];
    }
Её эффективность обеспечивается [помимо простоты реализации] отсутствием блокировки, которая зачем-то используется во всех стандартных функциях чтения файлов в C и C++. Блокировку можно ещё понять для записи [например, запись в общий лог файл из нескольких параллельных потоков]. Но для чтения то она к чему? (Если хочется защиты от UB из-за чтения одного файла из разных потоков, то можно сохранять id потока в объекте файла при его открытии и в функциях чтения сравнивать id текущего потока с сохранённым, но блокировка при чтении явно излишня.)

Производительность


Дабы не быть голословным, приведу результаты тестов сравнения производительности функций чтения библиотеки ffh со стандартными функциями чтения файлов/потоков в C/C++.
(Исходный код тестов можно посмотреть тут.)

1. Побайтовое чтение файла размером 1 Мб
ffh (read_byte) [размер буфера по умолчанию (32 Кб)]
while (!f.at_eof())
    ... f.read_byte();
1x
ffh (read_byte) с размером буфера 4 Кб
f.set_buffer_size(4 * 1024);

while (!f.at_eof())
    ... f.read_byte();
1.4x
C (fgetc)
for (int c; (c = fgetc(f)) != EOF;)
    ...
22x
C++ (get)
for (int c; (c = f.get()) != std::ifstream::traits_type::eof();)
    ...
36x
C (fgetc) с эмуляцией at_eof()
bool at_eof_c(FILE *f)
{
    int c = fgetc(f);
    ungetc(c, f);
    return c == EOF;
}

while (!at_eof_c(f))
    ... fgetc(f);
68x
C++ (get) с эмуляцией at_eof() через unget()
bool at_eof_cpp_unget(std::ifstream &f)
{
    int c = f.get();
    if (c == std::ifstream::traits_type::eof())
        return true;
    f.unget();
    return false;
}

while (!at_eof_cpp_unget(f))
    ... f.get();
104x
C++ (get) с эмуляцией at_eof() через peek()
bool at_eof_cpp_peek(std::ifstream &f)
{
    return f.peek() == std::ifstream::traits_type::eof();
}

while (!at_eof_cpp_peek(f))
    ... f.get();
66x
C (fgetc) с размером буфера 32 Кб
setvbuf(f, NULL, _IOFBF, 32 * 1024);

for (int c; (c = fgetc(f)) != EOF;)
    ...
21x
C (fgetc) с размером буфера 1024 Кб
setvbuf(f, NULL, _IOFBF, 1024 * 1024);

for (int c; (c = fgetc(f)) != EOF;)
    ...
21x
C (fgetc) с размером буфера 256 байт
setvbuf(f, NULL, _IOFBF, 256);

for (int c; (c = fgetc(f)) != EOF;)
    ...
28x
C++ (get) с размером буфера 32 Кб
static char buffer[32 * 1024];
f.rdbuf()->pubsetbuf(buffer, sizeof(buffer));

for (int c; (c = f.get()) != std::ifstream::traits_type::eof();)
    ...
35x
C (getc_unlocked)
for (int c; (c = getc_unlocked(f)) != EOF;)
    ...
2.3x
C (getc_unlocked) с размером буфера 32 Кб
setvbuf(f, NULL, _IOFBF, 32 * 1024);

for (int c; (c = getc_unlocked(f)) != EOF;)
    ...
1.9x
[Цифры в правой колонке обозначают в какое количество раз данный способ чтения медленнее ffh.]

2. Чтение файла блоками по 4 байта [размер файла — такой же (1 Мб)]
ffh [размер буфера по умолчанию (32 Кб)] 1x
ffh с размером буфера 4 Кб 1.4x
C (fread) 11x
C++ (read) 11x
C (fread) с эмуляцией at_eof() 24x
C++ (read) с эмуляцией at_eof() через unget() 30x
C++ (read) с эмуляцией at_eof() через peek() 20x
3. Чтение текстового файла unixdict.txt по строкам
ffh (read_line через read_until) 1x
ffh (read_line через read_byte) 1.3x
ffh (read_line через read_until) с буфером 4 Кб 1.2x
C (fgets) 2.5x
C (fgets) с эмуляцией at_eof() 4.5x
C++ (getline) 5x
C++ (getline) с эмуляцией at_eof() через unget() 8x
C++ (getline) с эмуляцией at_eof() через peek() 6x
Все тесты проводились с использованием компилятора MSVC 2019.

Как видно, реализация read_line() через read_until() быстрее реализации через read_byte() ненамного. А вот эмуляция at_eof() средствами Си значительно замедляет даже чтение по строкам, не говоря уже о побайтовом чтении.

Почему я добавил тесты ‘ffh с размером буфера 4 Кб’ — потому что именно такой размер буфера по умолчанию используется в реализации чтения файлов/потоков в C/C++ в MSVC 2019.

Вообще же, анализируя исходники crt (C runtime library), используемой компилятором MSVC 2019, создавалось впечатление, что этот код писался во времена перехода размера секторов жёстких дисков с 512 байт на 4 Кб около 14 лет назад (цитата: «Around 2010, hard drive companies began migrating away from the legacy sector size of 512 bytes to a larger, more efficient sector size of 4096 bytes, generally referred to as 4K sectors and now referred to as the Advanced Format»), а то и раньше — во времена господства 512-байтных секторов. Так, например, вызов fseek временно уменьшает размер буфера чтения с 4096 байт до 512 байт (как сказано в комментарии в исходном файле fseek.cpp: «decrease the _bufsiz so that the next call to *_read_* won't cost quite so much… since we don't know what the user will do next with the file.»). Вот только на даже не самых современных машинах это не имеет смысла, т.к. сектора в любом используемом в настоящее время HDD/SSD имеют размер не меньше 4 Кб.

Примечательно, что стандартная реализация fgetc/get настолько неэффективна, что установка даже огромного размера буфера в 1 Мб увеличивает производительность совсем незначительно.

Обзор поддерживаемых функций


Функции чтения разбиты на две группы: чтение текстовых файлов и чтение двоичных файлов. При этом соответствующий признак [текстовый файл или двоичный] при открытии файла указывать не нужно, т.к. это способствует возникновению ошибок. Например, в этом вопросе на Stack Overflow человек забыл указать ios_base::binary в конструкторе std::ifstream, а метод read() в этом случае работает некорректно. (В ffh подобная ошибка исключена, и по коду сразу понятно, что read_bytes() осуществляет двоичное чтение, а read_text() или read_line() читает текст. [В любом случае, сделать как в Python {то есть, чтобы в зависимости от режима открытия файла (двоичный или текстовый) менялся тип возвращаемого значения функции read()} не получится из-за статической типизации, т.к. ffh написана на C++.])

Все функции чтения текстовых файлов поддерживают кодировку UTF-8 BOM [в терминологии Python она называется utf_8_sig — это UTF-8 с опциональным трёхбайтовым маркером порядка байтов в начале файла, программы от Microsoft любят пихать этот маркер при сохранении файла в UTF-8], а также прозрачно [без явного указания] поддерживают завершение строк в стиле Windows (когда строки завершаются парой символов "\r\n") — эдакая облегчённая версия universal newlines из Python — в возвращаемых прочитанных строках\strings, таким образом, строки\lines завершаются всегда одним символом \n.

Функции чтения текстовых файлов:

  • read_text() — читает текстовый файл целиком и возвращает его содержимое в виде std::string (буфер чтения при этом не создаётся и не используется — данные из файла читаются сразу в возвращаемую строку); функция read_text() проверяет, что файловый указатель находится в самом начале файла (т.е. что чтений из файла ещё не было), и порождает исключение, если это не так (для чтения файла не сначала используйте функцию read_text_to_end() — она отличается только тем, что не делает этой проверки);
  • read_until(delim, keep_delim = false) — читает символы в строку до тех пор, пока не будет достигнут разделитель delim или конец файла, и возвращает эту строку. Если keep_delim установить в true, символ разделитель останется в возвращаемой строке;
  • read_line(keep_newline = false) — читает символы в строку до тех пор, пока не будет достигнут символ перевода строки (\n) или конец файла, и возвращает эту строку. Другими словами — читает из файла одну строку и возвращает её. Если keep_newline установить в true, символ перевода строки останется в возвращаемой строке;
  • read_line_reae(keep_newline = false) — читает строку из файла аналогично read_line(), только при этом при необходимости устанавливает EOF indicator (который возвращается методом eof_passed() [это аналог eof() из C++]), а также не порождает исключение в случае когда на момент вызова функции уже достигнут конец файла; данная функция может пригодиться при портировании кода чтения файла с C++/Python/Rust: read_line_reae() соответствует std::getline() из C++, а read_line_reae(true) соответствует readline() в Python и read_line() в Rust;
  • read_char() — читает из файла и возвращает один символ в виде кодовой точки Юникода (Unicode code point); тип возвращаемого значения — char32_t.

Функции чтения двоичных файлов:

  • read_bytes() — читает двоичный файл целиком и возвращает его содержимое в виде std::vector<uint8_t> (буфер чтения при этом не создаётся и не используется — данные из файла читаются сразу в возвращаемый vector); функция read_bytes() проверяет, что файловый указатель находится в самом начале файла (т.е. что чтений из файла ещё не было), и порождает исключение, если это не так (для чтения файла не сначала используйте функцию read_bytes_to_end() — она отличается только тем, что не делает этой проверки);
  • read_bytes(count) — читает из файла count байт и возвращает массив из этих count байт в виде std::vector<uint8_t>;
  • read_bytes<count>() — читает из файла count байт и возвращает массив из этих count байт в виде std::array<uint8_t, count> [std::array, в отличие от std::vector, выделяется на стеке, т.е. не требует динамической аллокации памяти];
  • read_struct(s) — читает из файла структуру [количество читаемых байт определяется по размеру структуры — sizeof(s)] и записывает её по ссылке s, ничего при этом не возвращая;
  • read_bytes(p, count) — читает из файла count байт и записывает их по адресу/указателю p;
  • read_bytes_at_most(p, count) — читает из файла не более count байт и записывает их по адресу/указателю p, возвращает количество прочитанных байт (это аналог функции read() из C++/Rust);
  • read_bytes_at_most(count) — читает из файла не более count байт и возвращает массив из прочитанных байт в виде std::vector<uint8_t> (размер/длина массива может быть меньше count, в отличие от read_bytes(count), которая гарантированно читает count байт, либо порождает исключение если это не удалось); это аналог read(count) в Python;
  • read_byte() — читает из файла и возвращает один байт; тип возвращаемого значения — uint8_t.

Для открытия файлов есть метод open(), однако я рекомендую открывать файлы через конструктор, в котором задаётся имя файла. Метод open(), в отличие от конструктора, в случае ошибки при открытии файла не порождает исключение FileOpenError, а возвращает false. Если требуется открыть файл не сразу [при создании/инициализации объекта файла], но поведение метода open() не устраивает, тогда можно вместо f.open(fname); использовать запись f = IFile(fname);.

Имя файла можно задавать как в UTF-8, так и в UTF-16. Первая кодировка является родной для Linux, а вторая — для Windows, поэтому библиотека ffh поддерживает обе эти кодировки.

Поддерживаются традиционные функции позиционирования tell() и seek(). [Аргумент origin/whence у функции seek(), к слову, отсутствует: не вижу в нём смысла, когда есть эффективные tell() и get_file_size() (а последнюю приходится вызывать ещё и для проверки корректности).] Реализация seek() оптимизирована на случай, когда новая позиция чтения находится в пределах буфера — в этом случае производится только корректировка значения поля buffer_pos. Т.к. системный вызов SetFilePointer() достаточно дорогой (порядка 3000 тактов), то вместо него для изменения текущей позиции чтения [если она не находится в пределах буфера] используется последний аргумент функции ReadFile()lpOverlapped. В случае когда файл не поддерживает позиционирование, оно эмулируется чтениями в цикле до заданной позиции чтения (правда поддерживается только перемещение вперёд). Это позволяет переиспользовать код чтения файла: данные из стандартного потока ввода [stdin] можно читать также, как и файл на диске.

Также есть возможность чтения одного байта без его извлечения — метод peek_byte(). Я думал над возможностью реализации более универсального метода peek_bytes(n), но возникает проблема на границе буфера чтения: предположим в буфере остался только один непрочитанный байт, тогда вызов peek_bytes(2) должен перед тем как обновлять буфер чтения, куда-то сохранить этот непрочитанный байт, а потом его ещё нужно будет как-то учитывать в функциях чтения. В общем, я решил отказаться от этой функции, тем более, что нужно такое как правило в начале файла, а в этом случае можно использовать seek(), который хорошо оптимизирован (см. чуть выше). Например, автоопределение типа файла изображения можно организовать так:
bool try_read_png(IFile &f)
{
    if (memcmp(f.read_bytes<8>().data(), "\x89PNG\r\n\x1a\n", 8) != 0) {
        f.seek(0);
        return false;
    }

    ...
    return true;
}

bool try_read_bmp(IFile &f)
{
    if (memcmp(f.read_bytes<2>().data(), "BM", 2) != 0) {
        f.seek(0);
        return false;
    }

    ...
    return true;
}
Но проверку сигнатуры в самом начале файла можно произвести ещё проще — используя метод starts_with():
bool try_read_bmp(IFile &f)
{
    if (!f.starts_with("BM"))
        return false;
    ...
}

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

В заключение, ffh позволяет узнать размер открытого файла с помощью метода get_file_size(), а также время модификации (get_last_write_time()) и создания (get_creation_time()) файла. Время возвращается в виде количества наносекунд прошедших с 1 января 1970 года (Unix epoch start). 64-разрядного целого числа со знаком достаточно для представления даты/времени до 2262 года [или до 2554 года если использовать число без знака (так сделано в APFS), или до 2446 года если использовать другое начало эпохи], что я считаю более чем достаточно [на наш век хватит :)(: … и, откровенно говоря, я считаю, что сингулярность наступит гораздо раньше; и нужен ли кому-то будет написанный сейчас код в эпоху пост-сингулярности — это большой вопрос...].
После продолжительных размышлений [даже ещё не связанных с ffh] я пришёл к выводу, что это наиболее универсальное, точное, удобное и быстрое представление времени [на данный момент времени]:
  • универсальность и точность заключаются в том, что оно без потерь отражает[/сохраняет]/представляет время, полученное во всех распространённых операционных системах:
    • в Unix-системах функции clock_gettime и stat возвращают время в виде структуры timespec, которая состоит из двух полей: time_t tv_sec и long tv_nsec, которые очень просто преобразовать к "UnixNanotime": tv_sec * 1'000'000'000 + tv_nsec;
    • в Windows наиболее точное системное время возвращает функция GetSystemTimePreciseAsFileTime() в виде FILETIME, функция GetFileTime() также возвращает время модификации/создания файла в виде FILETIME, имеющее точность в 100 наносекунд, только отсчёт ведётся не с 1970, а с 1601 года, таким образом, для преобразования FILETIME в "UnixNanotime" достаточно простой целочисленной арифметики: (filetime - 116444736000000000) * 100;
  • удобство и эффективность заключаются в том, что очень легко считать разницу между двумя временами: простое вычитание 64-разрядных целых чисел работает корректно и очень быстро; преобразование "UnixNanotime" в time_t также достаточно быстро, т.к. целочисленное деление на константу компиляторы уже давно научились заменять на целочисленное умножение.
Почему бы не возвращать просто стандартный тип времени из std::chrono? А много ли из читателей данной статьи, знающих C++, смогут по памяти написать этот тип? Думаю, что немного. :)(: [Более точное количество покажут результаты опроса.]
Ну ладно, допустим тип времени вы знаете. А теперь представьте, что вам нужно передать это время по сети или записать в файл. В таком случае, с этим временем придётся работать на разных платформах и с помощью различных языков программирования, которые (: сюрприз! :) библиотеку chrono не поддерживают.
Почему бы не возвращать просто time_t? Потому что секундной точности сейчас явно не достаточно: представьте компилятор, который за одну секунду создал 100 объектных файлов, и вы хотите отсортировать эти файлы по времени создания/модификации.

Обработка ошибок


Это, пожалуй, самая непроработанная часть библиотеки ffh.

Нет, я, конечно, постарался аккуратно обработать практически все возможные ошибки, которые только могут возникнуть при работе с библиотекой, включая даже преднамеренно некорректное поведение (например, попытка чтения файла сразу после вызова close()). Вот только все исключения, которые порождаются при возникновении ошибок, являются просто пустыми классами-заглушками. Частично, это от того, что я пока не определился как их структурировать (наследовать ли их от какого-то базового класса или нет) и какую информацию в них передавать. А частично от того, что в большинстве случаев для идентификации ошибки достаточно увидеть тип исключения и трассировку стека (например, если сработало исключение FileIsAlreadyOpened, то значит вызывается лишний open(), а срабатывание AttemptToReadAClosedFile говорит о попытке чтения файла после его закрытия).


В заключение, отвечу на возможный вопрос «почему в репозитории ffh нет функциональных тестов? библиотека вообще не тестировалась, что ли?»
Тестировалась. Просто код тестов несколько неряшливый, и мне было стыдно выкладывать его в репозиторий. :)(:

P.S. Примечательно, что размер текста данной статьи превышает размер исходного кода библиотеки ffh примерно в полтора раза.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как с помощью std::chrono получить наиболее точное текущее значение системного времени?
1.8% std::chrono::system_time()2
2.7% std::chrono::system_time::current()3
10.81% std::chrono::system_time::now()12
0.9% std::chrono::system_clock::time()1
16.22% std::chrono::system_clock::now()18
1.8% std::chrono::clock::system_time()2
13.51% std::chrono::high_resolution_timer::now()15
2.7% std::chrono::high_resolution_clock::time()3
49.55% std::chrono::high_resolution_clock::now()55
Проголосовали 111 пользователей. Воздержались 53 пользователя.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Какой тип возвращает эта функция?
4.44% std::chrono::system_time4
5.56% std::chrono::system_time::point5
3.33% std::chrono::system_clock::time3
16.67% std::chrono::system_clock::time_point15
12.22% std::chrono::high_resolution_clock::time11
57.78% std::chrono::high_resolution_clock::time_point52
Проголосовали 90 пользователей. Воздержались 49 пользователей.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Сколько байт занимает этот возвращаемый тип?
6.82% 46
35.23% 831
2.27% 122
7.95% 167
47.73% Зависит от реализации42
Проголосовали 88 пользователей. Воздержались 52 пользователя.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Что означает этот тип, какое значение в нём хранится?
5.56% Количество секунд с начала эпохи5
16.67% Количество микросекунд с начала эпохи15
31.11% Количество наносекунд с начала эпохи28
46.67% Зависит от реализации42
Проголосовали 90 пользователей. Воздержались 43 пользователя.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Чем вы пользовались для того, чтобы дать ответ на предыдущие вопросы? :)(:
21.21% Ничем. Я помню это наизусть. Такие вещи должен знать любой уважающий себя разработчик на C++!21
62.63% Ничем. Я не помню этого наизусть [а разве должен?]. Надеюсь просто угадать.62
1.01% ISO C++ Standard [working draft или final, неважно]1
6.06% cppreference.com/cplusplus.com6
0% Stack Overflow0
2.02% Google2
1.01% Яндекс1
3.03% GPT-4 или другая LLM3
3.03% Другое3
Проголосовали 99 пользователей. Воздержались 43 пользователя.
Теги:
Хабы:
Всего голосов 37: ↑33 и ↓4+29
Комментарии134

Публикации

Истории

Работа

Программист C++
112 вакансий
QT разработчик
10 вакансий

Ближайшие события

One day offer от ВСК
Дата16 – 17 мая
Время09:00 – 18:00
Место
Онлайн
Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург
Summer Merge
Дата28 – 30 июня
Время11:00
Место
Ульяновская область