JavaRush /Курсы /C++ SELF /Текстовый и бинарный формат

Текстовый и бинарный формат

C++ SELF
63 уровень , 2 лекция
Открыта

1. Файл — это байты, а формат — договорённость

Когда вы впервые слышите «текстовый» и «бинарный» файл, легко представить два разных мира: один для людей, другой для машин. Но на самом деле «на диске» у вас почти всегда лежит одно и то же: последовательность байтов. Разница начинается тогда, когда мы договариваемся, как эти байты понимать и как их читать/писать через потоки.

Представьте, что файл — это коробка с деталями LEGO, но без инструкции. Байты — это сами детали. «Формат» — это инструкция: как собирать из деталей смысл. Можно сложить детали в пакет с бумажкой "2x4 красный, 2x2 синий..." — это похоже на текст. А можно сложить детали по пакетам с кодами и размерами — это похоже на бинарный формат.

Чтобы этот договор работал, нам нужны две вещи: во‑первых, выбрать, текст мы пишем или «сырые байты», во‑вторых, правильно открыть файл режимом (open mode), чтобы поток вёл себя так, как мы ожидаем.

Текстовый формат: удобно читать, удобно ломать

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

Но у текста есть обратная сторона: он требует парсинга и строгой дисциплины. Если вы договорились, что задача записывается как id done title, то одна лишняя «палочка» |, лишний пробел или пустая строка могут сделать чтение неудобным. То есть текст легко дебажить, но легко и испортить.

Давайте продолжим наше учебное приложение. Пусть это будет маленький консольный трекер задач TaskBook. Пока он живёт в памяти (в std::vector), но теперь мы хотим научиться сохранять его в файл.

Мини-модель задачи:

#include <string>

struct Task {
    int id = 0;
    bool done = false;
    std::string title;
};

Сериализация в одну строку текста (самая простая «договорённость»):

#include <string>

std::string to_line(const Task& t) {
    // Формат: id done title
    // Пример: 7 1 Buy_milk
    return std::to_string(t.id) + " " + (t.done ? "1" : "0") + " " + t.title;
}

Обратите внимание на маленькую хитрость: я использовал Buy_milk, а не Buy milk. Потому что «слова с пробелами» в таком примитивном формате сразу заставляют нас думать о кавычках или чтении строкой. Это нормально, но сегодня мы концентрируемся на режимах открытия, а не на полноценном формате.

2. Бинарный формат: компактно и быстро

Бинарный формат — это когда вы храните данные не в виде символов '1', '2', 'a', 'b', а в виде «как оно лежит в памяти» (или близко к этому). Обычно это компактнее и быстрее для машины, но совершенно нечитаемо для человека. Открыли в блокноте — увидели «кракозябры», и это нормально.

При этом бинарный формат требует ещё более жёсткой договорённости: «какие поля в каком порядке», «какого они размера», «как записываем строку», «какая кодировка». И самое важное: переносимость. Один и тот же бинарный файл может читаться по-разному на разных платформах, если вы не продумали формат.

Сегодня мы не будем углубляться в полноценную бинарную сериализацию (это отдельная история), но нам нужно понять ключевую вещь: чтобы работать «как с байтами», файл обычно открывают с флагом std::ios::binary. Этот флаг влияет на то, как библиотека делает (или не делает) текстовые преобразования, особенно на платформах, где есть разница между "\n" и «концом строки в файле».

4. Режимы открытия std::ios::openmode

Когда вы открываете файл, вы как бы подписываете контракт: «я хочу читать», «я хочу писать», «я хочу перезаписать файл полностью», «я хочу дописывать в конец», «я хочу работать как с байтами».

В стандартной библиотеке это выражается флагами режима открытия. Исторически их называют openmode, и они живут рядом с потоками ввода-вывода. В черновиках и документах стандарта это понятие фигурирует как ios::openmode и связано с семейством ios-типов.

