Работа с файлами и директориями в Qt

Чтение и запись бинарных файлов через QDataStream: версии формата и совместимость

Введение

Когда приложению нужно сохранять структурированные данные — настройки, сериализованные объекты, кэш — в собственном бинарном формате, на помощь приходит QDataStream. Этот класс умеет сериализовать большинство типов Qt (QString, QList, QMap, пользовательские структуры) в платформо-независимом виде. Но без должного контроля версий такой формат легко превращается в источник проблем совместимости между версиями приложения.

Концепция

QDataStream оборачивает QIODevice (как правило, QFile) и предоставляет операторы << и >> для записи и чтения данных в бинарном виде с учётом порядка байт (big-endian по умолчанию, независимо от архитектуры процессора).

Ключевая особенность — setVersion(). Внутренний бинарный формат самих типов Qt (например, QString, QVariant) менялся между версиями Qt. Если файл, записанный новой версией Qt, читается старой (или наоборот) без согласования версии потока, данные могут быть прочитаны неверно.

Пример кода: Чтение и запись

#include <QDataStream>
#include <QFile>
#include <QDebug>
#include <QString>
#include <QList>

struct Record {
    quint32 id;
    QString name;
    double value;
};

// Версия формата файла приложения (наша собственная, не путать с QDataStream::Version)
constexpr quint32 FILE_MAGIC = 0x51544831; // "QTH1"
constexpr quint16 FILE_FORMAT_VERSION = 2;

QDataStream &operator<<(QDataStream &out, const Record &r)
{
    out << r.id << r.name << r.value;
    return out;
}

QDataStream &operator>>(QDataStream &in, Record &r)
{
    in >> r.id >> r.name >> r.value;
    return in;
}

bool saveRecords(const QString &path, const QList<Record> &records)
{
    QFile file(path);
    if (!file.open(QIODevice::WriteOnly))
        return false;

    QDataStream out(&file);
    out.setVersion(QDataStream::Qt_6_5);

    out << FILE_MAGIC << FILE_FORMAT_VERSION;
    out << quint32(records.size());
    for (const Record &r : records)
        out << r;

    file.close();
    return out.status() == QDataStream::Ok;
}

bool loadRecords(const QString &path, QList<Record> &records)
{
    QFile file(path);
    if (!file.open(QIODevice::ReadOnly))
        return false;

    QDataStream in(&file);
    in.setVersion(QDataStream::Qt_6_5);

    quint32 magic;
    quint16 formatVersion;
    in >> magic >> formatVersion;

    if (magic != FILE_MAGIC) {
        qWarning() << "Неверный формат файла";
        return false;
    }

    if (formatVersion > FILE_FORMAT_VERSION) {
        qWarning() << "Файл создан более новой версией приложения";
        return false;
    }

    quint32 count;
    in >> count;
    records.clear();
    records.reserve(count);

    for (quint32 i = 0; i < count; ++i) {
        Record r;
        in >> r;

        // Поддержка старого формата версии 1: поле value появилось
        // только в версии 2, поэтому для старых файлов задаём дефолт.
        if (formatVersion < 2)
            r.value = 0.0;

        records.append(r);
    }

    if (in.status() != QDataStream::Ok) {
        qWarning() << "Ошибка чтения потока:" << in.status();
        return false;
    }

    return true;
}

int main()
{
    QList<Record> data = {
        {1, "Первая запись", 3.14},
        {2, "Вторая запись", 2.71}
    };

    if (saveRecords("records.dat", data)) {
        qDebug() << "Данные сохранены";
    }

    QList<Record> loaded;
    if (loadRecords("records.dat", loaded)) {
        for (const Record &r : loaded)
            qDebug() << r.id << r.name << r.value;
    }

    return 0;
}

Пояснения к коду

Магическое число FILE_MAGIC в начале файла позволяет быстро отличить «свой» файл от случайного или повреждённого, а FILE_FORMAT_VERSION — собственная версия формата приложения, не зависящая от версии библиотеки Qt. Это два разных понятия версионирования, и их не стоит путать.

setVersion(QDataStream::Qt_6_5) фиксирует, как именно сериализуются встроенные типы Qt (QString, контейнеры) — без этого вызова используется версия по умолчанию для текущей версии Qt, что создаёт риск несовместимости при переходе на новую версию библиотеки в будущем.

Подводные камни

  1. Отсутствие setVersion(). Если не зафиксировать версию явно, формат может незаметно измениться при обновлении Qt в проекте, и старые файлы, записанные предыдущей версией библиотеки, перестанут корректно читаться.
  1. Путаница версии Qt и версии формата приложения. QDataStream::Version отвечает только за бинарное представление встроенных типов Qt. Изменения структуры ваших собственных данных (добавление полей, изменение порядка) должны версионироваться отдельно — как в примере с FILE_FORMAT_VERSION.
  1. Отсутствие проверки status(). Каждая операция чтения может завершиться с ошибкой (конец файла, повреждённые данные), но operator>> не бросает исключений — нужно проверять stream.status() после серии операций, иначе можно получить «мусорные», частично инициализированные данные без явного признака ошибки.
  1. Несовместимость при добавлении полей. Если просто добавить новое поле в структуру и не предусмотреть ветвление по версии формата при чтении, старые файлы будут читаться с сдвигом, и все последующие поля окажутся повреждены. Любое изменение бинарного макета требует явной поддержки обратной совместимости в коде чтения.
  1. Платформенная зависимость размеров типов. Используйте типы фиксированного размера (quint32, qint64), а не int/long, размер которых может отличаться между платформами и компиляторами.
  1. Производительность при сериализации QVariant. Сериализация QVariant через QDataStream требует регистрации пользовательских типов (Q_DECLARE_METATYPE) и операторов <</>>, иначе во время выполнения возникнет предупреждение и данные будут потеряны.

Бинарный формат на основе QDataStream — мощный инструмент, но он требует дисциплины: фиксированной версии потока, магического числа, собственного версионирования формата и обязательной проверки статуса при каждой операции чтения.

================================================================================