Strumienie




Programowanie aplikacji niemal zawsze wymaga wykonywania operacji wejścia-wyjścia. W Javie nie jest to banalne, choćby ze względu na bardzo dużą liczbę klas, które temu służą. Tu poznamy prawie wszystkie te klasy, ale przede wszystkim koncepcje, leżące u podstaw konstrukcji niezwykle bogatego w możliwości, ale jednocześnie dość zawikłanego środowiska  programowania wejścia-wyjścia w Javie.

1. Ogólny obraz: pojęcie strumienia i pakietu we-wy Javy

Java dostarcza dwóch podstawowych pakietów (z podpakietami), służących do przeprowadzania operacji wejścia-wyjścia:
Pakiet java.io zawiera przede wszystkim klasy, które pozwalają operować na strumieniach danych.


Strumień danych jest pojęciem abstrakcyjnym, logicznym.

Oznacza ciąg danych, właśnie „strumień”, do którego dane mogą być dodawane i z którego dane mogą być pobierane.

Przy czym:
  • strumień związany jest ze źródłem lub odbiornikiem danych
  • źródło lub odbiornik mogą być dowolne: plik, pamięć, URL, gniazdo, potok ...
  • strumień służy do zapisywania-odczytywania informacji - dowolnych danych
  • program:
    • kojarzy strumień z zewnętrznym źródłem/odbiornikiem,
    • otwiera strumień,
    • dodaje lub pobiera dane ze strumienia,
    • zamyka strumień.
  • przy czytaniu lub zapisie danych z/do strumienia mogą być wykonywane dodatkowe operacje (np. buforowanie, kodowanie-dekodowanie, kompresja-dekompresja)
  • w Javie dostarczono klas, reprezentujących  strumienie. Hierarchia tych klas pozwala na programowanie w sposób abstrahujący od konkretnych źródeł i odbiorników.


Klasy reprezentujące strumienie (inaczej: klasy strumieniowe) omówimy najpierw, są one bowiem podstawowym środkiem programowania operacji wejścia-wyjścia w Javie.

W pakiecie java.nio ("Java new input-output", w skrócie NIO) wprowadzono dodatkowe środki wejścia-wyjścia, takie jak kanały, bufory i selektory . Mimo nazwy ("new input-output") środki te nie zastępują klas strumieniowych. Służą przede wszystkim do zapewnienia wysokiej efektywności i elastyczności programów, które w bardzo dużym stopniu obciążone są operacjami wejścia-wyjścia. W szczególności dotyczy to serwerów, które muszą równolegle obsługiwać ogromną liczbę połączeń sieciowych. Elementom nowych klas wejściowo-wyjściowych przyjrzymy się w przyszłym semestrze, przy okazji omaiwania  zasad programowania klient-serwer.   

Oprócz tego Java dostarcza klas reprezentujących inne od strumieni obiekty operacji wejścia-wyjścia.
Do klas tych należy np. klasa File z pakietu java.io - opisująca pliki i katalogi, a także - w pakiecie java.net - klasy reprezentujące obiekty "sieciowe", takie jak URL czy gniazdo (socket), mogące stanowić źródło lub odbiornik danych w sieci (w szczególności w Internecie).
Obiekty tych klas nie stanowią strumieni. Do operowania na nich strumienie (lub kanały) są jednak potrzebne i możemy je uzyskać albo przez użycie odpowiednich konstruktorów lub metod.


2. Klasy strumieniowe

Na strumieniach możemy wykonywać dwie podstawowe operacje: odczytywanie danych i zapisywanie danych. Z tego punktu widzenia możemy mówić o strumieniach wejściowych i wyjściowych. I odpowiednio do tego – Java wprowadza dwie rozłączne hierarchie klas strumieniowych: klasy strumieni wejściowych i klasy strumieni wyjściowych.

Dalej, pobranie/zapis danych może dotyczyć określonych  atomistycznych (minimalnie rozróżnialnych w trakcie operacji) „porcji danych”. Okazuje się, że nie są to tylko bajty. W Javie – ze względu na przyjęcie standardu kodowania znaków (Unikod) – wyróżnia się również inny „atom danych” – znak Unikodu, złożony z dwóch bajtów. Wobec tego powstają kolejne dwie rozłączne hierarchie klas strumieniowych: klasy strumieni  bajtowych („atomem” operacji we-wy jest bajt) oraz klasy strumieni znakowych („atomem” są znaki Unikodu – 2 bajty).

Przy przetwarzaniu tekstów należy korzystać ze strumieni znakowych ze względu na to, iż w trakcie czytania/pisania wykonywane są odpowiednie operacje kodowania/dekodowania ze względu na stronę kodową właściwą dla źródła/odbiornika


Zatem mamy aż cztery hierarchie klas strumieniowych.

Początkowe nadklasy tych hierarchii pokazuje poniższa tabela.


WejścieWyjście
Strumienie bajtoweInputStreamOutputStream
Strumienie znakoweReaderWriter

Są to klasy abstrakcyjne, zatem bezpośrednio nie można tworzyć obiektów tych klas.
Dostarczają one  natomiast podstaw dla wszystkich innych klas strumieniowych oraz paru ogólnych użytecznych (choć  bardzo podstawowych)  metod. Metody te umożliwiają m.in.  
Metody te są zazwyczaj odpowiednio przedefiniowane w klasach dziedziczących; polimorfizm zapewnia ich oszczędne i właściwe użycie.

Dzięki temu możemy np. opracować ogólną klasę udostępniającą rudymentarne kopiowanie strumieni.

import java.io.*;

class Stream {

  static void copy(InputStream in, OutputStream  out) throws IOException {
    int c;
    while ((c = in.read()) != -1) out.write(c);
  }

  static void copy(Reader in, Writer out) throws IOException {
    int c;
    while ((c = in.read()) != -1) out.write(c);
  }
}

Uwaga: metoda read() zwraca liczbę całkowitą, reprezentującą kolejny znak ze strumienia znakowego (lub bajt ze strumienia bajtowego) albo wartość -1 gdy czytanie sięga poza koniec pliku.

Możemy teraz użyć  metody copy wobec dowolnych strumieni z odpowiednich konkretnych klas hierarchii klas strumieniowych, np.

    Stream.copy(input, output);

Właśnie! Po to by kopiowanie miało sens input musi oznaczać konkretne źródło danych , a output – konkretny odbiornik danych.

Strumień abstrakcyjny (w którymś momencie) musi być związany z konkretnym źródlem bądź odbiornikiem.

W Javie jest to możliwe głównie (ale nie tylko) dzięki wprowadzeniu na kolejnych szczeblach dziedziczenia  omawianych czterech hierarchii (we-wy, bajty-znaki) konkretnych klas oznaczających różne rodzaje źródła/odbiornika danych. Można by je nazwać klasami przedmiotowymi, bowiem mają one  ustalone „przedmioty” operacji - konkretne rodzaje źródła bądź odbiornika.

3. Strumieniowe klasy przedmiotowe. Wiązanie strumieni ze źródłem/odbiornikiem

Źródła bądź odbiorniki danych mogą być różnorodne. Strumień może być związany np. z plikiem, z pamięcią operacyjną,  z potokiem, z URLem, z gniazdkiem (socket)....
Klasy przedmiotowe wprowadzono dla wygody operowania na konkretnych rodzajach żródeł i odbiorników.

Klasy przedmiotowe


Plik
Źródło/odbiornikStrumienie znakoweStrumienie bajtowe
PamięćCharArrayReader,
CharArrayWriter
ByteArrayInputStream,
ByteArrayOutputStream
StringReader,
StringWriter
StringBufferInputStream
PotokPipedReader,
PipedWriter
PipedInputStream,
PipedOutputStream
FileReader,
FileWriter
FileInputStream,
FileOutputStream

Teraz już możemy użyć przykładowej (pokazanej poprzednio) klasy Stream  np.do kopiowania plików tekstowych i do zapisu zawartości łańcucha znakowego do pliku

class StreamCopy1 {
  static public void main(String[] args)  {
    try {
      FileReader in1 = new FileReader("plik0.txt");
      FileWriter out1 = new FileWriter("plik1.txt");
      Stream.copy(in1, out1);
      in1.close();
      out1.close();

      String msg = "Ala ma kota";
      StringReader in2 = new StringReader(msg);
      FileWriter out2 = new FileWriter("plik2.txt");
      Stream.copy(in2, out2);
      in2.close();
      out2.close();
    } catch(IOException exc) {
      exc.printStackTrace();
    }

  }
}
Komentarze:
Użycie klas przedmiotowych nie jest jedynym sposobem związania logicznego strumienia z fizycznym źródłem lub odbiornikiem.
Inne klasy (spoza pakietu java.io, np. klasy sieciowe) mogą dostarczać metod, które zwracają jako wynik referencję do abstrakcyjnego strumienia związanego z konkretnym źródłem odbiornikiem (np. plikiem w Sieci).

