Turbo Pascal. Programowanie

Turbo Pascal.
Programowanie

Autor: Tomasz M. Sadowski
Format: B5, stron: 136

Copyright © 1996 by Wydawnictwo Helion
wersja spakowana

helion.pl

Pliki, czyli jak uchronić dane przed zgubą

Pamięć operacyjna, wykorzystywana do przechowywania danych przetwarzanych przez program, ma dwie zasadnicze wady: ograniczoną pojemność i ulotność (to poetyckie słowo oznacza po prostu, że zawartość pamięci jest tracona w chwili wyłączenia zasilania). Komputer umożliwiający przechowywanie danych wyłącznie podczas włączenia do sieci byłby raczej mało użyteczny, dlatego też projektanci sprzętu opracowali szereg urządzeń - tzw. pamięci masowych - pozwalających na trwałe przechowywanie danych. Rolę pamięci masowej w komputerze osobistym pełnią dyskietki oraz dyski twarde (oba rozwiązania wykorzystują identyczną metodę zapisu i różnią się rozwiązaniami technologicznymi). Z logicznego punktu widzenia, dane zapisywane na dyskach organizowane są w pliki, a te z kolei przechowywane są w katalogach. Całością zarządza system operacyjny, z którego usług korzystają programy użytkowe.

Rzecz jasna, również i Turbo Pascal dysponuje możliwością korzystania z plików. Z trzech dostępnych w Pascalu rodzajów plików - elementowych (jednorodnych), tekstowych i amorficznych - omówimy dwa pierwsze, mające największe zastosowanie w praktyce.

Na początek nieco teorii. Sam plik jest pewną strukturą danych zapisaną na dysku i identyfikowaną za pomocą nazwy (ściślej - ścieżki dostępu). Dane przechowywane w pliku mogą mieć reprezentację binarną (taką samą, jak w pamięci komputera) lub tekstową (taką, jaka używana jest do wprowadzania informacji z klawiatury i wyprowadzania jej na ekran monitora lub drukarkę). Reprezentacjom tym odpowiadają w Pascalu pliki elementowe oraz tekstowe.

Odwołania do pliku (zapisy, odczyty i inne operacje) realizowane są przez wywołanie odpowiednich funkcji systemu operacyjnego, który z kolei posługuje się liczbowymi identyfikatorami plików (korzystanie z nazw byłoby niewygodne). Również program pascalowy nie odwołuje się do plików "bezpośrednio", lecz poprzez tak zwane zmienne plikowe, czyli złożone struktury danych reprezentujące fizyczne pliki zapisane na dysku. Ogólny schemat operacji plikowej w Pascalu obejmuje cztery etapy:

Samą zmienną plikową deklaruje się w sposób następujący:

nazwa : file of typ { dla pliku elementowego }
nazwa : text { dla pliku tekstowego }

gdzie typ określa typ elementu składowego pliku i może być dowolnym identyfikatorem typu prostego lub strukturalnego (z wyjątkiem typu plikowego i obiektowego). Zauważ, że pojedynczym elementem pliku elementowego jest nie bajt, lecz właśnie obiekt zadanego typu (co jest dość logiczne). Dlatego też do pliku rekordów (drugi przykład poniżej) możesz wpisywać wyłącznie całe rekordy (zapisywanie lub odczytywanie pojedynczych pól jest niewykonalne), a jego długość będzie zawsze równa wielokrotności rozmiaru pojedynczego rekordu. Podobnie deklaracja pliku, którego elementami składowymi są tablice liczb całkowitych, zmusi Cię do zapisywania i odczytywania całych tablic, gdyż odwołanie się do pojedynczego elementu tablicy będzie niemożliwe. Ten sam plik można oczywiście otworzyć jako plik liczb całkowitych, co pozwoli nam na odczytywanie pojedynczych wartości. W przypadku, gdy plik przechowuje jednorodne dane, deklaracja zmiennej plikowej wykorzystuje na ogół elementy składowe odpowiedniego typu prostego. Dla baz danych, złożonych z rekordów (przechowujących dane różnych typów), jedynym wyjściem jest deklaracja pliku rekordów.

Oto kilka przykładowych deklaracji:

  var
    Probki : file of real; { plik liczb rzeczywistych }
    KatalogNaDysku : file of Ksiazka; { plik rekordów }
    KatalogTekstowy : text; { plik tekstowy }

