Zajímavých streamů je mnohem víc, než jsme si ukázali minule. Proto nyní dojde i na některé další. Přijde řada i na tvorbu vlastních streamů pro specifické účely.
23.6.2005 07:00 | Lukáš Jelínek | přečteno 37205×
Ve standardních knihovnách se nachází řada zajímavých streamů, na které stojí za to prozkoumat. Tedy vzhůru do toho, podívejme se na některé z nich!
PrintStream je výstupní stream, který nemá svůj vstupní protějšek a slouží k tisku uživatelsky srozumitelných dat různým způsobem. Může být napojen přímo na výstupní soubor nebo (protože je to filtrový stream) na libovolný jiný výstupní stream.
Pozn.: Napojení PrintStream
na soubor lze používat od JDK verze 1.5,
u dřívějších verzí je nutno použít přístup přes FileOutputStream
.
Charakteristickou vlastností třídy PrintStream
je, že nevyhazuje
výjimky IOException
- chyby jsou v podstatě ignorovány (lze je
ale zjistit voláním metody checkError()
). Proto se hodí hlavně
tam, kde fungování streamu není z hlediska funkce celého programu důležité
(logování, informativní výpisy apod.).
Ještě důležitější je ovšem, že PrintStream
poskytuje přímou
podporu převodu
různých primitivních typů na textovou reprezentaci, a následně na proud
bajtů. Při tomto převodu se uplatní kódová tabulka platformy nebo (pokud
byl zavolán příslušný konstruktor) kódování poskytnuté vytvářené instanci
streamu.
Třída samozřejmě disponuje metodami write()
, které se chovají tak, jak je
pro výstupní streamy obvyklé. Hlavní síla je však v metodách print()
a println()
- rozdíl je pouze ten, že druhá z metod navíc vloží
konec řádku. Právě tyto metody provádějí výše uvedené konverze. Od verze 1.5
lze použít i volání printf()
s podobným chováním, jako má stejnojmenná
funkce v jazyce C (stejnou službu ale poskytne i metoda format()
).
Konstruktoru lze poskytnout argument říkající, že se má provádět tzv.
autoflush (automatický zápis výstupního bufferu). Pokud je tato
funkce zapnuta, buffer se zapíše ihned po zavolání některé metody
println()
, po zápisu znaku pro odřádkování anebo po zápisu pole
bajtů. Stream vytvořený přímým napojením na soubor má funkci autoflush
vypnutou. Více ukáže následující příklad:
PrintStream ps = null; try { ps = new PrintStream("vystup.txt"); } catch (FileNotFoundException e) { System.err.println("Vystupni soubor nelze otevrit"); ps = System.out; // použije se standardní výstup } ps.println("nejaky text"); ps.printf("%X", new Integer(120)); // vypíše hexadecimální číslo ... ps.println(); ps.close();
Konstruktor je v příkladu uzavřen do bloku try - to je nezbytné kvůli výjimce FileNotFoundException, kterou konstruktor může vyhodit. Pokud k vyhození dojde (nastane problém s otevřením souboru), použije se v příkladu standardní výstupní stream. Zbylá část programu už nemusí mít (a nemá) kontrolu výjimek.
Zvláštním případem jsou dva standardní (systémové) streamy: standardní
výstup (System.out
) a standardní chybový výstup (System.err
). Tyto streamy
(v příkladu jsou použity)
jsou v každém programu k dispozici a navenek (z pohledu operačního systému)
se chovají úplně stejně jako jejich céčkovské obdoby. Pro úplnost uvádím,
že je k dispozici i standardní vstup (System.in
), na který se díváme přes
rozhraní InputStream
.
Tyto dva páry streamů představují tzv. roury (pipe), což jsou vlastně vzájemně propojené streamy. Vezmeme jeden vstupní a jeden výstupní stream, propojíme je, a s každým koncem pracujeme úplně stejně, jako by to byl normální vstupní, resp. výstupní stream. K čemu je to dobré?
Nejčastějším použitím je komunikace mezi vlákny (o vláknech samotných bude řeč někdy později), kdy se v jednom vlákně vytvářejí nějaká data a současně se ve druhém tato data zpracovávají. Je to výhodné hlavně proto, že se nemusíme starat o synchronizaci přístupu k datům, práce se streamy je velmi jednoduchá, můžeme využít veškeré možnosti nabízené streamy a při změně způsobu práce není třeba příliš do programu zasahovat.
Tyto streamy se vytvářejí tak, že vytvoříme jeden z nich a druhému ho předáme
jako argument v konstruktoru. Jinou cestou je vytvořit je nezávisle a pak
na některém z nich zavolat metodu connect()
. Viz příklad:
// první možnost PipedInputStream is = new PipedInputStream(); PipedOutputStream os = new PipedOutputStream(is); // druhá možnost PipedOutputStream os = new PipedOutputStream(); PipedInputStream is = new PipedInputStream(os); // třetí možnost PipedReader pr = new PipedReader(); PipedWriter pw = new PipedWriter(); pr.connect(pw);
Jedná se o dvojici streamů, které pracují nad polem bajtů. Je to podobné
jako u známých tříd StringReader/StringWriter
(a dalších podobných, kam
patří třeba StringBufferInputStream
nebo CharArrayWriter
). Máme nějakou
oblast v paměti, kam se zapisují (resp. odkud se čtou) data streamu.
S těmito daty pak můžeme naložit dle libosti.
Výstupní stream funguje tak, že si spravuje vlastní buffer, a dokud se tento
nevymaže, stále se zapisováním plní. Pokud potřebujeme jeho obsah, získáme
kopii dat (ne tedy přístup k původnímu bufferu) zavoláním toByteArray
.
To je třeba si dobře uvědomit kvůli výkonnostním úvahám! Data lze
získat i ve formě textového řetězce (voláním toString()
).
Vstupní stream naopak pracuje vždy s bufferem pevné velikosti. Přečíst lze jen tolik bajtů, kolik jich v bufferu je.
Velice často máme nějak uložená data (v primitivních nebo složitějších datových typech) a potřebujeme je uložit nebo přenést na jiné místo. Musíme to udělat tak, aby se v jiném čase nebo na jiném místě data správně zrekonstruovala do původní podoby. Těmto činnostem říkáme serializace a deserializace.
Serializace je konverze obecných dat (nějakým způsobem uložených) na proud bajtů tak, aby je šlo následně snadno zrekonstruovat. Naopak deserializace je právě rekonstrukce proudu bajtů na data použitelná v programu. Java k těmto činnostem poskytuje výraznou podporu.
Celý mechanismus okolo serializace/deserializace je docela složitý, proto
bych se nyní chtěl zaměřit jen na to, co je důležité pro základní práci.
Protože v Javě nikdy nevíme, jak jsou jednotlivé datové typy uloženy
(i když třeba známe jejich číselné rozsahy), nelze jednoduše rozsekat
třeba long
na 8 bajtů (většinou by to sice šlo, ale ztrácíme tím plnou
přenositelnost - obecně se totiž může stát, že "předpoklady" nejsou zcela
naplněny), natož něco kopírovat rovnou (pořadí bajtů!). Naštěstí se zrovna o toto nemusíme
starat.
Máme totiž dvě třídy, DataInputStream
a DataOutputStream
, které potřebné
konverze bezpečně udělají za nás. Streamy mají metody pro uložení/načtení
všech primitivních datových typů. Pozor samozřejmě na to, v jakém pořadí
se data ukládají. Tento způsob serializace neumožňuje jednotlivé typy
zpětně identifikovat! Příklad naznačí, jak se s uvedenými třídami pracuje:
int i = 165; float f = 0.35; try { DataOutputStream os = new DataOutputStream(new FileOutputStream("soubor.dat")); os.writeInt(i); // bezpečné uložení hodnoty typu int os.writeFloat(f); // bezpečné uložení hodnoty typu float os.close(); } catch (IOException e) { ... }
Trochu složitější je to s instancemi objektů. Ale i tady máme podobné
prostředky - v podobě tříd ObjectInputStream
a ObjectOutputStream
. Ty nejenže
ukládají a načítají instance objektů, ale poradí si i s primitivními typy
(takže pokud je používáme, nemusíme už používat třídy DataInputStream/DataOutputStream
).
Nelze ukládat všechny objekty. Nutnou podmínkou je, aby implementovaly
rozhraní Serializable
(pokud se pokusíme serializovat nevyhovující objekt,
dočkáme se výjimky NotSerializableException
). Protože se instance serializuje
i se všemi odkazovanými objekty, musí být i tyto serializovatelné, anebo
označené modifikátorem transient
(tedy že nebudou uloženy).
Narozdíl od primitivních typů, u objektů lze při deserializaci zjistit jejich
typ (a nejen to, k úspěšné deserializaci musí být k dispozici příslušná
třída - jinak to skončí výjimkou ClassNotFoundException
; případné poškození
dat vyvolá zase jiné výjimky). Metoda readObject()
sice vrací referenci
na typ Object
, ale třídu si můžeme zjistit voláním getClass()
na vrácené
instanci nebo jiným způsobem, a následně přetypovat podle potřeby. Více opět napoví příklad:
ArrayList list = new ArrayList(); // vytvoříme seznam list.add("nejaky text"); // vložíme hodnoty list.add(new Double(1.655)); list.add(new Integer(123)); try { ObjectOutputStream os = new ObjectOutputStream(new FileOutputStream("seznam.dat")); os.writeObject(list); // celý seznam se bezpečně uloží os.close(); } catch (IOException e) { ... }
Zde je dobře vidět, že můžeme snadno uložit nebo přenést celý kontejner i s obsahem. Jen pozor na to, že všechny obsažené objekty musí být serializovatelné. K procesu serializace se ještě někdy později vrátíme a podíváme se na něj podrobněji - toto by jako úvod stačilo.
Někdy potřebujeme stream, který má nějaké speciální vlastnosti. Proto si můžeme (pokud nám žádný z dostupných streamů nevyhovuje) vytvořit vlastní stream, do kterého přidáme potřebné funkce. Nejlepší je rozšířit nějaký už existující stream.
Ukážeme si to na streamu filtrového typu. Požadujeme, aby stream sledoval
četnost jednotlivých bajtů (tedy hodnoty 0-255). Nový stream odvodíme
od třídy FilterInputStream
předefinováním potřebných metod.
Mohlo by to vypadat třeba takto:
public class CounterInputStream extends FilterInputStream { private long cnt [] = new long[256]; // pole pro uložení četností public FilterInputStream(InputStream in) { super(in); // konstruktor pouze zavolá předka } // Metoda resetuje počitadla četností public void resetCounters() { for (int i=0; i<256; i++) { cnt[i] = 0; } } // Vrací četnost daného bajtu. // Meze indexu se netestují. public long getCount(int index) { return cnt[index]; } // Základní metoda - přečtení bajtu public int read() throws IOException { int b = super.read(); // přečte se bajt if (b >= 0) cnt[b]++; // pokud je platný, inkrementuje se počitadlo return b; } // Metoda pro čtení bloku bajtů public int read(byte[] b, int off, int len) throws IOException { int r = super.read(b, off, len); if (r > 0) { for (int i=0; i<r; i++) { cnt[b[i]]++; // není třeba testovat platnost } } return r; } }
Z metod read()
předka předefinováváme pouze ty dvě uvedené, třetí může zůstat
v původní podobě (volá totiž jednu z těch zbývajících). Uvedená implementace
má jednu zásadní vlastnost, a sice tu, že při návratu na označenou pozici
(metodou reset()
- pokud to samozřejmě podřízený stream umožňuje) se
znovu čtené bajty opět započítají.
Zde je jednoduchý příklad použití vytvořeného streamu:
try { CounterInputStream cis = new CounterInputStream(System.in); int b = 0; for (int i=0; i<100, b>=0; i++) { b = cis.read(); if (b >= 0) { ... // nějaká činnost } } cis.close(); System.out.println("Cetnost hodnoty 54 je " + cis.getCount(54)); } catch (IOException e) { ... }
Příklad ukazuje analýzu dat načítaných ze standardního vstupu. Po skončení čtení (přečte se 100 bajtů, při chybě už se dál nečte) se vypíše četnost hodnoty 54.
Dostali jsme se na konec úvodní části o výměně dat mezi programem a vnějším prostředím. Příště se vrhneme na důležitou věc, které se při psaní aplikací nikdo nevyhne, a to je práce se soubory. Prozkoumáme, jak jsou řešeny takové operace, jako je mazání nebo přejmenování souborů, jak se vytvářejí dočasné soubory, a v neposlední řadě, jak je řešena rozdílnost různých platforem, na kterých Java může běžet.