4. Klasy przetwarzające (przekształacanie danych w trakcie operacji na strumieniach)

Przy wykonywaniu operacji we-wy mogą być dokonywane przekształcenia danych.
Java oferuje nam wiele klas wyspecjalizowanych w konkretnych rodzajach automatycznego przetwarzania strumieni. Klasy te implementują określone rodzaje przetwarzania strumieni, niezależnie od rodzaju źródła/odbiornika

Rodzaj przetwarzania Strumienie znakoweStrumienie bajtowe
BuforowanieBufferedReader,
BufferedWriter
BufferedInputStream,
BufferedOutputStream
FiltrowanieFilterReader,
FilterWriter
FilterInputStream,
FilterOutputStream
Konwersja: bajty-znakiInputStreamReader,
OutputStreamWriter
 
Konkatenacja SequenceInputStream
Serializacja obiektów ObjectInputStream,
ObjectOutputStream
Konwersje danych DataInputStream,
DataOutputStream
Zliczanie wierszyLineNumberReaderLineNumberInputStream
PodglądaniePushbackReaderPushbackInputStream
DrukowaniePrintWriterPrintStream

Komentarze:

Można tworzyć własne filtry.
Konstruktory klas przetwarzających mają jako argument referencję do obiektów podstawowych klas abstrakcyjnych hierarchii dziedziczenia (InputSteram, OutputSteram, Reader, Writer).
Dlatego przetwarzanie (automatyczna transformacja) danych jest logicznie oderwana od fizycznego strumienia, stanowi swoistą na niego nakładkę.

Zatem zastosowanie klas przetwarzających wymaga:
Przykłady tego zobaczymy za chwilę. Przedtem jednak - dla lepszej orientacji w gąszczu klas strumieniowych - warto przedstawić ich hierarchie dziedziczenia.

5. Hierarchie dziedziczenia klas strumieniowych


Na poniższych rysunkach pokazano hierarchię klas znakowych i strumieniowych.
Zaciemnione elementy oznaczają klasy przedmiotowe (związane z konkretnym źródłem/odbiornikiem), jasne - klasy przetwarzające (realizujące określone rodzaje orzetwarzania).

Klasy dla strumieni bajtowych

r

r

Żródło: Java Tutorial, Sun Microsystems 2002
 

Klasy dla strumieni znakowych

r

r

Żródło: Java Tutorial, Sun Microsystems 2002


6. Buforowanie

Buforowanie ogranicza liczbę fizycznych odwołań do urządzeń zewnętrznych, dzięki temu, że fizyczny odczyt lub zapis dotyczy całych porcji danych, gromadzonych w buforze (wydzielonym obszarze pamięci). Jedno fizyczne odwołanie wczytuje dane ze strumienia do bufora lub zapisuje zawartość bufora do strumienia. W naszym programie operacje czytania lub pisania dotyczą w większości bufora (dopóki są w nim dane lub dopóki jest miejsce na dane) i tylko niekiedy  powodują fizyczny odczyt (gdy bufor jest pusty) lub zapis (gdy bufor jest pełny).

Np. przy czytaniu dużych plików tekstowych należy unikać bezpośredniego czytania za pomocą klasy FileReader. To samo dotyczy zapisu.
Zastosowanie klasy BufferedReader (czy BufferedWriter) powinno przynieść poprawę efektywności działania programu.
Ale klasa BufferedReader (BufferedWriter) jest klasą przetwarzającą, a wobec tego w jej konstruktorze nie możemy bezpośrednio podać fizycznego źródła danych.

Np. przy czytaniu plików źródło to podajemy przy konstrukcji obiektu typu FileReader, a po to, żeby uzyskać buforowanie, "opakowujemy" FileReadera BufferedReaderem.

Wygląda to mniej więcej tak:
FileReader fr = new FileReader("plik.txt");   // tu powstaje związek
                                              // z fizycznym źródłem

BufferedReader br = new BufferedReader(fr);    // tu dodajemy "opakowanie"
                                               // umożliwiające buforowanie
// czytamy wiersz po wierszu
String line;
while  ((line = br.readLine()) != null) {      // kolejny wiersz pliku:
                                               // metoda readLine zwraca wiersz
                                               // lub null jeśli koniec pliku
   // ... tu coś robimy z odczytanym wierszem
}
Uwagi.
Przykład buforowania: program, czytający plik tekstowy i zapisujący jego zawartośc do innego pliku wraz z numerami wierszy.

import java.io.*;

class Lines {
	
  public static void main(String args[]) {
    try {
      FileReader fr = new FileReader(args[0]);
      LineNumberReader lr = new LineNumberReader(fr);
      BufferedWriter bw = new BufferedWriter(
                              new FileWriter(args[1]));

      String line;
      while  ((line = lr.readLine()) != null) {
        bw.write( lr.getLineNumber() + " " + line);
        bw.newLine();
      }
      lr.close();
      bw.close();
    } catch(IOException exc) {
        System.out.println(exc.toString());
        System.exit(1);
   }

  }
}
Komentarze:

7. Strumienie binarne

Klasy przetwarzające DataInputStream i  DataOutputStream służą do odczytu/zapisu danych typów pierwotnych w postaci binarnej (oraz łańcuchów znakowych).

Metody tych klas mają postać:

typ readTyp( )  
void writeTyp(typ arg)

gdzie typ odpowiada nazwie któregoś z typów pierwotnych
Mamy więc np. metody readInt(),  readDouble() itp.

Dane typu String mogą być zapisywane/czytane do/z strumieni binarnych za pomocą metod writeUTF i readUTF.

Dla przykładu, stwórzmy klasę Obs, której obiekty reprezentują obserwacje. Każda obserwacaja ma nazwę oraz odpowiadający jej ciąg (tablicę) liczb rzeczywistych. Może to być np. MAX_TEMPERATURA z 12 liczbami, pokazującymi maksymalną temperaturę w 12 miesiącach roku.
W klasie tej zdefiniujemy także dwie metody, slużące do zapisu obserwacji w postaci binarnej do strumienia i odczytywania binarnych strumieni obserwacji.

import java.io.*;

class Obs {
  String name;
  double[] data;

  public Obs() {}

  public Obs(String nam, double[] dat) {
    name = nam;
    data = dat;
  }

  public void writeTo(DataOutputStream dout)
         throws IOException  {
    dout.writeUTF(name);
    dout.writeInt(data.length);
    for (int i=0; i<data.length; i++) dout.writeDouble(data[i]);
  }

  public Obs readFrom(DataInputStream din)
         throws IOException {
    name = din.readUTF();
    int n = din.readInt();
    data = new double[n];
    for (int i=0; i<n; i++) data[i] = din.readDouble();
    return this;
  }

  public void show() {
    System.out.println(name);
    for (int i=0; i<data.length; i++) System.out.print(data[i] + " ");
    System.out.println("");
  }
}
Zwróćmy uwagę, że przyjęliśmy następujący format zapisu obserwacji w pliku binarnym:
nazwa
liczba_elementów_tablicy
dane_tablicy.

Dzięki temu, metoda readFrom bez kłopotu może odczytywać dowolne obserwacje z dowolnych plików binarnych, pod warunkiem, że pliki te mają podany format.
 

Przykład wykorzystania klasy: tworzymy dwie obserwacje, pokazujemy jak wyglądają (show) zapisujemy je do pliku (writeTo(out)), po czym z tego samego pliku odczytujemy dane do innych (ad hoc tworzonych) obiektów-obserwacji i jednocześnie pokazujemy odczytane dane na konsoli (new Obs().readFrom(in).show()).

class BinDat {
	
  public static void main(String args[]) {
    double[] a = { 1, 2, 3, 4 };
    double[] b = { 7, 8, 9, 10 };

    Obs obsA = new Obs("Dane A", a);
    Obs obsB = new Obs("Dane B", b);

    obsA.show();
    obsB.show();

    try {
      DataOutputStream out = new DataOutputStream(
                                 new FileOutputStream("dane")
                                 );
      obsA.writeTo(out);
      obsB.writeTo(out);
      out.close();

      DataInputStream in = new DataInputStream(
                                 new FileInputStream("dane")
                                 );
      new Obs().readFrom(in).show();
      new Obs().readFrom(in).show();
      in.close();
      } catch (IOException exc) {
        exc.printStackTrace();
        System.exit(1);
        }


  }
}



