PO Strumienie

From Studia Informatyczne

<<< Powrót do przedmiotu Programowanie obiektowe

Spis treści

System wejścia/wyjścia

Opracowanie niewielkiego a zarazem spójnego systemu wejścia/wyjścia (ang. input/output) nie jest proste. Trudności biorą się z konieczności obsłużenia wielu możliwości. Dane mogą być wysyłane i pobierane z różnych mediów, jak konsola, pliki, połączenia sieciowe, łącza między procesami, itp. W każdym przypadku obsługa danych może przebiegać na wiele sposobów, np. sekwencyjnie, ze swobodnym dostępem, poprzez bufor, binarnie, znakowo czy linia po linii. Co więcej, czasami dane muszą być przetwarzane w trakcie przesyłania, np. kompresowane lub szyfrowane. Dzięki zastosowaniu wzorca Dekorator (ang. Decorator) w Javie nie nastąpiła eksplozja liczby klas obsługi wejścia/wyjścia, chociaż początkowo ich wielość może przytłaczać.

Strumienie

W większości języków programowania biblioteki wejścia/wyjścia ukrywają szczegóły obsługi poszczególnych mediów pod abstrakcją strumienia (ang. stream). Strumienie są używane zarówno do wysyłania/zapisywania jak i pobierania/odczytywania porcji danych danych. Główną zaletą takiego podejścia jest jego uniwersalność.

W Javie hierarchia strumieni oparta jest na czterech klasach InputStream, OutputStream, Reader i Writer. InputStream i Reader reprezentują strumienie danych wejściowych, a OutputStream i Writer strumienie danych wyjściowych. Para InputStream i OutputStream jest przeznaczona do obsługi danych binarnych. Reader i Writer dodano do języka w wersji 1.1 i służą one do obsługi danych znakowych. Strumienie znakowe oferują podobną funkcjonalność co binarne. Jeżeli jest to możliwe, należy używać klas z hierarchii Reader/Writer. W niektórych zastosowaniach (np. kompresja) posługiwanie się danymi binarnymi jest jednak bardziej naturalne, dlatego strumienie znakowe nie zastępują strumieni binarnych, ale je uzupełniają. Możliwa jest przy tym bardzo łatwa konwersja strumieni binarnych na znakowe.

Strumienie dla poszczególnych mediów

Strumienie ujednolicają obsługę poszczególnych rodzajów mediów. Standardowe biblioteki Javy zawierają klasy reprezentujące strumienie wejściowe i wyjściowe na:

  • pliku,
  • tablicy bajtów/znaków,
  • obiekcie String oraz
  • łączu (ang. pipe) służącym do komunikacji procesów.

Dodatkowo dalsze strumienie można uzyskać bezpośrednio z obiektów reprezentujących niektóre media, np. z gniazda sieciowego czy zasobu sieci WWW wskazanego przez adres URL.

Strumienie ze standardowych bibliotek Javy do obsługi różnych rodzajów mediów
Podklasy InputStream i OutputStream Podklasy Reader i Writer Opis
FileInputStream i FileOutputStream FileReader i FileWriter Pozwalają odczytywać i zapisywać pliki dyskowe. Jako parametr konstruktora przekaż nazwę pliku dyskowego lub wskazujący go obiekt File. Tworząc obiekt wyjściowy, jako drugi argument konstruktora, możesz przekazać wartość logicznią określającą czy zamiast zamazywać istniejący plik dopisywać kolejne dane na jego końcu.
ByteArrayInputStream i ByteArrayOutputStream CharArrayReader i CharArrayWriter Bufor w pamięci oparty na tablicy odpowiednio bajtów lub znaków. Tworząc obiekt wejściowy, przekaż konstruktorowi tablicę, na której ma być oparty. Tworząc obiekt wyjściowy, przekaż konstruktorowi początkowy rozmiar bufora.
StringBufferInputStream (nie ma odpowiednika do zapisu) StringReader i StringWriter Bufor w pamięci oparty na napisie String (implementacja posługuje się obiektem StringBuffer). Tworząc obiekt wejściowy, przekaż konstruktorowi napis, na którym ma być oparty. Tworząc obiekt wyjściowy przekaż konstruktorowi początkowy rozmiar bufora. Zaleca się używanie klas z hierarchii Reader/Writer. StringBufferInputStream jest oznaczony jako deprecated.
PipedInputStream i PipedOutputStream PipedReader i PipedWriter Łącze do komunikacji między procesami. Przy pomocy konstruktora bezparametrowego należy najpierw utworzyć obiekt jednego rodzaju (wejściowy lub wyjściowy), a następnie przekazać go jako parametr konstruktora obiektu drugiego rodzaju (odpowiednio wyjściowego lub wejściowego). Strumienie zostaną połączone łączem, które będzie przesyłać dane od strumienia wyjściowego do wejściowego.

