Kontakt  |  PRS 170  |  ALK 420  |  PZR 420  |  SIK 420  |  JPR 222  |  SOP 121


Języki programowania - ćwiczenia 8


Temat zajęć: Programowanie w języku Java: polimorfizm, interfejsy, strumienie, wyjątki.

Materiały dodatkowe:


 Przykład 1

Na dzisiejszych zajęciach zaimplementujemy znany nam już przykład z procesorem i zadaniami, wykorzystujący polimorfizm. Poznamy przy okazji następujące techniki:
  • definicja interfejsów i ich implementacja przez klasy,
  • generowanie i obsługa wyjątków,
  • wczytywanie danych z klawiatury przy wykorzystaniu strumienia System.in,
  • obsługa strumieni, zapisywanie i odczytywanie stanu obiektów ze strumieni.
Zgodnie z definicją podaną na poprzednich zajęciach, interfejsy są pewnego rodzaju abstrakcyjnymi klasami, w których deklarujemy wyłącznie metody. W każdej klasie implementującej dany interfejs musimy podać implementację wszystkich metod, które zostały wymienione w interfejsie (oczywiście oprócz nich nasza klasa może posiadać dowolną liczbę innych metod).
Załóżmy, że dany jest interfejs Interfejs1, który deklaruje metodę int m(int x). Jeśli dwie klasy: Klasa1 i Klasa2 implementują Interfejs1, to możemy z całą pewnością stwierdzić, że każda z nich posiada metodę int m(int x) (oprócz niej oczywiście może posiadać inne metody). Jeśli więc jakakolwiek metoda (dowolnej klasy) jako argumentu żąda obiektu "klasy" Interfejs1 (dokładniej rzecz biorąc żąda ona obiektu pewnej klasy, która implementuje Interfejs1 - nie jest możliwe tworzenie obiektów "typu" interfejs), to możemy bezpiecznie podać jej jako argument zarówno obiekt klasy Klasa1, jak i klasy Klasa2 (obie te klasy implementują Interfejs1).
Mówiąc kolokwialnie, interfejsy umożliwiają konstrukcję metod, które na wejściu stawiają wymaganie: "daj mi obiekt, który może wykonać pewną operację, nie obchodzi mnie jakiej będzie klasy i w jaki sposób wykonuje tą operację". Co więcej, różne klasy implementujące interfejs mogą (a zazwyczaj tak jest - wtedy mówimy o polimorfiźmie) posiadać różne implementacje metod opisanych w interfejsie. Ścisła definicja terminu polimorfizm jest trudna do sformułownia. Z polimorfizmem mamy do czynienia wtedy, gdy różne obiekty reagują w różny sposób na ten sam komunikat (przez komunikat - termin zapożyczony z Objective C - rozumiemy wywołanie metody obiektu).

Nasz przykład będzie składał się z kilku klas i jednego interfejsu.

Opis problemu:
Należy zaimplementować abstrakcyjny procesor, który potrafi wykonywać różne zadania. Operacje wchodzące w skład zadania opisane są w samym zadaniu, które posiada uniwersalną operację wykonaj zachowującą się różnie, w zależności od typu zadania. W tym sensie wszystkie zadania posiadają taki sam interfejs z punktu widzenia procesora - dają się wykonywać.
Program powinien umożliwić dodawanie różnych zadań do procesora oraz wykonanie zadań zgromadzonych w procesorze. W wyjątkowej sytuacji przepełnienia kolejki zadań do wykonania użytkownik powinien zostać powiadomiony o tym fakcie.

W powyższym opisie pogrubione zostały słowa-klucze, które pomogą nam w konstrukcji odpowiednich klas i interfejsów.
Po pierwsze zauważmy, że każde zadanie musi posiadać operację (w naszym przypadku metodę), która odpowiada wykonaniu zadania. To skłania nas do konstrukcji interfejsu, np. o nazwie Zadanie, który będzie opisywał funkcjonalność zadań.

