Artykuł opisuje jak zapisywać i odczytywać dane binarne z pliku, oraz w jakie pułapki można wpaść, próbując. (artykuł)
Panel użytkownika
Nazwa użytkownika:
Hasło:
Nie masz jeszcze konta?
Zarejestruj się!
dział serwisuArtykuły
kategoriaPomoce naukowe
artykułZapis binarny
Autor: pekfos

Zapis binarny

[artykuł] Artykuł opisuje jak zapisywać i odczytywać dane binarne z pliku, oraz w jakie pułapki można wpaść, próbując.

Wstęp

ale jak zapisuję w trybie binarnym, to w pliku jest dalej to samo. Mam ręcznie zmieniać na zera i jedynki, żeby było binarnie?
Pytania tego typu nieraz pojawiły się na forum, ile mają sensu, pozwolę sobie przemilczeć ;) Ten artykuł, mam nadzieję, rozwieje wątpliwości odnośnie binarnego wczytywania i zapisywania danych, a więc będzie można na forum do tego artykułu odsyłać, zamiast wyperswadowywać to każdemu z osobna, co zwykle idzie dość opornie. Nie ma co ukrywać, słowo 'binarnie' nieźle tu miesza. Działanie na danych binarnych do wkładanie ręki komputerowi w bebechy więc niestety, im dalej zajdziemy, tym więcej szczegółów technicznych. Witaj w prawdziwym świecie..

Tryb binarny?

Najlepiej zacząć od tego, co to jest zapis binarny i ten domyślny, tekstowy. Wbrew pozorom to prawie to samo. Słowo na niedzielę, standard C o strumieniach:
A  text  stream is an ordered sequence of characters composed into lines, each line consisting of zero or more characters plus a terminating new-line character [..] Characters may have to  be  added, altered, or deleted on input and output to conform to differing conventions for representing text in the host environment.  Thus, there need not be a one-to-one correspondence between the characters in a stream and those in the external representation.
A  binary stream is an ordered sequence of characters that can transparently record internal data.  Data read in from a binary stream shall compare equal to the data that were earlier written out to that stream
Zakładam, że angielski nie sprawia problemu. Podkreślone fragmenty opisują różnicę pomiędzy tymi dwoma trybami. Dane zapisane w trybie binarnym są w niezmienionej formie zapisywane do pliku, podczas gdy w trybie tekstowym, dane są modyfikowane, by pasowały do konwencji zapisu tekstu w danym środowisku. Najczęściej dotyczy to znaków nowej linii. To, co w C/C++ zapisujemy jako '\n', jest jednym znakiem, ale w czasie zapisu tekstowego do pliku może spuchnąć do dwóch znaków. Tak jest, na przykład, pod Windowsem, który używa dwóch znaków do reprezentacji przejścia do nowej linii.
A więc tak, można zapisywać dane 'binarne' do pliku w trybie tekstowym:
C/C++
#include <fstream>

void test( const char * nazwa, bool binarnie )
{
    std::ofstream plik( nazwa, binarnie ? std::ios::binary: std::ios::out );
   
    for( char c = 0; c < 32; ++c )
         plik.put( c );
   
}

int main()
{
    test( "1.bin", false );
    test( "2.bin", true );
}
Powyższy przykład zapisuje znaki 0-31 do pliku, raz w trybie tekstowym, drugi raz w trybie binarnym. (testowane pod Windowsem). Poniżej podaję plik 1.bin i 2.bin wypisane szesnastkowo (skopiowane z hexedytora)
Zapis tekstowy:
00 01 02 03 04 05 06 07 08 09 0D 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
Zapis binarny:
00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F 10 11 12 13 14 15 16 17 18 19 1A 1B 1C 1D 1E 1F
Znak 0x0A ('\n') zapisany tekstowo zmienił się w dwa znaki 0x0D 0x0A ('\r', '\n'), zgodnie z Windowsową reprezentacją przejścia do nowej linii (CRLF).
https://pl.wikipedia.org/wiki​/CRLF
W zapisie tekstu coś takiego nie robi dużej różnicy, bo linia to linia, pola w pliku są wydzielane przez separator (przejście do nowej linii). W zapisie danych binarnych takie zmiany w danych są niedopuszczalne, bo tu wszystko kręci się wokół długości pól. Program oczekuje 4 bajtów o określonym znaczeniu, tryb tekstowy przy zapisie zrobił z tego 5 bajtów - program odczyta 4 bajty, więc te i wszystkie następne pola mogą być uszkodzone, bo pojawił się nieoczekiwany znak, przesuwający wszystkie następujące po nim dane o jeden bajt dalej.