Po swoich nadklasach InputStream/OutputStream lub Reader/Writer strumienie dziedziczą podstawowe metody pozwalające odczytywać/zapisywać porcje danych. Do czytania danych ze strumienia służą metody read(). W wersji bezparametrowej dają jako wynik wartość całkowitą reprezentującą odczytany bajt (dla InputStream) lub odczytany znak (dla Reader). Jeżeli osiągnięto koniec strumienia bezparametrowy read() daje -1. Przeciążone wersje metody read() odczytują dane do tablicy lub do jej części. Do wysyłania danych do strumienia służą metody write(). W podstawowej wersji przyjmują jako parametr liczbę całkowitą zawierającą zapisywany bajt (dla OutputStream) lub zapisywany znak (dla Writer). Przeciążone wersje metody write() zapisują dane z przekazanej tablicy lub jej części. Dodatkowe wersje metody z klasy Writer zapisują wszystkie znaki z przekazanego obiektu String lub jego wskazanego wycinka.

W poniższym przykładzie plik tekstowy jest odczytywany znak po znaku przy pomocy strumienia FileReader i wypisywany na standardowe wyjście.

import java.io.FileReader;
import java.io.IOException;

public class ZnakPoZnaku {
  public static void main(String[] args) throws IOException {
    // wersja dla Linuxa
    FileReader rd = new FileReader("/tmp/io_test.txt");
    // wersja dla Windows
    // FileReader rd = new FileReader("c:\\io_test.txt");
    try {
      int i;
      // Reader.read() Daje wartość z przedziału 0 to 65535,
      // jeżeli odczyt się powiódł lub -1 jak nie
      while ((i = rd.read()) != -1)
        System.out.print((char) i);
    } finally {
      rd.close();
    }
  }
}

Po użyciu, strumień trzeba zamknąć przy pomocy metody close(). Trzeba o tym pamiętać. Szczególnie, że dla niektórych zasobów, np. dla plików dyskowych oraz połączeń sieciowych, obowiązują limity na liczbę naraz otwartych egzemplarzy. Żeby zagwarantować zwalnianie zasobów również w przypadku wystąpienia wyjątku, powinno się to robić w bloku finally lub skorzystać z instrukcji try-z-zasobami dodanej w Javie 7. Dla zwiększenia przejrzystości w pozostałych przykładach cały kod związany z obsługą błędów i zamykaniem zasobów został usunięty.

Konwersja między strumieniami binarnymi i znakowymi

Strumień binarny można przekształcić na strumień znakowy. Służą do tego klasy InputStreamReader i OutputStreamWriter. Taka konwersja czasami jest bardzo przydatna, np. podczas kompresji i dekompresji danych. W poniższym przykładzie bufor oparty na tablicy bajtów jest przekształcany na obiekt Writer.

import java.io.*;

public class KonwersjaStrumieni {
  public static void main(String[] args) throws IOException {
    String napis = "Test strumieni.\nąćęłńóśźż\n";

    ByteArrayOutputStream os = new ByteArrayOutputStream();
    // OutputStream jest przekształcany na Writer 
    Writer wr = new OutputStreamWriter(os);
    wr.write(napis);
    wr.close();

    ByteArrayInputStream is = new ByteArrayInputStream(os.toByteArray());
    // InputStream jest przekształcany na Reader 
    Reader rd = new InputStreamReader(is);
    int i;
    while ((i = rd.read()) != -1)
      System.out.print((char) i);
    rd.close();
  }
}

Kodowanie znaków

