IT Notes

Сериализация данных в Qt

Введение

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

Другой вариант заключается в использовании обычных файлов. Довольно распространенным форматом в этом случае выступают текстовые файлы, составленные по определенным правилам. В последнее время для этой цели чаще всего используют XML. Парсеры для XML существуют для всех популярных языков программирования, а его синтаксис настолько прост, что без проблем позволяет редактировать содержимое в простом текстовом редакторе. Более подробно об XML вы можете почитать в другой моей заметке: Паттерн Строитель и XML. Этот формат довольно удобный, но крайне избыточный. К тому же, имеет смысл использовать его лишь тогда, когда вы сохраняете текстовые данные. Конечно, вы можете задействовать кодирование, типа base64, для бинарных данных, но проблема избыточности остается не менее актуальной и в этом случае.

Следующий вариант заключается в использовании чисто бинарных файлов. Это решение уже не такое гибкое, как вариант с текстовыми файлами. Однако оно обеспечивает достижение минимального размера выходных файлов. При этом вы всегда можете открыть внутреннюю структуру формата, добившись тем самым высокой переносимости, поскольку побайтовые операции при работе с данными доступны в большинстве языков программирования. Однако средства компоновки подобных бинарных файлов тоже могут быть различными. Qt для этих целей предлагает свои возможности для сериализации данных с помощью QDataStream. Об этом мы сейчас и поговорим.

Сериализация базовых типов в файл

Пользоваться QDataStrem совсем не сложно. Все примитивные типы C++ и основные классы Qt уже поддерживаются им. Рассмотрим небольшой пример:

#include <QDebug>
#include <QFile>
#include <QDataStream>
#include <QRect>

static const char* const FILE_NAME = "test.bin";

int main() {
    QFile file( FILE_NAME );
    QDataStream stream( &file );

    file.open( QIODevice::WriteOnly );
    stream << 5 << 3.14 << QString( "Hello, world!" ) << QRect( 0, 0, 20, 10 );
    file.close();

    file.open( QIODevice::ReadOnly );
    int x = 0;
    float y = 0.0;
    QString str;
    QRect r;
    stream >> x >> y >> str >> r;
    qDebug() << x << y << str << r;
    file.close();

    return 0;
}

Сначала мы создаем объект файла QFile, для которого определили имя test.bin. Затем определяем поток QDataStream, передавая ему в качестве аргумента конструктора указатель на файл. Далее мы открываем файл в режиме только для записи WriteOnly. После чего отправляем несколько значений в поток stream с помощью оператора << (прямая аналогия с std::cout). Для примера я выбрал целое число, число с плавающей точкой, строку и объект прямоугольника. Полный список поддерживаемых типов гораздо обширнее, поэтому за подробностями обращайтесь к официальной документации. Однако если вы собирается записать какой-то из встроенных типов C++ или Qt, то почти наверняка он уже поддерживается.

Обратите внимание, что после записи в поток, я закрываю файл с помощью close(). Это необходимо для того, чтобы вытолкнуть данные из буфера на жесткий диск. Кроме того, сразу после этого мы вновь открываем файл, но уже в режиме для чтения. Здесь следует заметить, что я проигнорировал возможные ошибки чтения-записи, чтобы не загромождать код, но в реальном приложении это обязательно нужно делать.

Теперь, когда данные уже на диске, мы готовы их считать. Если вы заглянете в test.bin, то не увидите ничего особенно интересного. В HEX-режиме редактора это будет выглядеть примерно следующим образом:

00000000: 0000 0005 4009 1eb8 51eb 851f 0000 001a  ....@…Q.......
00000010: 0048 0065 006c 006c 006f 002c 0020 0077  .H.e.l.l.o.,. .w
00000020: 006f 0072 006c 0064 0021 0000 0000 0000  .o.r.l.d.!......
00000030: 0000 0000 0013 0000 0009                 ..........

Перед тем, как прочитать содержимое файла, мы должны подготовить набор переменных, которые примут считанные значения. Поэтому мы определили 4 переменных в соответствии с тем, что было записано в файл ранее. Само считывание осуществляется практически так же, как и запись, однако теперь мы используем оператор >> (так же работает std::cin). Вот и все. Данные из файла получены. Теперь мы можем спокойно вывести их на консоль с помощью QDebug и убедиться, что все в порядке. В конце же мы вновь закрываем файл с помощью close(). Однако делать это совсем не обязательно, поскольку QFile работает по принципу RAII, поэтому файл в любом случае будет закрыт, когда произойдет вызов деструктора объекта.

Сериализация в QByteArray

Аналогичным образом вы можете работать с объектами QByteArray для сериализации данных:

#include <QDebug>
#include <QDataStream>
#include <QRect>

int main() {
    QByteArray ba;
    QDataStream out( &ba, QIODevice::WriteOnly );

    out << 5 << 3.14 << QString( "Hello, world!" ) << QRect( 0, 0, 20, 10 );

    QDataStream in( ba );
    int x = 0;
    double y = 0.0;
    QString str;
    QRect r;
    in >> x >> y >> str >> r;
    qDebug() << x << y << str << r;

    return 0;
}

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

Для чтения из байтового массива нам придется создать новый поток, передав константную ссылку в конструктор. А дальше все уже знакомо: объявляем переменные, считываем значения, выводим результаты на консоль для проверки.

