NVault

[C++] Де/сериализация данных

Что такое сериализация/десериализация?

Точнее, чем википедия я не скажу, поэтому приведу оттуда цитату:

Сериализация — процесс перевода какой-либо структуры данных в последовательность битов. Обратной к операции сериализации является операция десериализации — восстановление начального состояния структуры данных из битовой последовательности. Википедия

Довольно просто (IMHO). В нашем случае мы будем последовательность битов записывать в бинарные файлы на диск. Проще всего объяснить будет на двух примерах.

Кейс первый: сохранение встроенных типов

Задача: ввести из стандартного потока матрицу целых чисел размерности NxM, вывести её в файл в двоичном формате, затем из двоичного файла вывести её в стандартный поток.

Напишем скелет программы

// подключаем библиотеки
// потоки i/o
#include<iostream>
// файловые потоки i/o
#include<fstream>

// разворачиваем пространство имён
using namespace std;

// точка входа в приложение
int main() {
    return 0;
}

Дальше я буду сокращать код, подписывая в какой функции мы находимся, либо ещё как-нибудь обозначу позицию.

Опишем сигнатуры функций

...
using namespace std;

// функция чтения целых чисел
int readBinInt(istream &ist);
// функция записи целых чисел
void writeBinInt(int num, ostream &ost);

