Wstęp do programowania/Pliki

From Studia Informatyczne

<<< Powrót

Spis treści

Pliki

Wprowadzenie

Jak wiadomo, architektura powszechnie stosowanych komputerów charakteryzuje się tym, że pamięć operacyjna jest ulotna, a jej rozmiar jest niewystarczający do przechowywania dużej liczby danych. W związku z powyższym komputery wyposażane są w pamięci masowe (zewnętrzne) pozwalające na trwały zapis danych o dużych rozmiarach. W rozważaniach teoretycznych często przyjmuje się, że rozmiar pamięć masowej jest nieograniczony.

Konstrukcja nośników danych wykorzystywanych do realizacji pamięci masowych sprawia, że dane zapisywane są i odczytywane w postaci wielobajtowych bloków. Blok danych zgodny z fizyczną charakterystyką danego użadzenia zwany jest rekordem fizycznym. Typowy rozmiary rekordu fizycznego mieści się w zakresie od 512 bajtów do kilku kilobajtów.

Fizyczne właściwości urządzenia na jakim składowane są dane nie powinny rozpraszać uwagi programisty. Działanie programu nie może być zależne od rodzaju nośnika. W związku z tym na poziomie systemu operacyjnego wprowadzono pojęcie pliku. Plik jest logiczną jednostką przechowywania danych. Aby cokolwiek przechować w pamięci masowej, trzeba utworzyć co najmniej jeden plik. Warto w tym miejscu wspomnieć, że nie ma jednej uniwersalnej metody realizacji pojęcia pliku w systemie operacyjnym. Pliki realizowane są w strukturach zwanych systemami plików, które różnie wyglądają w różnych systemach operacyjnych. Często też w obrębie danego systemu operacyjnego mamy do wyboru wiele systemów plików o różnych właściwościach.

Model mentalny pliku

W pewnym uproszczeniu każdy plik możemy wyobrazić sobie jako ciąg kolejnych elementów. Za ostatnim znajduje się znacznik końca pliku. Element który zostanie przewożony przy kolejnym odczycie lub zapisie wskazywany jest przez wskaźnik pliku stojący na początku tego elementu. Proszę jednak pamiętać, że jest to bardzo uproszczony model mentalny pliku, bo choćby wskaźnik końca może być realizowane na różne sposoby. Czasami jest to wyróżniona wartość (inna niż dowolna przechowywana w pliku informacja) zapisana na końcu pliku, a w innych rozwiązaniach informacja o końcu pliku zapisana jest w strukturze systemu zbiorów zwanej FAT (file allocation table).

Grafika:WDP-model-pliku.jpg

Typy plików

Ze względu na organizacje danych wewnątrz pliku (na poziomie logicznym), będziemy rozróżniać trzy typy plików: pliki binarne, pliki tekstowe i pliki ogólne.

Pliki binarne

Plik binarny to po prostu ciąg bajtów. Inaczej można powiedzieć, że jest to plik, w którym rekord logiczny ma rozmiar jednego bajta. Dane zawarte w pliku binarnym zawsze traktowane są jak ciąg bajtów, bez względu na rodzaj zapisanej w pliku informacji. Co więcej każdy plik możemy potraktować jak plik binarny i w pewnym sensie „ręcznie” (nie zdając się na system operacyjny) interpretować jego zawartość bajt po bajcie.

Pliki ogólne

Plik ogólny podzielony jest na mniejsze/równe porcje zwane rekordami logicznymi. Generalnie plik ogólny możemy wyobrazić sobie jako zbiór rekordów logicznych ułożonych jeden za drugim podobnie jak komórki w tablicy. W języku Pascal mamy nawet pewne podobieństwa w składni deklaracji typu tablicy i pliku ogólnego. W obydwu przypadkach nazwa typu składowego występuje po słowie kluczowym of. (array[1..n] of integer; file of integer;)

Elementami składowymi pliku nie mogą być inne pliki ani wskaźniki (patrz następny wykład).

