Rambler's Top100 Service калинин.ru / программирование / c и c++ /  << 31.07.00 >>

Запись структур данных в двоичные файлы

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

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

Итак, как все это выглядит обычно? Имеется некоторая структура данных:

struct data_item
{
  type_1 field_1;
  type_2 field_2;
  // ...
  type_n field_n; 
};

data_item i1;

Каким образом, например, сохранить информацию из i1 так, что бы программа во время своего повторного запуска, смогла восстановить ее? Наиболее частое решение следующее:

FILE* f = fopen("file", "wb");
fwrite((char*)&i1, sizeof(i1), 1, f);
fclose(f);

assert расставляется по вкусу, проверка инвариантов в данном примере не является сутью. Тем не менее, несмотря на частоту использования, этот вариант решения проблемы не верен.

Нет, он будет компилироваться и, даже будет работать. Мало того, будет работать и соответствующий код для чтения структуры:

FILE* f = fopen("file", "rb");
fread((char*)&i1, sizeof(i1), 1, f);
fclose(f);

Что же тут неправильного? Ну что же, для этого придется немного пофилософствовать. Как бы много не говорили о том, что C --- это почти то же самое, что и ассемблер, не надо забывать, что он является все-таки языком высокого уровня. Следовательно, в принципе, программа написанная на C (или C++) может (теоретически) компилироваться на разных компиляторах и разных плафтормах. К чему это? К тому, что данные, которые сохранены подобным образом, в принципе не переносимы.

Стоит вспомнить о том, что для структур неизвестно их физическое представление. То есть, для конкретного компилятора оно, быть может, и известно (для этого достаточно посмотреть работу программы "вооруженным взглядом", т.е. отладчиком), но о том, как будут расположены в памяти поля структуры на какой-нибудь оригинальной машине, неизвестно. Компилятор со спокойной душой может перетасовать поля (это, в принципе, возможно, но я такого, честно говоря, не встречал) или выравнять положение полей по размеру машинного слова (встречается сплошь и рядом). Для чего? Для увеличения скорости доступа к полям. Понятно, что если поле начинается с адреса, не кратного машинному слову, то прочитать его содержимое не так быстро, как в ином случае. Таким образом, сохранив данные из памяти в бинарный файл напрямую мы получаем дамп памяти конкретной архитектуры (и это я еще не сказал о том, что sizeof совершенно не обязан возвращать количество байт).

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

Обычный "костыль", который применяется, например, при проблемах с выравниванием, заключается в том, что компилятору явно указывается как надо расставлять поля в структурах. В принципе, любой компилятор дает возможность управлять выравниванием. Но выставить одно значение для всего проекта при помощи ключей компилятора (обычно это значение равно 1, потому что при этом в сохраненном файле не будет пустых мест) нехорошо, потому что это может снизить скорость выполнения программы. Есть еще один способ указания компилятору размера выравнивания, он заключается в использовании директивы препроцессора #pragma. Это не оговорено стандартом, но обычно есть директива #pragma pack, позволяющая сменить выравнивание для определенного отрезка исходного текста. Выглядит это обычно примерно так:

#pragma pack(1)

struct { /* ... */ };

#pragma pack(4)

Последняя директива #pragma pack(4) служит для того, что бы вернуться к более раннему значению выравнивания. В принципе, конечно же при написании исходного текста никогда доподлинно заранее неизвестно, какое же было значение выравнивания до его смены, поэтому в некоторых компиляторах под Win32 есть возможность использования стека значений (пошло это, насколько я понимаю, из MS Visual C++):

#pragma pack(push, 1)

struct { /* ... */ };

#pragma pack(pop)