Dla potrzeb naszego programu bibliotecznego możemy wykorzystać drugi lub trzeci z powyższych przykładów. Najpopularniejszym rozwiązaniem dla baz danych (a nasz program jest właśnie prostą bazą danych) są - jak już powiedziano - pliki rekordów, umożliwiające swobodny dostęp do danych i lepsze zagospodarowanie dysku. Jeśli jednak zależy Ci na czytelności pliku z danymi, możesz wykorzystać reprezentację w postaci pliku tekstowego.

Po zadeklarowaniu odpowiedniej zmiennej plikowej można przystąpić do właściwych operacji związanych z zapisywaniem i odczytywaniem danych. Przede wszystkim musimy skojarzyć zmienną plikową z fizycznym plikiem znajdującym się na dysku. Służy do tego procedura assign:

assign(zmienna-plikowa, nazwa-pliku)

Nazwa-pliku określa tu plik, do którego chcemy się odwoływać (łącznie z ewentualną ścieżką dostępu, czyli nazwą dysku, katalogu i ewentualnych podkatalogów zawierających plik). Po wykonaniu procedury assign wszelkie odwołania do zmiennej plikowej będą dotyczyły skojarzonego z nią pliku (o nazwie którego możemy zapomnieć). Jest to dość istotne, gdyż jednym z błędów często popełnianych przez początkujących programistów jest próba odwoływania się do pliku przez podanie jego nazwy, co jest rzecz jasna nielegalne.

Przykładowe skojarzenie zmiennej plikowej z plikiem może mieć postać

assign(KatalogNaDysku, 'c:\biblio\dane\katalog.dat')

lub (lepiej)

assign(KatalogNaDysku, NazwaPliku)

W drugim przypadku nazwa pliku przekazywana jest jako zmienna, co umożliwia np. wprowadzenie jej z zewnątrz ("zaszycie" nazwy wewnątrz programu zmniejsza jego uniwersalność).

Następnym krokiem jest otwarcie pliku, czyli przygotowanie go do odczytu lub zapisu. Konieczność otwarcia (i późniejszego zamknięcia) pliku wynika z metod obsługi plików przyjętych w systemie operacyjnym, którego funkcje są zresztą w tym celu wykorzystywane. Wymiana informacji pomiędzy plikiem a programem możliwa jest dopiero po otwarciu tego ostatniego.

Dwiema podstawowymi procedurami używanymi w Pascalu do otwierania plików są reset i rewrite:

reset(zmienna-plikowa)
rewrite(zmienna-plikowa)

Procedura reset umożliwia otwarcie już istniejącego pliku, ustawiając tzw. wskaźnik plikowy na jego początku. W przypadku, gdy otwierany plik nie istnieje, wywołanie procedury reset kończy się błędem wykonania. Z kolei rewrite umożliwia otwarcie pliku niezależnie od tego, czy istniał on poprzednio: jeśli nie - tworzy ona nowy plik o danej nazwie, zaś jeśli tak - zeruje długość istniejącego pliku i ustawia wskaźnik plikowy na jego początku (czego efektem jest utracenie wszystkich danych zawartych w pliku). Warto pamiętać, że w przypadku plików tekstowych procedura reset otwiera plik wyłącznie do odczytu, zaś rewrite - wyłącznie do zapisu (nie ma zatem możliwości mieszania odczytów i zapisów w jednym cyklu otwarcia). Zasada ta nie obowiązuje dla plików elementowych, które można odczytywać i zapisywać bez ograniczeń niezależnie od tego, czy zostały otwarte za pomocą procedury reset, czy rewrite (w tym ostatnim przypadku trzeba najpierw zapisać do pliku jakieś dane).

Sam wskaźnik plikowy jest po prostu kolejnym numerem elementu (nie bajtu!) w pliku, przy czym numeracja rozpoczyna się od zera. Każda operacja odczytu lub zapisu powoduje przesunięcie wskaźnika o wartość równą liczbie odczytanych lub zapisanych elementów, przy czym dla plików o dostępie swobodnym (elementowych) możliwe jest również jego dowolne przestawianie (nawet poza koniec pliku, choć ma to mały sens i najczęściej powoduje błędy wykonania).