Plik Zadanie.java  pobierz  
public interface Zadanie {
void wykonaj();
}
Publiczny interfejs Zadanie (definiowany jak klasa ale przy użyciu słowa kluczowego interface zamiast class) definiuje tylko jedną, bezparametrową, bezwynikową metodę: wykonaj(). Każda klasa implementująca interfejs Zadanie będzie musiała dostarczyć specyficznej dla siebie implementacji tej metody.

Rozważmy dla przykładu dwie różne implementacje interfejsu Zadanie. W klasie Zadanie1 jako wykonanie przyjmiemy wyświetlenie stałego łańcucha tekstowego, zaś klasa Zadanie2 będzie obliczać logarytm naturalny podanej wartości.

Plik Zadanie1.java  pobierz  
public class Zadanie1
implements Zadanie {

private String txt = "Jestem Zadanie1";

public void wykonaj() {
System.out.println(txt);
}
}

Plik Zadanie2.java  pobierz  
public class Zadanie2
implements Zadanie {

private double val;

public Zadanie2(double aval) {
val = aval;
}

public void wykonaj() {
System.out.println("log("+val+") = "+Math.log(val));
}
}
Fakt, że dana klasa implementuje interfejs oznaczony jest klauzulą implements w definicji klasy. Jak widać, każda z podanych wyżej klas posiada inną implementację metody wykonaj.

Zaimplementujmy teraz nasz procesor. Z opisu problemu wynika, że musi on mieć możliwość przechowywania pewnej liczby zadań do wykonania, możliwość dodawania zadań oraz wykonania wszystkich posiadanych zadań. Ponieważ "pojemność" procesora będzie ograniczona, pojawia się problem co zrobić kiedy użytkownik będzie próbował dodać kolejne zadanie w sytuacji, gdy tablica zadań będzie już pełna. Z samego opisu problemu wynika, że taka sytuacja jest "wyjątkowa". Język Java dostarcza specjalnego mechanizmu obsługi takich sytuacji za pomocą wyjątków (ang. exception).
Jeśli dana metoda może potencjalnie "wyrzucić" (zgłosić) wyjątek, należy opatrzyć ją klauzulą throws klasa_wyjątku. Każda klasa, której obiekty mogą być "rzucane" (zgłaszane) jako wyjątki powinna rozszerzać klasę java.lang.Exception, (a co najmniej  java.lang.Throwable).
Zaimplementujmy zatem klasę-wyjątek ZaDuzoZadan, której obiekty, rzucane jako wyjątki przez procesor w momencie przepełnienia kolejki zadań, będą informowały program główny, że dodanie kolejnego zadania nie jest możliwe.

Plik ZaDuzoZadan.java  pobierz  
public class ZaDuzoZadan 
extends Exception {
public ZaDuzoZadan(String komunikat) {
super(komunikat);
}
}
Możemy teraz przejść do implementacji samego procesora, której przykład podany został poniżej.