А зачем это может понадобиться? На ум приходит несколько вариантов:

  • возможность записи в базу данных. Вероятно, если вам понадобилось сделать что-то подобное, то архитектура приложения или самой БД оказалась не самой удачной. С другой стороны, бывают случаи, когда имеет смысл обращаться с данными однообразно. Если поле таблицы в БД потенциально может хранить изображения, звукозаписи или pdf-файлы, то есть содержимое не столь однозначно, то хранение пользовательских бинарных данных в этом же поле кажется уже не такой плохой идеей;
  • замена QVariant. Здесь уже все не так однозначно, как в случае с базами данных. Хотя это и может оказаться вполне рабочим решением, реальной необходимости его использования я придумать не могу. Очевидных преимуществ оно не дает, но ситуации бывают разные, поэтому иметь запасные варианты всегда полезно.

Сериализация пользовательских типов данных

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

#include <QDebug>
#include <QFile>
#include <QDataStream>
#include <QRect>

static const char* const FILE_NAME = "test.bin";

class User {
public:
    User() : m_name( "" ), m_age( 0 ) { }

    User( const QString& name, int age ) : m_name( name ), m_age( age ) {
    }

    QString getName() const {
        return m_name;
    }

    int getAge() const {
        return m_age;
    }

    friend QDataStream& operator>>( QDataStream& d, User& u );

private:
    QString m_name;
    int m_age;
};

QDataStream& operator<<( QDataStream& d, const User& u ) {
    d << u.getName() << u.getAge();
    return d;
}

QDataStream& operator>>( QDataStream& d, User& u ) {
    d >> u.m_name >> u.m_age;
    return d;
}

QDebug operator<<( QDebug d, const User& u ) {
    d << QString( "User( %1, %2 )" ).arg( u.getName() ).arg( u.getAge() );
    return d;
}

int main() {
    QFile file( FILE_NAME );
    QDataStream stream( &file );

    file.open( QIODevice::WriteOnly );
    stream << User( "User1", 20 ) << User( "User2", 21 ) << User( "User3", 22 ) << User( "User4", 23 );
    file.close();

    file.open( QIODevice::ReadOnly );
    User u1, u2, u3, u4;
    stream >> u1 >> u2 >> u3 >> u4;
    qDebug() << u1 << u2 << u3 << u4;
    file.close();

    return 0;
}

Сама запись и чтение с помощью QDataStream абсолютно идентична тому, что мы уже видели для базовых типов, поэтому особо останавливаться на этом не будем. Сам класс User, который мы сериализуем, тоже довольно простой. Он имеет всего два поля: имя и возраст. Интерес в этом фрагменте представляют три оператора которые мы определяем для этого класса.

Первым идет оператор <<:

QDataStream& operator<<( QDataStream& d, const User& u ) {
    d << u.getName() << u.getAge();
    return d;
}

Обратите внимание, что мы объявили его вне класса. То есть это обычная функция. Ее сигнатура и содержание очень напоминает то, что у вас получится, если вы решите обеспечить совместимость класса со стандартным потоком вывода. Этих строк уже достаточно, чтобы запись работала.

Теперь чтение:

QDataStream& operator>>( QDataStream& d, User& u ) {
    d >> u.m_name >> u.m_age;
    return d;
}

По сути это тоже обычная функция, но я сделал ее дружественной (friend) по отношению к классу User. Здесь это требуется для того, чтобы получить доступ к полям m_name и m_age. Если бы мы предусмотрели соответствующие функции-члены для установки этих значений, то объявление friend нам бы не потребовалось. Однако в этом случае мы были бы вынуждены объявлять временные переменные, а размер функции увеличился бы. Сам же вид функции очень напоминает то, что получится у вас, если вы добавите совместимость класса со стандартным потоком ввода.

Запись и чтение уже готовы. Теперь мы можем использовать класс User совместно с потоками QDataStream. Однако остается вопрос проверки того, что мы считали из файла. Для этого я добавил возможность вывода содержимого класса с помощью QDebug:

QDebug operator<<( QDebug d, const User& u ) {
    d << QString( "User( %1, %2 )" ).arg( u.getName() ).arg( u.getAge() );
    return d;
}

В очередной раз никаких откровений. Что-то подобное мы уже видели, не правда ли? Единственное, на что следует обратить внимание: QDebug принимается и возвращается не по ссылке, а по значению.

Вот наше знакомство с основами сериализации в Qt и подходит к концу, однако остается один вопрос. Как проверить, что запись и/или чтение прошли без ошибок? Просто проигнорировать мы это не можем. Поэтому в QDataStream предусмотрена функция-член status(). Она возвращает его текущее состояние и указывает на возникшие проблемы.

Кроме того, обратите внимание на различия в версиях форматов сериализации Qt. Между разными выпусками библиотеки могут быть отличия, которые приведут к неожиданным проблемам и ошибкам. Поэтому если при разработке вашего Qt-приложения вы решите перейти на более свежую версию SDK, то при сериализации вам придется ориентироваться на самую старую, если вы хотите сохранить совместимость. Изменить используемую версию можно с помощью QDataStream::setVersion().

Заключение

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

Похожие публикации

Комментарии

make operator<< inline - inline functions are allowed to be defined more than once, as long as all the definitions are identical.