Trzecią procedurą otwierającą, dostępną wyłącznie dla plików tekstowych, jest Append. Procedura ta otwiera plik do dopisywania, tj. otwiera go do zapisu nie niszcząc poprzedniej zawartości i ustawia wskaźnik plikowy na jego końcu. Umożliwia to dodawanie danych do plików tekstowych, które - jako pliki o dostępie sekwencyjnym - nie umożliwiają programowego przestawiania wskaźnika plikowego.

Do wymiany danych pomiędzy programem a plikiem służą znane nam już procedury read (odczyt) i write (zapis). Ponieważ w "standardowej" wersji obsługują one ekran monitora i klawiaturę, niezbędne jest podanie dodatkowego argumentu określającego plik, z/do którego informacja ma być odczytana lub zapisana. Argumentem tym jest właśnie nazwa odpowiedniej zmiennej plikowej:

read(zmienna-plikowa, lista-elementów)
write(zmienna-plikowa, lista-elementów)

Powyższe operacje odnoszą się zarówno do plików elementowych, jak i tekstowych. Dla tych ostatnich możliwe jest ponadto użycie procedur readln i writeln, odczytujących lub zapisujących dane wraz ze znakami końca wiersza. Ponieważ pliki elementowe przechowują wyłącznie dane określonego typu i nie mogą zawierać znaków końca wiersza, użycie procedur readln i writeln jest w ich przypadku nielegalne. Drugą istotną różnicą jest zawartość listy-elementów. W przypadku plików tekstowych lista ta może zawierać dowolne zmienne, stałe i wyrażenia (gdyż wszystkie zapisywane są w pliku w postaci tekstu), natomiast dla plików elementowych jej składnikami mogą być wyłącznie zmienne odpowiedniego typu. Dzieje się tak dlatego, że plik elementowy może zawierać wyłącznie dane jednego typu, zaś poszczególne elementy listy przekazywane są przez nazwę.

Po wykonaniu żądanych operacji zapisu i odczytu danych plik należy zamknąć. Ta bardzo ważna operacja jest ignorowana przez wielu programistów, co w efekcie prowadzi do przykrych niespodzianek w postaci zgubionych danych. U podłoża całego problemu leży tak zwane buforowanie operacji dyskowych, czyli technika polegająca na odczytywaniu i zapisywaniu danych nie pojedynczo, lecz całymi paczkami, za pośrednictwem specjalnego obszaru pamięci - tzw. bufora dyskowego. Wykorzystanie bufora pozwala na zredukowanie liczby fizycznych odczytów i zapisów na dysku, a przez to zmniejszenie jego mechanicznego obciążenia i poprawę wydajności operacji dyskowych. Ponieważ jednak podczas zapisu zawartość bufora wysyłana jest na dysk dopiero po jego zapełnieniu (lub w chwili zamknięcia pliku), przerwanie wykonywania programu może spowodować utratę danych. Również poprawne zakończenie programu powoduje co prawda automatyczne zamknięcie otwartych plików, nie opróżnia jednak buforów przechowujących nie zapisane jeszcze dane, co może spowodować ich utratę. W przypadku, gdy plik wykorzystywany jest wyłącznie do odczytu danych, niezamknięcie nie powoduje utraty informacji, co nie znaczy, że można je sobie odpuścić, bowiem lenistwo takie zwykle mści się w najmniej stosownych okolicznościach.

Pamiętaj: zamknięcie pliku jest praktycznie jedynym sposobem na bezpieczne zapisanie w nim wszystkich danych.

Na szczęście zamknięcie pliku jest bardzo proste. Realizuje je procedura close:

close(zmienna-plikowa)

Jej wywołanie ma tę samą formę dla wszystkich rodzajów plików.

Tyle teorii. Aby wprowadzić ją w życie, spróbujmy napisać zestaw procedur pozwalających na zapisanie naszego katalogu w pliku dyskowym i odczytanie go z pliku. Zgodnie z tym, co powiedzieliśmy wcześniej, do przechowywania zawartości katalogu wykorzystamy plik elementowy typu file of record.

  procedure ZapiszNaDysku(NazwaPliku : string);
   
  var 
    f : file of Ksiazka;
    i : integer;
   
  begin
    assign(f, NazwaPliku); { skojarz plik ze zmienną plikową }
    rewrite(f); { otwórz (utwórz) plik }
    for i := 1 to LbPoz do { zapisz kolejne rekordy }
      write(f, Katalog[i]);
    close(f); { zamknij plik }
  end;