Zapis binarny

We wcześniejszym przykładzie, do zapisu danych zastosowałem metodę put(), wstawiającą jeden znak na wyjście. Zapisywanie po jednym znaku w większości przypadków jest niewygodne, zamiast tego stosuje się zapis bloku danych do pliku. Realizuje to metoda write(). Liczba, przykładowo typu int, jest pewnym blokiem danych w pamięci, o określonym rozmiarze. Zapis binarny zmiennej to zapis do pliku takiego bloku:
C/C++
std::ofstream plik( "test.bin", std::ios::binary );
int x = 42;
plik.write(( const char * ) & x, sizeof x );
test.bin zawiera teraz, w moim przypadku, 4 bajty 2A 00 00 00. Pierwsze, co się rzuca w oczy, to to, że długość danych nie zależy od ich wartości, 42 zajmie 4 bajty, podobnie jak 10000 - wszystko, co się zmieści w incie, może być zapisane w pliku jako reprezentacja inta. W zapisie tekstowym zapisanych jest tyle bajtów, ile liczba ma cyfr. Tekst jest bardziej czytelny dla człowieka, niż dla komputera.
Druga sprawa: Dlaczego jest to 2A 00 00 00, a nie 00 00 00 2A, jak mogło by to wynikać z zapisu liczby w systemie pozycyjnym? Zapisując liczbę jako jej reprezentację w pamięci, uzależniamy się trochę od tego, jak ona jest w tej pamięci reprezentowana. Kolejność bajtów (ang. endianness) w zapisie liczby jest zależna od architektury komputera. Najczęściej spotykane, lecz nie jedyne, kolejności to big-endian (najbardziej znaczący bajt na początku) i little-endian (najbardziej znaczący bajt na końcu).
https://en.wikipedia.org/wiki​/Endianness
Problem z tym pojawi się, gdy plik z danymi będzie przeniesiony i odczytany na komputerze o innej kolejności bajtów. Można zapobiec tego typu problemom, zapisując liczby w ściśle określonej kolejności bajtów, a więc niezależnej od architektury. By nie wymyślać koła na nowo, można zastosować Network Byte Order (identyczny z big-endian, btw), biblioteka WinSock ma funkcje do konwersji 32- i 16-bitowych liczb w obie strony.

Odczyt danych binarnych

No to mamy sobie zapisaną liczbę w pliku binarnym, przydałoby się móc ją odczytać. Ponownie, działamy na blokach, odczyt realizuję metoda read(). Tak się złożyło, że argumenty write() i read(), podobnie jak przy funkcjach fwrite() i fread(), różnią sie tylko w słówkach const. Zatem by napisać kod odczytujący, można prawie że wziąść kod zapisujący i pozmieniać write() na read():
C/C++
std::ifstream plik( "test.bin", std::ios::binary );
int x = 0;
plik.read(( char * ) & x, sizeof x );
std::cout << x;
42

Zapis struktur

Liczba to trochę mało, najlepiej zapisywać hurtem, całą strukturę danych. Przez strukturę danych rozumiem pewną ilość pól opakowaną w struct, czy class, a nie szeroko rozumianą strukturę danych, typu drzewo, czy coś. A dlaczego, o tym później. Zapis według tego samego schematu, co poprzednio:
C/C++
std::ofstream plik( "test.bin", std::ios::binary );
Struktura s;
s.i1 = 42;
s.i2 = 667;
s.c = 'X';
plik.write(( const char * ) & s, sizeof s );
Teraz zaczyna się zabawa.. ;)
2A 00 00 00 9B 02 00 00 58 24 40 00
(Dane wpisane do struktury w programie są pogrubione.) Skąd te 3 bajty na końcu? Policzmy, ile danych jest z i bez nich. 9 bajtów bez i 12 z. 12 to całkiem ładna liczba, 3 * 4. Przykładowy program był kompilowany pod 32 bity, stąd te dodatkowe 3 bajty, by struktura miała wielokrotność 4 bajtów. Po co? Załóżmy, że mamy na stosie tablicę takich struktur. Z definicji tablicy, elementy są umieszczone jeden za drugim bez odstępów. Kompilator dodał do struktury odstęp, by między innymi w takim przypadku, każdy element zaczynał się z początkiem słowa stosu, w zależności od architektury, adresowanie bardziej po słowach, niż po bajtach, może być szybsze, podobnie jak ładowanie całych słów do pamięci podręcznej. Wyrównanie jest swego rodzaju optymalizacją szybkości kosztem zużycia pamięci.
http://www.ibm.com​/developerworks/library​/pa-dalign/