Rozmiar rekordu logicznego nie musi być równy rozmiarowi rekordu fizycznego. W związku z tym do manipulowania danymi w pliku potrzebny jest wydzielony obszar pamięci operacyjnej zwany buforem, w którym rekordy fizyczne „przerabiane” są na rekordy logiczne. Bufor stosowany jest również w pozostałych typach plików z uwagi na efektywność zapisu. Jest ona większa, gdy zapis lub odczyt obywa się odpowiednio dużymi porcjami będącymi całkowitą wielokrotnością rekordu fizycznego. Zatem jeśli chcemy pobrać z pliku tylko małą ilość informacji, choćby jeden bajt, do bufora zostanie ściągnięta większa ich porcja: zazwyczaj od kilkuset do kilkunastu tysięcy bajtów i próba odczytu kolejnego bajtu nie będzie już wymagała drogiej komunikacji z urządzeniem zewnętrznym. Zakładamy bowiem, że typowe przetwarzanie plików polega na pobieraniu informacji kolejno bajt po bajcie i wtedy dodatkowe czynności związane z transmisją danych są tak kosztowne, że warto na zapas ściągnąć większą porcję danych, co średnio na pewno się opłaci, nawet jeśli w jakimś konkretnym przypadku tych dodatkowych danych nie wykorzystamy. Podobnie przy zapisywaniu danych program czeka, aż bufor się wypełni i dopiero gdy uzna, że już jest ich wystarczająco dużo, żeby zainicjować fizyczny transfer, uruchomi odpowiednie procedury. Jedną z ról zamknięcia pliku jest wyczyszczenie bufora wyjściowego, czyli spowodowanie wymuszonego transferu danych, które czekały w buforze na transmisję. Często w drukarkach wierszowych użytkownicy skarżą się, że ostatni wiersz się im nie wydrukował i że przy wyłączaniu drukarki nagle się coś jeszcze drukuje. Jest to nic innego jak wydrukowanie wiersza zalegającego w buforze drukarki.

Prędkość działania urządzeń zewnętrznych jest o kilka rzędów wielkości mniejsza niż pamięci operacyjnej. Jest bowiem często związana z fizycznym ruchem pewnego urządzenia (głowicy), a tu nie mamy aż takich możliwości przyspieszania jak w przypadku pamięci rdzeniowych. Mechanika rządzi się swoimi prawami.

Pliki tekstowe

Pliki tekstowe zasługują na szczególną uwagę, gdyż sposób kodowania informacji w tych plikach jest najbardziej rozpowszechnionym standardem, a także dlatego, że w wielu językach programowania stanowią one osobny wyróżniony typ danych z dodatkowym zestawem poleceń. Najprościej mówiąc, plik tekstowy to ciąg znaków z podziałem na wiersze. Można powiedzieć, że podstawową jednostką danych w pliku tekstowym jest jeden znak, co w plikach zakodowanych w ASCII przekłada się na 1 bajt. Ale nie zawsze jeden znak to 1 bajt. W standardzie unicode jeden znak ma dwa bajty, a dodatkowo w standardzie tym występują sekwencje kilku bajtów oznaczające jeden znak. Również koniec wiersza kodowany jest na różne sposoby. W systemie Mac OS X jest to znak powrotu karetki, w systemie UNIX znak przejścia do nowego wiersza, a w systemie MS Windows koniec wiersza oznaczony jest dwoma znakami przejściem do nowego wiersza i powrotem karetki.

Rodzaje dostępu do pliku

Budowa pamięci masowej może też powodować ograniczenia w sposobie dostępu do poszczególnych części pliku. Ze względu na te ograniczenia pliki możemy podzielić na pilik sekwencyjne i pliki o dostępie bezpośrednim.

Pliki o dostępie sekwencyjnym

Pliki o dostępie sekwencyjnym charakteryzują się ograniczoną swobodą poruszania się po rekordach. Aby przeczytać k-ty rekord trzeba przeczytać poprzedzające go k-1 rekordów. Dodatkowo możemy przeskoczyć na początek albo na koniec pliku, ale nigdy w dowolne miejsce wewnątrz pliku.

Pliki o dostępie bezpośrednim

Pliki o dostępie bezpośrednim charakteryzują się tym, że rekordy logiczne są ponumerowane i można odczytywać je w dowolnym porządku dzięki operacji pozwalającej na przeskoczenie do rekordu o podanym numerze. W tego rodzaju plikach wszystkie rekordy logiczne muszą mieć taki sam rozmiar. Pliki o dostępie bezpośrednim realizuje się na takich urządzeniach, w których łatwo można się fizycznie dostać do dowolnego miejsca, niezależnie od jego położenia. Przykładami takich urządzeń są twarde dyski, pamięci flash.

Przetwarzanie plików

Generalnie każde przetwarzanie pliku odbywa się według schematu:

1. Otwarcie pliku 
2. Operacje na danych w pliku
3. Zamknięcie pliku.

Otwarcie pliku