8. Kodowanie

Java posługuje się znakami w formacie Unicode. Są to - ogólnie - wielkości 16-bitowe.
Środowiska natywne (np. Windows) najczęściej zapisują teksty jako sekwencje bajtów (z przyjętą stroną kodową).
Jak pogodzić najczęściej bajtowy charakter plików natywnych ze znakowymi strumieniami?
Otóż strumienie znakowe potrafią - niewidocznie dla nas -  przekształcać bajtowe źródła w znaki Unikodu i odwrotnie. "Pod pokrywką" tego procesu znajdują się dwie klasy: InputStreamReader i OutputStreamWriter, które dokonują właściwych konwersji w trakcie czytania/pisania.

Klasy te możemy wykorzystać również samodzielnie.
Jeśli w konstruktorach tych klas nie podamy strony kodowej - przy konwersjach zostanie przyjęta domyślna strona kodowa.
Aby się dowiedzieć, jakie jest domyślne kodowanie można użyć następującego programiku:

public class DefaultEncoding {
      public static void main(String args[])
      {
        String p = System.getProperty("file.encoding");
        System.out.println(p);
      }   
}

W zależności od ustawień na danej platformie otrzymamy różne wyniki. Np. ibm-852 lub Cp852 (Latin 2) albo Cp1252 (Windows Western Europe / Latin-1).

Inna wersja konstruktorów pozwala na podanie stron kodowych, które będą używane do kodownia i dekodowania bajty-znaki .
Napiszmy program wykonujący konwersje plików z-do dowolnych (dopuszczalnych przez Javę) formatów kodowania.
Dopuszczalne symbole kodowania można znaleźć na stronach java.sun.com.

import java.io.*;

class Convert {

  public static void main(String[] args) {

    if (args.length != 4) {
      System.out.println("Syntax: in in_enc out out_enc");
      System.exit(1);
    }

    String infile  = args[0],     // plik wejściowy
           in_enc  = args[1],     // wejściowa strona kodowa
           outfile = args[2],     // plik wyjściowy
           out_enc = args[3];     // wyjściowa strona kodowa

    try {
       FileInputStream fis = new FileInputStream(infile);
       BufferedReader in = new BufferedReader(new InputStreamReader(fis, in_enc));
       FileOutputStream fos = new FileOutputStream(outfile);
       BufferedWriter out = new BufferedWriter(new OutputStreamWriter(fos, out_enc));
       String line;
       while ((line = in.readLine()) != null) {
         out.write(line);
         out.newLine();
       }
       in.close();
       out.close();
    } catch (IOException e) {
        System.err.println(e);
        System.exit(1);
    }

  }
}

Przykładowe wykorzystanie do konwersji pliku zle.htm (zapisanego w Windows 1250) na plik dobrze.htm ( ISO-8859-2):

     java Convert zle.htm Cp1250 dobrze.htm ISO8859_2

O innych sposobach kodowania i dekodowania danych wejściowo-wyjściowych - zob. punkt dotyczący "nowego wejścia-wyjścia" (java.nio).

9. Serializacja obiektów

Obiekty  tworzone przez program  rezydują w pamięci operacyjnej, w przestrzeni adresowej procesu.
Są zatem nietrwałe, bo kiedy program kończy działanie wszystko co znajduje się w jego przestrzeni adresowej ulega wyczyszczeniu i nie może być odtworzone.

Serializacja (szeregowanie) pozwala na utrwalaniu obiektów.
W Javie polega ona na zapisywaniu obiektów do strumienia.


Podstawowe zastosowania serializacji:
  • komunikacja pomiędzy obiektami/aplikacjami poprzez gniazdka (sockets),
  • zachowanie obiektu (jego stanu i właściwości) do późniejszego odtworzenia i wykorzystania przez tę samą lub inną aplikację.


Do zapisywania/odczytywania obiektów służą klasy ObjectOutputStream oraz ObjectInputStream, które należą do strumieniowych klas przetwarzających.


Metoda klasy ObjectInputStream:

            void writeObject(Object o)  zapisuje obiekt o do strumienia

Metoda klasy ObjectInputStream:

            Object readObjectO   odczytuje obiekt ze strumienia i zwraca referencję do niego
   

Do strumieni mogą być zapisywane tylko serializowalne obiekty.
Obiekt jest serializowalny jeśli jego klasa implementuje interfejs Serializable
Prawie wszystkie klasy standardowych pakietów Javy implementują ten interfejs.
Również tablice (które są obiektami specjalnych klas definiowanych w trakcie kompilacji) są serializowalne.

Zatem bez problemu możemy utrwalać obiekty większości klas standardowych Javy oraz tablice.
Przykład: program zapisuje do strumienia obiekty - datę, tablicę opisów i odpowiadającą każdemu opisowi temperaturę. Następnie odczytuje te obiekty ze strumienia i odtwarza je.

import java.io.*;
import java.util.*;

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

    Date data = new Date();
    int[] temperatura = { 25, 19 , 22};
    String[] opis = { "dzień", "noc", "woda" };

    // Zapis
    try {

      ObjectOutputStream out = new ObjectOutputStream(
                                 new FileOutputStream("test.ser")
                                 );
      out.writeObject(data);
      out.writeObject(opis);
      out.writeObject(temperatura);
      out.close();
    } catch(IOException exc) {
        exc.printStackTrace();
        System.exit(1);
    }

    // Odtworzenie (zazwyczaj w innym programie)
    try {
      ObjectInputStream in = new ObjectInputStream(
                                 new FileInputStream("test.ser")
                                );
      Date odczytData = (Date) in.readObject();
      String[] odczytOpis = (String[]) in.readObject();
      int[] odczytTemp = (int[]) in.readObject();
      in.close();
      System.out.println(String.valueOf(odczytData));
      for (int i=0; i<odczytOpis.length; i++)
          System.out.println(odczytOpis[i] + " " + odczytTemp[i]);

    } catch(IOException exc) {
        exc.printStackTrace();
        System.exit(1);
    } catch(ClassNotFoundException exc) {
        System.out.println("Nie można odnaleźć klasy obiektu");
        System.exit(1);
    }

  }

}
Przykładowy wydruk programu.

Wed Jan 15 18:30:17 CET 2003
dzień 25
noc 19
woda 22


Metoda readObject() pobiera ze strumienia zapisane charakterystyki obiektu (w tym również oznaczenie klasy do której należy zapisany obiekt) - na ich podstawie tworzy nowy obiekt tej klasy i inicjuje go odczytanymi wartościami. Wynikiem jest referencja formalnego typu Object wskazująca na nowoutworzony obiekt, który jest identyczny z zapisanym. 
Ponieważ wynikiem jest Object, nalezy  wykonać odpowiednią konwersję zawężającą do właściwego typu (referencji do konkretnej podklasy klasy Object, tej mianowicie, której egzemplarzem faktycznie jest odczytany obiekt).
Może się też okazać, że w strumieniu zapisano obiekt klasy, która nie jest dostępna przy odczytywaniu (np. została usunięta). Wtedy przy tworzeniu obiektu z odczytanych danych powstanie wyjątek ClassNotFoundException, który musimy obslugiwać.

A jak serializować obiekty własnych klas?
Odpowiedź już znamy: klasa winna implementować interfejs Serializable.

Takie interfejsy (bez metod) nazywane są interfejsami znacznikowymi. Ich jedyną funkcją jest umożliwienie sprawdzenia typu np. za pomocą operatora instanceof. Metoda writeObject to własnie robi, gdy podejmuje decyzje o zapisie: jeśli jej argument x jest typu Serializable (x instanceof Serializable ma wartośc true), to obiekt jest zapisywany do strumienia, w przeciwnym razie - nie
Rzecz nie jest trudna, bowiem interfejs ten jest pusty - nie musimy więc implementować żadnych jego metod, wystarczy tylko wskazać, że nasza klasa implementuje interfesj Serializable.