Plik Procesor.java  pobierz  
public class Procesor {
private Zadanie[] zadania;
private int ilemam;

public Procesor(int ilezadan) {
zadania = new Zadanie[ilezadan];
ilemam = 0;
}

public void dodajZadanie(Zadanie z)
throws ZaDuzoZadan {
if (ilemam >= zadania.length) {
throw new ZaDuzoZadan(
"Za duzo zadan - mam juz "+ilemam
);
} else {
zadania[ilemam] = z;
ilemam++;
}
}

public void wykonajZadania() {
int i;
for (i=0; i<ilemam; i++) {
zadania[i].wykonaj();
}
}
}
Metoda wykonajZadania() korzysta w pełni z mechanizmu implementacji interfejsów i polimorfizmu (przypomnijmy, że w języku Java wszystkie niestatyczne metody są wirtualne, czyli uruchamiane są implementacje metod z tych klas, do których faktycznie należą obiekty, na rzecz których wywoływane są metody, niezależnie od formalnego typu referencji). Zauważmy, że same zadania przechowywane są w tablicy zadania, której każdym elementem jest obiekt (dokładniej referencja do obiektu) pewnej klasy implementującej interfejs Zadanie. Metoda wykonajZadania korzysta z tego faktu, wywołując w pętli metodę wykonaj ze wszystkich obiektów w tablicy. Nie dba ona o to, jakiej klasy faktycznie jest dany obiekt w tablicy zadania. Ważne jest jedynie to, że klasa ta implementuje interfejs Zadanie (a to wymusza definicja tablicy zadania - nie da się umieścić w niej obiektu klasy, która nie implementuje interfejsu Zadanie), posiada więc na pewno metodę wykonaj(), zatem można ją bezpiecznie wywołać z każdego obiektu z tablicy zadania po kolei. Ponieważ jednak obiekty w tablicy zadania mogą posiadać różne implementacje metody wykonaj(), skutek wywołania wykonaj() dla różnych elementów tablicy może być różny (tu właśnie objawia się polimorfizm).
Metoda dodajZadanie może potencjalnie wyrzucić wyjątek klasy ZaDuzoZadan, który będziemy przechwytywać w programie głównym i informować użytkownika o zaistniałym problemie.
Jednak aby skorzystać z mechanizmu wyjątków nie wystarczy je rzucać - trzeba jeszcze łapać :)
Jeśli chcemy wykonać blok instrukcji, który może spowodować powstanie wyjątku, należy skorzystać z konstrukcji:
try {
    instrukcje
} catch (KlasaWyjątku obiekt) {
    obsługa wyjątku
} [catch (InnaKlasaWyjątku obiekt) ...]
Program główny, działający w trybie interaktywnym, umożliwiający dodawanie zadań do procesora i ich seryjne wykonywanie podany został poniżej.

Plik ZadaniaDemo.java  pobierz  
public class ZadaniaDemo {

private java.io.BufferedReader klawiatura;
private Procesor p;

public ZadaniaDemo() {
klawiatura = new java.io.BufferedReader(
new java.io.InputStreamReader(System.in)
);
p = new Procesor(4); /* maksymalnie 4 zadania */
}

public void menu() {
String wybor;

System.out.println("1 - Dodaj nowe Zadanie1");
System.out.println("2 - Dodaj nowe Zadanie2");
System.out.println("3 - Wykonaj zadania");
System.out.println("4 - Koniec");

try {
wybor = klawiatura.readLine();
} catch (java.io.IOException ioe) {
return;
}

if (wybor.equals("1")) {
try {
p.dodajZadanie(new Zadanie1());
} catch (ZaDuzoZadan e) {
System.out.println("Wyjatek: "+e.getMessage());
return;
}
} else if (wybor.equals("2")) {
double v;
System.out.print("Podaj wartosc do zlogarytmowania: ");
try {
v = Double.parseDouble(klawiatura.readLine());
} catch (java.io.IOException ioe) {
return;
} catch (NumberFormatException nfe) {
System.out.println("Nie oszukuj! Podaj liczbe.");
return;
}
try {
p.dodajZadanie(new Zadanie2(v));
} catch (ZaDuzoZadan e) {
System.out.println("Wyjatek: "+e.getMessage());
return;
}
} else if (wybor.equals("3")) {
p.wykonajZadania();
return;
} else if (wybor.equals("4")) {
System.exit(0);
}
}


public static void main(String[] args) {
ZadaniaDemo zd = new ZadaniaDemo();
while (true) {
zd.menu();
}
}
}
W konstruktorze klasy ZadaniaDemo tworzony jest jeden obiekt - procesor, który może przechowywać maksymalnie 4 zadania. Cała obsługa interakcji z użytkownikiem zawarta jest w metodzie menu(), która wyświetla dostępne operacje i oczekuje wyboru od użytkownika. Skoro mówimy o interakcji, potrzebujemy jakiejś techniki wczytania danych z klawiatury. W programach w Javie działających w trybie konsoli (bez GUI) nie jest to przyjemne zadanie. Jednym z najprostszych rozwiązań jest skorzystanie z predefiniowanego obiektu System.in klasy java.io.InputStream. Niestety klasa ta jest dość uboga - nie ma tam metody pozwalającej wczyatć z klawiatury np. wiersz tekstu. Metoda taka zawarta jest natomiast w klasie java.io.BufferedReader, no ale nie tej klasy jest System.in. Czyżby ślepy zaułek? Na szczęście nie. Możemy bowiem skonstruować sobie obiekt klasy java.io.BufferedReader na podstawie obiektu klasy java.io.InputStreamReader, który z kolei jako parametr konstruktora przyjmuje obiekt klasy java.io.InputStream. Niezbyt eleganckie, ale rozwiązuje nasz problem. Można do tego podejść następująco:
java.io.BufferedReader klawiatura =
    new java.io.BufferedReader(
        new java.io.InputStreamReader(System.in)
        );
i odtąd możemy korzystać z "wygodnej" metody klawiatura.readLine() pamiętając jedynie o przechwyceniu wyjątku java.io.IOException, który ta metoda może wyrzucić. Dokładnie z takiego mechanizmu korzysta nasza klasa ZadaniaDemo.
Przyjrzyjmy się jeszcze jednemu fragmentowi programu:
System.out.print("Podaj wartosc do zlogarytmowania: ");
try {
    v = Double.parseDouble(klawiatura.readLine());
} catch (java.io.IOException ioe) {
    return;
} catch (NumberFormatException nfe) {
    System.out.println("Nie oszukuj! Podaj liczbe.");
    return;
}
Jak widać, przechwytujemy tutaj osobno wyjątek java.io.IOException (jeśli wystąpi po prostu przerywamy obsługę danej operacji w menu) oraz wyjątek java.lang.NumberFormatException. Ten pierwszy wyjątek może być rzucony przez klawiatura.readLine(), zaś drugi może się pojawić w Double.parseDouble() jeśli podany łańcucha nie da się skonwertować na liczbę rzeczywistą. Mechanizm wyjątków umożliwia więc osobną obsługę każdego rodzaju wyjątków.
Pilni studenci zapewne zauważyli, że w ZadaniaDemo znajduje się konstrukcja, która zwykle jest zakazana przez wszelkie podręczniki i prawidła. Mamy mianowicie w metodzie main pętlę nieskończoną while (true), która potencjalnie powoduje zapętlenie programu w nieskończoność. Byłoby tak, gdyby nie obsługa operacji "4" z menu, która korzysta z metody System.exit() powodującej natychmiastowe zakończenie działania programu i powrót do systemu operacyjnego.

 Obsługa plików dyskowych w języku Java, zapisywanie stanu obiektów

Do obsługi strumieni wejścia / wyjścia (obejmujących również pliki na dysku) służą klasy z pakietu java.io. Do podstawowych należą:
  • InputStream - podstawowy strumień wejściowy (nie będziemy korzystać z niego bezpośrednio)
  • OutputStream - podstawowy strumień wyjściowy (również nie będziemy z niego bezpośrednio korzystać)
  • FileInputStream, FileOutputStream - strumienie związane z plikami na dysku,
  • BufferedReader, BufferedWriter - klasy umożliwiające czytanie i pisanie danych tekstowych w strumieniach w postaci wierszowej
  • DataInputStream, DataOutputStream - klasy umożliwiające czytanie / pisanie danych interpretowanych (określonych typów)
  • ObjectInputStream, ObjectOutputStream - klasy umożliwiające odczyt / zapis obiektów, o ile obiekty te są klas implementujących interfejs java.io.Serializable
Obiekty klas FileInputStream i FileOutputStream tworzymy podając w konstruktorze albo obiekt klasy java.io.File (reprezentujący ścieżkę dostępu do pliku), albo bezpośrednio ścieżkę do pliku jako łańcuch.
W praktyce klasy FileInputStream i FileOutputStream są zbyt ubogie, zawierają jedynie metody umożliwiające odczyt / zapis ciągu bajtów, bez interpretacji. Dlatego zazwyczaj stosuje się nadbudowywanie na obiektach tych klas obiektów innych klas, bardziej specjalizowanych.
Aby np. czytać plik tekstowy wiersz po wierszu możemy skorzystać z konstrukcji:
java.io.BufferedReader plik = new java.io.BufferedReader(
    new java.io.InputStreamReader(
        new java.io.FileInputStream("sciezka do pliku")
        )
    );
a następnie wywoływać
wiersz = plik.readLine()
w celu czytania kolejnych wierszy pliku (analogicznie z zapisem).
Klasy DataInputStream i DataOutputStream są jeszcze wygodniejsze: umożliwiają zapis i odczyt danych konkretnych typów. Ważniejsze metody DataInputStream to:
  • readBoolean() - czyta wartość typu boolean
  • readByte() - czyta wartość typu byte
  • readChar() - czyta wartość typu char
  • readDouble() - czyta wartość typu double
  • readFloat() - czyta wartość typu float
  • readInt() - czyta wartość typu int
  • readLong() - czyta wartość typu long
  • readShort() - czyta wartość typu short
  • readUTF() - czyta wartość typu String zakodowaną w standardzie UTF (opis kodowania UTF można znaleźć w dokumentacji JDK, pakiet java.io, interfejs DataInput, metoda readUTF)
Prawdopodobnie najciekawszymi klasami są ObjectInputStream i ObjectOutputStream. Oprócz metod oferowanych przez DataInputStream i DataOutputStream posiadają one metody readObject i WriteObject, które służą do odczytu i zapisu stanu całych obiektów. Aby obiekty można było zapisywać do strumieni i ze strumieni odtwarzać, ich klasy muszą implementować java.io.Serializable. W większości przypadków (jeśli klasa zawiera tylko pola "standardowych" typów, a dokładniej jeśli wszystkie jej pola są serializowalne) wystarczy zadeklarować implementację tego interfejsu, jeśli jednak zapis / odczyt obiektu ma odbywać się w specyficzny sposób, należy zaimplementować następujące metody:
  • private void writeObject(java.io.ObjectOutputStream out) throws IOException
  • private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException
Pierwsza z nich musi zapisywać stan obiektu do strumienia, druga zaś odtwarzać zawartość pól na podstawie danych czytanych ze strumienia.
Aby obiekty klas DataInputStream, DataOutputStream, ObjectInputStream i ObjectOutputStream jako nośnik danych wykorzystywały pliki dyskowe, należy je "nadbudować" nad odpowiednimi obiektami klasy FileInputStream lub FileOutputStream np.:
ObjectOutputStream oos = new ObjectOutputStream(
    new FileOutputStream("sciezka")
    );

 Przykład 2

Jako przykład rozważmy następującą klasę, reprezentującą dane osób.

Plik DaneOsoby.java  pobierz  
public class DaneOsoby
implements java.io.Serializable {

private String imie;
private String nazwisko;
private int wiek;

public DaneOsoby() {
imie = "";
nazwisko = "";
wiek = 0;
}

public DaneOsoby(String i, String n, int w) {
imie = new String(i);
nazwisko = new String(n);
wiek = w;
}

public String daneOsoby() {
return new String(imie+" "+nazwisko+", lat "+wiek);
}
}
Zaimplementujemy dwa proste programy. Pierwszy z nich (Zapis) będzie pobierał dane osoby z klawiatury, tworzył nowy obiekt klasy DaneOsoby, po czym zapisze go do pliku na dysku. Drugi program (Odczyt) utworzy obiekt na podstawie danych przeczytanych ze strumienia i wyświetli dane osoby na ekranie.

Plik Zapis.java  pobierz  
import java.io.*;

public class Zapis {

public static final String plik = "osoba.stan";

public static void main(String args[]) {
BufferedReader klawiatura =
new BufferedReader(
new InputStreamReader(System.in)
);
String imie,nazwisko;
int wiek;
DaneOsoby osoba;

try {
System.out.print("Imie: ");
imie = klawiatura.readLine();
System.out.print("Nazwisko: ");
nazwisko = klawiatura.readLine();
System.out.print("Wiek: ");
wiek = Integer.parseInt(klawiatura.readLine());
} catch (Exception e) {
System.out.println("Blad wprowadzania danych.");
return;
}
osoba = new DaneOsoby(imie,nazwisko,wiek);
System.out.println("Zapisuje dane osoby:");
System.out.println(osoba.daneOsoby());
System.out.println("do pliku "+plik);
try {
ObjectOutputStream oos =
new ObjectOutputStream(
new FileOutputStream(plik)
);
oos.writeObject(osoba);
oos.flush(); /* zrzuc bufory na dysk */
oos.close();
} catch (Exception e) {
System.out.println("Blad zapisu.");
return;
}
}
}

Plik Odczyt.java  pobierz  
import java.io.*;

public class Odczyt {

public static final String plik = "osoba.stan";

public static void main(String args[]) {
DaneOsoby osoba = null;

System.out.println("Czytam dane osoby z pliku "+plik);

try {
ObjectInputStream ois =
new ObjectInputStream(
new FileInputStream(plik)
);
osoba = (DaneOsoby) ois.readObject();
ois.close();
} catch (Exception e) {
System.out.println("Blad odczytu.");
return;
}

System.out.println("Przeczytalem dane osoby:");
System.out.println(osoba.daneOsoby());

}
}

Należy podkreślić, że w strumieniu zapisywany jest jedynie stan obiektu, a nie cały bytecode jego klasy. Odczyt obiektu ze strumienia polega na utworzeniu nowego obiektu i ustaleniu jego stanu (wartości pól) na podstawie danych ze strumienia. Można się o tym łatwo przekonać przeprowadzając następujący eksperyment:
  1. Uruchomić Zapis i podać jakieś dane osoby aby spowodować zapis obiektu do pliku.
  2. Usunąć plik DaneOsoby.class (bytecode klasy DaneOsoby).
  3. Uruchomić Odczyt.
Skutkiem będzie wyrzucenie wyjątku ClassNotFoundException - maszyna wirtualna nie znalazła klasy DaneOsoby, a jest ona niezbędna aby utworzyć nowy obiekt klasy DaneOsoby i przywrócić jego stan na podstawie danych przeczytanych z pliku. Zapisując i odczytując obiekty korzystając ze strumieni należy pamiętać o tym, aby zapewnić dostępność bytecode'u klasy, do której należy obiekt czytany ze strumienia.

 Zadanie 1

Zmodyfikować klasy z poprzedniego przykładu (procesor i zadania) tak, aby w klasie ZadaniaDemo można było dodać kolejne funkcje menu: zapisz procesor i odczytaj procesor. Zaimplementować w/w funkcjonalność za pomocą ObjectOutputStream i ObjectInputStream. Funkcja zapisz procesor ma zapisywać do pliku (np. procesor.stan) bieżący stan procesora (obejmujący także wszystkie zadania) tak, aby można było zakończyć program ZadaniaDemo bez wykonywania zadań, a następnie uruchomić go ponownie, przeczytać procesor (za pomocą funkcji odczytaj procesor z menu) i wykonać wcześniej wprowadzone i zapisane zadania.
Jeśli ktoś utknie, poniżej proponowana przeze mnie implementacja (korzystać tylko w razie konieczności, proszę spróbować wykonać to zadanie samodzielnie ! ):


Valid HTML 4.01!