Obsługa plików należy do nieco „wyższego” poziomu wtajemniczenia. Jednakże przy pewnym zaawansowaniu programowania trudno bez niej się obejść. W zakresie tej obsługi trzeba wyróżnić obsługę plików tekstowych, czyli takich, które zawierają wartości będące ekwiwalentem tekstu pisanego i które winny być interpretowane jako tekst; oraz pliki binarne, które zaś mogą zawierać dowolne wartości, i trudno jednoznacznie przyjąć co jest ich treścią. Mogą takie pliki zawierać tożsame treści jednakże często są one reprezentowane w różniący się sposób.

Pliki tekstowe

Pliki takie zawierają z zasady w sobie znaki uznawane za elementy tekstu pisanego, a każdy z nich tworzy ciąg tekstowy zakończony znakiem końca linii. Do takich typów plików (zazwyczaj noszących rozszerzenie.txt) stosujemy analogiczne metody jak w przypadku czytania z konsoli (czyli z klawiatury) i pisania na konsolę (ekran). Jedyną zmianą jest jedynie przekierowanie typowego strumienia wejściowego, czy wyjściowego nie na konsolę, a do wskazanego pliku.

Czytanie z pliku

Przykładem programu czytającego z pliku może być poniższy program:

import java.io.File;
import java.io.FileNotFoundException;
import java.util.Scanner;


public class PlikiTekstowe {
    public static void main(String[] args) {

      File handler=new File("plik.txt");
        try {
            Scanner linia=new Scanner(handler);            
            while (linia.hasNextLine()) System.out.println(linia.nextLine());
            } 
        catch (FileNotFoundException ex) {
            System.err.println("Nie udało się otworzyć pliku - prawdopodobnie plik nie istnieje...");            
                                         }
    }    
}

Powyższy program nie różni się zbytnio od poprzedniego modułu traktującego o obsłudze klawiatury. Jedynym novum jest utworzenie uchwytu pliku (ang: handler), tak aby można było potraktować go jako źródło naszych danych z pliku. Podobną funkcję pełnił przy odczycie z klawiatury strumień System.in.

Aby utworzyć uchwyt pliku należy użyć np. klasy File, której bibliotekę należy podlinkować do naszego programu. Parametrem wiążącym z plikiem fizycznym jest nazwa pliku ze ścieżką dostępu. Domyślnie jest to nasz katalog projektu (ten z plikiem manifestu).

Wszystkie operacje dyskowe muszą być obsłużone co do ewentualnie generowanych błędów. Tak i tutaj należy to uczynić. Można to zrobić za pomocą klauzuli throws dodawanej do metody wewnątrz której programujemy dostęp do pliku. Można też to uczynić za pomocą znanej z poprzednich zajęć klauzury try/catch, dzięki której mamy możliwość precyzyjnego obsłużenia błędów i bezpośredniej reakcji na nie.

Po uruchomieniu napisanego wyżej programu raczej nie możemy się spodziewać wyraźnego sukcesu, bo pomimo poprawności w zapisie kodu, program zgłasza nam błąd:

Dzieje się to z powodu tego, że w katalogu, w którym szukamy pliku plik.txt de facto go nie ma. Spróbujmy zatem go stworzyć w dowolnym edytorze np. w notatniku Windows, a w jego wnętrzu wpisać kilka zdań rozdzielonych klawiszem Enter. Po zapisaniu pliku uruchommy raz jeszcze nasz program.

Jaki mamy skutek?! Czy aby może taki?!

Widać, że w pliku pomimo użycia polskich liter, przy odczycie pojawiły się jakieś krzaczki… Winą jest różnica stron kodowych w jakich pracuje NetBeans i w jakiej zapisaliśmy nasz plik… Aby reprezentacja znaków diakrytycznych była poprawna musimy zapewnić użycie tej samej strony kodowej…

W NetBeans oraz przy zapisie z notatnika musimy upewnić się, że mamy tę samą stronę kodową. Tutaj widać, że NetBeans używa Windows-1250, gdy natomiast notatnik zapisuje w UTF-8 (a powinien użyć ANSI).