Давайте разберём основные флаги именно как «намерение программиста»:

Флаг Идея Типичный поток
std::ios::in
открыть для чтения
std::ifstream
std::ios::out
открыть для записи
std::ofstream
std::ios::trunc
«обнулить» файл при открытии (перезапись) чаще с
out
std::ios::app
всегда писать в конец (лог/история) чаще с
out
std::ios::binary
работать «как с байтами», без текстовых преобразований любые потоки

Важная бытовая мысль: trunc и app — это не «украшения». Это два совершенно разных сценария. trunc говорит: «я создаю новую версию файла». app говорит: «я веду журнал, ничего старого не трогаю».

5. Почему флаги объединяют через |, а не через ||

На этом месте обычно происходит одна из самых типичных ошибок новичка: рука тянется написать ||, потому что «вроде как ИЛИ». И это логично по-человечески, но неверно по C++-овски.

Оператор || — логическое ИЛИ. Он работает с булевыми выражениями, типа x > 0 || y > 0, и результат у него — bool.

А вот std::ios::openmode — это набор «битовых флагов». То есть там не «истина/ложь», а «включён/выключен конкретный флажок». Для объединения таких флажков используется побитовое ИЛИ |.

Мини-пример «как надо»:

#include <fstream>

int main() {
    std::ofstream log("taskbook.log", std::ios::out | std::ios::app);
    log << "App started\n";
}

Мини-пример «как не надо» (и почему):

#include <fstream>

int main() {
    // ОШИБКА: || возвращает bool, а open ожидает openmode.
    std::ofstream log("taskbook.log", std::ios::out || std::ios::app);
}

Если компилятор ругается здесь — он не вредничает. Он реально спасает вас от ситуации, где режим открытия внезапно превращается в «true/false», а дальше вы открываете файл вообще не тем способом, которым думали.

6. Практика: сохраняем, логируем и открываем в binary

Перезаписываем файл задач (trunc) — «сохранить состояние»

Когда мы делаем кнопку «Сохранить», чаще всего мы хотим именно перезаписать файл целиком: старая версия больше не нужна, мы создаём новую «снимком текущей памяти».

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

#include <fstream>
#include <string>
#include <vector>

bool save_tasks_text(const std::string& filename, const std::vector<Task>& tasks) {
    std::ofstream out(filename, std::ios::out | std::ios::trunc);
    if (!out) return false;

    for (const Task& t : tasks) {
        out << to_line(t) << '\n';
    }
    return static_cast<bool>(out);
}

Обратите внимание: std::ofstream и так обычно открывает файл «на запись», и во многих реализациях по умолчанию он делает перезапись. Но мы специально прописываем std::ios::trunc, потому что это делает намерение очевидным: «да, я хочу уничтожить старое содержимое». Это не про «быстрее», это про «читабельнее и безопаснее».

Дописываем лог (app) — «журнал событий»

Другая частая потребность — логирование. Например, мы хотим писать в файл каждый запуск приложения, каждое добавление задачи и каждую отметку "done". Это уже не «снимок состояния», это «история событий». И вот тут app — ваш лучший друг.

#include <fstream>
#include <string>

void append_log(const std::string& filename, const std::string& message) {
    std::ofstream log(filename, std::ios::out | std::ios::app);
    if (!log) return;

    log << message << '\n';
}

Тут есть тонкость: при app запись идёт в конец, даже если вы где-то пытались «перемотать позицию». Мы сегодня не используем перемотку, но полезно запомнить идею: app — это режим «только добавляю в хвост».

Текст vs binary: где реально важен std::ios::binary

Часто спрашивают: «Но я же и так пишу байты, почему мне вообще нужен binary

В переносимом смысле binary важен потому, что в текстовом режиме библиотека может выполнять преобразования конца строки. Самый известный пример — историческая разница между "\n" в программе и тем, как «конец строки» хранится в текстовом файле на конкретной платформе. Если вы пишете и читаете «как текст» — это обычно нормально, библиотека делает удобство для вас. Если вы пишете и читаете «как данные» — любые преобразования опасны, потому что вы ожидаете, что байты совпадут 1-в-1.