Powyższa procedura ilustruje typową metodę zapisywania w pliku zawartości bazy danych. Po skojarzeniu pliku z odpowiednią zmienną i otwarciu go zapisujemy kolejne rekordy znajdujące się w tablicy (zwróć uwagę, że rekordy zapisywane są w całości, a nie polami). Po zapisaniu właściwej liczby rekordów zamykamy plik... i to wszystko. Musisz jeszcze zdawać sobie sprawę, że każde otwarcie będzie powodowało utratę poprzedniej zawartości pliku (rewrite!), ale ponieważ przed chwilą niejawnie założyliśmy, że każdorazowo zapisujemy cały katalog, jest to do przyjęcia.

Odczytanie zawartości katalogu z pliku przebiega według nieco innego schematu. Ponieważ nie wiemy, ile rekordów mamy właściwie odczytać, nie możemy zastosować pętli for. Najpopularniejszym rozwiązaniem jest czytanie kolejnych rekordów do momentu napotkania końca pliku, co wykrywane jest przez procedurę eof (ang. end-of-file - koniec pliku). Jak nietrudno się domyślić, tym razem zastosujemy pętlę while (lub repeat):

  procedure OdczytajZDysku(NazwaPliku : string);
   
  var
    f : file of Ksiazka;
    i : integer;
   
  begin
    assign(f, NazwaPliku); { skojarz plik ze zmienną plikową }
    reset(f); { otwórz plik (musi istnieć!) }
    i := 0; { wyzeruj licznik rekordów }
    while not eof(f) do { czytaj aż do końca pliku }
      begin
        Inc(i); { kolejny rekord }
        read(f, Katalog[i]);
      end;
    LbPoz := i; { wczytano tyle rekordów }
      close(f);
  end;

Pozostałe operacje wykonywane w procedurze są praktycznie takie same, jedynie do otwarcia pliku wykorzystujemy tym razem procedurę reset. Zauważ, że konstrukcja procedur ZapiszNaDysku i OdczytajZDysku przewiduje przekazanie nazwy pliku jako parametru, co z kolei pozwala na ich użycie bez konieczności każdorazowego komunikowania się z użytkownikiem (nazwę można "zaszyć" w programie jako stałą). Jednym z możliwych (i często stosowanych) rozwiązań kwestii zapamiętywania danych jest każdorazowe zapisywanie całej bazy w chwili zakończenia programu i odczytywanie jej zaraz po uruchomieniu. W tym celu wystarczy na początku części operacyjnej (przed wywołaniem menu) wstawić instrukcję

OdczytajZDysku(PLIK_KATALOGU);

zaś przed samym końcem programu dopisać

ZapiszNaDysku(PLIK_KATALOGU);

przy czym stałą PLIK_KATALOGU należy wcześniej zdefiniować jako np.

PLIK_KATALOGU = 'Katalog.dat'

Metoda ta pozwala na każdorazowe zapamiętywanie treści katalogu po zakończeniu programu i jej odtwarzanie na początku kolejnej sesji bez konieczności podawania nazwy pliku (uwaga: przed pierwszym uruchomieniem programu musisz utworzyć pusty plik o nazwie KATALOG.DAT, w przeciwnym przypadku próba otwarcia nie powiedzie się). Innym sposobem jest uzupełnienie menu o polecenia zapisu i odczytu danych, co jednak wiąże się z koniecznością wywoływania odpowiednich poleceń. Jeśli wreszcie chcesz dać użytkownikowi możliwość zmiany nazwy pliku z danymi, musisz uzupełnić program o odpowiednie instrukcje wczytujące ją z klawiatury.