W Javie znaki są wewnętrznie kodowane w standardzie Unicode[1]. Jeżeli używamy jednoparametrowego konstruktora, klasa OutputStreamWriter wykonuje konwersję na domyślne kodowanie dla danej platformy[2]. Klasa InputStreamReader wykonuje konwersję odwrotną. Konstruktory obu klas są przeciążone i jako drugi parametr można wskazać jakiego kodowania używać zamiast domyślnego.

Domyślne kodowanie znaków jest również stosowane podczas obsługi plików za pomocą klas FileWriter i FileReader. Klasy te same nie implementują operacji na plikach. Wewnętrznie korzystają ze strumieni binarnych przekształconych przy pomocy OutputStreamWriter i InputStreamReader. Aby obsługiwać pliki przy pomocy innego niż domyślne kodowania znaków należy samemu stworzyć odpowiedni strumień binarny i przekształcić go na wersję znakową wskazując kodowanie jako drugi parametr konstruktora klas InputStreamReader bądź OutputStreamWriter.

Pobieranie danych po kolei z grupy strumieni

Klasa SequenceInputStream pozwala używać grupy strumieni InputStream tak, jakby były skonkatenowane. Najpierw pobierane są dane z pierwszego strumienia. Gdy ten się skończy, pobierane są dane z następnego, itd. SequenceInputStream posiada dwa konstruktory. Pierwszy przyjmuje jako parametr jedynie dwa strumienie, a drugi obiekt Enumeration<? extends InputStream>. Poniższy przykład pokazuje użycie klasy SequenceInputStream do odczytania po kolei dwóch buforów w pamięci.

import java.io.*;

public class KonkatenacjaStrumieni {
  public static void main(String[] args) throws IOException {
    String daneDlaBufora1 = "Dane dla bufora 1.\nąćęłńóśźż\n";
    String daneDlaBufora2 = "Dane dla bufora 2.\nąćęłńóśźż\n";

    // getBytes() daje tablicę bajtów reprezentujących kolejne znaki napisu
    // w domyślnym dla danej platformy kodowaniu znaków (są też wersje przeciążone)
    ByteArrayInputStream is1 = new ByteArrayInputStream(daneDlaBufora1.getBytes());
    ByteArrayInputStream is2 = new ByteArrayInputStream(daneDlaBufora2.getBytes());
    
    SequenceInputStream seq = new SequenceInputStream(is1, is2);
    Reader rd = new InputStreamReader(seq);
    int i;
    while ((i = rd.read()) != -1)
      System.out.print((char) i);
  }
}

Wzorzec Dekorator

Omówione dotychczas klasy ukrywają szczegóły obsługi poszczególnych mediów pod abstrakcją strumienia. W każdym przypadku ze strumienia można korzystać jedynie w podstawowy sposób – odczytując lub zapisując poszczególne bajty/znaki. Przy pomocy dziedziczenia strumienie można by rozszerzyć o nową funkcjonalność, np. buforowanie danych, kompresję lub szyfrowanie. Można by też dodać nowe metody ułatwiające wykonywanie często spotykanych operacji, np. przesyłanie przez strumień innych typów danych lub przesyłanie linia po linii. Niestety potencjalna liczba kombinacji, które trzeba uwzględnić jest ogromna. Mogą być potrzebne strumienie tylko z buforowaniem, tylko z kompresją, z buforowaniem i kompresją, itd. Przy zastosowaniu dziedziczenia, wszystkie kombinacje trzeba by przewidzieć zawczasu i przygotować odpowiednie podklasy. W Javie uniknięto eksplozji klas dzięki zastosowaniu wzorca Dekorator. Udostępniający podstawową funkcjonalność egzemplarz strumienia przekazuje się do obiektu "opakowującego", czyli tak zwanego dekoratora. Obiekt dekorator implementuje ten sam interfejs lub rozszerza tę samą klasę bazową. Dzięki temu można go używać zamiast obiektu oryginalnego. Poszczególne metody dekoratora wywołują metody oryginalnego obiektu, a ponadto dodają nową funkcjonalność. W odróżnieniu od dziedziczenia, nowe zachowanie dodawane jest dynamicznie, w trakcie działania programu i tylko dla pojedynczego obiektu, a nie całej klasy. Tym samym nie trzeba zawczasu przewidzieć wszystkich możliwych kombinacji. Pojedynczy obiekt można opakować kilkoma różnymi dekoratorami, wzbogacając go o funkcjonalność każdego z nich. Dekorator może również rozszerzać interfejs dekorowanego obiektu. W ten sposób do strumieni dodawane są metody ułatwiające wykonywanie często spotykanych operacji. Poniższy diagram pokazuje schemat hierarchii klas przy zastosowaniu wzorca Dekorator.