int main() {
...

Итак, какие существуют алгоритмы сериализации в таком случае? Вообще их много, например, можно подключить библиотеку Boost, но зачем нам это. Нам оно не нужно, воспользуемся стандартными средствами.

Для начала опишем функцию записи. Так как мы её сигнатуру описали до main(), функцию можно описывать ниже точки входа.

void writeBinInt(int num, ostream &ost) {
    // Да, reinterpret_cast - плохо, но что ты мне сделаешь?
    ost.write(reinterpret_cast<char*>(&num), sizeof(int));
}

Теперь разберём по частям:

  1. Функция ostream.write в качестве двух аргументов принимает указатель на char, а вторым - колличество байт, которые необходимо записать.

  2. Т.к. нам надо записать число, мы приведём ссылку(!) на число к указателю на char

    Почему char? Всё просто - нам нужны байты. Вот мы и обращаемся к тому месту, где int “начинается”, считываем sizeof(int) байт и записываем их в поток.

  3. Вторым параметром передаём размер int, который вычисляется с помощью sizeof.

    Вообще, можно было бы написать sizeof(num)

Теперь опишем функцию чтения из потока.

int readBinInt(istream &ist) {
    int x = 0;
    ist.read(reinterpret_cast<char*>(&x), sizeof(int));
    return x;
}

И разберёмся в ней:

  1. Объявляем целочисленную переменную x .

  2. Функция istream.write принимает таки-же аргументы, как и istream.read, поэтому, думаю, можно не объяснять что и почему?

    В любом случае - есть комментарии ниже статьи

  3. Возвращаем значение переменной x.

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

Вернёмся к main(). Все комментарии я приведу прямо в коде.

//inside of main()

// объявим целочисленные переменные N и M
// и прочитаем их из STDIN
int N, M;
cin >> N >> M;

// динамически выделим память под N указателей
int **mtx = new int*[N];

// введём данные
for (int i = 0; i < N; i++) {
    // выделим память под ряд матрицы
    // и введём его
    int row = new int[M];
    for (int j = 0; j < M; j++) {
        cin >> row[i];
    }
    mtx[i] = row;
}

// откроем двоичный поток
// `ios::binary` говорит нам, что будет производиться
// работа с двоичными данными
ofstream ofs("./out.bin", ios::binary);

// выведем в этот поток разммерность
writeBinInt(N, ofs);
writeBinInt(M, ofs);

// и выведем в него нашу матрицу
//
// сразу же займёмся удалением матрицы,
// потому что она нам будет не нужна
for (int i = 0; i < N; i++) {
    for (int j = 0; j < M; j++) {
        writeBinInt(mtx[i][j], ofs);
    }
    delete[] mtx[i];
}
delete[] mtx;

// закроем поток - это всегда хорошо
ofs.close();

// и откроем новый, но теперь на ввод
ifstream binfs(path_out, ios::binary);

// новые переменные размерности
// которые мы сразу же введём
int nN = readBinInt(binfs),
    nM = readBinInt(binfs);

// новая матрица
int **nmtx = new int*[nN];

// теперь введём данные из нового потока
for (int i = 0; i < nN; i++) {
    int row = new int[M];
    for (int j = 0; j < nM; j++) {
        row[i] = readBinInt(binfs);;
    }
    nmtx[i] = row;
}

// а теперь выведем матрицу
for (int i = 0; i < nN; i++) {
    for (int j = 0; j < nM; j++) {
        cout << nmtx[i][j] << " ";
    }
    cout << endl;
    delete[] nmtx[i];
}
delete[] nmtx;

return 0;

Таким образом, мы прочитаем из STDIN размерность матрицы, саму матрицу, потом выведем её в двоичный файл, из которого потом прочитаем и выведем матрицу на экран. Всё довольно просто, пока мы работаем с базовыми типами данных. Шучу. Со структурами ни на толику не сложнее.


Кейс второй: сохранение структур

Входные данные: исходная база данных находится в текстовом файле. Одному объекту соответствует одна строка. Поля данных разделены пробелами.

Формат данных: SURNAME AGE SALARY

SURNAME - char[]
AGE - unsigned short
SALARY - double

Задача: ввести данные, записать их в двоичный файл, затем прочитать из него данные и вывести их, отсортировав по полю AGE.

Напишем скелет нашей программы

#include <iostream>
#include <fstream>
// структура данных `vector`,
// чтобы не париться с памятью
#include <vector>
// для функции сортировки `sort()`
#include <algorithm>

using namespace std;

int main() {
    return 0;
}

Введём структуру элемента базы данных, пропишем её до main():

struct DB_record {
    char surname[32];
    unsigned short age;
    double salary;

    // определим для структуры оператор `<`(меньше),
    // чтобы не писать компаратор для функции sort
    bool operator< (const DB_record& rhs) const {
        return age < rhs.age;
    }
}

Переместимся в main()

// путь до входного файла
string path_in = "in.txt";
// путь до выходного файла
string path_out = "out.bin";

// входной текстовый поток
ifstream ifs(path_in);
// выходной бинарный поток
ofstream ofs(path_out, ios::binary);

// выведем ошибку и закроем программу,
// если не можем открыть входной файл
if (!ifs.is_open())
{
    cerr << "Unable to open file" << endl;
    return 1;
}

// т.к. нам не сказано, что обязательно нужно выводить
// "сырые" входные данные, мы будем считывать и
// сразу выводить в двоичный поток
//
// читаем из потока, пока не случилась ошибка
// или не дошли до конца файла
while (!ifs.fail() && !ifs.eof()) {
    // создаём инстанс нашей структуры
    // и считываем в него данные
    DB_record record{};
    ifs >> record.surname >> record.age >> record.salary;
    // схема записи структуры в двичный файл не изменилась:
    // приводим ссылку на структуру к указателю на `char`
    // и записываем в поток `sizeof` байт
    ofs.write(reinterpret_cast<char*>(&record), sizeof(record));
}

// закроем потоки
ifs.close();
ofs.close();

// создадим вектор структур
vector<DB_record> records_fb;

// откроем бинарный поток
ifstream binfs(path_out, ios::binary);

// читаем из потока, пока не случилась ошибка
// или не дошли до конца файла
while (!binfs.eof() && !binfs.fail()) {
    // создаём инстанс нашей структуры
    // и считываем в него данные
    DB_record record{};
    binfs.read(reinterpret_cast<char*>(&record), sizeof(record));
    // схема чтения - та же
    records_fb.push_back(record);
}

// сортируем вектор
sort(records_fb.begin(), records_fb.end());

// с помощью цикла foreach выводим структуру
for (auto record : records_fb) {
    cout << record.surname << endl;
    cout << "\tAge: " << record.age << endl;
    cout << "\Salary: " << record.salary << endl;
    cout << endl;
}

return 0;

В общем-то сериализация/десериализация структур простых объектов и структур не отличается.