В примере выше сначала сохраняется текущее значение выравнивания, затем оно заменяется 1, затем восстанавливается ранее сохраненное значение. При этом, подобный синтаксис поддерживает даже gcc для win32 (еще стоит заметить, что, вроде, он же под Unix использовать такую запись #pragma pack не дает). Есть альтернативная форма #pragma pack(), поддерживаемая многими компилятороами (включая msvc и gcc), которая устанавливает значение выравнивания по-умолчанию.

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

#ifndef __inc_h__
#define __inc_h__

class Object
{
  // ...
};

#endif // __inc_h__

Представьте себе, что существуют три файла file1.cpp, file2.cpp и file2.h, которые этот хидер используют. Допустим, что в file2.h находится функция foo, которая (например) записывает Object в файл:

// file1.cpp
#include "inc.h"
#include "file2.h"

int main()
{
  Object* obj = new Object();

  foo(obj, "file");

  delete obj;

  return 0;
}
// file2.h
#ifndef __file2_h__
#define __file2_h__

#pragma pack(1)

#include "inc.h"

void foo(const Object* obj, const char* fname);

#pragma pack(4)

#endif // __file2_h__
// file2.cpp
#include "file2.h"

void foo(const Object* obj, const char* fname)
{
  // ...
}

Это все скомпилируется, но работать не будет. Почему? Потому что в двух разных единицах компиляции (file1.cpp и file2.cpp) используется разное выравнивание для одних и тех же структур данных (в данном случае, для объектов класса Object). Это даст то, что объект переданный по указателю в функцию foo() из функции main() будет разным (и, конечно же, совсем неправдоподобным). Понятно, что это явный пример "плохой" организации исходных текстов --- использование директив компилятора при включении заголовочных файлов, но, поверьте, он не высосан из пальца. Мне такое несколько раз попадалось.

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

К чему я веду: на самом деле #pragma pack не является панацеей. Мало того, использование этой директивы практически всегда неправомерно. Я даже могу сказать более: эта директива в принципе редко когда нужна (во всяком случае, при прикладном программировании).

Правильным же подходом, на мой взгляд, является сначала запись всех полей структуры в нужном порядке в некоторый буфер и скидывать в файл уже содержимое буфера. Это очень просто и очень эффективно, потому что все операции чтения/записи можно собрать в подпрограммы и менять их при необходимости таким образом, что бы обеспечить нормальную работу с внешними файлами. Проиллюстрирую этот подход (примерно так, кстати, я все это использую у себя):

template<class T>
inline size_t get_size(const T& obj)
{
  return sizeof(obj);
}

Эта функция возвращает размер, необходимый для записи объекта. Зачем она понадобилась? Во-первых, возможен вариант, что sizeof возвращает размер не в байтах, а в каких-то собственных единицах. Во-вторых, и это значительно более необходимо, объекты, для которых вычисляется размер, могут быть не настолько простыми, как int. Например:

template<>
inline size_t get_size<std::string>(const std::string& s)
{
  return s.length() + 1;
}

Надеюсь, понятно, почему выше нельзя было использовать sizeof.

Аналогичным образом определяются функции, сохраняющие в буфер данные и извлекающие из буфера информацию:

typedef unsigned char byte_t;

template<class T>
inline size_t save(const T& i, byte_t* buf)
{
  *((T*)buf) = i;
  return get_size(i);
}

template<class T>
inline size_t restore(T& i, const byte_t* buf)
{
  i = *((T*)buf);
  return get_size(i);
}

Понятно, что это работает только для простых типов (int или float), уж очень много чего наворочено: явное приведение указателя к другому типу, оператор присваивания... конечно же, очень нехорошо, что такой save() доступен для всех объектов. Понятно, что очень просто от него избавиться убрав шаблонность функции и реализовав аналогичный save() для каждого из простых типов данных. Тем не менее, это всего-лишь примеры использования, не судите строго --- я писал их параллельно с этим текстом.

template<>
inline size_t save<MyObject>(const MyObject& s, byte_t* buf)
{
  // ...
}

Не спорю, можно сделать и по другому. Например, ввести методы save() и restore() в каждый из сохраняемых классов, но это не столь важно для принципа этой схемы. Поверьте, это достаточно просто использовать, надо только попробовать. Мало того, здесь можно вставить в save<long>() вызов htonl() и в restore<long>() вызов ntohl(), после чего сразу же упрощяется перенос двоичных файлов на плафтормы с другим порядком байтов в слове... в общем, преимуществ --- море. Перечислять все из них не стоит, но как после этого лучше выглядит исходный текст ;) а как приятно вносить изменения ;)

Резюме