Zobaczmy to na przykladzie nieco bardziej praktycznego zastosowania serializacji.
Przypomnijmy sobie klasę TravelSearcher z poprzedniego semestru. Slużyła ona do zgromadzenia w tablicy obiektów klasy Travel, opisujących destynacje (napis, np. Cypr) i ceny podróży (liczba całkowita, np. 1500) oraz dostarczała metody wyszukiwania informacji o cenie na podstawie podanej destynacji.
Klasa Travel jest bardzo prosta:
public class Travel ... {

   private String dest; // destynacja podrózy
   private int price;   // cena

   public Travel(String s, int p) {
     dest = s;
     price = p;
   }

   public String getDest() { return dest; }
   public int getPrice() { return price; }
   public String toString() { return dest + ", cena: " + price; }

}

Schemat klasy TravelSearcher przedstawiono poniżej:
public class TravelSearcher ... {

  private Travel[] travel;            // tablica podróży
  private int lastIndex = -1;         // indeks ostatnio zapisanej
  private final int MAX_COUNT = 5;    // max rozmiar tablicy
  private boolean sorted = false;     // czy jest posortowana

  // Konstruktor: tworzy tablicę
  public TravelSearcher() {
    travel = new Travel[MAX_COUNT];
  }

  // Metoda add dodaje nowy element do tablicy
  // jeżeli przekrozcono zakres
  // - zgłaszany jest wyjątek własnej klasy NoSpaceForTravelException
  public void add(Travel t) throws NoSpaceForTravelException {
    try {
      lastIndex++;
      travel[lastIndex] = t;
    } catch (ArrayIndexOutOfBoundsException exc) {
        lastIndex--;
        throw new NoSpaceForTravelException("Brakuje miejsca dla dodania podróży");
    }
    sorted = false;
  }

  // Jaki jest ostatni zapisany indeks
  public int getLastIndex() { return lastIndex; }


  // Wyszukiwanie podróży na podstawie podanego celu (destynacji)
  public Travel search(String dest) {
    if (!sorted) sortByDest();
    // ... wyszukiwanie binarne
  }

  // Sortowanie - aby można było stosować wyszukiwanie binarne
  private void sortByDest() {
    // ... sortowanie
    sorted = true;
  }

  public String toString() {
    // zwraca spis podróży z tablicy travel (destynacji i cen)
  }

}

Wyobraźmy sobie, że w innej klasie dostarczamy jakiś interfejs użytkownika, umożliwiający wprowadzanie informacji o podróżach i na tej podstawie tworzenie obiektów klasy Travel oraz wpisywanie ich do tablicy w klasie TravelSearcher.
Informacje podawane są na bieżąco (w jakichś oknach dialogowych) i zapisywane j dodawane do tablicy travel w klasie TravelSearcher.
W trakcie dzialania tego programu możemy zapewnić wyszuskiwanie informacji o wpisanych podróżach. Ale gdy program zakończy działanie - obiekt klasy TravelSearcher, a co za tym idzie cala tablica informacji o podróżach - znikną.
Możemy temu zaradzić zapewniając zapis informacji do pliku. Jeśli zastosujemy zwykłe strumienie (np. znakowe), to - oczywiście - w dość prosty sposób możemy zapisać do pliku listę podróży i wprowadzić ją znowu przy ponownym uruchomieniu aplikacji.

Dużo prościej jednak będzie zapisać  do strumienia obiekt klasy TravelSearcher.
Dodatkowo zyskamy całkowite odtworzenie stanu aplikacji (obiektu TravelSearcher) zapamiętanego przy poprzednim jej uruchomieniu. A ten stan, to nie tylko tablica wycieczek, ale również istotne dla dzialania aplikacji wartości indeksu ostatniego zapisanego elementu tablicy oraz zmiennej sorted pokazującej czy tablica jest posortowana.

Zatem należy zagwarantować serializowalność obiektów klasy TravelSearcher oraz - w głównej aplikacji (określającej interfejs użytkownika) zapewnić ich serializację poprzez użycie metody writeObject oraz odtwarzanie - za pomoca metody readObject.

Napiszemy więc na pewno:

public class TravelSearcher implements Serializable {
        ...
}


Czy to wystarczy? Zwróćmy uwagę, że polem klasy TravelSearcher jest tablica referencji do obiektów klasy Travel. Tablice - jak widzieliśmy poprzednio - zapisują się do strumieni obiektowych bez kłopotu. A co z obiektami klasy Travel, na które wskazują elementy tablicy?

Szczęśliwie:

Przy serializacji zapisywany i odtwarzany jest pełny stan obiektu (w tym - rekursywnie - obiektów składowych).

Ale nie są  zapisywane stany obiektów składowych, które należą do klas nieserializowalnych. Bo choć pola, odpowiadające tym obiektom są zapisywane, to przy odtwarzaniu, takie obiekty są tworzone za pomocą konstruktorów bezparametrowych z ich klas i nie ma żadnej innej inicjacji ich elementów.

Musimy zatem zapewnić również serializację obiektów klasy Travel:

public class Travel implements Serializable {
        ...
}


Po tych poprawkach, klas TravelSearcher i Travel możemy użyć w przykladowej aplikacji, stanowiącej interfejs użytkownika do wprowadzania, wyszukiwania, zapisywania i lstowania podróży.