Schemat hierarchii klas dla dekoratora

Niektóre klasy ze standardowych bibliotek Javy rozszerzające funkcjonalność strumieni są wymienione w tabeli.

Niektóre klasy ze standardowych bibliotek Javy rozszerzające funkcjonalność strumieni
strumienie binarne strumienie znakowe Opis
BufferedInputStream i BufferedOutputStream BufferedReader i BufferedWriter Operacje na strumieniu stają się buforowane. W większości przypadków skutkuje to znaczącym wzrostem efektywności. Zamiast wykonywać wiele drobnych operacji na strumieniu, np. wiele razy odczytywać/zapisywać z pliku dyskowego małe porcje danych. Dekorator odczytuje większą porcję na zapas lub zapamiętuje dane, które mają być wysłane do strumienia dopóki nie uzbiera się ich dostateczna ilość. Jeżeli dane powinny być wysłane do strumienia niezwłocznie, niezależnie od tego, czy bufor jest pełen czy nie, można to wymusić wywołując metodę flush(). Zazwyczaj bufor jest opróżniany również w momencie wywołania metody close().
PrintStream PrintWriter Dodają wiele nowych metod pozwalających wypisywać do strumienia dane w sposób czytelny dla człowieka. Nowe metody występują w dwóch wersjach print() i println(), z których druga po ewentualnym wypisaniu przechodzi do nowego wiersza. Dodatkowo od Javay 1.5 pojawiła się również znana z C metoda printf(). Dla wygody klasy mają dodatkowe konstruktory, które zwalniają z konieczności otwierania samemu strumienia do pisania do pliku i dekorowania go buforowaniem. Zalecaną klasą jest PrintWriter, gdyż reprezentuje końce linii w sposób przyjęty dla używanej platformy. Uwaga: metody obu klas nie przepuszczają wyjątków IOException. To czy wystąpiły trzeba kontrolować przy pomocy metody checkError().
PushBackInputStream PushBackReader Dodają nową metodę unread() pozwalającą odesłać z powrotem ostatnio odczytane dane. Odesłane dane są zapamiętywane w buforze. Kolejne odczyty najpierw pobierają dane z bufora, a dopiero jak jest pusty ze strumienia. Taka funkcjonalność przydaje się podczas budowy kompilatora.
LineNumberInputStream LineNumberReader Dodają nową metodę getLineNumber(), która daje liczbę odczytanych do tej pory linii. LineNumberInputStream jest oznaczona jako deprecated, należy używać LineNumberReader.
DataInputStream i DataOutputStream (brak odpowiedników znakowych) Dodają wiele nowych metod pozwalających przesyłać przez strumień wartości typów podstawowych Javy oraz napisy[3] w sposób niezależny od platformy.
ObjectInputStream i ObjectOutputStream (brak odpowiedników znakowych) Dodają nowe metody pozwalające przesyłać przez strumień obiekty implementujące interfejs java.io.Serializable. Serializacja zostanie omówiona na następnym wykładzie.
CheckedInputStream i CheckedOutputStream (brak odpowiednika znakowego) Wylicza sumę kontrolną dla danych przesyłanych przez strumień. CheckedInputStream i CheckedOutputStream należą do pakietu java.util.zip.
GZIPInputStream i GZIPOutputStream (brak odpowiedników znakowych) Dane przesyłane przez strumień są kompresowane przy pomocy prostego algorytmu GZIP. GZIPInputStream i GZIPOutputStream należą do pakietu java.util.zip.
ZipInputStream i ZipOutputStream (brak odpowiedników znakowych) Dane przesyłane przez strumień są kompresowane przy pomocy algorytmu Zip. ZipInputStream i ZipOutputStream należą do pakietu java.util.zip.
CipherInputStream i CipherOutputStream (brak odpowiedników znakowych) Dane przesyłane przez strumień są szyfrowane lub deszyfrowane przy pomocy obiektu klasy Cipher. CipherInputStream, CipherOutputStream i Cipher należą do pakietu javax.crypto.