Wyłączenie wyrównania

Można to zrobić, lecz nie ma to tego mechanizmu w standardzie języka. Zamiast tego, jest kilka rozszerzeń kompilatorów. Przypominam, że struktura z wyrównaniem miała 12 bajtów.
C/C++
// GCC
struct Struktura
{
    int i1, i2;
    char c;
} __attribute__(( packed ) );

std::cout << sizeof( Struktura );
9
C/C++
// Visual C++, wspierane również przez GCC
#pragma pack(push, 1)
struct Struktura
{
    int i1, i2;
    char c;
};
#pragma pack(pop)

std::cout << sizeof( Struktura );
9
__attribute__((packed)) i #pragma pack() mają więcej możliwości zastosowania, niż pokazano na przykładach. Jeśli kiedyś coś podobnego będzie potrzebne, zachęcam do zajrzenia do dokumentacji.

POD

La, la la, to nawet proste, dodam sobie jeszcze stringa.. *wybuch atomowy*. Już nawet nie będę próbować liczyć, ile tematów na forum do tego można sprowadzić ;) Technika zapisu struktury jako bloku danych może być zastosowana wyłącznie wtedy, jeśli struktura jest tzw. PODem (Plain Old Data), czyli są to stare dobre płaskie dane z C.
Co się dzieje, gdy próbujemy zapisać obiekt std::string? Obiekt tego typu nie trzyma danych, którymi zarządza, w bloku danych, w którym sam rezyduje. W C/C++ typ ma stały rozmiar, jeśli zachodzi potrzeba posiadania potencjalnie nieograniczonych ilości pamięci, to trzeba tą pamięć dynamicznie zaalokować, do obiektu trafia jedynie adres tej pamięci, wskaźnik. Zapis reprezentacji std::string do pliku przez write() zapisze tylko ten wskaźnik, właściwe dane będą nietknięte. Jeśli wczytamy od razu ten obiekt z pliku, to to nawet zadziała, bo adres dalej będzie poprawny, ale jak wyłączymy program - przepadło.
http://en.cppreference.com/w​/cpp/concept/PODType

Metody wirtualne

C/C++
struct Struktura
{
    int i1, i2;
    char c;
   
    virtual void f() { }
};

std::cout << sizeof( Struktura );
16
Dodanie metody wirtualnej powiększyło rozmiar struktury o 4 bajty. Powiem więcej, te 4 bajty to wskaźnik, więc lepiej czegoś takiego nie zapisywać jak PODa. Dodatkowe dane to efekt uboczny implementacji polimorfizmu. Niejawnie dodany wskaźnik (vptr) wskazuje na statyczną tablicę wskaźników na funkcje, a jego wartość jest niejawnie ustawiana w konstruktorze.
Jeśli z jakiegoś powodu mamy konieczność stosowania metod wirtualnych w klasie, którą chcemy zapisywać do pliku, najlepiej dodać metody wirtualne do serializacji i deserializacji, by każda z podklas dbała o siebie w tej kwestii.

Zapis tekstu

Tekst stałej długości

Jeśli tekst trzymamy w tablicy znaków (stałego rozmiaru), można taką tablicę zapisać jako blok danych. Takie rozwiązanie ogranicza maksymalną długość tekstu, kosztuje pamięć i miejsce w pliku, ale można przechowywać tak tekst w strukturze nie tracąc właściwości PODa.
C/C++
char tekst[ 16 ] = "Alamakota";
std::ofstream plik( "test.bin", std::ios::binary );
plik.write( tekst, sizeof tekst );
41 6C 61 6D 61 6B 6F 74 61 00 00 00 00 00 00 00
Znaki od drugiego zera do końca są niewykorzystane, ale swoją obecnością zapewniają stałą długość pola w pliku. 6 bajtów można było zaoszczędzić.