Po przestawieniu zapisu w notatniku na kodowanie ANSI otrzymujemy taki rezultat:

Pisanie do pliku

Pisanie do pliku tekstowego wiąże się z użyciem biblioteki potrafiącej skierować strumień do pliku fizycznego. Jedną z klas potrafiących to zrobić jest klasa PrintWriter. Program wykonujący zapis do pliku może wyglądać następująco:

import java.io.FileNotFoundException;
import java.io.PrintWriter;
import java.util.Scanner;

public class PlikiTekstowe {
    public static void main(String[] args) {
      
        System.out.println("Pisz zdania i kończ enterem... jeśli w zdaniu będzie sama tylko . to program się zakończy.");
        Scanner klawiatura=new Scanner(System.in); 
        String linia; 
        PrintWriter plikZapisywany;
        try {
            plikZapisywany=new PrintWriter("plik2.txt");
            
            do {
                linia=klawiatura.nextLine();
                if (!linia.equals(".")) {
                    plikZapisywany.write(linia+"\r\n");  
                      //to co jest ważne - to musimy dodać znaki końca linii,
                      // bowiem .write() ich nie dodaje.

                    plikZapisywany.flush();  //jeśli nie nastąpiło przepełnienie buforów, 
                                     // to do pliku nie zostało nic zapisane. 
                                     // .flush() wymusza opróżnienie buforów i zapis do pliku.
                                       }
                    else break;
            } while (1==1);    // pętla nieskończona, opuszczana za pomocą break; 
                               // Nic nie stoi na przeszkodzie, aby w warunku 
                               // powtórzyć warunek z if () zamiast break;

            plikZapisywany.close();   // zamknięcie strumienia,
                     // gdy z niego już nie korzystamy jest powinnością programisty.
            } 
        catch (FileNotFoundException ex) {
            System.err.println("Nie udało się otworzyć pliku do zapisu...");
                                         }       
    }    
}

Pamiętamy program „Papuga”?! Tutaj został on „zatrudniony” do pracy jako maszyna do pisania. Wynikiem działania takiego programu będzie plik tekstowy w którym znajdziemy:

Ważną uwagą do odczytu i zapisu (choć przy zapisie ważniejszą) jest powinność zamykania strumienia, gdy z niego nie będziemy już korzystać.

W obecnej sytuacji wykonuje to za nas kompilator, zamykając program, zamyka wszelkie otwarte strumienie. Jednakże jest to powinnością programisty aby tego samemu dopilnować. Jeżeli tego nie dopilnujemy, to może się zdarzyć, że bufory strumienia nie zostaną opróżnione, a program spotka nieoczekiwany wyjątek i zakończy działanie. Wtedy utracimy te dane, które wydawało się nam, że już do pliku wprowadziliśmy!

Zadania do samodzielnej pracy:

1. Napisz program, który odczyta wskazany plik tekstowy (podana nazwa ze ścieżką dostępu z klawiatury), a następnie utworzy drugi plik z nazwą jak pierwszy + rozszerzenie .stat. W drugim pliku ma znaleźć się statystyka zawierająca:
• ile wyrazów jest w pliku,
• ile zdań jest w pliku (zdanie to ciąg wyrazów zakończony ’.’ co znaczy, że '…’ to nie trzy zdania, a wielokropek; znowuż zapis „10 .. 12” to określenie zakresu),
• ile liter jest w pliku (znaki spacji i znaki specjalne nie liczą się!),
• ile jest samogłosek, a ile spółgłosek.

Zadanie specjalne dla programistycznych hardcorowców: Policz ile i jakich wyrazów jest w pliku, wynik posortuj w kolejności malejącej (od najczęściej występujących wyrazów do najmniej) oraz alfabetycznie od 'a’ do 'z’.

np: „Ala ma kota, kota czarnego. Tomek też ma kota, ale kota w plamki i w prążki. Ten kot bardzo lubi kota Ali. Ala też lubi Tomka.” da wynik:

kota: 5
Ala: 2
lubi: 2
ma: 2
też: 2
w: 2
ale: 1
Ali: 1
bardzo: 1
czarnego: 1
i: 1
kot: 1
plamki: 1
prążki: 1
ten: 1
Tomek: 1
Tomka: 1

Pliki binarne

Plikami binarnymi są takie pliki, w których znajdują się dane niekoniecznie będące ciągami tekstowymi. Pliki binarne zawierają w sobie dane, które mogą być dowolnymi strukturami, a to czym są faktycznie zależy od zamysłu ich twórcy i od interpretacji ich zawartości. Pliki tekstowe także są plikami binarnymi, ale w drugim kierunku ta zależność nie zachodzi. W pliku może być dana 4-bajtowa będąca reprezentacją wartości typu int, ale to czy faktycznie będzie po odczytaniu int’em zależy od tego jak te 4 bajty zostaną zinterpretowane. Jednym słowem, nie ma w plikach binarnych znaczników typu, są tylko dane. To programista nakładając na plik swoisty szablon odtwarza te dane z niego w odpowiedni sposób i w odpowiedniej kolejności.

W języku Java jest kilka sposobów przetwarzania plików binarnych. Są dedykowane specjalne biblioteki do odczytu plików i do ich zapisu. Ogólnie opierają się one na hierarchii obiektowej przedstawionej poniżej:

Files and I/O - JAVA - CWIKI.US
Hierarchia obiektowa klas odpowiedzialnych za dostęp do plików

Więcej o układzie hierarchii klas w bibliotece Java.io jest tutaj:

https://docstore.mik.ua/orelly/java-ent/jnut/figs/JN3_1101.gif

InputStreamOutputStream

Dwie klasy (abstrakcyjne!!! – co to znaczy o tym już niedługo) dostępu do plików binarnych dedykowane do odczytu i zapisu danych z/do tych plików mają kilka metod użytecznych dla programistów. Same jednak nie dostarczają wystarczających narzędzi do manipulacjami plików. W tym celu należy skierować uwagę w kierunku rozszerzających je klas takich jak FileInputStream, FileOutputStream, oraz DataOutputStream (patrz graf zależności klas zamieszczony powyżej).

Używając FileInputStream możemy skonstruować program, który potrafi uzyskać dostęp do pliku fizycznego. Wykorzystamy w tym celu znany już mechanizm tworzenia uchwytu pliku za pomocą klasy File. Taki program może przybrać postać jak poniżej zaprezentowano:

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;

public class PlikiBinarne {

    public static void main(String[] args) {
        try {
            FileInputStream plik=new FileInputStream(new File("plikBinarny.dat"));
            
            int b;
            
            while ((b=plik.read())!=-1) {
                System.out.print((char)b);
                                        }
            
            } 
        catch (FileNotFoundException ex) {System.err.println("Plik nie został znaleziony...");}
        catch (IOException ex) { System.err.println("Wystąpił błąd wejścia/wyjścia..."); } // to jest przykład obsłużenia kolejnego wyjątku.
        catch (SecurityException ex) {System.err.println("Poziom zabezpieczeń blokuje dostęp do pliku..."); }
    }
    
}

Celem jest przeczytanie zawartości pliku „plikBinarny.dat” 1stworzonego w notatniku, kodowanie ANSI, dla projektu ze stroną kodową Windows-1250 o zawartości jak poniżej:

To jest plik binarny, chociaż zawiera tekst.
Nie jest to błąd, bo przecież tekst to też dane binarne.
Nie zawsze jednak tekst będzie tak bardzo czytelny jak teraz.
A i teraz widać, że jest problem z polskimi literami.

W wyniku działania naszego programu zostaje wyświetlone:

Jak widać próba odczytu pliku tekstowego poprzez podejście binarne wiąże się z nieoczekiwanymi problemami – tutaj z polskimi znakami.

Zapis do pliku jest możliwy także poprzez potomną klasę dla OutputStream, taką klasą może być poprzez FilterOutputStream klasa DataOutputStream posiadająca metody umożliwiające zapis różnych typów danych. Spróbujmy zatem przekształcić nasz program „Papuga” w program oparty o obsługę binarną pliku. Może on wyglądać następująco:

import java.io.IOException;
import java.io.DataOutputStream;
import java.io.FileOutputStream;
import java.util.Scanner;

public class PlikiBinarne {

    public static void main(String[] args) {
        
        Scanner klawiatura=new Scanner(System.in); 
        String linia; 
        DataOutputStream plikZapisywany;
        try {
            plikZapisywany=new DataOutputStream(new FileOutputStream("plikBinarny2.dat"));
            
            do {
                linia=klawiatura.nextLine();
                if (!linia.equals(".")) {
                        plikZapisywany.writeUTF(linia+"\r\n");
                        plikZapisywany.flush();
                                        }                
                    else break;
            } while (1==1);
            plikZapisywany.close();
            } 
        catch (FileNotFoundException ex) { System.err.println("Nie udało się otworzyć pliku do zapisu..."); }
        catch (IOException ex) {System.err.println("Błąd zapisu..."); }       
   
    }    
}

Program odczytu tego co zapisaliśmy w formie binarnej musi rozpoznawać standard UTF, zatem napiszmy go.

import java.io.DataInputStream;
import java.io.FileInputStream;
import java.io.EOFException;
import java.io.UTFDataFormatException;
import java.io.IOException;

public class PlikiBinarne {

    public static void main(String[] args) {

        DataInputStream plikOdczytywany;
        String linia;
        try {
            plikOdczytywany=new DataInputStream(new FileInputStream("plikBinarny2.dat"));
            while (1==1) {
                linia=plikOdczytywany.readUTF();
                System.out.println(linia);
            }
        } 
        
        catch (EOFException ex) { System.out.println("Koniec pliku do odczytu..."); }
        catch (UTFDataFormatException ex) { System.err.println("Plik nie posiada danych zakodowanych w UTF..."); }
        catch (IOException ex) { System.err.println("Nie udało się otworzyć pliku do odczytu..."); }
           
    }
    
}

Po uruchomieniu takiego programu, możemy odczytać to co przed chwilą zapisaliśmy do pliku:

To co jest istotne w powyższych przykładach to… kolejność przechwytywania błędów. Najbardziej ogólnym błędem (wyjątkiem) jest General IOException – stąd, jeśli przechwycimy go jako pierwszy (catch), to nie ma szans na jego obsłużenie w pozostałych liniach. Stąd trzeba przemyślanie ustawić kolejność od „najwęższego” zakresu błędu, aż do pełnej obsługi wszystkich możliwych do wystąpienia błędów. Jako test wystarczy spróbować zmienić kolejność linijek z catch, aby otrzymać odpowiednie uwagi od środowiska programistycznego o ich sensowności.

Dodatkowo mamy tutaj przypadek tzw. exception driven activity czyli aktywności programu kierowanej wyjątkiem (EOFException – ang: End of File Exception – wyjątek wystąpienia Końca Pliku). Nie ma tutaj typowego warunku badającego czy jest coś w pliku do odczytania. Odczyt kończy się w momencie wygenerowania wyjątku wystąpienia końca pliku przy próbie odczytu. Tym niemniej dzięki obsłudze błędu, program nie zakończył działalności, a elastycznie przeszedł do realizowania dalszych instrukcji.

Oczywistym jest, że do pliku binarnego możemy zapisywać także dane, które nie są ciągiem znakowym (String). Wystarczy użyć pozostałych metod dostępnych w klasie DataOutputStream DataInputStream. Ważne jest, aby odczytując te dane z pliku interpretować je za pomocą odpowiedniej metody, której użyliśmy do zapisu.

Zadanie do samodzielnej pracy:

Zapoznaj się z wymienionymi klasami i ich metodami. Przeprowadź analizę programu (włącznie z kolejnością przechwytywania wyjątków).

Napisz program (zmodyfikowaną „Papugę”), który zapisze do pliku Imię, Nazwisko, datę urodzin i numer karty studenta. BINARNIE!!! NIE TEKSTOWO!!! Data urodzin to rok, miesiąc i dzień jako liczby typu byte (jak zakodujesz rok??), albo short. To samo z numerem karty studenta – jaki typ danych zaproponujesz??. Imię i Nazwisko to String.

Następnie program powinien odczytać zapisane wartości i wyświetlić je w sformatowanej formie (zwróć uwagę na formatowanie kolumn!). Zmierz się z tym wyzwaniem:

Powodzenia 🙂

RandomAccessFile

Alternatywnym sposobem obsługi plików jest klasa RandomAccessFile pozwalająca na zajęcie się zapisem i odczytem z plików w jednej asygnacji. Nie ma potrzeby otwierania raz pliku do odczytu za pomocą jednej z klas, a kolejnym razem do zapisu za pomocą odrębnych klas. Wszystko odbywa się w ramach jednej klasy.

Przykładem programu wykorzystującego klasę RandomAccessFile jest poniższy kod.

import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.RandomAccessFile;


public class RandomAccess_doPLiku {

    public static void main(String[] args) {
     try    {
        RandomAccessFile plik=new RandomAccessFile("PlikDostepuSwobodnego.bin","rw");
     
        plik.writeInt(2020);
        plik.writeInt(12);
        plik.writeInt(4);
        plik.writeUTF("Zapisałem ten string do pliku...");
        
        plik.seek(0);  //przesunięcie na pozycję 0 (początek pliku)
        System.out.println(plik.readInt()+"/"+plik.readInt()+"/"+plik.readInt())
        System.out.println(plik.readUTF());
            }
     catch (FileNotFoundException ex) { System.err.println("Nie mogę zrealizować dostępu do pliku...");}
     catch (IOException ex) { System.err.println("Błąd zapisu...");}
    }
    
}

Uzyskując efekt:

Zwraca uwagę fakt, że w ramach klasy istnieje wskaźnik pozycji pliku, co za tym idzie można odczytać jego pozycję2getFilePointer();, a także przesunąć go na pozycję inną bezwzględnie bądź relatywnie do wskazywanej pozycji3seek();. Dzięki temu możemy na bieżąco pracować z plikiem, odczytywać dane i modyfikować jego zawartość w dowolny sposób. Wyróżnikiem nadanych praw jest sposób otwarcia pliku:

  • r (read only) tylko do odczytu,
  • w (write only) tylko do zapisu,
  • rw (read/write) odczyt/zapis.

Sugestia jaka powstaje w wyniku powyższych tematów na czas egzaminu to czytać UWAŻNIE treść zadania. Poniżej przykładowe zadanie egzaminacyjne:

Dany jest plik danych o strukturze:

• imię (łańcuch znaków)
• nazwisko (łańcuch znaków)
• kredyt (liczba rzeczywista)
• pensja (liczba rzeczywista)
• wiek (liczba całkowita)

Napisać funkcję wpisującą w tym pliku proponowaną kwotę kredytu. Kwota jest wyliczana wg wzoru:
kredyt= (pensja/7)*(65-wiek)
Parametrem funkcji jest nazwa pliku. Funkcja powinna zwrócić łańcuch znaków, zawierający imię i nazwisko osoby z największym proponowanym kredytem (dowolnej osoby, gdy jest ich więcej).

Zmierz się z tym zadaniem…

Analiza zadania wskazuje, że plik NIE JEST tekstowy, a binarny (wynika to chociażby poprzez użyte hasło: plik danych) bo zawiera dane i tekstowe (String) i inne typy (double/float + int). Poza tym informacja o modyfikacji w tym pliku nasuwa rozwiązanie z niezbędnym wykorzystaniem klasy RandomAccessFile. Reszta to już praca programistyczna.