Поэтому правило простое: если вы храните «человеческий текст» — открывайте как текст (то есть без binary). Если вы храните «формат данных, где каждый байт важен» — открывайте с binary.

Сравним это в таблице:

Вопрос Текстовый формат Бинарный формат
Можно открыть глазами и понять? да почти никогда
Удобно дебажить вручную? да нет
Компактность обычно хуже обычно лучше
Требует строгой схемы умеренно очень сильно
Нужен std::ios::binary обычно нет почти всегда да

Мини-открытие «под бинарный формат» (без записи, просто демонстрация режима):

#include <fstream>
#include <string>

bool open_binary_for_write(const std::string& filename) {
    std::ofstream out(filename, std::ios::out | std::ios::binary | std::ios::trunc);
    return static_cast<bool>(out);
}

Смысл здесь не в том, чтобы «срочно писать бинарщину», а в том, чтобы увидеть: binary — это такой же флаг режима открытия, как app или trunc, и комбинируется так же через |.

7. Буферизация и flush: почему std::endl иногда медленный

Теперь добавим ещё одну практическую вещь: вывод в файл буферизуется. То есть когда вы делаете out << "hello\n";, байты могут не улететь на диск мгновенно. Они могут подождать в буфере, пока буфер не заполнится или пока поток не будет принудительно сброшен.

Принудительный сброс буфера называется flush. В терминах стандартной библиотеки это связано с flush() и манипуляторами вроде std::flush. В редакторских заметках и обсуждениях стандартных формулировок прямо подчёркивается, что flush() ведёт себя как «unformatted output function» (то есть это отдельное действие потока, не про форматирование текста).

С точки зрения программиста это означает: если вы делаете flush слишком часто, вы можете замедлить программу, потому что каждый flush — это попытка «вытолкнуть» данные наружу прямо сейчас.

Сравним '\n' и std::endl в действии:

#include <fstream>

int main() {
    std::ofstream out("demo.txt", std::ios::out | std::ios::trunc);

    out << "Line 1\n";            // просто перевод строки
    out << "Line 2" << std::endl; // перевод строки + flush (обычно)
}

Практическое правило по умолчанию такое: в файлы и в консоль чаще пишут '\n', а std::endl используют тогда, когда flush действительно нужен по смыслу. Например, вы пишете лог, и после строки "CRITICAL ERROR" хотите быть уверены, что она улетела в файл до возможного аварийного завершения.

Если вам нужен flush без перевода строки, используйте std::flush или out.flush():

#include <fstream>

int main() {
    std::ofstream log("taskbook.log", std::ios::out | std::ios::app);

    log << "Before critical step...";
    log.flush(); // хотим вытолкнуть это прямо сейчас
}

8. Мини-приложение TaskBook: сохранить + логировать

Соберём кусочки в более цельную картинку. Пусть у нас есть простое меню: добавляем задачи, показываем, сохраняем, выходим. Здесь не важна красота UI, нам важны режимы открытия и выбранный формат.

Пример «скелета» main (укороченный, чтобы не утонуть в деталях):

#include <iostream>
#include <string>
#include <vector>

int main() {
    std::vector<Task> tasks;
    tasks.push_back(Task{1, false, "Buy_milk"});

    if (!save_tasks_text("tasks.txt", tasks)) {
        std::cerr << "Cannot save tasks.txt\n";
        return 1;
    }

    append_log("taskbook.log", "Saved 1 task to tasks.txt");
    std::cout << "Saved!\n"; // Saved!
}

Если открыть tasks.txt, вы увидите вполне человеческое:

1 0 Buy_milk

А taskbook.log будет копиться, не уничтожая старые записи:

Saved 1 task to tasks.txt