W momencie otwarcia pliku system tworzy deskryptor pliku (jeżeli wcześniej plik nie został otworzony), w którym przechowywane są między innymi takie informacje jak: określenie urządzenia pamięci masowej, na którym znajduje się plik, nazwa pliku, położenie bufora do przesyłania danych z pliku i do pliku oraz inne informacje. W programie odwołujemy się do pliku przez zmienną, w której umieszczony jest wskaźnik (uchwyt) do deskryptora pliku. W Pascalu operacja otwarcia pliku rozbita jest na dwie instrukcje. Pierwsza Assign(f, nazwa_pliku) wiąże zmienną f z plikiem o podanej nazwie. Tworzony jest deskryptor i umieszczany wskaźnik do niego w zmiennej f. Następnie drugą instrukcją plik otwierany jest od odczytu reset(f), zapisu rewrite(f) lub dopisywania danych append(f); Reset ustawia wskaźnik pliku na początku i przygotowuje plik do odczytu. Rewrite kasuje zawartości pliku (wskaźnik jest na początku) i przygotowuje plik do zapisu. Append nie kasuje zawartości pliku, ustawia wskaźnik na końcu pliku i przygotowuje plik do dopisania kolejnych elementów.

Operacje na danych w pliku (odczyt lub zapis)

W Pascalu jest to odpowiednio instrukcja read(f, zmienna), która powoduje odczyt danych z bieżącego rekordu logicznego (w pliku ogólnym) do zmiennej i przesuniecie wskaźnika pliku na początek kolejnego rekordu logicznego. W przypadku pliku tekstowego zachowanie instrukcji read jest zależne od typu zmiennej. Dla zmiennej typu char odczytywany jest jeden znak, a wskaźnik pliku przesuwa się do następnego zanku. Jeżeli zmienna jest typu liczbowego wtedy następuje odczytanie ciagu znaków odpowiadającego składniowo jednej liczbie danego typu. Jeżeli zmienna jest typu string, to odczytywany jest ciąg znaków, aż do końca bieżącego wiersza, a wskaźnik pliku zatrzymuje się przed znakiem końca wiersza. Aby przetworzyć znak końca wiersza, należy wykonać polecenie readln(f). Po jego wykonaniu wskaźnik pliku ustawia się na początku kolejnego wiersza. Aby zapisać informacje w pliku należy użyć polecenia write(f, zmienna). Polecenie to spowoduje zapisanie zawartości zmiennej do pliku i ustawi wskaźnik pliku na końcu zapisanego rekordu (w pliku ogólnym). W przypadku pliku tekstowego liczba zapisanych znaków zależna jest od typu zmiennej. I tak odpowiednie dla typu char zapisze się jeden znak, a wskaźnik pliku ustawi się za zapisanym znakiem. Dla typu liczbowego zapisze się ciąg znaków opowiadający danej liczbie. Dla typu string zapisane zostaną wszystkie znaki znajdujące się w zmiennej, a wskaźnik pliku ustawi się za ostatnim z zapisanych znaków. Aby zapisać znacznik końca linii, należy użyć polecenia writeln(f).

Zamknięcie pliku

W Pascalu zamknięcie pliku realizowane jest poleceniem Close(f). Uwaga, należy pamiętać o zamknięciu pliku nie tylko ze względu na zwolnienie pamięci zajmowanej przez deskryptor i bufor danych. Zamknięcie pliku ważne jest też ze względu na opóźniony zapis. Opóźniony zapis ma na celu zwiększenie efektywności zapisu danych w pamięci masowej. Polega on na tym, że dane z bufora nie są zapisywane dopóki nie zgromadzi się na tyle duża porcja, by zapis był efektywny (np. porcja równa rozmiarowi rekordu fizycznego). Wiąże się z tym niebezpieczeństwo utraty danych, jeżeli plik nie zostanie poprawnie zamknięty. Wtedy mogą zostać utracone dane, które gromadziły się w buforze i nie zostały jeszcze zapisane. Zamknięcie piku gwarantuje zapisanie danych zalegających w buforze. Czasami twórcy systemu operacyjnego stosują zabezpieczenie, które polega na tym, że w momencie gdy program kończy swoje działanie, automatycznie zapisywane są dane z buforów dla wszystkich niezamkniętych plików używanych przez dany program. Podobne rozwiązanie stosowano kiedyś w drukarkach igłowych, co przejawiało się dosyć zaskakującym zachowaniem drukarki, która czasami w momencie wyłączania, ku zaskoczeniu użytkownika, dodrukowywała brakującą część wiersza.

Dostęp bezpośredni