Do przechowywania danych można również wykorzystać plik tekstowy, jednak operacje odczytu i zapisu będą nieco bardziej skomplikowane. Wymiana danych z plikiem tekstowym odbywa się tak samo, jak z monitorem i klawiaturą, a więc poszczególne pola rekordów należy zapisywać i odczytywać indywidualnie. W zamian za to uzyskujemy możliwość łatwego obejrzenia treści pliku (gdyż zawiera on wyłącznie tekst), a także skierowania (lub odczytania) danych do (z) jednego ze standardowych urządzeń wyjścia (wejścia) obsługiwanych przez system operacyjny (np. drukarki). Oto przykład procedury zapisującej katalog do pliku tekstowego (operację odczytu możesz zrealizować w ramach ćwiczeń):

  procedure ZapiszNaDysku(NazwaPliku : string);
   
  var
    f : text; { tym razem plik tekstowy }
    i : integer;
   
  begin
    assign(f, NazwaPliku); { skojarz plik ze zmienną plikową }
    rewrite(f); { utwórz plik }
    for i := 1 to LbPoz do { zapisz kolejne rekordy }
      with Katalog[i] do { wyprowadź poszczególne pola rekordu }
        begin
          writeln(f, 'Pozycja katalogu: ', i);
          writeln(f, 'Tytul: ', Tytul);
          writeln(f, 'Autor: ', Autor);
          if Wypozyczajacy = '' then { nikt nie wypożyczył }
            writeln(f, 'Ksiazka znajduje sie na polce.')
          else { pole zawiera nazwisko wypożyczającego }
            writeln(f, 'Wypozyczajacy: ', Wypozyczajacy);
          writeln(f);
        end;
    close(f); { zamknij plik }
  end;

Sposób zapisania treści rekordu do pliku przypomina procedurę WypiszDane (zobacz poprzedni rozdział), z tym, że każda instrukcja write(ln) uzupełniona jest o specyfikację zmiennej plikowej, np.

writeln(f, 'Autor: ', Autor);

Odczytywanie danych z plików tekstowych nastręcza nieco więcej kłopotów, zwłaszcza gdy plik zawiera informacje mieszanych typów (tekst i liczby). Ceną za "luźny" format zapisu jest najczęściej konieczność tworzenia skomplikowanych procedur realizujących konwersje typów i sprawdzanie poprawności danych.

Na szczęście pliki tekstowe posiadają również zalety (jedną z nich jest właśnie czytelny format). Ponieważ standardowe urządzenia wyjściowe są w systemie DOS obsługiwane tak samo, jak pliki tekstowe, aby wyprowadzić treść katalogu na ekran (tzw. konsolę operatorską), wystarczy następujące wywołanie:

ZapiszNaDysku('con');

zaś aby wydrukować katalog na drukarce, musisz użyć wywołania

ZapiszNaDysku('prn');

gdzie con i prn są nazwami systemowych urządzeń wyjścia (konsoli i drukarki).

Na koniec wrócimy na chwilę do plików elementowych, by powiedzieć kilka słów o metodach realizowania swobodnego dostępu do danych. Bieżący element pliku (tj. element, którego dotyczyła będzie kolejna instrukcja read lub write) wskazywany jest przez wspomniany już wskaźnik plikowy. Wywołanie

Seek(zmienna-plikowa, numer-elementu)

powoduje ustawienie wskaźnika plikowego tak, by kolejna operacja odczytu lub zapisu rozpoczęła się od elementu danego numerem-elementu (elementy numerowane są od zera). Dodatkowymi funkcjami wykorzystywanymi podczas manipulowania wskaźnikiem plikowym są FilePos, zwracająca jego bieżącą wartość, oraz FileSize, zwracająca rozmiar pliku. Tak więc - zakładając, że f jest plikiem typu file of integer - wywołanie

Seek(f, FileSize(f));

ustawi wskaźnik plikowy za ostatnim elementem pliku (czyli przygotuje plik do dopisywania), zaś konstrukcja

  for i := 1 to 10 do
    begin
      Seek(f, random(FileSize(f));
      read(f, i);
      writeln(i);
    end;

odczyta z pliku dziesięć losowo wybranych liczb (funkcja random(k) zwraca liczbę pseudoprzypadkową z zakresu od zera do k) i wyświetli je na ekranie.

Swobodny dostęp do danych, chociaż bardzo przydatny, nie powinien być nadużywany. Wszelkie operacje na plikach są znacznie bardziej czasochłonne, niż operacje na danych umieszczonych w pamięci, zaś operacje w trybie sekwencyjnym są znacznie szybsze, niż w trybie dostępu swobodnego. Niezależnie od tych uwag musisz pamiętać, że wszelkie manipulacje na zawartości plików wiążą się z ryzykiem utraty danych w przypadku awarii systemu (np. wyłączenia zasilania). Dlatego też pliki należy zamykać bezpośrednio po wykonaniu niezbędnych czynności.

Zapamiętaj

Poprzedni | Spis treści | Następny | Wersja spakowana |