Tekst zmiennej długości

Tekst zmiennej (dowolnej) długości, na przykład z std::string, zapisuje się w zupełnie inny sposób. Dalej jako blok danych, ale zmiennej długości, dyktowanej przez długość tekstu.
C/C++
std::string tekst = "Alamakota";
std::ofstream plik( "test.bin", std::ios::binary );
plik.write( tekst.c_str(), tekst.size() + 1 );
41 6C 61 6D 61 6B 6F 74 61 00
Metoda c_str() zwraca c-string, łańcuch znaków zakończony zerem, jednak size() nie uwzględnia zera, stąd trzeba dodać 1, by te zero zapisać. Jest ono niezbędne, bo ta metoda zapisuje blok zmiennej długości - przy odczycie nie wiemy, ile danych przyjdzie wczytać. 0 na końcu sygnalizuje, kiedy przestać czytać.
C/C++
std::ifstream plik( "test.bin", std::ios::binary );
std::string str;
getline( plik, str, '\0' );
std::cout << str;
Alamakota
Trzeci argument getline() określa znak, przy którym następuje koniec wczytywania. Dodatkowo, znak kończący jest usuwany ze strumienia, więc można od razu kontynuować wczytywanie dalszych pól z pliku.

Pamiętaj, że to nie jest jedyna możliwość zapisu i odczytu tekstu. Nikt nie zabroni ci zapisywać rozmiaru a potem opisanego nim bloku z tekstem, wtedy już bez zera na końcu. Zauważ, że zero nie będzie wtedy zarezerwowane jako symbol końca, więc będzie mogło należeć do tekstu, jak każdy inny znak. Można powiedzieć, że to nie będzie już tekst, lecz blok dowolnych danych, co daje nowe możliwości.

Serializacja struktur danych

Jak zserializować drzewo, graf, czy cykliczną listę dwukierunkową? Ten punkt artykułu, ze względu na mnogość możliwych problemów, będzie raczej teoretyczny. W przypadku złożonych struktur danych, warto pamiętać prostą zasadę: nie zawsze zapis wprost jest najłatwiejszy. W przypadku listy, zapis wprost elementu listy, jako blok danych, zapisze wskaźniki i różne dane odpowiadające za jej funkcjonowanie, a nie tylko dane, jakie ta lista ma przechowywać. Dwukierunkowa lista cykliczna liczb, wzbogacona o jakieś dodatkowe dane poprawiające jej porządkowanie, czy coś, to dalej jest kontener zawierający sekwencję liczb. Wystarczy zapisać n i n liczb, czyli same dane z kontenera i ich ilość. Sam kontener można z danych zrekonstruować, a najlepiej stworzyć go od nowa, a wczytane liczby do niego dodać. Wtedy mamy pewność, że kontener zawiera legalny stan, osiągalny przez operacje dodawania liczb.
Drzewo można łatwo rekurencyjnie wypisać, na przykład w kolejności pre-order, w zależności od sytuacji, to może być dobry sposób, do serializacji drzewa: wartość korzenia (na przykład 4 bajty), potem 1 bajt na zapisanie, które z dzieci istnieje, a następnie rekurencyjnie wypisać w ten sam sposób wszystkie z istniejących dzieci. Jeśli drzewo jest binarne pełne, można je zapisać jako ciąg liczb, jak w przypadku kopca binarnego. Możliwości jest wiele.

Podsumowanie

Nie należy pokładać zbytniego zaufania do danych, jakie ktoś usiłuje programowi podsunąć - jeśli zakładamy, że plik zawiera posortowane dane i ten fakt jest kluczowy dla działania programu, należy te dane posortować. Najlepiej algorytmem, dla którego już posortowane dane są przypadkiem optymistycznym, z którym sobie bardzo szybko poradzi. Ta wskazówka podchodzi trochę pod szeroki temat zabezpieczania, czy też sprawdzania poprawności danych w plikach - może kiedyś, w innym artykule.. ;)