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:
#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 1FZapis 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 1FZnak
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/CRLFW 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:
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/EndiannessProblem 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():
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:
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.
struct Struktura
{
int i1, i2;
char c;
} __attribute__(( packed ) );
std::cout << sizeof( Struktura );
9
#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/PODTypeMetody wirtualne
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.
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 00Znaki 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.
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 00Metoda
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ć.
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.. ;)