Argumentem aplikacji jest plik "kartoteka", który zawiera lub będzie zawierał utrwalony obiekt klasy TravelSearcher. Jeżeli taki plik już istnieje, to informacje o wycieczkach są z niego odtwarzana za pomoca deserializacji utrwalonego obiektu. Użytkownik ma do wyboru różne tryby działania (np. wprowadzanie nowych danych, ich wyszukiwanie i - oczywiście - utrwalenie.

Pokazuje to poniższy program.
Przy okazji warto zwrócić uwagę na sposoby oprogramowania dialogow wyboru (zastosowane tu postaci metod z klasy JOptionPane będą wyjaśnione w następnych wykładach; już teraz można jednak samodzielnie zapoznać się z nimi na podstawie dokumentacji).

import java.io.*;
import java.util.*;
import javax.swing.*;

public class TravelApp {

  private String travFileName;
  private TravelSearcher travels;
  private boolean dataSaved = false;

  public TravelApp(String[] tfn) {
    try {
      travFileName = tfn[0];
      ObjectInputStream in = new ObjectInputStream(
                                 new FileInputStream(travFileName)
                             );
      travels = (TravelSearcher) in.readObject();
      in.close();

    } catch(ArrayIndexOutOfBoundsException exc) {
        showMsg("Syntax: java TravelApp plik_kartoteki");
        System.exit(1);

    } catch(FileNotFoundException exc) {
        showMsg("Nowa kartoteka!!!");
        travels = new TravelSearcher();

    } catch(IOException exc) {
        exc.printStackTrace();
        System.exit(2);

    } catch(ClassNotFoundException exc) {
       showMsg("Brak klasy dostępu do klasy TravelSearcher");
       System.exit(3);
    }

    String[] modes = { "Wprowadzanie", "Szukanie", "Zapis", "Pokaz", "Koniec" };
    while (true) {
      switch ( select("Wybierz tryb działania", modes)) {
        case 'W' : inputData(); break;
        case 'S' : searchData(); break;
        case 'Z' : saveData(); break;
        case 'P' : showData(); break;
        case 'K' : finish(); break;
        default : break;
      }
    }
  }

  private char select(String msg, String[] modes) {
    int sel = JOptionPane.showOptionDialog(null, msg,
                         "Travel App", 0, JOptionPane.QUESTION_MESSAGE,
                          null, modes, modes[1]);
    if (sel == JOptionPane.CLOSED_OPTION) return 0;
    return modes[sel].charAt(0);
  }

  public void inputData() {
    String data = "";
    String msg = "Wprowadź dane";
    while((data = ask(msg, data)) != null) {
      StringTokenizer st = new StringTokenizer(data);
      try {
        String dest = st.nextToken();
        int price = Integer.parseInt(st.nextToken());
        travels.add(new Travel(dest, price));
        dataSaved = false;
      } catch(NoSpaceForTravelException exc) {
          showMsg(exc.getMessage());
          return;
      } catch(Exception exc) {
          msg = "Dane wadliwe - popraw";
          continue;
      }

      msg = "Wprowadź dane";
      data = "";
    }
  }

  public void searchData() {
    if (travels.getLastIndex() >= 0) {
      String dest = "";
      String msg = "Podaj miejsce podróży";
      while((dest = ask(msg, "")) != null) {
        Travel t = travels.search(dest);
        String info = (t == null ? "Nie ma takiej podróży!" : t.toString() );
        showMsg(info);
      }
    }
    else showMsg("Nie ma żadnych danych do przeszukiwania!");
  }

  public void saveData() {
    ObjectOutputStream out = null;
    try {
      out = new ObjectOutputStream(
                new FileOutputStream(travFileName)
                );
      out.writeObject(travels);
    } catch(IOException exc) {
        showMsg(exc.getMessage());
    } finally {
        try { out.close(); } catch (Exception exc) {}
    }
    dataSaved = true;
  }

  public void showData() {
    System.out.println("Dane\n" + travels);
  }

  public void finish() {
    while (!dataSaved) {
      char ans = select("Czy zapisać dane?", new String[] { "Tak", "Nie" } );
      if (ans == 'T')  saveData();
      else if (ans == 'N') break;
    }
    System.exit(0);
  }

  private void showMsg(String msg) {
    JOptionPane.showMessageDialog(null, msg);
  }

  private String ask(String msg, String initVal) {
    return JOptionPane.showInputDialog(null, msg, initVal);
  }

  public static void main(String[] args) {
    new TravelApp(args);
  }

}


Na koniec warto powiedzieć, że:

 private void readObject(java.io.ObjectInputStream stream)
     throws IOException, ClassNotFoundException;
 private void writeObject(java.io.ObjectOutputStream stream)
     throws IOException
 

10. Potoki

Potoki służą do przesyłania danych pomiędzy równolegle działającymi wątkami.


Wątek produkujący dane zapisuje je do potoku wyjściowego (PipedWriter lub PipedOutputStream).
Potok ten za pomocą konstruktorów potokowych klas wejściowych (PipedReader i PipedInputStream) można przyłączyć do strumienia wejściowego, z którego inny watek będzie czytał dane.

Niech na przyklad obiekt-wątek klasy DataPutter produkuje jakieś dane i umieszcza je w strumieniu wyjściowym, do którego referencję otrzymuje konstruktor.

class DataPutter extends Thread {

  OutputStream out;

  public DataPutter(OutputStream o) {
    out = o;
  }

  public void run() {
    try {
      for (char c = 'a'; c <= 'z'; c++) out.write(c);
      out.close();
    } catch(IOException exc) { return; }

  }
}

a obiekt-wątek klasy DataGetter, odczytuje jakieś dane ze strumienia i wypisuje je na konsoli.

class DataGetter extends Thread {

  InputStream in;

  public DataGetter(InputStream i) {
    in = i;
  }

  public void run() {
    try {
      int c;
      while ((c = in.read()) != -1) System.out.println((char) c);
    } catch(IOException exc) { return; }
  }
}

Za pomocą potoku możemy połączyć wyjściowy strumień, do którego pisze DataPutter z wejściowym strumieniem czytanym przez DataGetter.

W tym celu zwiążemy strumień wyjściowy, do którego ma pisać DataPutter z potokiem:
PipedOutputStream pout = new PipedOutputStream();
i potok ten połączymy ze strumieniem wejściowym, z którego będzie czytał DataGetter. 

    PipedInputStream pin = new PipedInputStream(pout);

Po uruchomieniu obu watków:

class Main {
  public static void main(String[] args) throws IOException {
    PipedOutputStream pout = new PipedOutputStream();
    PipedInputStream pin = new PipedInputStream(pout);
    new DataPutter(pout).start();
    new DataGetter(pin).start();
  }
}
uzyskamy oczekiwany wynik: produkowanie przez jeden z nich danych i przesylanie ich potokiem do drugiego wątku.
Uwaga: przy pisaniu/czytaniu znaków Unikodu należy stosować klasy PipeWriter i  PipeReader.

Podstawową zaletą potoków jest to, iż umożliwiają one uproszczenie komunikacji pomiędzy wątkami. Wątek zapisuje dane do potoku i o nic więej nie musi dbać. Inny wątek czyta dane z potoku za pomocą zwyklego read(), na którym - ew. jest blokowany, jeśli danych jeszcze nie ma. Nie musimy martwić się o synchronizację i koordynację dzialania wątków. Samo pisanie i czytanie danych za pomocą potoków taką synchronizację i koordynacje już zapewnia. 

Przykład-zadanie:
Wątek-Autor pisze teksty, skladając losowo wybrane słowa w wiersze o losowo wybranej liczbie słów. Wiersze te pobiera wątek-Duplikator i rozdysponowuje je do wielu wątków-przepisywaczy (TxtWriter), Każdy z przepisywaczy równolegle  wypisuje tekst Autora w przydzielonynym mu miejscu (nazwiemy je SpaceToWrite). 

Poniżej pokazano możliwe rozwiązanie. Klasy są bardzo szczegółowo komentowane, zatem dodatkowy opis programu ograniczymy do minimum.

Klasa Author zzapewnie generowanie tekstów autora. Są one tworzone w wątku głównym programu, zatem nie dostarczyliśmy tu metody run().

import java.io.*;
import java.util.*;

public class Author {

  private int linesToWrite;    // ile wierszy ma napisac autor
  String[] words;              // z jakich slów się będą składać
  private Writer out;          // strumień do którego zapisuje teksty
  static final int N = 5;      // maksymalna liczba słów w wierszu

  public Author(int l, String[] words, Writer w) {
    linesToWrite = l;
    this.words = words;
    out = w;
    try {
      write();                  // wywołanie pisania
    } catch(IOException exc) {
        System.out.println(exc.toString());
    } catch(InterruptedException exc) {}
  }

  // Metoda pisania przez autora
  public void write() throws IOException,
                             InterruptedException {
    Random rand = new Random();
    for (int i=0; i < linesToWrite; i++) {

      // Każdy wiersz składa się z losowo wybranej nw liczby słów
      int nw = rand.nextInt(N) + 1;
      String line = "";

      for (int k=0; k<nw; k++) {   // słowa są losowane z tablicy words
        int wordNum = rand.nextInt(words.length);
        line += words[wordNum] + " ";
      }
      out.write(line);
      out.write('\n');
      Thread.sleep((rand.nextInt(3) + 1) * 1000);  // autor myśi nad
    }                                              // następnym wierszem :-)
    out.write("Koniec pracy\n");
    out.close();
    System.out.println("Autor skończył pisać");
  }
} 
Każdy z przepisywaczy (klasa TxtWriter) stanowi odrębny wątek, czyta teksty ze strumienia  wypisuje je w miejscu określonym przez przekazany konstruktorowi obiekt SpaceToWrite. Generalnie może to być cokolwiek, na czym będzie widać tekst. Jak zobaczymy dalej - w tym programie zastosujemy wielowierszowe pola edycyjne umieszczone w jednym oknie (po jednym polu dla każdego z przepisywaczy).

import java.io.*;

public class TxtWriter extends Thread {  // Klasa przepisywacza

  private LineNumberReader in;   // strumień skąd czyta
  private SpaceToWrite spw;      // miejsce gdzie pisze

  public TxtWriter(String name,      // nazwa przepisywacza
                   Reader in_,       // z jakiego strumeinia czyta
                   SpaceToWrite spw_ // gdzie pisze
                   )
  {
    super(name);
    in = new LineNumberReader(in_);  // filtrowanie strumienia
                                     // by mieć numery wierszy
    spw = spw_;
  }

  // Kod wątku przepisywacza
  // czyta wiersze ze strumienia wejściowego
  // i zapisuje je w miejscu oznaczanym spw (SpaceToWrite)
  // dopóki nie nadszedl sygnał o końcu pracy (tekst "Koniec pracy")
  public void run() {
    spw.writeLine(" *** " + getName() + " rozpoczął pracę" + " ***");
    spw.writeLine("---> czekam na teksty !");
    String txt;
    try {
      txt = in.readLine();
      while(!txt.equals("Koniec pracy")) {
        spw.writeLine(in.getLineNumber() + " " + txt);
        txt = in.readLine();
      }
      in.close();
      spw.writeLine("**** " + getName() + " skończył pracę");
    } catch(IOException exc) {
        spw.writeLine("****" + getName() + " - zakonczenie na skutek bledu");
        exc.printStackTrace();
        return;
    }
  }
}

Wątki przepisywaczy tworzy i uruchamia Duplikator. Pośredniczy on rownież w przekazywaniu tekstów od autora do przepisywaczy i czyni to właśnie za pomocą potoków.

import java.io.*;

public class Duplicator extends Thread {

  PipedReader fromAuthor;    // potok od autora
  PipedWriter[] toWriters;   // potoki do przepisywaczy

  public Duplicator(PipedReader pr,       // potok od autora
                    SpaceToWrite[] space  // na czym piszą pzrepisywacze?
                    ) throws IOException {
    fromAuthor = pr;

    int numOfWriters = space.length;      // tylu jest przepisywaczy
                                          // ile miejsc na których piszą

    // Tworzymy tablicę potoków do przepisywaczy
    toWriters = new PipedWriter[numOfWriters];

    for (int i = 0; i < numOfWriters; i++) { // dla każdego przepisywacza

      // tworzymy potok do niego
      toWriters[i] = new PipedWriter();

      // tworzymy przepisywacza
      // podając: nazwę, z jakiego potoku ma czytać, miejsce gdzie ma pisać
      TxtWriter tw = new TxtWriter("TxtWriter " + (i+1),
                                   new PipedReader( toWriters[i]), // połączenie!
                                   space[i]);

      // uruchamiamy wątek przepisywacza
      tw.start();
    }
  }

  // Kod wykonywany w wątku Duplikatora
  public void run() {
    try {
      // Buforowanie potoku od autora
      BufferedReader in = new BufferedReader(fromAuthor);

      // czytanie wierszy z potoku od autora
      // i zapisywanie ich do potoków, czytanych przez przepisywaczy
      while (true) {
        String line = in.readLine();
        for (int i = 0; i < toWriters.length; i++) {
          toWriters[i].write(line);
          toWriters[i].write('\n');
        }
        if (line.equals("Koniec pracy")) break;
      }
    } catch (IOException exc) { return; }
    System.out.println("Duplikator zakończył działanie");
  }

}

Główna klasa aplikacji organizuje cały ten "proceder".

import java.io.*;

class PipesShow {

  PipedWriter authorWrites = new PipedWriter(); // potok, do którego pisze autor
  PipedReader duplicatorReads;                  // potok, z ktorego czyta duplikator

  Duplicator dup;

  PipesShow(int numLines, int numWriters) {

    // każdy przepisywacz na swoją przestrzeń pisania
    SpaceToWrite[] writeSpace = new SpaceToWrite[numWriters];
    for (int i=0; i < writeSpace.length; i++)
      writeSpace[i] = new SpaceToWrite(20, 30); // 20 wierszy, 30 kolumn

    try {
      // Połączenie potoku do ktorego pisze autor
      // z nowoutworzonym potokiem, z którego będzie czytał duplikator
      duplicatorReads = new PipedReader(authorWrites);

      // utworzenie duplikatora (on z kolei stworzy i uruchomi przepisywaczy)
      dup = new Duplicator(duplicatorReads, // skąd będzie czytał
                           writeSpace);     // przetstrzeń pisania dla przepisywaczy

      // start wątku duplikatora
      dup.start();

    } catch (IOException exc) {
        System.out.println("Nie można stworzyć duplikatora");
        exc.printStackTrace();
        System.exit(1);
    }

    SpaceToWrite.show(numWriters); // pokazanie ogólnej przestrzeni pisania
                                   // grupującej przestrzenie pisania
                                   // każdego przepisywacza

    // Teraz autor będzie pisał!
    // Utworzenie obiektu klasy Autor powoduje rozpoczęcie przez niego pisania

    String words[] = { "Ala", "ma", "kota", "i", "psa" };

    Author autor = new Author(numLines,      // ile wierszy ma napisać
                              words,         // z jakich słów składać teksty
                              authorWrites); // Dokąd je zapisywać
  }

  public static void main(String args[]) {
    int numLin = 0; // ile wierszy ma napisać autor
    int numWri = 0; // ilu jest przepisywaczy
    try {
      numLin = Integer.parseInt(args[0]);
      numWri = Integer.parseInt(args[1]);
    } catch(Exception exc) {
        System.out.println("Syntax: java  PipesShow numLines numWri");
        System.exit(1);
    }
    new PipesShow(numLin, numWri);
  }
}

No i w końcu przestrzeń przepisywania. Uprzedzając nieco wykłady o graficznych interfejscah użytkownika zastosujemy tu proste elementy AWT. Jeżeli w tej chwili będzie to niezrozumiałe - proszę się nie martwić (dzięki klasie SpaceToWrite odseparowaliśmy wygląd aplikacji od jej funkcjonalności, zatem z punktu widzenia eksperymentowania z potokami nie ma większego znaczenia jaką postać ma ta klasa).

// Klasa, określająca przestrzenie
// na których piszą przepisywacze
// oraz grupująca te przestrzenie w oknie.
// Każdy przepisywacz wypisuje tekst
// do wielowierszowego pola edycyjnego (TextArea z pakietu AWT)
// do czego służy mu metoda writeLine.
// Wszystkie przestrzenie grupowane są w oknie frame.

import java.awt.*;
import java.awt.event.*;

public class SpaceToWrite extends TextArea {

  private static Frame frame = new Frame("Write space");

  // Konstruktor: tworzy nową przetrzeń pisania dla jednego przepisywacza
  public SpaceToWrite(int rows, int cols) {
    super(rows, cols);  // utworzenie TextArea  - z podaną liczbą wierszy, kolumn
    frame.add(this);    // dodanie TextArea do okna
  }

  // Metoda dopisująca nowy wiersz do textarea
  public void writeLine(String s) {
    this.append(s + '\n');
  }

  // Metoda ustalająca ułożenie pól edycyjnych w oknie
  // rozmiar okna (pack daje rozmiar taki jak akurat potrzreba)
  // i pokazująca okno
  public static void show(int numWriters) {
    frame.setLayout(new GridLayout(0, numWriters));
    frame.pack();
    frame.show();

    // Umożliwienie zakończenia aplikacji poprzez zamknięcie okna
    frame.addWindowListener(new WindowAdapter() {
      public void windowClosing(WindowEvent e) {
        frame.dispose();
        System.exit(1);
      }
    });
  }

Wynik dzialania całego programu pokazuje poniższy rysunek:
r

Dla prześledzenia równoleglej pracy przepisywaczy proszę skompilować i uruchomić ten program na własnym komputerze (program znajduje się w katalogu PipesTest).



11. Obiekty plikowe

Klasa File oznacza obiekty plikowe (pliki i katalogi). Jej metody umożliwiają m.in. uzyskiwanie informacji o plikach i katalogach, jak również wykonywanie działań na systemie plikowym.
 
Wybrane metody klasy File
 booleancanRead()
          Czy plik może być czytany?
 booleancanWrite()
          Czy plik może być zapisywany?
 booleancreateNewFile()
          Tworzy nowy plik
static FilecreateTempFile(String prefix, String suffix)
         Tworzy nowy plik tymczasowy z nazwą wg wzorca
static FilecreateTempFile(String prefix, String suffix, File directory)
         Tworzy nowy plik tymczasowy z nazwą wg wzorca w podanym katalogu
 booleandelete()
          Usuwa plik lub katalog
 voiddeleteOnExit()
          Zaznacza plik do usunięcia po zakończeniu programu.
 booleanexists()
          Czy plik/katalog istnieje?
 StringgetName()
          Nazwa pliku lub katalogu
 StringgetParent()
          Katalog nadrzędny
 StringgetPath()
          Ścieżka
 booleanisDirectory()
          Czy to katalog?
 booleanisFile()
          Czy plik?
 booleanisHidden()
          Czy ukryty?
 longlastModified()
          Czas ostatniej modyfikacji
 longlength()
          Rozmiar
 String[]list()
          Lista nazw plików i katalogów w katalogu
 String[]list(FilenameFilter filter)
          Lista nazw plików wg wzorca; zob wykład 3
 File[]listFiles()
          Lista plików i katalogów
 File[]listFiles(FileFilter filter)
           Lista plików i katalogów
 File[]listFiles(FilenameFilter filter)
          Lista plików i katalogów
static File[]listRoots()
          Lista dostępnych "rootów" w systemie plikowym
 booleanmkdir()
          Tworzy katalog
 booleanmkdirs()
          Tworzy katalog i ew. niezbędne (niestniejące) katalogi nadrzędne
 booleanrenameTo(File dest)
          Renames the file denoted by this abstract pathname.
 booleansetLastModified(long time)
          Ustala czas ostatniej modyfikacji
 booleansetReadOnly()
          Zaznacza jako tylko od odczytu
 URItoURI()
          Tworzy obiekt klasy URI (Uniform Resource Identifier), reprezentujący ten obiekt plikowy 
 URLtoURL()
           Tworzy obiekt klasy URL (Uniform Resource Locator), reprezentujący ten obiekt plikowy 



12. Pliki o dostępie swobodnym

Klasa RandomAccessFile definiuje pliki o dostępie swobodnym, które mogą być otwarte z trybie "czytania" lub "czytania i pisania".  Swobodny dostęp oznacza dostęp do dowolnego bajtu danych bez potrzeby sekwencyjnego przetwarzania pliku od początku. 

Konstruktory klasy mają następującą postać:

    RandomAccessFile(String filename, String mode)
    RandomAccessFile(File file, String mode)

    gdzie mode oznacza jeden z następujących trybów otwarcia


Pliki o dostępie swobodnym mogą być traktowane jako ciągi bajtów. Bieżący bajt  do odczytu lub miejsce do zapisu określa specjalny wskaźnik pozycji w pliku (filePointer). Pozycję tę możemy zmieniać za pomoca metod seek() i skip(). Jest także zmieniana przy każdej operacji czytania lub pisania.
Do czytania/pisania służy wiele metod read... i write... , które pozwalają operować  na różnych rodzaajch danych odczytywanych z i zapisywanych do pliku (np. readDouble, readLine, writeInt itp.),

Pliki o dostępie swobodnym nie są strumieniami. Klasa RandomAccessFile nie należy więc do hierarchii klas strumieniowych


13. Archiwizacja, kompresja i dekompresja

Pakiet java.util.zip dostarcza klas umożliwiających kompresję i dekompresję danych.
Należą do nich klasy pokazane w tabeli.

Tabela. Wybrane klasy pakietu java.util.zip

KlasaFunkcjonalność
Deflater Kompresja dowolnyc danych z użyciem biblioteki ZLIB (działanie na danych w pamięci np. Stringach)
InflaterKompresja dowolnyc danych z użyciem biblioteki ZLIB (działanie na danych w pamięci np. Stringach)
DeflaterOutputStream Strumieniowa klasa przetwarzająca: pozwala na kompresję danych  (wg protokołu ZLIB) w trakcie zapisu dotsrumienia wyjściowego.
InflaterInputStream Strumieniowa klasa przetwarzająca; pozwala na dekompresję danych w trakcie odczytu.
GZIPInputStreamStrumieniowa klasa przetwarzająca: dekompresja w trakcie odczytywania plików w formacie GZIP.
GZIPOutputStreamStrumieniowa klasa przetwarzająca: kompresja w trakcie zapisu do  plików w formacie GZIP.
ZipInputStream Jak poprzednie klasy, ale czytanie i rozpakowywanie plików ZIP
ZipOutputStreamJak poprzednie klasy, ale kompresja danych i zapis do plików ZIP
ZipEntry Reprezentuje element ("wejście") w pliku ZIP
ZipFile Klasa pozwalająca czytać "wejścia" elementy z pliku ZIP w dowolnym porządku.

Zastosowanie narzędzi kompresji - dekompresji rozpatrzymy na przykładzie przetwarzania plików ZIP. Zauważmy, że wraz ze spakowaną zawartością plików, archiwum ZIP zawiera elementy opisujące każdy plik (tzw. "wejścia" - entry). Więcej informacji nt kompresji i dekompresji, a wsczególności formatów ZLIB, ZIP i GZIP  można znaleźć w ogólniedostępnych materiałach w Internecie.

Aby skompresować (spakować) dane i zapisać je do pliku ZIP trzeba wykonać następujące kroki.

* Nałożyć na strumień wyjściowy związany z  nowotworzonym archiwum obiekt klasy przetwarzającej ZipOutputStream:

    ZipOutputStreame zip = new ZipOutputStream(
                                            new BufferedInputStream(
                                                new FileInputStream("nazwa.zip")
                                             }
                                          );

* Dla każdego pliku wejściowego (który ma podlegać kompresji) utworzyć "entry" w pliku ZIP:

    ZipEntry entry = new ZipEntry(nazwa);

* Zapisać "entry":

    zip.putNextEntry(entry);

* Zapisać do strumienia zip zawartość pliku wejściowego.

* Zamknąć bieżące "entry" (kolejne putNextEntry robi to automatycznie):

    zip.closeEntry();


Przy rozpakowaniu archiwum możemy użyć klasy ZipFile lub ZipInputSteram. W tym ostatnim przypadku kolejność działań jest następująca.

* Stworzenie rozpakowującego strumienia wejściowego:

        ZipInputStream  zis = new  ZipInputStream(
                                            new BufferedInputStream(
                                                new FileInputSTream("nazwa.zip")
                                            )
                                          );

* Przetwarzanie (rozpakownaie) elementów archiwum:
   
    ZipEntry entry; // element archiwum (spakowany plik lub katalog)

        // Dopóki są w w archiwum elementy
        // Pobieramy je i przetwarzamy
        while((entry = zis.getNextEntry()) != null) {
            String ename = entry.getName(); // nazwa elementu archiwum
             ...
         }

* W powyższej pętli dla każdego elementu (pliku) archiwum tworzymy strumień wyjściowy (do zapisu)/ o nazwie takiej samej jak w archiwum (ename)

* I dalej w tej pętli czytamy dane ze strumeinia zis (metoda read() zwróci -1 gdy przeczytamy dany element - plik - z archiwum) i zapisujemy je do strumienia wyjściowego (reprezentującego rozpakowany plik).

Dla przykładu stworzymy klasę ZipArch, która będzie pozwalać na archiwizowanie w formacie zip plikóe i (rekursywnie) katalogów oraz na rozpakowywanie takich archiwów.

class ZipArch {

  // rozmiar bufora dla plików
  static final int BUF_SIZE = 4096;

  // Znak separatora katalogu ("\" lub "/" - zależnie od platformy)
  static final private String fileSep = System.getProperty("file.separator");

  private String zipFileName;  // archiwum ZIP

  // konstruktor; argument - nazwa archiwum (tworzonego lub rozpakowywanego)
  public ZipArch(String fileName) {
    zipFileName = fileName;
  }

  private boolean verbose = true; // czy pokazywać postępy?
  public void setVerbose(boolean b) { verbose = b; }
  public boolean isVerbose() { return verbose; }

  // Metoda rozpakowuje archiwum o nazwie zipFileName
  public void unzip() throws IOException, ZipException {
    // ...
  }  

  // Metoda kompresująca: argument - plik lub katalog do spakowania
  public void zip(String srcFileName) throws IOException, ZipException {
    // ...
   }

W prezentowanej dalej metodzie unzip ciekawe jest odtwarzanie struktury katalogowej zapisanej w archiwum.

  public void unzip() throws IOException, ZipException {

    // Zbiór createdDirs przechowuje utworzone przy rozpakowywaniu katalogi,
    // tak by szybko stwierdzić czy już katalog utworzyliśmy
    // i nie próbować go tworzyć jeszcze raz (zob. zbiory w rozdz.o kolekcjach)
    Set createdDirs = new HashSet();

    // Utworzenie wejściowego strumienia związanego z plikiem ZIP
    FileInputStream fis = new FileInputStream(zipFileName);

    // Nałożenie na ten strumień przetwarzania w postaci
    // - najpierw buforowania, następnie - dekompresji
    ZipInputStream  zis = new  ZipInputStream(
                            new BufferedInputStream(fis));

    ZipEntry entry; // element archiwum (spakowany plik lub katalog)

    // Dopóki są w w archiwum elementy
    // Pobieramy je i przetwarzamy
    while((entry = zis.getNextEntry()) != null) {

      String ename = entry.getName(); // nazwa elementu archiwum
                                      // np. \windows\bum.txt
      // Gdy wączona opcja pokazywania postępów (zob. pole verbose)
      if (verbose) System.out.println("Inflating " + ename);

      // Tworzenie ew. pustych katalogów zapisanych w ZIPie
      // Tylko dla pustego katalogu entry.isDirectory() będzie true
      // Nazwa każdego innego elementu archiwum będzie miała postać
      // [d:][\katalog1\katalog2\...\]plik
      if (entry.isDirectory()) {
        new File(ename).mkdirs(); // tworzymy pusty (z nadkatalogami)
        continue;                 // z pustego nie ma co rozpakować!
      }

      // Jeżeli archiwum zawiera (niepuste) katalogi,
      // to musimy je utworzyć przed rozpakowaniem plików

      int p = ename.lastIndexOf(fileSep); // ostatni znak "/" lub "\"
      if (p != -1) {     // czy "entry" jest plikiem w katalogu?

        // Nazwa katalogu
        String dirName = ename.substring(0,p+1);

        // Jeśli go jeszcze nie utworzyliśmy ...
        if (!createdDirs.contains(dirName)) {

          createdDirs.add(dirName);       // rejestrujemy, że utworzony!
          File dir = new File(dirName);
          boolean created = dir.mkdirs(); // tworzymy (wraz z nadkatalogami)

          if (!created) {                 // nie udało się utworzyć
            int rc = JOptionPane.showConfirmDialog(null,
                       "Katalog " + dirName + " już istnieje." +
                       "Kontynuować?");
            if (rc != 0)  { // niezgoda na kontynuację programu
              throw
              new IOException("Unable to create directory "+ dirName);
             }
           }
         }
     }  // Koniec tworzenia ew. niezbędnego katalogu

     // Teraz dla każdego elementu (pliku) archiwum tworzymy
     // buforowany strumień wyjściowy (do zapisu)
     // o nazwie takiej samej jak w archiwum (ename)

     BufferedOutputStream out = new BufferedOutputStream(
                                  new FileOutputStream(ename), // plik out
                                  BUF_SIZE                // rozmiar bufora
                                 );

      byte data[] = new byte[BUF_SIZE]; // tablica. do wczytywania danych
      int count;                        // liczba przeczytanych bajtów
      while ((count = zis.read(data, 0, BUF_SIZE)) != -1) {
        out.write(data, 0, count);
      }
      out.close();
    }
    zis.close();
  }  // Koniec metody unzip
Natomiast w kodzie metody zip interesujące może być zarówno rekursywne archiwizowanie struktur katalogowych, jak i sposób na pozbywanie się w archiwizowanej strukturze katalogu głównego dysku.
  public void zip(String srcFileName) throws IOException, ZipException {

    // Strumień wyjściowy archiuwm ZIP
    // na zapisywany plik ZIP nałożone jest buforowanie,
    // a następnie kompresja (strumień przetwarzający ZipOutputStream)
    ZipOutputStream zos = new ZipOutputStream(
                             new BufferedOutputStream(
                                new FileOutputStream(zipFileName),
                                BUF_SIZE
                                )
                             );
     // Ze względu na ew. rekurencyjne wchodzenie w podkatalogi żródła
     // wywołujemy wewnętrzną metodę doZip
     // z argumentami plik (lub katalog) do archiwizacji,
     //               zip-strumień wyjściowy
     doZip(new File(srcFileName), zos);
     zos.close();
  }

  // Metoda rekurencyjnie archiwizuje pliki podane jako fileToZip
  // do archiwum, do którego przyłączony jest strumień zos
  private void doZip(File fileToZip, ZipOutputStream zos)
                     throws IOException, ZipException
  {
    if (fileToZip.isDirectory()) {  // Jeżeli archiwizacja ma dotyczyć katalogu

      File[] listToZip = fileToZip.listFiles(); // lista obiektów plikowych

      for (int i=0; i<listToZip.length; i++) {
        doZip(listToZip[i], zos);  // dla każdego obiektu plikowego w tym
      }                            // katalogu wolamy rekurencyjnie doZip

    }
    else {  // jezeli to plik - zipujemy!

      String fname = fileToZip.toString();   // nazwa pliku

      // czy przypadkiem nie ma postaci d:\plik_lub_katalog
      // w tym przypadku w nazwie elementu ("wejścia") archiwum
      // pominiemy literowe oznaczenie dysku, dwukropek i separator
      int colon = fname.indexOf(":") + 1;
      if (colon!= 0) fname = fname.substring(colon); // bez "d:"
      if (fname.charAt(0) == fileSep.charAt(0)) { // zdjąć ew. separator
        fname = fname.substring(1);
      }

      // Tworzymy nowe "wejście" - opisujące nowy element archiwum
      ZipEntry entry = new ZipEntry(fname);
      // i zapisujemy to "wejście"
      zos.putNextEntry(entry);

      // informacja o postępach kompresji
      if (verbose) System.out.println("Deflating " + entry);

      // Czytamy plik i zapisujemy jego zawartość
      // w skompresowanej postaci "pod" otwartym "wejściem" entry
      BufferedInputStream in = new BufferedInputStream(
                                    new FileInputStream(fileToZip),
                                    BUF_SIZE
                                   );
      byte data[] = new byte[BUF_SIZE]; // tablica. do wczytywania danych
      int count;                        // liczba przeczytanych bajtów

      while ((count = in.read(data)) != -1) { // czytanie i zapis z kompresją
            zos.write(data, 0, count);
      }
      in.close(); // zamknięcie pliku wejściowego

      zos.closeEntry(); // zamknięcie elementu (koniec zapisu elementu)
    }
  }

Przedstawiony kod klasy ZipArch  można łatwo dostosować do różnych potrzeb i opcji działania.

Zauważmy na koniec, że również pliki JAR (które służą w Javie do zapisywania klas w strukturach pakietów oraz innych zasobów - np. plików graficznych czy dźwiękowych) są tak naprawdę archiwami ZIP. Istotna różnica w stosunku do plikó ZIP polega na tym, że JAR zawiera specjalny plik tzw. manifest , w którym dostarcza się informacji potzrebnych do własciwej interpretacji zawartości archiwum (np. która z klas zaiera metodę main, jaka jest stosowana polityka bezpieczeństwa itp.).
Pakiet java.util.jar zawiera klasy do  manipulowania zawartością plików JAR (m.in. dające możliwość dynamicznego ładowania zapisanych tam klas i ich "wykonywania").

14. Skaner i formator

W wykładzie "Podstawy programowania w języku Java" poznaliśmy już dość szczegółowo zastosowania skanera i fotmatora. Teraz więc tylko krótkie przypomneinie.

Klasa java.util.Scanner pozwala na łatwy, elastyczny i o bardzo dużych możliwościach rozbiór informacji zawierającej napisy i dane typów prostych.

Możliwości
:


Przykład.

import java.util.*;

class Employee {
 String name;
 double salary;

 Employee(String n, double s) {
   name = n; salary = s;
 }

 public double getSalary() { return salary; }

 public String toString() { return name + " " + salary; }

}

public class Skaner1 {

 String s1 = "1 2 3";
 String s2 = "Jan Kowalski/1200\nA. Grabowski/1500";


 public Skaner1() {
   Scanner scan1 = new Scanner(s1);
   int suma = 0;
   while (scan1.hasNextInt()) suma += scan1.nextInt();
   System.out.println("Suma = " + suma);

   List<Employee> list = new ArrayList<Employee>();
   Scanner scan2 = new Scanner(s2);
   while (scan2.hasNextLine()) {
     Scanner scan3 = new Scanner(scan2.nextLine()).useDelimiter("/");
     String name = scan3.next();
     double salary = scan3.nextDouble();
     list.add(new Employee(name, salary));
   }

   double value = 0;
   for (Employee emp : list) {
     value += emp.getSalary();
     System.out.println(emp);
   }
   System.out.println("Suma zarobków: " + value);
 }

 public static void main(String[] args) {
   Skaner1 skaner1 = new Skaner1();
 }

}

Wynik działania programu:
Suma = 6
Jan Kowalski 1200.0
A. Grabowski 1500.0
Suma zarobków: 2700.0

Klasa java.utill.Formatter zapewnia możliwości formatowania danych.

Tworząc formator (za pomocą wywołania konstruktora) możemy określić:

File, String, OutputStream, obiekty klas implementujących interfejs Appendable, czyli:
 
Uwaga: formatory dla destynacji implementującyh interfejs Closeable (m.in. pliki, strumienie) powinny być po użyciu zamykane lub wymiatane (close() flush()), co powoduje zamknięcie lub wymiecenie buforów tych destynacji.

Formatowanie polega na wywołaniu jednej z dwóch wersji metody format (na rzecz fornatora):

 Formatter format(Locale l, String format, Object... args)
         
 Formatter format(String format, Object... args)

Łańcuch formatu zawiera dowolne ciągi znaków oraz specjalne symbole formatujące (podobne jak w printf w jezyku C). Dalej następują dane do "wstawienia" w łańcuch formatu w miejscu symboli formatu i do sformatowania podług tych symboli (zmienna liczba argumentów). Wszystko działa podobnie jak printf. Dzięki autoboxingowi nie ma problemu z formatowaniem danych typów prostych.

Ciekawe właściwości formatora to m.in.:
Dla uproszczenia dostępne są: