Binarny zapis i odczyt z plików

Dla każdego programisty tematyka wejścia i wyjścia nie powinna być obca. Dzięki niej jesteśmy w stanie pobrać dane od użytkownika, czy zapisać wynik programu w pliku. Z tego powodu powinniśmy szczegółowo przeanalizować to zagadnienie. W poprzednich lekcjach zajmowaliśmy się podstawowym wejściem-wyjściem przy użyciu Scannera oraz zapisem i odczytem z plików tekstowych.

*Lekcja wykracza poza zakres podstaw. Jeżeli temat ten sprawia Tobie problemy, to się tym nie martw i wróć do niego w dalszej części przygody z Javą.

W dzisiejszej lekcji zajmiemy się strumieniami binarnymi, czyli nauczymy się korzystania z danych binarnych. Poznamy takie klasy jak:

Zobaczmy jak wygląda hierarchia dziedziczenia dla strumieni binarnych:

SchematBinarny

Na powyższym schemacie widzimy, że wszystkie strumienie wejściowe dziedziczą po abstrakcyjnej klasie InputStream, a wyjściowe OutputStream. Zobaczmy, co one nam oferują.

InputStream:

Podstawową klasą, dzięki której możemy wczytywać dane binarne jest klasa InputStream. Zawiera ona trzy metody czytające:

int read() -- zwraca następny sczytany bajt

int read(byte[] b) -- czyta kolejne bajty i przechowuje je w buforze, czyli tablicy bajtowej

int read(byte[] b, int off, int len) -- dodatkowo możemy ustalić offset i długość

Kurs Java

Jak działa metoda int read(byte[] b, int off, int len)?

Aby użyć tej metody musimy najpierw utworzyć zwykłą tablicę bytową zwaną buforem. Wielkość takiej tablicy jest dowolna, dopasowywana do treści zadania, lecz najlepiej, gdy jest ona wielokrotnością 2. Przykładowo, nasz bufor może wyglądać tak:

bufor

Metoda read sczytuje bajty i zapisuje je na kolejnych miejscach tej tablicy.

Czym jest off i len?

Offset jest przesunięciem, czyli za jego pomocą ustawiamy od którego miejsca w buforze zostaną zapisywane dane. Przykładowo, gdy ustawimy offset na 1, to dane nie będą zapisywane jedynie na zerowej pozycji. Len to nic innego jak ilość danych wymaganych do sczytania. Jeżeli potrzebujemy jedynie sczytać 3 kolejne bajty z bufora, to możemy to zrobić poprzez ustawienia len na 3

int read(byte[] b) jest szczególnym przypadkiem wyżej opisanej metody dla off równego 0 i len b.length.

Jak sprawdzić, czy sczytaliśmy wszystkie dane?

Metody czytające zwracają, jak zauważyłeś, liczbę całkowitą. To sczytany przez metodę bajt. Jest to liczba naturalna, dlatego projektanci Javy zadecydowali, że jeżeli nie będzie więcej bajtów do sczytania, to metoda zwróci nam liczbę -1. Z tego powodu łatwo możemy zdefiniować warunek naszej pętli. Przykładowo:

while ((ilośćSczytanychBajtów = strumieńWejściowy.read(bufor)) != -1)
OutputStream:

Podobnie jak w przypadku InputStream, OutputStream udostępnia jedynie trzy podstawowe metody, które pozwalają nam zapisywać dane:

void write(int b) -- zapisuje określony bajt

void write(byte[] b) -- zapisuje wszystkie bajty z podanego bufora do miejsca określonego przez strumień

void write(byte[] b, int off, int len) - dodatkowo możemy ustalić offset i długość

Jak widzimy, za pomocą tych klas potrafimy przetworzyć dane binarne, lecz nie zawsze jest to najlepszym pomysłem. Z pomocą przychodzą nam jednak inne klasy.

W dalszej części lekcji nie będziemy się posługiwali InputStreamem i OutputStreamem tylko klasami, które posiadają takie same metody, lecz dzięki którym możemy korzystać z plików. Są to FileInputStream i FileOutputStream. Podczas korzystania z tych strumieni, musimy pamiętać, by obsłużyć wyjątek FileNotFoundException. Nie możemy także zapomnieć o zamknięciu strumienia, ponieważ w przeciwnym przypadku istnieje prawdopodobieństwo utraty danych.

Implementacja strumienia FileInputStream, czy FileOutputStream nie powinna stanowić większego problemu. W parametrze należy podać ścieżkę dostępu do pliku, lub jeżeli plik ten jest w tym samym katalogu co nasz program, wystarczy podać jego nazwę.

Przykładowo:

String plik = "D:\\JavaStart\\binarnie.txt";
FileOutputStream strumieńWyjściowy= new FileOutputStream(plik);
DataInputStream / DataOutputStream:

Z pomocą w przetwarzaniu danych binarnych przychodzą nam klasy DataInputStream/DataOutputStream. Udostępniają one metody, dzięki którym nie musimy w naszym programie operować jedynie na danych, którymi posługuje się komputer, ale także na typach prostych takich, jak int, double itp.

Przy tworzeniu obiektu klasy DataInputStream i DataOutputStream musimy skorzystać z opakowania (ang. wrapping) jednego strumienia w drugi. W tym przypadku wygląda to następująco:

DataInputStream strumieńWejściowy = new DataInputStream(new FileInputStream(plik));

Metody w tych dwóch klasach mają podobne nazwy(read zamiast write) i są bardzo intuicyjne, dlatego pozwolę sobie wypisać jedynie parę najważniejszych metody z tych klas, po więcej informacji na temat metod odsyłam do dokumentacji Javy.

Kurs Java

void writeInt(int v) -- zapisuje liczbę całkowitą

void writeDouble(double v) -- zapisuje liczbę zmiennoprzecinkową

void writeUTF(String str) -- zapisuje ciąg znaków

void writeBoolean(boolean v) -- zapisuje zmienną logiczną.

RandomAccessFile:

W przypadku, gdy chcemy odczytywać i zapisywać dane korzystając z jednego pliku, nie ma potrzeby tworzenia dwóch strumieni. W Javie istnieje klasa RandomAccessFile, która udostępnia nam taką możliwość. Zawiera ona takie same metody jak DataOutpuStream i DataInputStream. Jedyną różnicą w posługiwaniu się tą klasą jest jej implementacja. Podczas tworzenia klasy RandomAccessFile nie musimy opakowywać innego strumienia, ale dodatkowo określamy prawa dostępu do pliku. Mamy do wyboru dwie opcje:

rw(r-read, w-write) -- możliwość zapisywania i czytania z pliku

r -- jedynie możliwość czytania z pliku

Tworzenie obiektu klasy RandomAccessFile wygląda następująco:

RandomAccessFile strumień = new RandomAccessFile(plik, "rw");

Korzystając z klasy RandomAccessFile musimy pamiętać, że wskaźnik w pliku nie jest osobny dla write i read. Jeżeli na początku zapisujemy dane, a potem chcemy je odczytać, należy wtedy przesunąć wskaźnik na początek. Można to zrobić poprzez ponowne zadeklarowanie strumienia, czyli:

strumień = new RandomAccessFile(plik, "rw");
Tworzenie strumieni z użyciem wyjątków

O wyjątkach więcej dowiemy się w kolejnych lekcjach: try-catch, throw. Na chwilę obecną, przyjmujemy, że wszystkie metody związane ze strumieniami musimy umieścić w bloku try{} zakończonym catch(IOException){}. Istnieją dwa sposoby korzystania z wyjątków. Zacznijmy od tego, który jest, przy większej ilości kodu, mało przejrzysty, bardziej podatny na błędy, oraz który ogranicza możliwości dobrego wyłapania wyjątku. W tym przykładzie tworzymy i zamykamy strumień w jednym bloku.

import java.io.DataOutputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;


public class Strumienie{
	public static void main(String[] args){
		try{
			DataOutputStream strumień = new DataOutputStream(new FileOutputStream("plik.txt"));
			/*
			 * Dowolne
			 * metody
			 * 
			 */
			strumień.close();	
		} catch(FileNotFoundException e){
			System.out.println("Nie znaleziono pliku");
		} catch(IOException e){
			System.out.println("Błąd wejścia-wyjścia");
		}
	}
}

Drugi sposób jest dużo bardziej elegancki. Polega on na osobnej inicjalizacji, korzystania i zamykania strumienia. Mamy wtedy najlepszą możliwość dobrego wyłapania wyjątku. Zobaczmy na przykładowy kod.

import java.io.DataOutputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;

public class Strumienie {
	public static void main(String[] args) {
		DataOutputStream strumień = null;

		try {
			strumień = new DataOutputStream(new FileOutputStream("plik.txt"));
		} catch (FileNotFoundException e) {
			System.out.println("Nie znaleziono pliku");
		}

		try {
			/*
			 * Dowolne metody
			 */
		} catch (IOException e) {
			// Obsłużenie wyjątku
		}

		try {
			if (strumień != null)
				strumień.close();
		} catch (IOException e) {
			System.out.println("Błąd zamykania strumienia");
		}
	}
}

Jak widzimy, w tym sposobie na początku musimy zainicjalizować strumień i przypisać mu wartość null. Spowodowane jest to tym, że jeżeli zmienna jest stworzona w danym bloku to jest widoczna tylko w nim. W tym przypadku, jeżeli inicjalizacja miałaby miejsce w bloku try to nie byłoby możliwe korzystanie ze strumienia w innym miejscu tej metody.

Dyskusja i komentarze

Masz pytania do tego wpisu? Może chcesz się podzielić spostrzeżeniami? Zapraszamy dyskusji na naszej grupie na Facebooku.

Poniżej znajdziesz archiwalne wpisy z czasów, gdy strona była jeszcze hobbystycznym blogiem.

Janek

Świetny kurs! Mały błąd: gdy przerabia się lekcje z "Podstaw języka Java" i przechodzi się do następnych lekcji po strzałkach pod tematami () to ta lekcja jest pomijana - tzn. z "Tablic wielowymiarowych" od razu przechodzi do "Klasy, metody..."

Janosch

Stąd pewnie dlatego jest tutaj tak mało komentarzy bo nikt nie wszedł :) Dzięki Janek, ja też ten rozdział ominąłem bo przechodziłem strzałkami.

Dawid Kunert

Dziękuję za komentarz. Już dodałem odpowiednie odnośniki :)

snt.banzai

'Strumienie strumienieBinarne = new Strumienie(); strumienieBinarne.stwórzStrumień("plik");" Czy ten zapis oby na pewno jest poprawny(nazwa obiektu i metody po polsku)?

Dawid Kunert

W Javie możemy stosować polskie nazwy, aczkolwiek wskazane jest używanie słów angielskich. W tym zapisie jest jeden błąd, który już poprawiłem, a mianowicie zamiast "plik", powinno być "plik.txt".

snt.banzai

Przy próbie zliczenia ilości bajtów z pliku zamiast tejże ilości pojawia mi sie zapis: java.io.DataInputStream@.. i dalej ciąg cyfer i liczb. Dodam, że program kompiluje się i działa poprawnie z wyjątkiem tego fragmentu. Anyone?

Dawid Kunert

Dzieje się tak, ponieważ wyświetlasz obiekt, w którym nie ma zdefiniowanej metody toString(). Wtedy wyświetla się "getClass().getName() + '@' + Integer.toHexString(hashCode())".