W poniższym przykładzie plik wypełniany jest różnymi danymi przy pomocy DataOutputStream, a następnie odczytywany przy pomocy DataInputStream. Dla przyspieszenia operacje na pliku są dodatkowo buforowane.

import java.io.*;

public class TestDekoratorów {
  public static void main(String[] args) throws IOException {
    // wersja dla Linuxa
    String nazwaPliku = "/tmp/io_test.txt";
    // wersja dla Windows
    //String nazwaPliku = "c:\\io_test.txt";

    DataOutputStream os = new DataOutputStream(
                            new BufferedOutputStream (
                              new FileOutputStream(nazwaPliku)));
    os.writeBoolean(true);
    os.writeInt(1234567890);
    os.writeDouble(Math.PI);
    os.writeUTF("Test strumieni.\nąćęłńóśźż\n");
    os.close();// wykonuje flush()
   
    DataInputStream is = new DataInputStream(
                           new BufferedInputStream (
                             new FileInputStream(nazwaPliku)));
    Boolean b = is.readBoolean();
    Integer i = is.readInt();
    Double d = is.readDouble();
    String str = is.readUTF();
    is.close();
   
    System.out.printf("Odczytano: b=%s, i=%s, d=%s, str=%s", b, i, d, str);
  }  
}

Połączenie DataOutputStream, BufferedOutputStream i FileOutputStream wyjaśnia poniższa animacja, w której dla uproszczenia bufor ma rozmiar 10 bajtów. W rzeczywistości, jeżeli sami nie wskażemy innej wielkości, bufor będzie miał 8192 bajty.



Zapisywanie różnych typów danych do pliku z buforowaniem

Standardowe wejście/wyjście

Pomysł, aby dane wejściowe dla programu były odczytywane z jednego strumienia – standardowego wejścia (ang. standard input), dane wyjściowe były wysyłane do standardowego wyjścia (ang. standard output), a informacje o błędach do standardowego wyjścia błędu (ang. standard error) pochodzi z systemów Unixowych. Dzięki temu staje się możliwe łączenia programów w potoki przetwarzania – standardowe wyjście jednego programu staje się standardowym wyjściem drugiego, itd. Ten pomysł został zaadoptowany w wielu innych systemach operacyjnych, m.in. również w Windows.

W Javie do standardowego wyjścia/wejścia mamy dostęp poprzez zmienne statyczne klasy System. Na System.out i System.err przypisane są obiekt PrintStream. System.in to zwykły InputStream. Najczęściej ze standardowego wejścia będziemy wczytywać dane tekstowe, np. polecenia od użytkownika, dlatego warto standardowe wejście opakować w BufferedReader i używać jego metody readLine().

BufferedReader stin = new BufferedReader(new InputStreamReader(System.in));

Przekierowywanie standardowego wejścia/wyjścia

Możliwe jest przekierowanie standardowego wyjścia, wejścia i wyjścia dla komunikatach o błędach. Służą do tego metody setOut(PrintStream), setIn(InputStream) i setErr(PrintStream). Może to, np. ułatwić testowanie aplikacji. Sekwencje poleceń użytkownika wystarczy zapisać w pliku i przekierować standardowe wejście na strumień go odczytujący. Standardowe wyjście można przekierować do pliku, żeby łatwiej móc sprawdzić, czy wyniki są zgodne z oczekiwaniami.

Kompresja

Dzięki gotowym dekoratorom z pakietu java.util.zip kompresja w Javie jest bardzo prosta. W poniższym przykładzie dane zapisywane do pliku są kompresowane przy pomocy prostego algorytmu GZIP.

import java.io.*;
import java.util.zip.GZIPInputStream;
import java.util.zip.GZIPOutputStream;