Работа с бинарными файлами далеко не так проста как кажется. Немного невнимательности, и работа программы уже не так предсказуема, как ранее.


Версия для печати


  Ссылки по теме:
Бъерн Страуструп
   Язык программирования C++, 3 издание.
  Рядом в разделе:
Оператор безусловного перехода goto (10.08.00)
   Так уж сложилось, что именно присутствие или отсутствие этого оператора в яызке программирования всегда вызывает жаркие дебаты среди сторонников "хорошего стиля"...   >>>>
Виртуальные деструкторы (28.07.00)
   В принципе, в практически любой мало-мальски толковой книге по C++ рассказывается, зачем нужны виртуальные деструкторы и почему их надо использовать. Тем...   >>>>
  Рядом по дате:
Кто я / Who am i, 1998 (01.08.00)
   Пожалуй, фраза, с которой стоит начать комментарий: в этом фильме играет Джекки Чан. Мало того, на мой взгляд, это у него...   >>>>
"Человеческое лицо" в интернете (30.07.00)
   Все-таки, на мой взгляд, самое интересное, что есть в интернете, это "домашние странички". Многие из них, зачастую, плохо сделаны, большинство ---...   >>>>
  Содержание:
Заглавная страница
Мой блог
Мое резюме
Дайджест
Программирование
   C&C++
Сети
Unix
Алгоритмы
Оптимизация
Соревнования
Отвлеченно
XML
TeX
Просто так
Студенческое
Туризм
  Байки
Фотографии
Комментарии
   Книги
Web-ресурсы
Фильмы
Интернет
Программное обеспечение
Жизнь
Благодарности
Форум
Хронология
 
  В этом разделе:
Простой, но полезный аллокатор памяти (18.02.03)
   Эта заметка --- продолжение "Postfix изнутри" в том смысле, что в качестве примера опять берется postfix. Но если в прошлый раз...   >>>>
C или C++? (09.07.01)
   Существуют два диаметрально противоположенных, но одинаково распространенных мнения, которые можно выразить как "C++ это C с классами" и "C++ и C...   >>>>
Религия и goto (14.04.01)
   Начнем несколько издалека. В программировании существует тенденция к алгоритмизации самого процесса программирования. То есть, выведение некоторых универсальных правил, использование которых в...   >>>>
ploticus (16.10.00)
   Есть такая программа, предназначенная для создания графиков различных видов из командной строки, называется ploticus. Программа сама по себе достаточно удобная ---...   >>>>
Шаманство, или ошибки работы с памятью (25.09.00)
   Когда программа становится внушительной по своему содержанию (то есть, не по количеству строчек, а по непонятности внутренних связей), то ее поведение...   >>>>
Библиотека консорциума W3, libwww (20.09.00)
   Популярный нынче термин "веб-программирование" обычно подразумевает под собой программирование, в лучшем случае, на perl, в худшем --- на PHP, в совсем...   >>>>
Инварианты внутри программы (18.09.00)
   Вы когда-нибудь задумывались, над тем, как вы пишите программы? Если нет, то, я думаю, сегодняшняя заметка будет вам полезна. Итак, как...   >>>>
Содержание раздела полностью...
   Примерно в тоже время
Кто я / Who am i, 1998 (01.08.00)
   Пожалуй, фраза, с которой стоит начать комментарий: в этом фильме играет Джекки Чан. Мало того, на мой взгляд, это у него...   >>>>
"Человеческое лицо" в интернете (30.07.00)
   Все-таки, на мой взгляд, самое интересное, что есть в интернете, это "домашние странички". Многие из них, зачастую, плохо сделаны, большинство ---...   >>>>
Хронология полностью...
   Содержание
Заглавная страница
Мой блог
Мое резюме
Дайджест
Программирование
  C&C++
Сети
Unix
Алгоритмы
Оптимизация
Соревнования
Отвлеченно
XML
TeX
Туризм
  Байки
Фотографии
Комментарии
  Книги
Web-ресурсы
Фильмы
Интернет
Программное обеспечение
Жизнь
Студенческое
Просто так
Благодарности
Форум
Хронология
© 2000-2008, Andrey L. Kalinin
mailto:andrey@kalinin.ru
Rambler's Top100