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ście | Wyjście |
Strumienie bajtowe | InputStream | OutputStream |
Strumienie znakowe | Reader | Writer |
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.
- czytanie - read(..) (bajtów, znaków) - różne wersje tej (przeciążonej)
metody pozwalają na przeczytanie jednego bajtu ze strumienia bajtowego lub
znaku ze strumienia znakowego albo całej porcji bajtów/znaków,
- zapisywanie (write(...)) (bajtów/znaków) - różne wersje tej
(przeciążonej) metody pozwalają zapisywac pojedyńcze bajty/znaki lub tablice
bajtów/znaków, a w przypadku strumieni znakowych również napisy (obiekty
klasy String),
- pozycjonowanie strumieni (metody skip(..), mark(..), reset()
) - każdy strumień może być traktowany jako sekwencja bajtów/znaków, czytanie
i zapisywanie zawsze dotyczy bieżącej pozycji tej sekwencji; po wykonaniu
operacji czytania lub zapisu bieżąca pozycja zwiększa się o jeden; metody
pozycjonowania pozwalają zmieniać bieżącą pozycję.
- zamykanie strumieni (metoda close()) - strumień zawsze należy zamknąć po zakończeniu operacji na nim.
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/odbiornik | Strumienie znakowe | Strumienie bajtowe |
---|
Pamięć | CharArrayReader, CharArrayWriter | ByteArrayInputStream, ByteArrayOutputStream |
StringReader, StringWriter | StringBufferInputStream |
Potok | PipedReader, 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:
- jedną z wersji konstruktorów klas strumieniowych związanych z plikami
są konstruktory, w których podajemy jako argument nazwę pliku (można także
utworzyć strumień plikowy podając jako argument konstruktora referenecję
do obiektu klasy File),
- przy tworzeniu obiektów klas strumieniowych, związanych z plikami,
odpowiednie pliki są otwierane; strumienie wejściowe są otwierane "tylko
do odczytu", strumienie wyjściowe "tylko do zapisu".
- strumienie
wyjściowe mogą być otwarte w trybie dopisywania (należy użyć konstruktora
z drugim argumentem "append mode" = true); w takim przypadku dane będo dopisywane
do końca strumienia,
- przy operacjach na strumieniach może powstać wyjątek klasy IOException
oznaczający błąd operacji (np. odczytu lub zapisu), a także wyjątki klas
pochodnych FileNotFoundException (brak pliku) oraz EOFException (w trakcie
operacji czytania lub pozycjonowania osiągnięto koniec pliku),
- przy obsłudze wyjątków wejścia-wyjścia stosujemy metodę printStackTrace(),
która wyprowadza dokładne informacje o przyczynie i miejscu wystąpienia wyjątku.
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 znakowe | Strumienie bajtowe |
---|
Buforowanie | BufferedReader,
BufferedWriter | BufferedInputStream,
BufferedOutputStream |
Filtrowanie | FilterReader,
FilterWriter | FilterInputStream,
FilterOutputStream |
Konwersja: bajty-znaki | InputStreamReader,
OutputStreamWriter | |
Konkatenacja | | SequenceInputStream |
Serializacja obiektów | | ObjectInputStream,
ObjectOutputStream |
Konwersje danych | | DataInputStream,
DataOutputStream |
Zliczanie wierszy | LineNumberReader | LineNumberInputStream |
Podglądanie | PushbackReader | PushbackInputStream |
Drukowanie | PrintWriter | PrintStream |
Komentarze:
-
Buforowanie ogranicza liczbę fizycznych odwołań do urządzeń zewnętrznych.
-
Klasy Filter... są klasami abstrakcyjnymi, definiującymi interfejs dla
rzeczywistych filtrów. Filtrami są np.:
-
DataInputStream i DataOutputStream,
-
BufferedInputStream i BufferedOutputStream,
-
LineNumberInputStream,
-
PushbackInputStream,
-
PrintStream,
Można tworzyć własne filtry.
-
Konwersje bajty-znaki
-
InputStreamReader czyta bajty ze strumienia definiowanego przez InputStream
(strumień bajtowy) i zamienia je na znaki (16 bitowe), używając domyślnej
lub podanej strony kodowej,
-
OutputStreamWriter wykonuje przy zapisie konwersję odwrotną.
-
Konkatenacja strumieni wejściowych pozwala połączyć strumienie i traktować
je jak jeden strumień.
-
Serializacja służy do "utrwalania" obiektów po to, by odtworzyć je w innym
kontekście (przy ponownym uruchomieniu programu lub w innym miejscu (np.
programie działającym w innym miejscu sieci po przekazaniu "utrwalonego"
obiektu przez socket),
- DataInputStream i DataOutputStream pozwalają
czytać/pisać dane typów pierwotnych (np. liczby rzeczywiste) w postaci binarnej.
Strumienie są tutaj strumieniami binarnymi, w związku z tym koniec strumienia
rozpoznaje się jako wyjątek EOFException.
-
LineNumber... zlicza wiersze strumienia przy czytaniu (i pozwala w każdym
momencie uzyskać informację o numerze wiersza).
-
PushBack.. pozwala podglądnąć następny znak/bajt w strumieniu bez "wyciągania" tego znaku/bajtu.
-
Klasy Print... zawierają wygodne metody wyjścia (np. println). Niekoniecznie
oznacza to drukowanie fizyczne, często wykorzystywane jest w powiązaniu z
innymi strumieniami po to by łatwo wyprowadzać informacje.
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:
- stworzenia obiektu związanego z fizycznym źródłem/odbiornikiem
- stworzenie obiektu odpowiedniej klasy przetwarzającej, "nałożonego" na fizyczny strumień.
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ódło: Java Tutorial, Sun Microsystems 2002
Klasy dla strumieni znakowych
Ż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.
-
w konstruktorach klas Buffered... możemy podać rozmiar bufora. Domyślny rozmiar jest wystarczający dla codziennych zastosowań.
- w klasie BufferedReader zdefiniowano wygodną metodę readLine(), umożliwiająca czytanie pliku tekstwoego wiersz po wierszu.
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:
- klasa LineNumberReader dziedziczy klasę BufferedReader, dając możliwość
prostego uzyskiwania informacji o numerze bieżącego wiersza (metoda getLineNumner(),
- zastosowanie metody newLine() z klasy BufferedWriter pozwala w niezależny
od platformy systemowej sposób zapisywać separatory wierszy,
- przy zamknięciu wyjściowego strumienia buforowanego zawartość bufora
jest zapisywana do strumienia; istnieje też metoda void flush( ), zapisujące
dane które pozostaływ buforze a nie zostały jeszcze zapisane w miejscu przeznaczenia;
takie "ręczne" opróżnianie bufora jest czasem przydatne.
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:
- przy serializacji nie są zapisywane pola statyczne oraz pola deklarowane
ze specyfikatorem
transient; specyfikatora transient używamy więc wobec elementów informacji o obiekcie, których nie chcemy poddawać utrwaleniu.
- pełniejszą kontrolę nad sposobem serializacji możemy zyskać definiując
odpowiednie metody w klasie obiektu serializowanegoo, metody te winny mieć
następujące sygnatury:
private void readObject(java.io.ObjectInputStream stream)
throws IOException, ClassNotFoundException;
private void writeObject(java.io.ObjectOutputStream stream)
throws IOException
- calkowitą kontrolę nad formatem i sposobem serializacji zyskujemy
poprzez implementację w klasie interfejsu Externalizable i dostarczenie metod
writeExternal i readExternal
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:
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
|
boolean | canRead()
Czy plik może być czytany? |
boolean | canWrite() Czy plik może być zapisywany? |
boolean | createNewFile()
Tworzy nowy plik |
static File | createTempFile(String prefix,
String suffix)
Tworzy nowy plik tymczasowy z nazwą wg wzorca |
static File | createTempFile(String prefix,
String suffix,
File directory)
Tworzy nowy plik tymczasowy z nazwą wg wzorca w podanym katalogu |
boolean | delete()
Usuwa plik lub katalog |
void | deleteOnExit()
Zaznacza plik do usunięcia po zakończeniu programu. |
boolean | exists()
Czy plik/katalog istnieje? |
String | getName()
Nazwa pliku lub katalogu |
String | getParent()
Katalog nadrzędny |
String | getPath()
Ścieżka |
boolean | isDirectory()
Czy to katalog? |
boolean | isFile()
Czy plik? |
boolean | isHidden()
Czy ukryty? |
long | lastModified()
Czas ostatniej modyfikacji |
long | length()
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 |
boolean | mkdir()
Tworzy katalog |
boolean | mkdirs()
Tworzy katalog i ew. niezbędne (niestniejące) katalogi nadrzędne |
boolean | renameTo(File dest)
Renames the file denoted by this abstract pathname. |
boolean | setLastModified(long time)
Ustala czas ostatniej modyfikacji |
boolean | setReadOnly()
Zaznacza jako tylko od odczytu |
URI | toURI()
Tworzy obiekt
klasy URI (Uniform Resource Identifier), reprezentujący ten obiekt
plikowy |
URL | toURL()
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
-
"r" - plik tylko do odczytu,
-
"rw" - plik do odczytu i zapisu,
-
"rws", "rwd" - jak "rw", ale z wymuszeniem synchronicznego zapisu każdej zmiany na dysk.
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
Klasa | Funkcjonalność |
---|
Deflater |
Kompresja dowolnyc danych z użyciem biblioteki ZLIB (działanie na danych w
pamięci np. Stringach) |
Inflater | Kompresja 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. |
GZIPInputStream | Strumieniowa klasa przetwarzająca: dekompresja w trakcie
odczytywania plików w formacie GZIP. |
GZIPOutputStream | Strumieniowa klasa przetwarzająca: kompresja w trakcie
zapisu do plików w formacie GZIP. |
ZipInputStream |
Jak poprzednie klasy, ale czytanie i rozpakowywanie plików ZIP |
ZipOutputStream | Jak 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:
- działa na Stringach, plikach (File),
strumieniach, kanałach,
- do parsowania używa
wyrażeń regularnych (w tym prostych separatorów, ale również dowolnych
złożonych wyrażeń),
- łatwo rozbija teksty na
wiersze,
- umie wyróżnić i skonwertować dane typów
prostych (a także BigDecimal),
- pozwala na
rozbiór, polegający nie tylko na wyróżnianie symboli
rozdzielonych separatorami, ale również na wyróżnianie symboli
pasujących
do podanego wyrażenia regularnego (metoda findInText(...), metoda
skip(...)),
- sposób rozbioru można zmieniać w
trakcie skanowania tekstu, m.in.
stosując rozliczne metody next...(), w tym takie, które pozwalają
podawać
różne wyrażenia regularne.
- pozwala na
zlokalizowany rozbiór danych.
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ć:
- destynację formatowanych danych, którą
może być:
File, String,
OutputStream, obiekty klas implementujących interfejs Appendable, czyli:
- BufferedWriter,
CharArrayWriter, CharBuffer, FileWriter, FilterWriter,
LogStream, OutputStreamWriter, PipedWriter, PrintStream, PrintWriter,
StringBuffer,
StringBuilder (szybsza wersja StringBuffer, bo niesynchronizowana),
StringWriter,
Writer
- lokalizację (Locale), wpływającą na reprezentację liczb i dat,
- stronę kodową (do kodowania napisów) -
dla strumieni, plików i Stringów
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.:
- w symbolach formatu można używać indeksów,
odnoszących się do podanych
danych, w ten sposób można np. przestawiać dane miejscami lub powielać
je
(powtarzać indeksy).
- rozbudowane możliwości
formatowania czasu i dat.
Dla uproszczenia dostępne są:
- statyczne metody format w
klasie String,
- metody format i printf
(działające tak samo) w klasach PrintStream i PrintWriter,