Obsługa plików jest na pewnym poziomie pisania programów niezbędna. Język C i C++ umożliwiają dostęp do plików na różny sposób. W niniejszym materiale skupimy się na podejściu strumieniowym z wykorzystaniem dedykowanych temu klas (o klasach więcej będzie w części drugiej – obiektowej).

Można wyróżnić trzy klasy posiadające rozbudowane opcje obsługi plików i są to:

  • ofstream: Klasa strumieniowa obsługująca zapisywanie do pliku,
  • ifstream: Klasa strumieniowa obsługująca odczyt z pliku,
  • fstream: Uniwersalna klasa obsługująca odczyt/zapis z pliku.

Po dołączeniu niezbędnych bibliotek do programu można pokusić się o powiązanie fizycznego pliku na dysku (jeśli istnieje) ze zmienną, która umożliwi nam jego przetwarzanie. Taką czynność dokonuje się za pomocą metody open. Należy uprzednio zauważyć, że możliwe jest powiązanie fizycznego pliku z naszym tzw. uchwytem (ang. handler) w konkretnym trybie określanym tzw. flagą modyfikatora. Modyfikator jest ciągiem binarnym, którego bity określają specyficzne sposoby dostępu do pliku np. otwarcie go do odczytu, otwarcie go do zapisu (lub do zapisu i odczytu – jeśli oba bity zostaną razem ustawione na “1”) i może przyjmować wymienione niżej wartości:

  • ios::in – otwarcie dla operacji odczytu.
  • ios::out – otwarcie dla operacji zapisu.
  • ios::binary – otwarcie w trybie binarnym.
  • ios::ate – otwarcie z ustawieniem znacznika pozycji na końcu pliku (AT the END). Jeśli nie ustawimy tej flagi, znacznik zostanie ustawiony na początku pliku.
  • ios::app – plik otwarty z założeniem APPEND, czyli że wszystkie operacje zapisu mają być wykonywane na końcu pliku (dopisywane do niego po całej już istniejącej treści).
  • ios::trunc – jeśli plik jest otwierany do zapisu z założeniem TRUNCATE – czyli jeśli istnieje już taki plik na dysku, to zostanie on usunięty i stworzony na nowo i zastąpiony nowo wprowadzaną treścią.

Użycie open (jak i innych operacji wejścia/wyjścia) niesie za sobą ryzyko wystąpienia wyjątku związanego z błędem dostępu do pliku. Należy o tym pamiętać i obsłużyć je w sposób umożliwiający poprawne działanie programowi w sytuacji krytycznej. Występujące błędy (wyjątki są obsłużone w ramach klasy – stąd nie ma konieczności używania klauzuli try/catch) powodują ustawienie flag błędów operacji, i przybierają następujące formy:

  • bad() – zwraca true, jeśli operacja wejścia/wyjścia zawiodła np. gdy próbujemy zapisać do pliku, podczas gdy otwarliśmy go jedynie do odczytu, albo jeśli na nośniku, gdzie znajduje się plik zabrakło wolnego miejsca.
  • fail() – zwraca true, jeśli bad() zwróciłby true oraz np. w sytuacji, gdy format odczytu nie zgadza się z oczekiwanym typem – np. odczytaliśmy litery, a oczekiwaliśmy liczby.
  • eof() – zwraca true, jeśli znacznik pozycji w pliku osiągnie jego koniec, czyli… nie ma więcej danych do odczytania.
  • good() – zwraca true, jeśli wszystko z operacjami na pliku jest “OK”, czyli zwróci false jeśli któraś z powyższych operacji (jedna lub więcej) zwróciłaby true.

Znajomość powyższych modyfikatorów trybu i flag stanu jest w zasadzie niezbędna, aby poprawnie podejść do obsługi plików. W wymienionych na początku klasach istnieje o wiele więcej przydatnych metod, stąd należałoby się z nimi zaznajomić przeglądając ich dokumentacje (podpięte linki pod nazwy klas). Jeśli mamy to już za sobą, możemy z czystym sumieniem podejść do pierwszego programu.

#include <fstream>
int main()
{
 std::fstream plik;
 std::string nazwa="plik.txt";
 plik.open(nazwa,std::ios::out);

 if (plik.is_open()) plik << "Ciąg tekstowy, jaki chcemy zapisać do pliku";

 if (plik.is_open()) plik.close();
 return 0;
}

W zamieszczonym wyżej kodzie z premedytacją nie ma użytego polecenia: using namespace std; w celu lepszego zobrazowania zakresu zawierania się poszczególnych elementów w strukturach.
Opisując krok po kroku jego działanie można wyszczególnić:

  • linię koniecznego dołączenia biblioteki fstream,
  • powiązanie nazwy zmiennej plik z typem (klasą) fstream;
  • stworzenie połączenia z plikiem fizycznym (uchwyt pliku) metodą open i otwarcie obsługi pliku do zapisu (ios::out),
  • sprawdzenie czy plik jest otwarty; i jeśli tak – zapis do niego ciągu znakowego,
  • sprawdzenie czy plik jest otwarty; i jeśli tak – to zamknięcie pliku (zwolnienie uchwytu pliku).

Można użyć stwierdzenia, że przedstawiona wyżej struktura jest referencyjnym podejściem do tematu obsługi plików. Plik otwarty (aktywne powiązanie [uchwyt] z plikiem) należy po zakończeniu na nim operacji zamknąć metodą close(). W większości przypadków nie zamknięcie pliku w sposób jawny nie wygeneruje błędów, jednakże takowe mogą się pojawić w przypadku używania wielodostępu do pliku.

Rezultatem działania programu winno być stworzenie pliku plik.txt na dysku w katalogu naszego projektu i zapisanie do niego ciągu tekstowego o treści: Ciąg tekstowy, jaki chcemy zapisać do pliku.

Odczyt tak zapisanej treści odbywa się analogicznie, z tym, że i tutaj występują pułapki wymienione już przy okazji obsługi klawiatury – bowiem najprostsze podejście do odczytu z pliku jest analogią użycia strumienia cin w przypadku klawiatury – trzeba wiedzieć, że jest; i że… nie należy go używać w ten sposób (chyba, że z jakiś względów jest to głęboko przemyślane i celowe). Przetestujmy poniższy przykład:

#include <iostream>
#include <fstream>
int main()
{
 std::fstream plik;
 std::string nazwa="plik.txt", zdanie="";
 plik.open(nazwa,std::ios::out | std::ios::in);
 
 if (plik.is_open() && ! plik.eof()) plik >> zdanie;
 std::cout << zdanie;
 
 if (plik.is_open()) plik.close();
 return 0;
}

Jaki jest wynik działania powyższego przykładu?! Zaskakujący?? Bierze się to z analogicznego sposobu obsługi bufora wejściowego jak to było przy odczycie z klawiatury. Właściwszym będzie użycie w przypadku plików tekstowych metody getline(); gdzie jako strumień wejściowy podamy nasz uchwyt pliku, czyli jak poniżej:

#include <iostream>
#include <fstream>
int main()
{
 std::fstream plik;
 std::string nazwa="plik.txt", zdanie="";
 plik.open(nazwa,std::ios::out | std::ios::in);
	
 if (plik.is_open() && ! plik.eof()) getline(plik,zdanie);
 std::cout << zdanie;
	
 if (plik.is_open()) plik.close();
 return 0;
}

I jak teraz wygląda działanie naszego programu? Odczytał cały tekst, ale… wyświetlił go z błędami wynikającymi z użycia polskich liter, a w pliku przecież jest “dobrze” (podglądaliśmy plik po jego stworzeniu pierwszym programem, prawda?!). Wiąże się to z wspomnianym już problemem polskich znaków w ramach stron kodowych w konsoli i jest w ten sposób również rozwiązywany.

Wymieniony wyżej sposób obsługi plików wskazuje poprzez wzgląd na ich zawartość, że jest to obsługa plików tekstowych. Większość jednak projektów informatycznych wymaga użycia plików binarnych, czyli takich, które mogą zawierać dane nieinterpretowalne jako ciąg tekstowy. Przykładem jest np. zapis do pliku liczby 67856321234, którą oczywiście możemy zapisać jako ciąg znaków ‘6’, ‘7’, ‘8’, ‘5’, ‘6’, ‘3’, ‘2’, ‘1’, ‘2’, ‘3’, ‘4’, ale chyba nie trzeba szukać usilnie problemów jakie się z tym wiążą – przede wszystkim problemy z ustawicznym wymogiem parsowania ciągu do właściwego typu liczbowego, czy też fakt, że ta liczba “mieści się” w zakresie int, który zajmuje tylko 4 bajty, a w postaci tekstowej zajmuje ona aż 11 bajtów.

Jeśli jednak powyżej wymienioną liczbę zapiszemy do pliku jako wartość typu int, to próba jej interpretacji jako tekstu spotka się nie tyle z błędem, co z absurdalnym ciągiem pobranym z pliku: “ŇJŤĚ”. Teoretycznie odczytaliśmy zapisaną liczbę jako ciąg znakowy, ale czy on coś znaczy?!

Jako ciekawostkę – spróbuj samodzielnie stworzyć powyżej opisany program, zapisać (binarnie!!!) zmienną typu int do pliku i sprawdzić co się w nim znajduje. Spróbuj również do tej zmiennej przypisać po prostu wartość 65 i… zapisać ją do pliku, a także w kolejnej próbie wartość: 6384705. A co znaczy liczba typu long long o wartości 117 815 654 322 500 973 187 132 4811Brakło zakresu aby zmieścić taką liczbę? Hmmm… może potrzebujesz biblioteki InfInt dla większych liczb?! . Podejrzyj plik. Co otrzymałeś?? Wiesz dlaczego? Zastanów się nad tym.

Przedstawiona wyżej sytuacja uzmysławia nam, że fakt zapisu do pliku w postaci binarnej wymusza na nas poprawne podejście do odczytu tego pliku i konsekwencję z podejściem do typów danych zapisanych, a następnie odczytywanych z pliku.

Jeśli jednak w powyższych próbach pomimo wysiłku zapisu w postaci binarnej cały czas tworzy Ci się plik tekstowy (podejrzany w notatniku ewidentnie zawiera liczbę wpisaną w postaci tekstowej) spróbuj innego podejścia do zapisu binarnego, adekwatny przykład zamieszczony jest nieco niżej.

Dlaczego tak się dzieje?! Po pierwsze – domyślnym sposobem zapisania do pliku danych ze strumienia jest podejście tekstowe. Za pierwszym sposobem użycie std::ios::binary nie zmienia sposobu zapisu na notację binarną, a jedynie wymusza binary mode, to znaczy tryb binarnej (equal to) zgodności danych odczytanych z danymi uprzednio zapisanymi do pliku. Co więc to znaczy?! Dla przykładu można wziąć pod rozważanie znak końca linii – dla systemów opartych na MS Windows jest on kompozycją dwuznakową \n\r (#10#13 dec lub 0A0D hex) W różnych systemach jednak ten sam znak końca linii może składać się jedynie z \n (#10 dec lub 0A hex). W przypadku pierwszego podejścia odczytamy z pliku wartość odpowiadającą znakowi końca linii w naszym systemie, przy podejściu binarnym odczytamy dokładnie to co zostało zapisane do pliku w systemie źródłowym.

Zadanie: Sprawdź – odczytując oba pliki – ten stworzony nie w trybie binarnym i ten w trybie binarnym, czy różnią się czymś – odczytując je bajt po bajcie (sprawdź w dokumentacji klas, jaka metoda Ci to umożliwi), porównując kolejne bajty ze sobą.

#include <fstream>
int main()
{
 std::ofstream plik;
 std::string nazwa="plik.txt";
 plik.open(nazwa,std::ios::out|std::ios::binary);
 int liczba=564737;
 if (plik.is_open())  plik.write(( const char * ) & liczba, sizeof(liczba));
 if (plik.is_open()) plik.close();
 return 0;
}

Co teraz uzyskałeś? Spróbuj podejrzeć plik za pomocą notatnika, a także napisać zmodyfikowany program, by poprawnie odczytać z takiego pliku wartości.

Co zmienił ten przykład, że dane zapisują się faktycznie w postaci binarnej?! Zapisujemy dane za pomocą rzutowania naszej liczby na wirtualną (tymczasową) strukturę (const char *) stworzoną w celu umożliwienia przepisania danych bajt po bajcie spod adresu wskazywanego przez utworzony w tym celu wskaźnik (temat wskaźników poruszymy niebawem). Wiemy, że int zajmuje 4 bajty i tyle (sizeof(liczba) lub w tym przypadku może być też sizeof(int)) wysyłamy spod adresu wskazanego przez wskaźnik do pliku.

Zadania do samodzielnego wykonania:

Zadanie 1: Napisz program, który będzie edytorem tekstowym, tzn. w którym będziesz pobierał z klawiatury zdania i zapisywał je we wskazanym pliku tekstowym. Zdanie, które będzie się składało jedynie z kropki: “.” będzie komendą zakończenia edycji i zamknięcia pliku. Następnie otwórz ten plik i wypisz wszystkie zdania w nim zapisane. Spodziewamy się dokładnej kopii wszystkich treści BEZ ostatniej linii komendy “stop” czyli naszego ciągu znakowego z samą kropką “.

Zadanie 2 (kompleksowe!): Napisz program, który będzie składował do pliku dane o książkach takie jak: numer kolejny (long?), sygnatura (string?), Autor książki (string?), Tytuł książki (string?), ISBN (string?), Wydawnictwo (string?), Rok wydania (short?) itp. Postaraj się napisać program w ten sposób, aby miał menu wyboru:
Dodaj książkę
Listuj książki
Wyszukaj książkę (po autorze, tytule itp.)
Usuń książkę
Zmierz się z zagadnieniem programu obsługi biblioteki.

Bufory i Synchronizacja

Należy pamiętać, że pomiędzy naszym programem, a fizycznym plikiem mogą znajdować się bufory streambuf co może i komplikuje przepływ danych pomiędzy zamysłem programisty w programie, a fizycznym plikiem. Może taka sytuacja powodować rozbieżność pomiędzy tym co wygenerował program do zapisu w pliku, a tym co fizycznie zostało w nim zapisane. Jednym ze sposobów synchronizacji jest opróżnianie buforów, co jest wykonywane przy przetwarzaniu następujących wydarzeń:

  • Gdy plik jest zamykany: zanim plik fizyczny zostanie odłączony od uchwytu, wszystkie bufory uczestniczące w jego przetwarzaniu zostają opróżnione, a dane z nich zapisane na jego fizycznym medium.
  • Gdy bufor ulega przepełnieniu: Bufory mają swoją wielkość, w momencie wypełnienia bufora następuje zrzut (ang: dump) danych na nośnik fizyczny.
  • Na żądanie, z użyciem manipulatorów: Kiedy właściwe manipulatory zostaną użyte następuje zrzut danych do pliku fizycznego, są nimi np.: flush oraz endl.
  • Na żądanie po użyciu metody sync(): Wywołanie metody sync() powoduje natychmiastowe uruchomienie synchronizacji. Metoda zwraca wartość int równą -1 jeśli strumień nie posiada skojarzonego bufora, lub w przypadku błędu. W przeciwnym przypadku zwracaną wartością jest 0.

Metodami przydatnymi w ramach obsługi plików są metod umożliwiające odczyt i modyfikację wskaźnika pozycji (miejsca w którym dokonana zostanie następna operacja na pliku np. zapis, lub odczyt) czyli:

Funkcje (metody): tellg() oraz tellp() wywoływane bez podania parametrów zwracają aktualną pozycję znacznika pozycji w pliku (streampos), odppowiednio dla get position (tellg) lub put position (tellp).

Funkcje (metody) seekg() oraz seekp() pozwalają na zmianę wartości znacznika pozycji w pliku poprzez użycie:

seekg ( pozycja );
seekp ( pozycja );

lub innym sposobem odwołując się do tzw offsetu:

seekg ( offset, kierunek );
seekp ( offset, kierunek );

Używając takiego sformułowania kierunek jest określany wg. poniższych wartości:

  • ios::beg offset liczony od początku strumienia
  • ios::cur offset liczony od obecnej pozycji
  • ios::end offset liczony od końca pliku