И это уже два разных «контракта» работы с файлами: один файл — «снимок», другой — «история».

Чтобы закрепить это в голове, полезно представить процесс в виде простой схемы:

flowchart TD
    A[Задачи в памяти: vector<Task>] --> B{Что за файл?}
    B -->|Снимок состояния| C["open: out | trunc"]
    B -->|Журнал событий| D["open: out | app"]
    C --> E[Записать все задачи]
    D --> F[Дописать 1 строку]

9. fstream и режим «читать + писать»

Когда вы видите std::fstream, появляется соблазн: «О! Открою один файл и буду и читать, и писать». Это реально возможно, но на практике новичку проще и безопаснее разделять сценарии: отдельно загрузили (через std::ifstream), отдельно сохранили (через std::ofstream). Так меньше шансов случайно открыть файл не тем режимом и получить странное поведение.

Если всё же хочется показать, как выглядит комбинация in | out, то вот минимальный пример «просто открыть»:

#include <fstream>
#include <string>

bool open_for_read_write(const std::string& filename) {
    std::fstream io(filename, std::ios::in | std::ios::out);
    return static_cast<bool>(io);
}

Здесь важно не то, что мы «умеем всё», а то, что вы видите ту же механику: флаги объединяются через |, и набор флагов описывает ваше намерение.

10. Типичные ошибки

Ошибка №1: использовать || вместо | при объединении флагов.
Это выглядит логично, пока не вспоминаешь, что || — логический оператор, который даёт bool, а режим открытия — это набор битовых флагов. В лучшем случае вы получите ошибку компиляции (и это хорошо). В худшем — где-то в другом API вы случайно скормите true вместо режима и будете долго искать, почему файл открылся «не так».

Ошибка №2: перепутать trunc и app и случайно потерять данные.
Если вы хотели вести лог и открыли файл с trunc, вы каждый запуск будете стирать историю. Если вы хотели «сохранить состояние» и открыли с app, у вас начнут копиться дубли, и загрузчик потом будет читать 17 версий одной и той же задачи. Эта ошибка особенно коварна тем, что компилятор не ругается: он не знает, что вы «хотели иначе».

Ошибка №3: считать std::endl просто «переводом строки».
Да, std::endl визуально похож на '\n', но обычно он ещё делает flush. Если вы ставите std::endl в цикле на тысячу строк лога, вы можете внезапно получить тормоза «на ровном месте». Держите '\n' как дефолт, а flush — как осознанное действие: «мне важно вытолкнуть данные сейчас».

Ошибка №4: писать «бинарные данные» в текстовом режиме (или наоборот) и надеяться, что «и так сойдёт».
Иногда кажется: «ну я же всё равно пишу байты, чего мне этот binary». Проблема в том, что текстовый режим может делать преобразования, которые для текста удобны, а для бинарного формата разрушительны. Поэтому если вы договорились, что файл — это именно формат данных, где важны байты, открывайте с std::ios::binary сразу, не откладывая на «потом разберусь».

Ошибка №5: не делать режим открытия частью читаемости кода.
Технически можно часто полагаться на режим по умолчанию, но для учебных и командных проектов полезно писать намерение явно: out | trunc для «сохранить заново», out | app для «дописать лог», in | binary для «читать как байты». Это снижает количество сюрпризов и делает код самообъясняющимся — почти как комментарий, только компилятор ещё и проверяет типы.

1
Задача
C++ SELF, 63 уровень, 2 лекция
Недоступна
Дописать лог
Дописать лог
1
Задача
C++ SELF, 63 уровень, 2 лекция
Недоступна
Снимок заметок
Снимок заметок
1
Задача
C++ SELF, 63 уровень, 2 лекция
Недоступна
Бинарная капсула
Бинарная капсула
1
Задача
C++ SELF, 63 уровень, 2 лекция
Недоступна
Двойное сохранение
Двойное сохранение
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