public class KompresjaGZIP {
  public static void main(String[] args) throws IOException {
    // wersja dla Linuxa
    String nazwaPliku = "/tmp/io_test.gzip";
    // wersja dla Windows
    //String nazwaPliku = "c:\\io_test.gzip";

    BufferedOutputStream os = new BufferedOutputStream(
                                new GZIPOutputStream(
                                  new FileOutputStream(nazwaPliku)));
    PrintWriter pw = new PrintWriter(
                       new OutputStreamWriter(os));
    pw.write("kompresja w Javie jest prosta");
    pw.close();
    
    // dekompresja
    BufferedInputStream is = new BufferedInputStream(
                               new GZIPInputStream(
                                 new FileInputStream(nazwaPliku)));
    BufferedReader br = new BufferedReader(
                          new InputStreamReader(is));
    String s;
    while ( (s = br.readLine()) != null )
      System.out.println(s);
    br.close();
  }
}

Format zip daje większe możliwości. W jednym archiwum można skompresować wiele plików. Kolejne elementy archiwum reprezentowane są przez obiekty ZipEntry. Poniższy przykład prezentuje tworzenie archiwum składającego się z wielu plików. Jako pierwszy parametr należy przekazać ścieżkę do katalogu, w którym znajduja sie pliki do skompresowania, a następnie ich nazwy. Kompresowane pliki mogą znajdować się w podkatalogach. Przykładowe wywołanie programu w systemi Linux może wyglądać następujaco:

java KompresjaZip /home/mójUżytkownik/ a.txt b.txt podkat\c.txt podkat\d.txt
import java.io.*;
import java.util.zip.*;

public class KompresjaZip {
  public static void main(String[] args) throws IOException {
    // wersja dla Linuxa
    String nazwaPliku = "/tmp/io_test.zip";
    // wersja dla Windows
    //String nazwaPliku = "c:\\io_test.zip";
    int x;

    ZipOutputStream zos = new ZipOutputStream(
                            new BufferedOutputStream(
                              new FileOutputStream(nazwaPliku)));
    for (int i = 1; i < args.length; i++) {
      System.out.println("Zapisywanie " + args[i]);
      BufferedInputStream in = new BufferedInputStream(new FileInputStream(args[0]+args[i]));
      zos.putNextEntry(new ZipEntry(args[i]));
      while ( (x = in.read()) != -1 )
        zos.write(x);
      in.close();
    }
    zos.close();
    
    ZipInputStream zis = new ZipInputStream(
                           new BufferedInputStream(
                             new FileInputStream(nazwaPliku)));
    ZipEntry ze;
    while ( (ze = zis.getNextEntry()) != null ) {
      System.out.println();
      System.out.println("Odczytywanie: " + ze);
      while ( (x = zis.read()) != -1 )
        System.out.write(x);
    }
    System.out.flush();
    zis.close();
  }
}

Nowe wejście/wyjście

W Javie 1.4 dodano "nowe" biblioteki wejścia/wyjścia (ang. new I/O). Są one zebrane w pakietach java.nio.*. Szczegółowe omówienie tych bibliotek wykracza poza zakres naszego wykładu. Głównym celem przy opracowywaniu nowych bibliotek był wzrost prędkości działania. Z tego powodu przyjęte rozwiązania są bliskie do sposobu organizacji wejścia/wyjścia w systemach operacyjnych. Posługiwanie się nowymi bibliotekami jest bardziej pracochłonne i wymaga więcej uwagi. Jeżeli nie zależy nam osiągnięciu maksymalnej możliwej prędkości działania wejścia/wyjścia, dotychczasowe "stare" biblioteki są nadal zalecane. Ich efektywność również uległa poprawieniu dzięki przepisaniu ich kodu przy pomocy nowych bibliotek.

Informacje o kodowaniu znaków w Javie

  1. Do Javy 1.5 każdy znak był pamiętany na dwóch bajtach i taki jest rozmiar typu char. Obecnie, ze względu na rozszerzenie standardu Unicode, niektóre znaki są pamiętane jako dwie wartości typu char, czyli zajmują cztery bajty. Więcej szczegółów na ten temat znajdziesz w dokumentacji
  2. Maszyna wirtualna odczytuje domyślne kodowanie z ustawień systemu operacyjnego. Jego wartość jest przechowywana przez klasę System i można ją uzyskać przy pomocy wywołania System.getProperty("file.encoding").
  3. Używane kodowanie jest modyfikacją UTF-8. Różnice względem standardu są opisane w dokumentacji.