Warto jeszcze wspomnieć, że w plikach ogólnych i binarnych o dostępie bezpośrednim możliwe jest bezpośrednie przejście do dowolnego rekordu logicznego. Operacje tę realizuje polecenie seek(f, N). Powoduje przejście do rekordu o numerze N, przy czym pierwszy rekord w pliku ma numer 0. Wykonanie instrukcji seek(f, 0) jest równoważne instrukcji Reset(f). Operacja seek nie może być stosowana w plikach tekstowych.

Pliki szczególne input i output

W większości języków programowania definiuje się szczególne pliki: standardowe wejście i wyjście, które w momencie uruchomienia programu są otwarte i gotowe do pracy. Standardowe wejście nazywa się input, a standardowe wyjście output; pierwsze z nich otwarte jest do czytania, drugie do pisania. Plik input jest związany z klawiaturą, a plik output z monitorem ekranowym. Jeśli chcemy przeczytać coś ze standardowego pliku wejściowego, albo wypisać coś do standardowego pliku wyjściowego, to nie musimy ani otwierać ich, ani nawet nazywać w instrukcjach czytania i pisania. Zakładamy, że jeśli w instrukcjach Read/Write nie pojawi się wśród argumentów jako pierwsza zmienna plikowa, to mamy do czynienia z czytaniem/pisaniem w plikach standardowych, tak jak to się dzieje w VIPie.

Przykład przetwarzania plików ogólnego lub binarnego

Przykład

var
  f:file of integer;
  zmienna:integer;
begin
  Assign(f, nazwa_pliku);
  Reset(f);
  while not Eof(f) do
    begin
      Read(f, zmienna);
      Przetwórz(zmienna)
    end;
  Close(f);
end.

Przykład przetwarzania plików tekstowych

Przykład

var
  f:text;
  ch:char;
begin
  Assign(f, nazwa_pliku);
  Reset(f);
  while not Eof(f) do
    begin
      while not Eoln(f) do
        begin
          Read(f, ch)
          Przetwórz(ch)
        end;
      if not Eof(f) then Readln(f);
    end;
  Close(f);
end.

Powyższy fragment programu należy uznać za wzorcową metodę przetwarzania plików tekstowych znak po znaku. Warto zwrócić uwagę na fakt, który często przyjmujemy za oczywisty. Powyższy program jest poprawny tylko przy założeniu, że Eof(f) \Rightarrow Eoln(f). Gdyby ta implikacja nie zachodziła, wówczas powyższy program wchodziłby w nieskończona pętlę po dojściu do końca pliku. Jeśli nie mamy pewności, że w realizacji języka programowania, z której korzystamy, ta implikacja nie zachodzi, trzeba tę pętlę zapisać inaczej. W większości realizacji języków programowania to założenie jest prawdziwe.

Przykład użycia wirtualnego czytania

Przy przetwarzaniu plików często mamy problem z tym, że aby poznać wartość elementu, trzeba go wpierw przeczytać. Przy czym należy pamiętać o tym, że element przeczytany z pliku niekoniecznie od razu wykorzystamy i będzie nam psuł czytelnośc algorytmu, gdyż będziemy go przechowywali poza plikiem do dalszego przetworzenia. Przykładowym programem, w którym wspomniany problem może wystąpić, jest następujące zadanie.

Przykład:

Usuń z posortowanego niemalejąco pliku f wszystkie wartości występujące w posortowanym
niemalejąco pliku g i wynik zapisz w pliku h. 

Bez utraty ogólności istoty rozwiązania możemy założyć, że wszystkie 3 pliki są typu file of Integer i zawierają liczby całkowite. Zauważmy, że całkiem prawdopodobnym wynikiem jest plik pusty, gdy się okaże, że wszystkie wartości z f występują w g. Zadanie to chciałoby się rozwiązać za pomocą liniowego algorytmu takiego jak na tablicach. Gdyby dane były zapisane w tablicach F,G,H:array[1..n] of integer, wówczas moglibyśmy zastosować następujący kod:

i:=1; j:=1; k:=1;
while (i<=n) and (j<=n) do {W H[1..k-1] są elementy F[1..i-1] których nie ma w G[1..j-1]
  if F[i]<G[j] then begin {Nie ma w tablicy G elementu F[i]}
                     H[k]:=F[i];
                     i:=i+1;
                     k:=k+1;
                    end
  else if F[i]>G[j] then {G[j] już nie dotyczy żadnego z elementów F[i..n]}
         j:=j+1 
       else {F[i]=G[j])
         i:=i+1; {zostawiamy j, żeby wytłuc z F[i..n] wszystkie elementy o wartości G[j]}
if i<=n then {skończyła się tablica G, a w F są jeszcze elementy do skopiowania}
for i:=i to n do 
 begin
  H[k]:=F[i];
  k:=k+1
 end

Spróbujmy teraz wykonać podobny algorytm z plikami. Musimy zacząć od sprawdzenia, czy oba są niepuste.


Przykład

var
  f,g,h:file of Integer;
  fx,gx:Integer;
begin
  Assign(f, nazwa_pliku1); Assign(g, nazwa_pliku2); 
  Reset(f); Reset(g); Rewrite(h);
  if eof(g) then {Niczego nie wyrzucamy z f; kopiujemy wszystko do h}
       while not(eof(f) do 
         begin
          Read(f,fx);
          Write(h,fx)
         end 
  else if not eof(f) then
  begin
  Read(f,fx);
  Read(g,gx);
  {teraz jesteśmy gotowi wystartować z porównywaniem decydującym o tym, który plik awansujemy}
  while (not Eof(f)) and (not eof(g)) do
     if fx < gx then begin
                        Write(h,fx); {nie ma w g elementu fx}
                        Read(f,fx)
                      end
     else if fx > gx then Read(g,gx) {stary element fg już był bezużyteczny}
         else {fx=gx}    Read(f,fx} {stary fx nie powinien być przepisany do h}

Jak na razie idzie nieźle. Wyjdziemy z pętli albo wtedy gdy z pliku f przeczytamy ostatni element, albo z g. Zauważmy, że dalsze przetwarzanie, gdyby którykolwiek z plików sie skończył, mogłoby doprowadzic do błędu czytania poza końcem pliku.

Teraz stoimy w obliczu sytuacji, kiedy jeden z plików się skończył (a może oba? - czy jest to możliwe?), trzymamy w ręce ostatni element z tego pliku i musimy jakoś zakończyć algorytm. Nie wolno nam już czytać z pliku, którego ostatni element został przeczytany. Spróbujmy kontynuwać

if eof(f) then {trzeba sprawdzić, czy w pliku g nie ma wartości fx}
 begin
  while (gx < fx) and (not eof(g) do Read(g,gx);
  if gx<>fx then Write(h,fx)
 end
else {w pliku g już nie ma niczego, więc tylko wartość gx trzeba wykluczyć z f}
 begin
  while (fx <> gx) and (not eof(f)) do 
    begin
      Write(h,fx);
      Read(f,fx)
    end;
  while (fx = gx) and (not eof(f)) do 
      Read(f,fx); {elementów równych gx nie wypisujemy}
  while not eof(f) do {kopiujemy resztę pliku f - elementy większe od gx}
    begin 
      Write(h,fx);
      Read(f,fx)
    end;
   if fx<>gx then Write(h,fx)  {i nie zapominamy o ostatnim elemencie}
 end
Close(f);
Close(g);
Close(h);
end;

Uff! Okazało się, że najżmudniejsza część programu pojawiła się wtedy, gdy wydawało się, że jesteśmy już blisko końca. Być może da się usprawnić jakoś ten kod w miarę normalnie, my jednak zajmiemy się nieco trikową metodą, ale za to niezwykle skracającą cały kod. Do tego załóżmy, że

  • istnieje największa liczba całkowita; nazwijmy ją MAXINT
  • w zadanych ciągach jej nie ma.

Napiszemy następującą procedurę

procedure Czytaj(var p:plik; var x:Integer);
{procedura przypisuje zmiennej x wartość MAXINT, jeśli jesteśmy na końcu pliku lub czyta
 z pliku p kolejną wartość i podaje ją jako wartość zmiennej x}
begin
 if eof(p) then x:=MAXINT
 else Read(p,x)
end;

Teraz wszystko jest bardzo proste. Dzięki procedurze Czytaj uzyskaliśmy pewność, że nie wystąpi błąd przy próbie czytania, nawet jeśli będziemy stali na końcu pliku. Zastosowaliśmy wirtualne czytanie, które nie zawsze daje wartość znajdującą się w pliku. Oto kod po deklaracjach i inicjalizacjach plików.

Czytaj(f,fx); Czytaj(g,gx);
while (fx < MAXINT) or (g < MAXINT) do 
  if fx < gx then begin
                        Write(h,fx); {nie ma w g elementu fx}
                        Czytaj(f,fx)
                      end
     else if fx > gx then Czytaj(g,gx) {stary element fg już był bezużyteczny}
          else {fx=gx}    Czytaj(f,fx} {stary fx nie powinien być przepisany do h}

I już! Nic więcej nie trzeba robić poza zamknięciem plików! Warto zapamiętać ten sposób uproszczenia kodu.