Как-то раз Бобу поручили построчно обработать текстовый файл. Боб решил решить эту задачу на C++, так как известно, что мало найдётся языков, которые могли бы потягаться с C++ в скорости. Поскольку C++ для Боба — дело новое, неосвоенное, он решил погуглить спросить ChatGPT, какой способ построчного считывания файла на C++. Для этого потребовалось немного затравочного кода, зато не пришлось пролистывать бесконечные страницы документации по стандартной библиотеке C++.
Боб — джун с большими амбициями. Он всерьёз относится к своему ремеслу и репутации, поэтому ему важно убедиться, что код у него получается аппетитным — быстрым, элегантным и лучшим в своём роде.
💡
После этого Боб выложил окончательную версию кода на GitHub в файле TextFileReader.h, и вы смело можете использовать его в ваших проектах.
Чтение строк - решение от ChatGPT
Когда Bob спросил ChatGPT, каков наиболее популярный подход для построчного чтения файла на C++, искусственный интеллект предложил решение на основе функции std::getline()
из стандартной библиотеки.
Вот как именно он выглядит
#include <iostream>
#include <fstream>
#include <string>
int main() {
std::ifstream file("example.txt"); // открыть файл
if (file.is_open()) { // проверить, успешно ли открылся файл
std::string line;
while (std::getline(file, line)) { // прочитать все строки в файле, одну за другой
std::cout << line << std::endl; // вывести строку
}
file.close(); // когда дело будет сделано — закрыть файл
} else {
// вывести сообщение об ошибке, если файл открыть не удастся
std::cerr << "Unable to open the file." << std::endl;
}
return 0;
}
Версия ChatGPT, решение задачи «как прочитать файл на C++ строку за строкой?»
Боб знал, что предложенное ChatGPT решение медленное, так как каждая строка в этом решении возвращается копированием. Бобу требовалось это исправить и найти более качественный подход, чем предложил ChatGPT.
Чтение строк - аппетитное решение
Боб обнаружил, что в C++ можно обойтись без константного объекта std::string
, изменять который он не собирался, можно вернуть std::string_view
— легковесный объект, который был впервые введён в C++17 ради того, чтобы обойтись без копирования константных строк. Внутрисистемно std::string_view
занимает всего 16 байт и содержит два поля-экземпляра:
указатель на строку
размер строки
На следующее утро (перед сном наш герой лишний раз обдумал задачу) у Боба сложился конкретный план действий, который формулируется примерно так:
Обратите внимание, как тщательно Боб обрабатывает неполные строки в конце буфера. Эти тонкости Боб отложил на потом. Пока Боба больше волнует, как его классом будут пользоваться коллеги.
Поток задач
Боб знал, что удобный в использовании и понятный публичный интерфейс не менее важен, чем быстрый и безопасный код. Прежде, чем приступать к программированию, хотелось решить, как именно его код будет использоваться, или как его будут вызывать другие — а за это отвечает публичный интерфейс.
Боб решил не изобретать велосипед, а придерживаться распространённого паттерна, который ему предложил ChatGPT. Этот подход строится так:
TextFileReader src;
if (Error err = 1️⃣ src.open("stocks_20240310.csv"); !err.empty())
return err;
Error err;
while (2️⃣ src.readline(err)) {
// Обработать 3️⃣ src.line()
}
if (!err.empty())
return err;
Построчная обработка текстового файла на C++
1️⃣ open()
Обычно, зная путь к файлу, мы для начала пробуем его открыть. По тем или иным причинам этот шаг может не удаться, и Боб решил, что это следует обозначить. Если случится проблема, то мы вернём строку с описанием ситуации, например, «File doesn't exist» (Файл не существует) или «Too many open files» (Открыто слишком много файлов). Это не идеальный способ указать на ошибку, но пока его вполне достаточно.
2️⃣ Цикл readline()
идентичен std::getline()
за тем исключением, что, когда мы обращаемся к актуальной строке при помощи 3️⃣line()
, и она возвращает std::string_view
во внутренний буфер. Операция эффективна, поскольку она не связана ни с каким копированием.
Боб также счёл, что будет полезно, если пользователи смогут считывать файл фиксированными фрагментами, не деля их на строки. Именно поэтому Боб предложил альтернативу для этапов 2️⃣ и 3️⃣, которая выглядит так:
Error err;
while (4️⃣ src.read(err)) {
// обработать 5️⃣ src.buf();
}
Обрабатываем текстовый файл на C++ фиксированными фрагментами
4️⃣ read() забирает следующий фрагмент данных и помещает его во внутренний буфер. Содержимое этого буфера можно прочитать при помощи метода 5️⃣TextFileReader::buf()
, который возвращает легковесное строковое представление, и это происходит без копирования.
Подробно о TextFileReader
Наконец, когда Боб сам стал лучше представлять то решение, которое придумал, пришло время приступать к программированию.
Ключевые методы
Метод readline() — это центральный элемент всего класса. Он определяет, какие ещё методы и члены данных должны содержаться в классе. Интуитивно Бобу понято, что любой метод будет бесполезен, пока он не доведёт readline()
до готовности.
Потратив примерно час на прототипирование, Боб обернул все операции в следующую функцию, которая состоит из пяти этапов:
bool
TextFileReader::readline(Error& err)
{
do {
// 1️⃣ Найти следующий символ \n
if (const auto p = (c8*)memchr(m_buf + m_cursor, '\n', m_size - m_cursor); p) {
m_line = string_view(m_buf + m_cursor, p - (m_buf + m_cursor));
m_cursor = p - m_buf + 1;
return true;
}
// 2️⃣ Символ \n не найден
if (std::feof(m_src)) {
m_line = string_view(m_buf + m_cursor, m_size - m_cursor);
return m_cursor < m_size;
}
// 3️⃣ Скопировать хвостовую часть в начало
if (m_cursor < m_size) {
std::memcpy(m_buf, m_buf + m_cursor, m_size - m_cursor);
}
// 4️⃣ Потребить ещё больше данных (не обновлять m_cursor)
if (err = read(m_size - m_cursor); !err.empty()) {
err = "TextFileReader::readline : " + err;
return false;
}
} while (m_size);
// 5️⃣ Вероятно, у нас уже должно быть возвращено false в ❷ (что означает «конец файла»)
err = "TextFileReader::readline : EOF";
return false;
};
read() — это вспомогательный метод, считывающий фрагменты текстового файла во внутренний буфер. Боб реализовал его следующим образом:
bool
TextFileReader::read(Error& err, u64 pos = 0)
{
if (m_size = std::fread(m_buf + pos, 1, m_capacity - pos, m_src); !m_size) {
if (std::ferror(m_src)) {
err = "Fail to read : errno = " + std::to_string(m_size);
return false
}
}
m_size += pos;
m_cursor = 0;
return true;
};
Боб счёл, что эта функция понравится его пользователям. Он предоставляет её как публичный метод. Правда, если одновременно считывать файл пофрагментно и построчно, из-за этого нарушается внутреннее состояние класса. Боб позаботился о том, чтобы предупредить об этом пользователя в документации:
💡
Запрещено одновременно использовать read()
и readline()
. После инициализации TextFileReader
следует вызывать только один из них.
Вспомогательные методы
Оставшиеся методы отвечают за управление ресурсов: объект FILE и внутренний буфер памяти, которую нужно выделять и возвращать обратно. Именно это и делают методы open()
и close()
.
Вызов close() опционален. Он будет автоматически вызываться деструктором метода open(), который для считывания нового файла повторно использует уже выделенный буфер. Так класс становится безопаснее, и при этом им удобнее пользоваться. Это отличный пример идиомы RAII на практике.
Алиса — более опытная коллега Боба — предложила ему отключить работающий по умолчанию конструктор копирования (или реализовать собственный, но добавила, что для данной задачи это многовато). Сказала, что работающий по умолчанию конструктор копирования дублирует указатели, помещаемые во внутренний буфер, но не сам буфер. В итоге из-за этого повреждается внутреннее состояние класса, когда продублированные читатели заполняют внутренние буферы информацией из одного и того же объекта FILE.
Бенчмарки
Бобу стало любопытно, как его код смотрится по сравнению с подходом std::getline
, предложенным ChatGPT. Боб создал набор файлов с длиной строки от 10 до 1000 и количеством строк от 1000 до 1000000.

Удивительно, но его код оказался не менее чем в 20 раз быстрее std::getline, а иногда даже в 180 раз быстрее. «Неплохо для любителя!» — воскликнул он, гордясь собой.
А потом сказал Алисе: «Вот, кстати, как важно проставлять бенчмарки в коде и опираться на конкретные цифры, когда проектируешь чистовое решение».
Заключение
Главный вывод из этой истории — не в том, что файлы можно считывать в 100 раз быстрее (конечно, и это тоже важно). Самое важное, что нужно прорабатывать собственные решения даже для очень простых задач, как минимум, размышлять, а возможно ли такое решение. Именно так и учишься эффективнее всего — на практике. Можно вызубрить стандартную библиотеку C++, но никогда не заучишь умения быть инженером. Инженерия – это практика, и, чтобы преуспеть в ней, ею нужно заниматься, даже, если поначалу у вас будет получаться не слишком эффективно. Рано или поздно вы набьёте руку, станете более результативно мыслить, и ваш код также улучшится.
Удачи в программировании!😏