JAVA. Uniwersalne techniki programowania - Krzysztof Barteczko

Kup ebooka

84.00 zł
67.20 zł (52,08 zł najniższa cena z 30 dni)

-
Proszę czekać

Wstęp

W książce przedstawiono sposoby i techniki programowania użyteczne we wszelkich zastosowaniach języka Java. Materiał nie jest przeznaczony dla całkiem początkujących programistów Javy. Jednak choćby pobieżna orientacja w podstawach programowania w tym języku wystarczy, aby korzystając z materiału książki, znacznie pogłębić swoją wiedzę. Mając na względzie programistów niezbyt zaawansowanych w języku Java, każdy z poruszanych ważnych tematów, takich jak:

- programowanie obiektowe i funkcyjne,

- obsługa wyjątków,

- przetwarzanie kolekcji,

- programowanie operacji wejścia-wyjścia,

- programowanie współbieżne i równoległe,

- programowanie dynamiczne,

- lokalizacja i internacjonalizacji aplikacji,

jest omawiany od podstaw. Jednocześnie położono nacisk na kontekst praktyczny: jak i do czego stosować poszczególne narzędzia językowe? Szczególną uwagę skierowano na nowe elementy języka dostępne w wersji 8 i 9.

Rozbudowane rozdziały o kolekcjach, wejściu-wyjściu, programowaniu współbieżnym i równoległym oraz programowaniu dynamicznym można traktować jako swoiste kompendia, które dają nie tylko orientację co do zestawu dostępnych uniwersalnych środków programowania, lecz także omawiają ważne szczegóły i niuanse ich stosowania. Będą one zapewne interesujące również dla Czytelników już zaawansowanych w programowaniu w Javie.

Warto może też wymienić szczególnie ciekawe tematy omawiane w książce. Należą do nich:

- statyczne i domyślne metody interfejsów oraz mixiny, a także szczegółowe przedstawienie gotowych implementacji takich metod w gotowych interfejsach (zwłaszcza kolekcyjnych);

- możliwości i sposoby implementacji w interfejsach metod prywatnych (poczynając od Javy w wersji 9);

- lambda-wyrażenia wraz z przeglądem gotowych interfejsów funkcyjnych i zastosowania ich domyślnych metod;

- sposoby postępowania z wyjątkami kontrolowanymi w lambda-wyrażeniach;

- obiekty typu Optional jako monady oraz ich zastosowanie;

- spliteratory;

- przetwarzanie strumieniowe (pakiet java.util.stream);

- instrukcja try-with-resources jako ogólny sposób działania na dowolnych zasobach wymagających zamykania (z prezentacją koncepcji tłumionych wyjątków oraz rozszerzeń składniowych w Javie w wersji 9);

- metody strumieniowego przetwarzania plików;

- działania na katalogach za pomocą wizytorów, a także z użyciem przetwarzania strumieniowego;

- działania na archiwach, w tym za pomocą systemu plikowego ZipFileSystem i metod interfejsu FileVisitor;

- szczegółowe i obrazowe omówienie mechanizmu fork-join oraz koncepcji work-steeling;

- obiekty typu CountedCompleter jako kontynuacje oraz inne ich zastosowania;

- zadania typu CompletableFuture (nowe w Javie 8) i ich praktyczne zastosowanie;

- zasady i sposoby kończenia z zewnątrz równolegle wykonujących się zadań, w zależności od typu tych zadań;

- wyspecjalizowane synchronizatory, w tym - nowe w Javie 8 - obiekty typu StampedLock;

- zastosowanie refleksji oraz uchwytów metod (MethodHandle);

- tworzenie dynamicznych klas i obiektów typu proxy;

- zastosowania JavaBeans i delegacyjnego modelu obsługi zdarzeń zmian ich właściwości;

- definiowanie i przetwarzanie adnotacji (w tym definiowanie procesorów adnotacji);

- użycie skryptów z poziomu aplikacji Javy.

Jak już wspomniano, lektura książki wymaga znajomości podstaw programowania w języku Java. Założono m.in., że Czytelnik jest świadomy znaczenia pojęcia referencji w Javie, dlatego są czasem stosowane uproszczone opisy elementów języka czy fragmentów kodu. Zamiast dość niezgrabnych (choć precyzyjnych) tekstów typu: "metodzie przekazujemy jako argument referencję do obiektu klasy String" lub "metoda zwraca referencję do obiektu klasy String", są używane prostsze sformułowania, np. "metodzie przekazujemy napis, metoda zwraca napis". Oczywiście, zawsze należy pamiętać o różnicy między obiektem a referencją do obiektu.

Informacje na ten temat (a także omówienie innych ważnych podstaw języka) można znaleźć np. w książce autora Programowanie praktyczne od podstaw, wydanej przez WN PWN w 2014 roku. Prezentowane w niej bardziej zaawansowane tematy w obecnej publikacji zostały poszerzone i uzupełnione.

W doborze treści i sposobu jej prezentacji autor wykorzystał swoje doświadczenia z prowadzenia wykładów i zajęć programistycznych w Polsko-Japońskiej Akademii Technik Komputerowych. Niektóre fragmenty książki powstały na ich podstawie.

Praca jest przeznaczona dla Czytelników, którzy chcą samodzielnie rozwijać umiejętności programowania. Z powodzeniem może być też używana np. na jedno- lub dwusemestralnych kursach programowania na studiach wyższych.

Krzysztof Barteczko

Warszawa, sierpień 2015

ROZDZIAŁ 3Wyjątki

Programowanie w Javie sprowadza się do tworzenia obiektów i wywoływania na ich rzecz metod albo też, gdy nie mamy obiektów, wywoływania metod statycznych. W konstruktorze i w trakcie wykonania metody może powstać jakiś błąd. W Javie błędy wykonania programu obsługuje się za pomocą obsługi wyjątków.

3.1. Obsługa wyjątków

Wyjątek to sygnał o błędzie w trakcie wykonania programu.

Wyjątek powstaje na skutek jakiegoś nieoczekiwanego błędu. Jest zgłaszany lub - mówiąc inaczej - sygnalizowany. Ponadto wyjątek jest (może lub musi być) obsługiwany.

Prosty schemat obsługi wyjątków:

try {

// ... w bloku try ujmujemy instrukcje, które mogą spowodować wyjątek

} catch(TypWyjątku exc) {

// ... w klauzuli catch umieszczamy obsługę wyjątku

}

Gdy w wyniku wykonania instrukcji w bloku try powstanie wyjątek typu TypWyjatku, sterowanie zostanie przekazane do kodu umieszczonego w powyższej klauzuli catch.

Przykłady

A. Brak jawnej obsługi wyjątku (kod 3.1). Powstały błąd (wyjątek) powoduje zakończenie programu, a JVM wyświetla komunikat o jego przyczynie.

public class NoCatch {

public static void main(String[] args) {

int a = 1, b = 0, c = 0;

c = a/b;

System.out.println(c);

}

}

Kod 3.1. Brak jawnej obsługi wyjątku

Wynik na konsoli:

Exception in thread "main" java.lang.ArithmeticException: / by zero

at NoCatch.main(NoCatch.java:6)

B.Zabezpieczamy się przed możliwymi skutkami całkowitoliczbowego dzielenia przez zero, obsługując wyjątek ArithmeticException (kod 3.2).

public class Catch1 {

public static void main(String[] args) {

int a = 1, b = 0, c = 0;

String wynik;

try {

c = a/b;

wynik = "" + c;

} catch (ArithmeticException exc) {

wynik = "***";

}

System.out.println(wynik);

}

}

Kod 3.2. Obsługa wyjątku ArithmeticException

ROZDZIAŁ 1Klasy

Rozdział zawiera syntetyczne informacje o charakterystykach klas i ich składowych. Szczegółowo omówione zostały (nie zawsze intuicyjne) reguły inicjowania pól. Przy okazji przedstawiono ważne praktyczne narzędzia: enumeracje i klasy opakowujące typy pierwotne. Naszkicowane zostały też pewne sposoby programowania obejmujące m.in. klasy niemodyfikowalne i singletony.

1.1. Abstrakcja i hermetyzacja

Java jest językiem obiektowym. Języki obiektowe posługują się pojęciem obiektu i klasy.

Obiekt to konkretny lub abstrakcyjny byt, wyróżnialny w modelowanej rzeczywistości, mający określone granice i atrybuty (właściwości) oraz mogący świadczyć określone usługi, czyli wykonywać określone działania lub przejawiać określone zachowanie.

Jak już wiemy, klasa opisuje wspólne cechy grupy podobnych obiektów.

Klasa to opis takich cech grupy podobnych obiektów, które są dla nich niezmienne, np. zestaw atrybutów i metod, czyli usług, które mogą świadczyć.

Przedstawione tu definicje[1] stanowią abstrakcyjne odzwierciedlenie cech rzeczywistości. Gdybyśmy mieli w języku programowania podobne pojęcia, moglibyśmy ujmować projekt rozwiązania rzeczywistego problemu i jego oprogramowanie w języku adekwatnym do problemu. I to zapewniają języki obiektowe. Jest to ich bardzo ważna cecha - zwana abstrakcją obiektową, znacznie ułatwiająca tworzenie oprogramowania.

Programowanie polega na przetwarzaniu danych. Dane zawsze są określonych typów, a typ to nic innego jak rodzaj danych i działania, które można na nich wykonać.

Z pragmatycznego punktu widzenie możemy więc powiedzieć, że klasa to typ, jej definicja opisuje właściwości typu danych (również funkcjonalne, tzn. dostępne operacje na danych tego typu).

Języki obiektowe pozwalają na definiowanie własnych klas - własnych typów danych, co oznacza programowanie w języku problemu.

O obiektach możemy myśleć jako o egzemplarzach określonych klas. Możemy mieć np. klasę pojazdów o następujących atrybutach: szerokość, wysokość, długość, ciężar, właściciel, stan (stoi, jedzie, zepsuty itp.) oraz udostępniających usługi: ruszania, zatrzymywania, zmiany właściciela (sprzedaż pojazdu) (zob. rys. 1.1).

Rys. 1.1. Początkowy schemat klasy Vehicle

Zauważmy: dzięki abstrakcji obiektowej w programowaniu posługujemy się językiem zbliżonym do języka opisu rzeczywistego problemu.

Oprócz odzwierciedlenia w programie "języka problemu" podejście obiektowe ma jeszcze jedną ważną zaletę: umożliwia zapewnienie, że atrybuty obiektów nie będą bezpośrednio dostępne poza klasą. W programie z obiektami "rozmawiamy" za pomocą wywołania na ich rzecz metod, obiekty same "wiedzą najlepiej", jak zmieniać swoje stany. Dzięki temu nie możemy niczego nieopatrznie popsuć, co więcej - nie możemy zażądać od obiektu usługi, której on nie udostępnia.

Dane (atrybuty) są ukryte i traktowane jako nierozdzielna całość z usługami. Nazywa się to hermetyzacją (lub enkapsulacją) i oznacza znaczne zwiększenie odporności programu na błędy.

Sama koncepcja klasy jako zestawu pól i metod już zapewnia określony poziom hermetyzacji. Nie możemy np. do obiektów klasy Vehicle posłać komunikatu sing(), ponieważ metoda sing() nie występuje jako składowa w tej klasie.

Dodatkowo języki obiektowe (w tym Java) pozwalają ukrywać dane (i metody) przed powszechnym dostępem. Dostęp do składowych klasy regulują tzw. specyfikatory dostępu, których używamy w deklaracjach zmiennych, stałych i metod.

Każda składowa klasy może być:

- prywatna - dostępna tylko w danej klasie (specyfikator private);

- zaprzyjaźniona - dostępna ze wszystkich klas danego pakietu; mówi się tu też o dostępie pakietowym lub domyślnym - domyślnym dlatego, że ten rodzaj dostępności występuje wtedy, gdy w deklaracji składowej nie użyjemy żadnego specyfikatora;

- chroniona lub zabezpieczona - dostępna z danej klasy, wszystkich klas ją dziedziczących oraz z klas danego pakietu (specyfikator protected);

- publiczna - dostępna zewsząd (specyfikator public).

Funkcją prywatności jest zapewnienie:

- ochrony przed zepsuciem (zazwyczaj pola powinny być prywatne; wyjątkiem są stałe) - użytkownik klasy nie ma dostępu do prywatnych pól i niczego nieświadomie nie popsuje;

- właściwego interfejsu (metody "robocze" powinny być prywatne) - użytkownik klasy ma do dyspozycji tylko niezbędne (klarowne) metody, co ułatwia mu korzystanie z klasy;

- ochrony przed konsekwencjami zmiany implementacji.

Twórca klasy może zmienić zestaw i implementację prywatnych metod, nie zmieniając interfejsu publicznego: wszystkie programy napisane przy wykorzystaniu tego interfejsu nie będą wymagały żadnych zmian.

Mamy też w Javie pojęcia klas publicznych i pakietowych. Klasa pakietowa jest dostępna tylko z klas pakietu. Klasa publiczna jest dostępna zewsząd (z innych pakietów). Klasę publiczną deklarujemy ze specyfikatorem public. Oprócz tego w Javie mamy specjalny rodzaj klas zwanych wyliczeniami (enumeracjami).

1.2. Enumeracje

Enumeracja (wyliczenie) to nic innego jak konkretny zbiór nazwanych stałych. Posługując się tylko typem int, możemy np. wprowadzić coś w rodzaju wyliczenia pór roku:

final int ZIMA = 0, WIOSNA = 1, LATO = 2, JESIEN = 3;

i używać ich w programie:

int poraRoku = LATO;

Takie podejście ma jednak wady:

- Brak kontroli poprawności danych - nic nie stoi na przeszkodzie, aby zmiennej poraRoku nadać nonsensowną wartość 111.

- Jeżeli zmienimy wyliczenie (np. dodamy stałe i dotychczasowe wartości stałych się zmienią, np. dodamy PRZEDWIOSNIE z wartością 1, a wartości WIOSNA, LATO, JESIEN zmienimy na 2, 3 , 4 ), to inne fragmenty kodu, już korzystające z tych stałych, nie będą prawidłowo działać.

- Wprowadzanie i wyprowadzanie informacji o wartościach jest żmudne (konwersja String - int), proste wyświetlenie wartości jest całkowicie "nieinformacyjne": println(ZIMA) da nam na wyjściu 0 (a co to?).

- Wyliczenie definiowane za pomocą typu int nie ma właściwości programowania obiektowego (nie ma odrębnego typu, który by zawężał zbiór dopuszczalnych wartości i możliwych operacji na nich).

W Javie usunięto te wady, pozostawiając jednocześnie zaletę efektywności działania (tak jak na liczbach całkowitych), wprowadzając specjalny rodzaj typów referencyjnych oznaczanych słowem kluczowym enum.

Definicja typu wyliczeniowego polega na umieszczeniu po słowie enum w nawiasach klamrowych elementów wyliczenia rozdzielonych przecinkami:

[ public] enum NazwaTypu {

elt1, elt2, ..., eltN

}

gdzie:

elt - elementy wyliczenia.

Na przykład:

enum Pora { ZIMA, WIOSNA, LATO, JESIEŃ }

Tutaj Pora jest nazwą typu wyliczeniowego, a ZIMA, WIOSNA, LATO, JESIEŃ oznaczają stałe tego typu (zbiór nazwanych stałych).

Zmienna zadeklarowana jako:

Pora p;

będzie mogła przyjmować wartości: Pora.ZIMA, Pora.WIOSNA, Pora.LATO, Pora.JESIEŃ (oraz null, ponieważ jest typu referencyjnego). Zauważmy, że wartości te to stałe statyczne i są one typu referencyjnego Pora.

Wobec danych typów wyliczeniowych możemy stosować różne metody, m.in.:

- Zastosowanie metody toString() wobec zmiennej typu wyliczeniowego zwróci jej wartość w postaci napisu (w naszym przykładowym przypadku może to być "ZIMA"); umożliwia to eleganckie i łatwe pokazywanie wartości typu wyliczeniowego, np. p = Pora.ZIMA; System.out.println(p); wyświetli na konsoli napis ZIMA.

- Zastosowanie statycznej metody valueOf(String s) zwróci wartość typu wyliczeniowego odpowiadającą podanemu jako argument napisowi, np. Pora.valueOf.

- ("LATO") zwróci wartość Pora.LATO.

- Metoda ordinal() zastosowana wobec zmiennej typu wyliczeniowego zwraca pozycję w wyliczeniu zajmowaną przez wartość tej zmiennej, przy czym pozycje numerowane są od 0 (np. po Pora p = Pora.WIOSNA; p.ordinal() zwróci 1).

- Metoda statyczna values() zwraca zestaw wszystkich wartości danego typu wyliczeniowego (w naszym przykładzie zestaw ten składa się z elementów Pora.ZIMA, Pora.WIOSNA, Pora.LATO, Pora.JESIEŃ; będzie to tablica).

- Do porównania wartości typów wyliczeniowych pod względem równości wystarczy operator == (tu przy porównaniach zawsze wchodzi w grę tylko identyczność obiektów, na które wskazują referencje).

Rys. 1.2. Wynik działania kodu 1.1

Przykładowy program umożliwia podanie nazwy pory roku i opisuje ją w okienku komunikatów (zob. kod 1.1). Po podaniu w dialogu wejściowym np. napisu "LATO" uzyskamy wynik jak na rys. 1.2:

import javax.swing.JOptionPane;

public class PoryRoku {

enum Pora { ZIMA, WIOSNA, LATO, JESIEŃ };

public static String opisz(Pora p) {

int nr = p.ordinal() + 1;

String txt = p + " (ma numer " + nr + ")";

if (p == Pora.ZIMA) return txt + " - pada śnieg.";

if (p == Pora.WIOSNA) return txt + " - kwitnie bez.";

if (p == Pora.LATO) return txt + " - jest ciepło.";

if (p == Pora.JESIEŃ) return txt + " - pada deszcz.";

return "To niemożliwe";

}

public static void main(String[] args) {

String nazwa = JOptionPane.showInputDialog("Podaj porę roku:");

Pora p = Pora.valueOf(nazwa);

JOptionPane.showMessageDialog(null, opisz(p) );

}

}

Kod 1.1. Zastosowanie enumeracji

Zwróćmy uwagę, że w powyższym przykładzie musieliśmy pisać Pora.LATO, Pora.ZIMA itd. W prostych przypadkach, gdy nie ma możliwości kolizji nazw, byłoby zapewne lepiej używać skróconych nazw (LATO, ZIMA), bo są one w takim kontekście perfekcyjnie jasne.

Jednak aby to zrobić, trzeba statycznie importować nazwy z wyliczenia Pora, a statyczny import możliwy jest tylko z klas umieszczonych w nazwanych pakietach. Możemy więc zrobić tak, jak pokazuje kod 1.2.

package enums;

import static enums.Pora.*;

enum Pora { ZIMA, WIOSNA, LATO, JESIEŃ };

public class PoryRoku1 {

public static void main(String[] args) {

Pora p = ZIMA;

System.out.println(p); // wyprowadzi napis ZIMA

}

}

Kod 1.2. Użycie niekwalifikowanych nazw elementów wyliczenia

Tu jednak definicję wyliczenia Pora musieliśmy umieścić poza klasą PoryRoku1. Czy przeczy to zasadzie, że w plikach źródłowych Javy poza deklaracjami pakietu i importów mogą być tylko definicje klas? Ależ skąd. Wyliczenia (enum) to tak naprawdę również klasy, tylko o pewnych specjalnych właściwościach. A skoro tak, to można z nimi postępować prawie identycznie jak z innymi klasami, czyli np. umieszczać w odrębnych plikach, definiować dodatkowe pola, konstruktory i metody. A jak wywołać konstruktor? Cóż, w przypadku enum nie ma możliwości użycia wyrażenia new. Konstruktor jest wołany przez użycie nawiasów okrągłych przy definiowaniu elementów wyliczenia.

Korzystając z tych możliwości, nieco rozbudujemy wyliczenie pór roku, umieszczając je w nazwanym pakiecie i odrębnym pliku źródłowym (kod 1.3).

package enums;

public enum PoraRoku {

ZIMA("zimno jest, może padać śnieg", 5),

WIOSNA("zakwita roślinnośc", 2),

LATO("może być ciepło, ale niekoniecznie", 3),

JESIEŃ("jest chłodno, często pada descz", 2);

private String opis;

private int liczbaMies;

private PoraRoku(String opis, int liczbaMies) {

this.opis = opis;

this.liczbaMies = liczbaMies;

}

public String getOpis() { return opis; }

public int getLiczbaMies() { return liczbaMies; }

}

Kod 1.3. Enum z konstruktorem i metodami

Przy opracowaniu tego wyliczenia dla każdego jego elementu wołany jest konstruktor, co skutkuje inicjowaniem pól opis i liczbaMies.

Zwróćmy uwagę, że konstruktor jest prywatny, co oznacza, że nie można go wywołać spoza tej klasy (enum). W ten sposób zabronione jest tworzenie wyliczeń za pomocą wyrażenia new.

Kod 1.4 przedstawia wykorzystanie tak zdefiniowanego wyliczenia PoraRoku.

package enums;

import static enums.PoraRoku.*;

import javax.swing.*;

public class PoryRoku2 {

public static void main(String[] args) {

String nazwa = JOptionPane.showInputDialog("Podaj porę roku:");

PoraRoku p = valueOf(nazwa);

System.out.println(p + " trwa miesięcy " +

p.getLiczbaMies() + ", " + p.getOpis());

System.out.println(JESIEŃ + " i " + ZIMA +

" trwają miesięcy " +

(JESIEŃ.getLiczbaMies() + ZIMA.getLiczbaMies()));

}

}

Kod 1.4. Przykład zastosowania enum z metodami

Wynik działania programu po wprowadzeniu w dialogu wejściowym słowa LATO pokazuje listing 1.1.

LATO trwa miesięcy 3, może być ciepło, ale niekoniecznie

JESIEŃ i ZIMA trwają miesięcy 7

Listing 1.1. Wynik działania kodu 1.4

Wyliczenia mają jeszcze inne dodatkowe właściwości, związane z metodami abstrakcyjnymi i przedefiniowaniem metod, o czym będzie mowa w rodz. 2.7.

1.3. Definiowanie klas

Stosując regułę ukrywania danych i specyfikatory dostępu, możemy teraz przedstawić przykładową definicję klasy Person (zob. kod 1.5), a następnie definicję klasy Vehicle.

public class Person {

private String name;

private String pesel;

Person(String aname, String id) {

name = aname;

pesel = id;

}

public String getName() {

return name;

}

public String getPesel() {

return pesel;

}

}

Kod 1.5. Przykładowa definicja klasy Person w myśl reguł hermetyzacji

Atrybuty obiektów klasy Person przedstawiono jako pola prywatne. Spoza klasy nie ma do nich dostępu.

Podczas tworzenia obiektu jego elementy odpowiadające tym polom są inicjowane za pomocą wywołania konstruktora. Później zmiany tych elementów danych nie są już możliwe, możemy tylko uzyskać dane za pomocą publicznych metod getName()i getPesel().

Modyfikując i rozbudowując klasę Vehicle w myśl reguł hermetyzacji (zob. kod 1.6), osiągniemy następujące rezultaty:

- Uczynimy atrybuty pojazdów prywatnymi.

- Możliwe stany pojazdu (stoi, jedzie, zepsuty) powinny stanowić publicznie dostępne atrybuty, wspólne dla szystkich obiektów klasy; wygodnie będzie tu użyć - zamiast typu public static int - publicznego wyliczenia (enum):

package hermet;

public enum VehState {

BROKEN, STOPPED, MOVING

}

- Dostarczymy - jako publicznego - tylko niezbędnego zestawu metod (przy okazji do poprzedniegi projektu klasy Vehicle dodamy metodę crash() symulującą zderzenie pojazdów).

- Ukryjemy przed powszechnym dostępem "robocze" metody: setState(), zmieniającą stan obiektu, i error(), zgłaszającą błędy.

package hermet;

import static hermet.VehState.*;

public class Vehicle {

private int width, height, length, weight;

private Person owner;

private VehState state;

public Vehicle(int w, int h, int l, int ww) {

this(null, w, h, l, ww);

}

public Vehicle(Person p, int w, int h, int l, int ww) {

owner = p; width = w; height = h;

length = l; weight = ww; state = STOPPED;

}

public void start() {

setState(MOVING);

}

public void stop() {

setState(STOPPED);

}

public void repair() {

if (state != BROKEN) error("Nie można reperować sprawnego pojazdu");

else state = STOPPED;

}

public VehState getState() {

return state;

}

public void sellTo(Person p) {

owner = p;

}

// dodana metoda crash() symulująca zderzenie samochodów

public void crash(Vehicle v) {

if (this.state != MOVING && v.state != MOVING)

error("Żaden z pojazdów nie jest w ruchu - nie ma zderzenia");

this.state = BROKEN;

v.state = BROKEN;

}

public String toString() {

String s = (owner == null ? "sklep" : owner.getName());

return "Pojazd, właścicielem którego jest " + s + " jest w stanie " + state;

}

private void setState(VehState newState) {

if (state == BROKEN)

error("Nie jest możliwe przejście ze stanu " + state + " do stanu " + newState);

else

state = newState;

}

protected void error(String msg) {

throw new IllegalArgumentException(msg);

}

}

Kod 1.6. Definicja klasy Vehicle w myśl reguł hermetyzacji

Zwróćmy uwagę, że:

- Atrybuty pojazdu (wysokość, waga itd.) nadawane są przy tworzeniu obiektu i żadna inna klasa nie może ich później zmienić.

- Stan pojazdu jest zmieniany tylko na skutek wywołania publicznych metod start(), stop(), repair(), przy czym sam obiekt dba, by nie doszło do niewłaściwych zmian (zob. definicje tych metod oraz prywatnej metody setState(...)).

- Metoda setState() jest prywatna - nie dopuszczamy możliwości arbitralnej zmiany stanu pojazdu spoza klasy, a procedurę zmiany stanu wyróżniliśmy w tej prywatnej metodzie, by oszczędzić na kodowaniu (ten sam kod wołamy w kilku miejscach).

- Dla oszczędności kodowania została również wprowadzona robocza metoda error(), wołaną w kilku miejscach dla zgłoszenia błędów; nadano jej specyfikator dostępu protected (a nie private), żeby mogła być używana w klasach dziedziczących Vehicle.

1.4. Wykorzystanie składowych statycznych

Składowe klasy mogą być statyczne i niestatyczne. Niestatyczne zawsze wiążą się z istnieniem jakiegoś obiektu (pola odpowiadają elementom obiektu, metody muszą być wywoływane na rzecz obiektu, są komunikatami do obiektu).

Składowe statyczne (pola i metody) są wspólne dla wszystkich obiektów oraz:

- są deklarowane przy użyciu specyfikatora static,

- mogą być używane nawet wtedy, gdy nie istnieje żaden obiekt klasy.

Uwaga. Ze statycznych metod nie wolno odwoływać się do niestatycznych składowych klasy (obiekt może nie istnieć). Możliwe są natomiast odwołania do innych statycznych składowych.

Spoza klasy do jej statycznych składowych możemy odwoływać się na dwa sposoby:

- NazwaKlasy.NazwaSkładowej,

- gdy istnieje jakiś obiekt: tak samo jak do niestatycznych składowych (ale jest to mylące i niepolecane).

Skorzystamy z koncepcji składowych statycznych po to, by każdemu pojazdowi w naszym programie nadawać unikatowy numer (np. od jednego), a także zawsze mieć rozeznanie, ile obiektów typu Vehicle dotąd utworzyliśmy:

public class Vehicle {

private static int count; // ile obiektów dotąd utworzyliśmy

private int currNr; // bieżący numer pojazdu

//...

public Vehicle(Person p, int w, int h, int l, int ww) {

// ....

// Każde utworzenie nowego obiektu zwiększa licznik o 1

// bieżąca wartość licznika nadawana jest jako numer pojazdu

// numer pojazdu jest niestatycznym polem klasy, a więc elementem obiektu

currNr = ++count;

}

//.....

// zwraca unikatowy numer pojazdu

public int getNr() { return currNr; }

// zwraca liczbę dotąd utworzonych obiektów

// metoda jest statyczna, by móc zwrócić 0

// gdy nie ma jeszcze żadnego obiektu

public static int getCount() { return count; }

1.5. Przeciążanie metod i konstruktorów

W klasie (i/lub jej klasach pochodnych) możemy zdefiniować metody o tej samej nazwie, ale różniące się liczbą i/lub typami parametrów. Nazywa się to przeciążaniem metod.

Po co istnieje taka możliwość? Wyobraźmy sobie, że na obiektach klasy par liczb całkowitych (znanej nam z poprzednich rozdziałów) chcielibyśmy wykonywać operacje:

- dodawania innych obiektów-par,

- dodawania (do składników pary) kolejno dwóch podanych liczb całkowitych,

- dodawania (do każdego składnika pary) tej samej podanej liczby całkowitej.

Gdyby nie było przeciążania metod, musielibyśmy dla każdej operacji wymyślać inną nazwę metody. A przecież istota operacji jest taka sama (więc wystarczy nazwa add), a jej użycie powinno być jasne z kontekstu (określanego przez argumenty).

Dzięki przeciążaniu można w klasie Para zdefiniować np. metody:

void add(Para p) // dodaje do pary, na rzecz której wywołano metodę, parę

// podaną jako argument

void add(int i) // do obu składników pary dodaje podaną liczbę

void add(int i, int k) // pierwszą podaną liczbę dodaje do pierwszego składnika

// pary a drugą - do drugiego

i użyć gdzie indziej w naturalny sposób:

Para p;.

Para jakasPara;

....

p.add(3); // wybierana jest ta metoda, która pasuje (najlepiej) do argumentów

p.add(1,2);

p.add(jakasPara);

Innym przykładem przeciążonej metody jest println(...). Ma ona bardzo wiele wersji - z argumentami różnych typów (m.in. wszystkich prostych, String i Object). I bardzo dobrze, bo w przeciwnym przypadku musielibyśmy pisać np. printInt(3) i printString("Ala"), aby wyświetlić odpowiednio liczbę całkowitą i napis.

Identyfikatory metod definiowanych w klasie muszą być od siebie różne. Wyjątkiem są metody przeciążone, tj. takie, które mają tę samą nazwę (identyfikator), ale różne typy i/lub liczbę argumentów.

Z przeciążaniem metod związany jest pewien problem. Otóż wywołanie do odpowiedniej wersji metody dopasowuje kompilator, na podstawie liczby i typów argumentów. Musimy przy tym uważać, bo kiedy liczba parametrów w różnych wersjach metody przeciążonej jest taka sama, a ich typy zbliżone - to może się okazać, że źle interpretujemy działanie programu. Na przykład co się stanie, jeśli mamy dwie metody o nazwie show, pierwsza z parametrem typu short, a druga z parametrem typu int, i wywołujemy metodę show z argumentem typu char? Powiedzieliśmy przed chwilą, że zostanie wywołana metoda, której parametry najlepiej pasują do argumentów wywołania. Ponieważ typ short jest "bliższy" typowi char niż typ int, mogłoby się wydawać, że zostanie wywołana metoda show(short). Tymczasem - jak wiemy - wykonywana jest promocja argumentu typu char do typu int i będzie wywołana metoda show(int).

Jeżeli nie znamy dobrze mechanizmów automatycznych konwersji, to w metodach przeciążonych specyfikujmy różną liczbę parametrów lub całkowicie różne typy parametrów.

Ogólnie, algorytm wyboru przez kompilator odpowiedniej metody przeciążonej jest dość skomplikowany i może mieć zaskakujące, nieintuicyjne konsekwencje (zob. specyfikację języka Java [10]). Również z tego powodu należy wyraźnie różnicować liczbę lub typy argumentów.

Zwróćmy też uwagę, że przeciążanie "rozciąga się" na różne rodzaje metod. Dwie metody - statyczna i niestatyczna - o tej samej nazwie, ale o różnych typach lub liczbie argumentów są przeciążone. Podobnie jak metody możemy przeciążać konstruktory. Znaczy to, że w jednej klasie możemy mieć kilka wersji konstruktorów z różnymi parametrami. W ten sposób udostępniamy różne sposoby inicjowania obiektów klasy.

W takim przypadku, by nie powtarzać wspólnego kodu w różnych konstruktorach, wygodna okazuje się możliwość wywoływania konstruktora z innego konstruktora. Do takiego wywołania stosujemy słowo kluczowe this, co ilustruje poniższy fragment składni:

Vehicle(int w, int h, int l, int ww) {

this(null, w, h, l, ww);

}

Vehicle(Person p, int w, int h, int l, int ww) {

owner = p; width = w; height = h;

length = l; weight = ww; state = STOPPED;

}

Konstrukcja this(...) powinna być pierwszą instrukcją w ciele konstruktora.

1.6. Klasy i obiekty niezmienne

Wróćmy jeszcze na chwilę do definicji klasy Person. Jak widzieliśmy - w ramach naszej aplikacji - jej obiekty po utworzeniu nie mogą zmieniać swoich stanów (w tym przypadku wartości pól name i pesel). Takie niezmienne (immutable) klasy odgrywają ważną rolę

w programowaniu, bo m.in:

- gwarantują, że ich obiekty po dodaniu do zbiorów i do map (jako klucze map) nigdy nie zmienią swoich stanów, co - jak pamiętamy - jest warunkiem, aby zbiory oraz klucze w mapach zachowywały spójność (oczywiście w pokazanej tu klasie Person trzeba by było jeszcze zdefiniować metody equals() i hashCode(), tak by obiekty tej klasy mogły być dodawane do HashSet lub występować jako klucze w HashMap);

- żadna metoda, uzyskując taki obiekt jako argument, nie może zmienić jego stanów, a zatem unikamy przypadkowych błędów;

- posługiwanie się nimi w programowaniu współbieżnym nie wymaga synchronizacji, która zwykle jest trudnym zadaniem (zob. rozdział o programowaniu współbieżnym).

Pokazana klasa Person nie jest absolutnie niezmienna. Zgodnie z zasadami powinniśmy w niej jeszcze w kilku miejscach użyć słowa kluczowego final:

final public class Person { // tu final zabrania dziedziczenia klasy Person

// a tu przez final zabezpieczamy się przed błędami w kodzie klasy:

private final String name;

private final String pesel;

// ...

}

Stosowanie klas niezmiennych znacznie ułatwia programowanie, ale oczywiście nie zawsze możemy to robić. Na przykład w przypadku klasy Vehicle jej zmienność (zmienne stany pojazdów: stoi, jedzie itp.) wydaje się naturalnym rozwiązaniem.

1.7. Inicjowanie

Podczas tworzenia obiektu:

- pola klasy mają zagwarantowane inicjowanie na wartości ZERO (0, false - dla typu boolean, null - dla referencji);

- zwykle w konstruktorze dokonuje się reinicjacji pól;

- ale można również posłużyć się jawną inicjacją podczas deklaracji pól.

W trakcie tworzenia obiektu klasy Vehicle (new Vehicle(...) - por. kod 1.6) elementy nowego obiektu, odpowiadające polom klasy, uzyskają wartości 0 (dla elementów typu int) oraz null dla elementu odpowiadającego referencjom (owner i state). Następnie zostanie wywołany konstruktor, w którym dokonujemy właściwego inicjowania.

Można by było napisać np.:

private VehState state = STOPPED; // jawna inicjacja pola

i usunąć z konstruktora instrukcję state = STOPPED.

Z inicjowaniem wiąże się również pojęcie bloków inicjacyjnych. W Javie w zasadzie nie można używać instrukcji wykonywalnych (m.in. sterujących) poza ciałami metod i konstruktorów. Od tej zasady są jednak dwa wyjątki:

- użycie niestatycznego bloku inicjacyjnego,

- użycie statycznego bloku inicjacyjnego.

Niestatyczny blok inicjacyjny wprowadzamy, ujmując kod wykonywalny w nawiasy klamrowe i umieszczając taką konstrukcję w definicji klasy poza ciałem jakiejkolwiek metody (czy konstruktora). Kod bloku zostanie wykonany zaraz po utworzeniu i domyślnym zainicjowaniu obiektu, przed wywołaniem jakiegokolwiek konstruktora. Taka możliwość może się okazać przydatna, gdy mamy kilka konstruktorów i chcemy wyróżnić pewien kod, który będzie inicjował obiekt niezależnie od użytego konstruktora i przed użyciem któregokolwiek z nich. Na przykład możemy w bloku inicjacyjnym wyodrębnić nieco bardziej zaawansowaną postać inicjacji zmiennej state, opisującej stan pojazdu (zob. kod 1.7).

// ... niezbędne importy

public class Vehicle {

// ...

private VehState state;

// Niestatyczny blok inicjacyjny

// -- w niedzielę wszystkie samochody inicjalnie stoją

// -- w poniedziałek te o parzystych numerach inicjalnie jadą,

// inne - stoją

// -- w pozostałe dni tygodnia:

// wszystkie jadą

{ // początek bloku

int dayOfWeek = Calendar.getInstance().get(Calendar.DAY_OF_WEEK);

switch (dayOfWeek) {

case Calendar.SUNDAY : state = STOPPED; break;

case Calendar.MONDAY : state = (currNr % 2 == 0 ? MOVING : STOPPED);

break;

default : state = MOVING; break;

}

} // koniec bloku

// Konstruktory i metody klasy

public Vehicle(...) {

// ...

}

// ...

}

Kod 1.7. Niestatyczny blok inicjacyjny

W pokazanym przypadku takie wyodrębnienie niestatycznego bloku inicjacyjnego ma pewien sens (wtedy kod ew. kilku konstruktorów jest może bardziej czytelny), ale zazwyczaj nie jest to zbyt interesujące rozwiązanie. Wyjątkiem jest zaawansowane inicjowanie pól wprowadzanych w anonimowej klasie wewnętrznej (w tym przypadku niestatyczny blok inicjacyjny zastępuje konstruktor, którego przecież nie można zdefiniować, bo klasa nie ma nazwy - zob. dalej o anonimowych klasach wewnętrznych).

Ogólnie ciekawszym rozwiązaniem wydaje się statyczny blok inicjacyjny. Czasem pojawia się potrzeba wykonania jakiegoś kodu jeden raz, przy pierwszym odwołaniu do klasy. Przy inicjowaniu pól statycznych możemy skorzystać z dowolnych wyrażeń składających się ze zmiennych i stałych statycznych, oraz z wywołań statycznych metod, ale - oczywiście - nie sposób użyć instrukcji wykonywalnych (np. sterujących).

Statyczny blok inicjacyjny wprowadzamy słowem kluczowym static z następującym po nim kodem ujętym w nawiasy klamrowe. Kod ten będzie wykonywany jeden raz przy pierwszym odwołaniu do klasy (np. użyciu metody statycznej lub utworzeniu pierwszego obiektu). Oczywiście z takiego bloku możemy odwoływać się wyłącznie do zmiennych statycznych (obiekt jeszcze nie istnieje).

Przykład. Wyobraźmy sobie, że pojazdy są numerowane w kolejności ich tworzenia. Aby to osiągnąć, wprowadzimy w klasie Vehicle pole statyczne initNr (początkowy numer, od którego zaczyna się numeracja pojazdów) oraz pole currNr - bieżący numer pojazdu:

class Vehicle {

// ...

private static int initNr;

//...

private int currNr = ++initNr;

}

Przy pierwszym odwołaniu do klasy zmienna initNr uzyskuje wartość 0, a przy każdym tworzeniu obiektu jej wartość zwiększa się o 1 i zostaje przypisana do zmiennej currNr (kolejny utworzony pojazd uzyskuje numer o 1 większy od poprzednio utworzonego).

Załóżmy dalej, że początkowe numery pojazdów zależą od domyślnej lokalizacji (ustawień regionalnych) aplikacji (ustawienia takie są inne dla Stanów Zjednoczonych, a inne dla, powiedzmy, Włoch). Będziemy więc inicjować zmienną statyczną initNr w zależności od lokalizacji (w tym przypadku oczywiście initNr nie oznacza liczby obiektów utworzonych w programie, ale początkowy numer pojazdu). Taki problem można rozwiązać właśnie za pomocą statycznego bloku inicjacyjnego (zob. kod 1.8).

public class Vehicle {

private static int initNr;

// Statyczny blok inicjacyjny

// za jego pomocą inicjujemy zmienną initNr w taki sposób,

// by numery pojazdów zaczynały się w zależności

// od domyślnej lokalizacji aplikacji

// np. jeśli aplikacja jest wykonywana w lokalizacji włoskiej,

// numery zaczynają się od 10000.

static {

Locale[] loc = { Locale.UK, Locale.US, Locale.JAPAN, Locale.ITALY, };

int[] begNr = { 1, 100, 1000, 10000, };

initNr = 200; // jeżeli aplikacja działa w innej lokalizacji niż wymienione

// w tablicy, zaczynamy numery od 200

Locale defLoc = Locale.getDefault(); // jaka jest domyślna lokalizacja?

for (int i=0; i < loc.length; i++)

if (defLoc.equals(loc[i])) {

initNr = begNr[i];

break;

}

}// koniec bloku

// pola niestatyczne

private int currNr = ++count;

private int width, height, length, weight;

// ...

// Konstruktory i metody

// ...

// zmieniona metoda toString() pokazuje numer pojazdu.

public String toString() {

String s = (owner == null ? "sklep" : owner.getName());

return "Pojazd " + currNr + ", którego właścicielem jest "

+ s + " jest w stanie " + state;

}

} // koniec klasy

Kod 1.8. Statyczny blok inicjacyjny

Uwaga. Obiekty klasy Locale z pakietu java.util oznaczają tzw. lokalizacje (ustawienia regionalne). W kodzie dostarczamy tablicę lokalizacji loc inicjowaną statycznymi stałymi klasy Locale. Metoda getDefaultLocale() zwraca aktualne systemowe ustawienia regionalne (aktualną lokalizację). Poczynając od Javy 7, są to ustawienia związane z graficznym interfejsem systemu (czyli np. w amerykańskiej wersji Windows nawet przy polskich ustawieniach języka, klawiatury itp. zostanie zwrócone Locale.US). Szczegóły podano w rozdz. 14.

Ogólnie inicjatory pól statycznych oraz statyczne bloki inicjacyjne nazywają się inicjatorami statycznymi, a niestatyczne bloki inicjacyjne i inicjatory niestatycznych pól - inicjatorami niestatycznymi.

Niezwykle ważne są podane tu reguły inicjowania:

- Inicjowanie klasy powoduje jednokrotne zainicjowanie elementów statycznych, tzn. najpierw wszystkie pola statyczne uzyskują wartości domyślne, a następnie wykonywane są inicjatory statyczne w kolejności ich występowania w klasie. Inicjowanie klasy następuje w wyniku jej załadowania przez JVM, co może się zdarzyć np. przy uruchomieniu głównej klasy programu lub pierwszym odwołaniu z programu do klasy, np. na skutek wywołania jej metody statycznej lub utworzenia obiektu (np. Vehicle v - new Vehicle(...)).

- Tworzenie każdego obiektu (new) powoduje nadanie niestatycznym polom klasy wartości domyślnych, następnie wykonanie inicjatorów niestatycznych w kolejności ich występowania w klasie, po czym wykonywany jest konstruktor.

- W inicjatorach statycznych można się odwoływać do wszystkich statycznych metod klasy, ale tylko do tych statycznych pól, których deklaracje poprzedzają inicjator.

- W inicjatorach niestatycznych można się odwoływać do wszystkich metod klasy, do wszystkich pól statycznych (niezależnie od miejsca ich występowania), ale tylko do tych pól niestatycznych, których deklaracje poprzedzają inicjator.

- W konstruktorze można się odwoływać do wszystkich metod i pól klasy.

Reguł inicjowania nie należy lekceważyć, bo niewłaściwa kolejność inicjatorów w kodzie może prowadzić do subtelnych błędów.

Kod 1.9 ułatwia zrozumienie powyższych reguł.

public class InitOrder {

private static int s = 100;

private static final int C;

private int a = 1;

InitOrder() {

report("Konstruktor: s, C, a, b mają wartości :", s, C, a, b);

}

private int b = 2;

{

report("Blok inicjacyjny: s, C, a =", s, C, a);

}

static {

report("Statyczny blok inicjacyjny, s, C = ", s, C);

C = 101; // opóźnione inicjowanie stałej!

}

private static void report(String msg, int ... args ) {

System.out.print(msg + " ");

for (int i : args) {

System.out.print(i + " ");

}

System.out.println();

}

public static void main(String[] args) {

report("Wywołanie metody main");

new InitOrder();

}

}

Kod 1.9. Kolejność inicjowania

Kod 1.9 wyświetli w wyniku:

Statyczny blok inicjacyjny, zmienna s = 100

Wywołanie metody main

Blok inicjacyjny: s, C, a = 100 101 1

Konstruktor: s, C, a, b mają wartości : 100 101 1 2

Gdybyśmy jednak w kodzie 1.9 dodali na początku klasy pole statyczne inicjowane referencją do nowo tworzonego obiektu tej klasy:

public class InitOrder {

private static InitOrder iord = new InitOrder();

private static int s = 100;

private static final int C;

private int a = 1;

private int b = 2;

// ..

}

wynik wyglądałby tak:

Blok inicjacyjny: s, C, a = 0 0 1

Konstruktor: s, C, a, b mają wartości : 0 0 1 2

Statyczny blok inicjacyjny, s = 100

Wywołanie metody main

Blok inicjacyjny: s, C, a = 100 101 1

Konstruktor: s, C, a, b mają wartości : 100 101 1 2

W tym przypadku niestatyczny blok inicjacyjny oraz konstruktor są wywoływane przed blokiem statycznym, ale nie przeczy to podanym regułom inicjowania. Faktycznie: najpierw wszystkie pola statyczne uzyskują wartości domyślne (zera), następnie inicjowane jest pierwsze pole statyczne iord. Tutaj inicjowanie polega na opracowaniu wyrażenia new InitOrder(). W nowo utworzonym przez nie obiekcie niestatyczne pola klasy uzyskują domyślne wartości (zera), następnie wykonywane są niestatyczne inicjatory i wołany jest konstruktor. Inne inicjatory statyczne klasy InitOrder jeszcze nie zostały wykonane, więc zarówno niestatyczny blok inicjacyjny, jak i konstruktor raportują zerowe (domyślne) wartości pól statycznych s i C.

Zwróćmy uwagę na jeszcze jeden niuans. Gdyby pole statyczne S zadeklarować jako final:

private static InitOrder iord = new InitOrder();

private static final int s = 100;

private static final int C;

początkowe wiersze wydruku wynikowego byłyby trochę inne:

Blok inicjacyjny: s, C, a, b = 100 0 1 2

Konstruktor: s, C, a, b mają wartości : 100 0 1 2

Dzieje się tak dlatego, że mamy tu do czynienia z wyrażeniem stałym, opracowywanym w fazie kompilacji (stała S uzyskuje wartość już w fazie kompilacji). Ale dlaczego stała C nadal ma wartość 0? To zrozumiałe, jej inicjowanie zostało opóźnione i stała ta uzyskuje wartość (101) w inicjacyjnym bloku statycznym, który się jeszcze nie wykonał.

W sytuacji dziedziczenia klas (to pojęcie zostanie szczegółowo omówione w następnym rozdziale) sekwencja działań przy inicjowaniu jest następująca:

- Najpierw wykonywane są inicjatory statyczne w kolejności klas w hierarchii dziedziczenia i kolejności ich występowania w poszczególnych klasach.

- Następnie wykonywane są inicjatory niestatyczne oraz wywoływane konstruktory w kolejności klas w hierarchii dziedziczenia.

A zatem przy takiej strukturze:

class A {

static { ... }

static int a = ...

int b = ...

{ ... }

public A() { }

}

class B extends A {

static { ... }

static int a = ...

int b = ...

{ ... }

public B() { }

}

po wykonaniu new B() uzyskamy następującą kolejność inicjowania:

Statyczny blok inicjacyjny klasy A

Inicjacja pola statycznego klasy A

Statyczny blok inicjacyjny klasy B

Inicjacja pola statycznego klasy B

inicjacja pola niestatycznego klasy A

Niestatyczny blok inicjacyjny klasy A

Konstruktor klasy A

inicjacja pola niestatycznego klasy B

Niestatyczny blok inicjacyjny klasy B

Konstruktor klasy B

Przy dziedziczeniu należy również pamiętać o następujących kwestiach:

- Wywołanie konstruktora nadklasy z konstruktora klasy pochodnej odbywa się za pomocą super(arg_konstruktora);, co musi być pierwszą instrukcją w ciele konstruktora podklasy.

- Jeśli nie ma takiego wywołania, to domyślnie wołany jest konstruktor bezparametrowy nadklasy.

- Konstruktor bezparametrowy jest automatycznie dodawany do klasy, jeśli nie zdefiniowano w niej żadnych konstruktorów.

- Jeśli jednak zdefiniowano konstruktory, to konstruktor bezparametrowy nie jest dodawany.

W takiej sytuacji:

class A {

A(int x) { ... }

}

class B extends A {

B() { // nie ma super(...) }

}

powstanie więc błąd w kompilacji, bo domyślnie ma być wywoływany konstruktor bezparametrowy klasy A, a jest on w tej klasie niezdefiniowany.

1.8. Singletony

Wzorzec singleton zapewnia, że klasa będzie miała tylko jedną instancję (można utworzyć tylko jeden obiekt tej klasy), a jednocześnie udostępnia globalny, jednolity sposób uzyskiwania i odwoływania się do tego obiektu z różnych fragmentów kodu (innych klas).

Przykładowo singletonem może być cennik używany przez różne klasy jakiegoś systemu rozliczeniowego (faktycznie w takiej sytuacji cennik powinien być jednolity, a więc zawsze przy odwołaniach do niego powinniśmy uzyskiwać jeden i ten sam obiekt).

Standardowa implementacja cennika jako singletona może wyglądać tak:

public final class PriceList extends HashMap<String, Double> {

public static final PriceList INSTANCE = new PriceList();

private PriceList() {}

}

Konstruktor klasy PriceList jest prywatny, a zatem nie może być wywołany z żadnej innej klasy. Dzięki temu powstanie tylko jeden obiekt tej klasy - właśnie w trakcie statycznego inicjowania podczas jej ładowania przez JVM.

W klasie PriceList dziedziczymy HashMap, w której pod kluczami z nazwami towarów umieszczone będą ich ceny. Dzięki temu kod klasy PriceList jest bardzo zwięzły. No i dodatkowo, klasa ta jest finalna, a więc nikt jej nie może odziedziczyć i np. podsuwać wadliwe ceny.

Teraz przy inicjowaniu systemu możemy utworzyć cennik:

PriceList.INSTANCE.put("mleko", 3.0);

PriceList.INSTANCE.put("krówki", 7.0);

// ...

PriceList.INSTANCE.put("schab", 15.0);

i korzystać z niego w innych klasach (modułach) systemu, np. tak:

double payment(String towar, double ilosc) {

return PriceList.INSTANCE.get(towar) * ilosc;

}

// ...

double cost = payment("schab", 1.5);

Oczywiście w realnym (produkcyjnym) programowaniu trzeba by zastosować bardziej zaawansowane konstrukcje, np. odpowiednią klasę dla reprezentacji towarów z jakimiś ich identyfikatorami i możliwością uwzględnienia różnych jednostek (sztuki, kilogramy itp.).

1.9. Klasy opakowujące typy proste

Bardzo często potrzebne jest traktowanie liczb jako obiektów. Możemy np. zechcieć utworzyć tablicę, która zawiera i liczby, i napisy. Wiemy, że można utworzyć tablicę dowolnych obiektów, np.:

Object[] arr = new Object[3];

Ale liczby są reprezentowane przez typy proste (i nie są obiektami). Kolekcje mogą zawierać tylko referencje do obiektów, więc znowu powstaje pytanie, jak wstawić do kolekcji liczby. Dlatego właśnie w standardowym pakiecie java.lang umieszczono specjalne klasy, opakowujące typy proste i czyniące z nich obiekty. Są to klasy:

- Integer

- Short

- Byte

- Long

- Float

- Double

- Boolean

Obiekty tych klas reprezentują (w sposób obiektowy) dane odpowiednich typów. Mówimy tu o opakowaniu liczby (czy też danej typu boolowskiego), bo liczba (lub wartość true|false) jest umieszczone "środku" obiektu odpowiedniej klasy, jako jego element.

Klasy opakowujące nie dostarczają żadnych metod operowania na liczbach, operatory arytmetyczne możemy zaś stosować tylko wobec liczbowych typów prostych. A przecież często potrzebujemy liczby jako obiektu, a jednocześnie jako typu prostego - do wykonania operacji. Musimy więc umieć zapakować liczbę do obiektu klasy opakowującej (by użyć jej wtedy, gdy wymagany jest obiekt) i wyciągnąć ją stamtąd (gdy potrzebne są obliczenia).

Typy proste możemy pakować i rozpakowywać "ręcznie". Na przykład obiektowy odpowiednik liczby 5 typu int uzyskamy, tworząc obiekt klasy Integer:

Integer a = new Integer(5);

Z obiektu takiej klasy możemy liczbę "wyciągnąć" za pomocą odpowiednich metod, np.

int i = a.intValue(); // zwraca wartość typu int, "zawartą" w obiekcie a

Podobnie:

Double dd = new Double(7.1);

double d = dd.doubleValue(); // d == 7.1

Jest to jednak nieco żmudne, dlatego w Javie, poczynając od wersji 5, wprowadzono mechanizm zwany autoboxingiem.

Autoboxing to automatyczne przekształcanie typów prostych na typy obiektów klas, opakowujących typy proste, i odwrotnie.

Przykładowo możemy pisać tak:

int n = 1;

Integer in = n; // (1)

n = in +1; // (2) zamiast in.intValue() + 1

W instrukcji (1) następuje tzw. boxing, czyli opakowanie wartości typu prostego w nowy obiekt klasy (nie musimy pisać new Integer(n), robi to za nas kompilator). W instrukcji (2) zachodzi natomiast tzw. unboxing, czyli automatyczne pobranie wartości typu prostego z obiektu klasy opakowującej (nie musimy pisać in.intValue()).

Takie automatyczne przekształcenia zachodzą nie tylko przy przypisaniach, ale również przy przekazywaniu argumentów (metodom i konstruktorom) i zwrocie wyników z metod.

Warto zauważyć, że opakowanie typów prostych zachodzi również przy przypisaniach (w tym przekazywaniu argumentów i zwrocie wyników) do zmiennej typu Object. Na przykład przypisanie:

Object o = 1;

spowoduje utworzenie obiektu klasy Integer opakowującego liczbę 1 i przypisanie referencji do tego obiektu na zmienną o.

Jednak wypakowanie nie jest już automatyczne. W kontekście:

Object o = 1;

przy próbie podstawienia na zmienną typu int:

int x = o;

w kompilacji wystąpi błąd.

Aby tego uniknąć, musimy użyć rzutowania do typu Integer:

int x = (Integer) o;

Choć głównego powodu istnienia klas opakowujących upatrywać można w konieczności obiektowej reprezentacji typów prostych, to - niejako przy okazji - w klasach tych zdefiniowano wiele użytecznych metod. Są to np. statyczne metody parse ..., przekształcające napisową reprezentację liczb w ich wartości binarne: int Integer.parseInt (String), long Long.parseLong(String) itd.

W klasach tych znaleźć można również wiele metod do operowania na bitach liczb, przekształcania na różne systemay liczbowe oraz przekształcania wartości na typy proste (np. doubleValue() zwraca wartość jako double).

Użytecznych dodatkowych metod dostarcza klasa Character (opakowująca typ char). Należą do nich metody stwierdzania rodzaju znaku, np.:

isDigit() // czy znak jest znakiem cyfry

isLetter() // czy znak jest znakiem litery

isLetterOrDigit() // czy znak jest litera lub cyfra

isWhiteSpace() // czy to "biały" znak (spacja, tabulacja itd.)

isUpperCase() // czy to duża litera

isLowerCase() // czy to mała litera

Metody te zwracają wartości true lub false. Dostępna jest też metoda Character.getType(char c), która zwraca stałą oznaczającą bardziej szczegółowy typ znaku, np. Character.CURRENCY_SYMBOL.

Klasa Character opakowuje dwubajtowy typ char (czyli znaki Unicode z tzw. Basic Multi Plane), ale ma statyczne metody pozwalające działać na tzw. dodatkowych znakach (o heksadecymalnych kodach z zakresu od 100000 do 10FFFF), reprezentujących pary danych typu char.

W klasach opakowujących typy numeryczne zdefiniowano także wiele użytecznych stałych statycznych. Należą do nich stałe zawierające maksymalne i minimalne wartości danego typu. Mają one nazwy MAX_VALUE i MIN_VALUE, więc nie musimy pamiętać zakresu wartości danego typu.

ROZDZIAŁ 2Ponowne wykorzystanie klas

Podejście obiektowe umożliwia ponowne wykorzystanie (reusing) już gotowych klas przy tworzeniu nowych, co znacznie oszczędza pracy przy kodowaniu, a także czyni programowanie mniej podatnym na błędy.

Są dwa sposoby ponownego wykorzystania klas:

- kompozycja,

- dziedziczenie.

Rozważymy najpierw dziedziczenie, po czym krótko powiemy o kompozycji.

2.1. Dziedziczenie

Dziedziczenie polega na przejęciu właściwości i funkcjonalności obiektów innej klasy i ewentualnej ich modyfikacji i/lub uzupelnieniu w taki sposób, by były bardziej wyspecjalizowane.

Jest to relacja nazywana generalizacją-specjalizacją: B "jest typu" A, "B jest A", a jednocześnie B specjalizuje A. A jest generalizacją B.

Niech klasa Publication opisuje właściwości publikacji, które kupuje i sprzedaje księgarnia:

public class Publication {

private String title;

private String publisher;

private int year;

private String ident;

private double price;

private int quantity;

public Publication() {

}

public Publication(String t, String pb, int y, String i, double pr, int q)

{

title = t;

publisher = pb;

year = y;

ident = i;

price = pr;

quantity = q;

}

public String getTitle() {

return title;

}

public String getPublisher() {

return publisher;

}

public int getYear() {

return year;

}

public String getIdent() {

return ident;

}

public double getPrice() {

return price;

}

public void setPrice(double p) {

price = p;

}

public int getQuantity() {

return quantity;

}

public void buy(int n) {

quantity += n;

}

public void sell(int n) {

quantity -= n;

}

}

Zauważmy, że za pomocą tej klasy nie możemy w pełni opisać książek. Książki są szczególną, "wyspecjalizowaną" wersją publikacji, bo oprócz tytułu, wydawcy, ceny itd. mają jeszcze jedną właściwość - autora (lub autorów).

Gdybyśmy w programie chcieli opisywać zakup i sprzedaż książek, to powinniśmy utworzyć nową klasę opisującą książki, np. o nazwie Book. Moglibyśmy to robić od podstaw (definiując w klasie Book pola author, title, ident, price i wszystkie metody operujące na nich, jak również metody sprzedaży i kupowania). Ale po co? Przecież klasa Publication dostarcza już większości potrzebnych nam pól i metod. Odziedziczymy ją zatem w klasie Book i dodamy tylko te nowe właściwości (pola i metody), których nie ma w klasie Publication, a które powinny charakteryzować książki.

Słowo kluczowe extends służy do wyrażenia relacji dziedziczenia jednej klasy przez drugą. Piszemy:

class B extends A {

...

}

co oznacza, że klasa B dziedziczy (rozszerza) klasę A.

Mówimy:

- Klasa A jest bezpośrednią nadklasą, superklasą, klasą bazową klasy B.

- Klasa B jest bezpośrednią podklasą, klasą pochodną klasy A.

Zapiszmy zatem:

public class Book extends Publication {

// definicja klasy Book

}

Co należy podać w definicji nowej klasy? Takie właściwości jak tytuł, wydawca, rok wydania, identyfikator, cena, liczba egzemplarzy publikacji "na stanie", metody uzyskiwania informacji o tych cechach obiektów oraz metody sprzedaży i zakupu przejmujemy z klasy Publication. Zatem nie musimy ich na nowo definiować. Pozostało nam tylko zdefiniować nowe pole opisujące autora (niech nazywa się author) oraz metodę, która umożliwia uzyskanie informacji o autorze (powiedzmy getAuthor()).

class Book extends Publication {

private String author;

public String getAuthor() {

return author;

}

}

Czy to wystarczy? Nie, bo jeszcze musimy powiedzieć, w jaki sposób mają być inicjowane obiekty klasy Book. Aha, potrzebny jest konstruktor.

Naturalnie, utworzenie obiektu-książki wymaga podania:

- autora,

- tytułu,

- wydawcy,

- roku wydania,

- identyfikatora (numeru ISBN)

- ceny,

- liczby książek aktualnie "na stanie".

Czyli konstruktor powinien mieć postać:

public Book(String aut, String tit, String pub, int y, String id, double price, int quant){

....

}

Zwróćmy jednak uwagę: pola tytułu, wydawcy, roku, identyfikatora, ceny i liczby są prywatnymi polami klasy Publication. Z klasy Book nie mamy do nich dostępu. Jak je zainicjować?

Pola nadklasy (klasy bazowej) inicjujemy za pomocą wywołania z konstruktora klasy pochodnej konstruktora klasy bazowej (nadklasy).

Użycie w konstruktorze następującej konstrukcji składniowej:

super(lista_argumentów);

oznacza wywołanie konstruktora klasy bazowej z argumentami lista_argumentów. Jeśli występuje, musi być pierwszą instrukcją konstruktora klasy pochodnej. Jeśli nie występuje, przed utworzeniem obiektu klasy pochodnej zostanie wywołany konstruktor bezparametrowy klasy bazowej. Konstruktor klasy Book musi więc wywołać konstruktora nadklasy, by zainicjować jej pola, a następnie zainicjować pole author.

A zatem pełna definicja klasy Book wygląda następująco (zob. kod 2.1).

public class Book extends Publication {

private String author;

public Book(String aut, String tit, String pub, int y, String id, double price, int quant) {

super(tit, pub, y, id, price, quant);

author = aut;

}

public String getAuthor() {

return author;

}

}

Kod 2.1. Dziedziczenie - definicja klasy Book

Zwróćmy uwagę: wykorzystanie klasy Publication (przez jej odziedziczenie) oszczędziło nam wiele pracy. Nie musieliśmy ponownie definiować pól i metod z klasy Publication w klasie Book.

Po takim zdefiniowaniu klasy Book możemy utworzyć jej obiekt:

Book b = new Book("James Gossling", "Moja Java", "WNT", 2002, "ISBN6893", 51.0, 0);

Ten obiekt zawiera (zob. rys. 2.1):

- elementy określane przez pola klasy dziedziczonej (Publication), czyli: title, publisher, year, ident, price, quantity,

- element określany przez pole klasy Book - author.

Rys. 2.1. Dziedziczenie - elementy obiektu klasy Book

Podkreślmy: jest to jeden obiekt klasy Book. Wiemy na pewno, że możemy użyć na jego rzecz metody z klasy Book - getAuthor(). Ale ponieważ klasa Book dziedziczy klasę Publication, obiekty klasy Book mają wszelkie właściwości obiektów klasy Publication, więc możemy na ich rzecz używać również metod zdefiniowanych w klasie Publication. Nic zatem nie stoi na przeszkodzie, by napisać program (zob. kod 2.2), który z tej właściwości korzysta:

class TestBook {

public static void main(String[] args) {

Book b = new Book("James Gossling", "Moja Java", "WNT", 2002, "ISBN6893", 51.0, 0);

int n = 100;

b.buy(n);

double koszt = n * b.getPrice();

System.out.println("Na zakup " + n + " książek:");

System.out.println(b.getAuthor());

System.out.println(b.getTitle());

System.out.println(b.getPublisher());

System.out.println(b.getYear());

System.out.println(b.getIdent());

System.out.println("---------------\nwydano: " + koszt);

b.sell(90);

System.out.println("---------------");

System.out.println("Po sprzedaży zostało " + b.getQuantity() + " pozycji");

}

}

Kod 2.2. Dziedziczenie - zastosowanie klasy Book

i który skompiluje się i wykona poprawnie, dając w wyniku listing 2.1.

Na zakup 100 książek:

James Gossling

Moja Java

WNT

2002

ISBN6893

---------------

wydano: 5100.0

---------------

Po sprzedaży zostało 10 pozycji

Listing 2.1. Wynik działania kodu 2.2.

Możemy powiedzieć, że obiekty klasy Book są również obiektami klasy Publication (w tym sensie, że mają wszelkie właściwości obiektów klasy Publication).

2.2. Konwersje referencyjne

Referencje do obiektów klasy Book możemy przypisywać zmiennym, oznaczającym obiekty klasy Publication (zawierającym referencje do obiektów klasy Publication), np.:

Book b = new Book(...);

Publication p = b;

Nazywa się to referencyjną konwersją rozszerzającą (widening reference conversion). Słowo "konwersja" oznacza, że dochodzi do przekształcenia z jednego typu na inny typ (np. z typu Book dona typ Publication). Konwersja jest rozszerzająca, bo przekształcamy typ "pochodny" (referencja do obiektu podklasy) na typ "wyższy" (referencja do obiektu nadklasy). A ponieważ chodzi o typy referencyjne, mówimy o referencyjnej konwersji rozszerzającej.

Obiektowe konwersje rozszerzające dokonywane są automatycznie przy:

- przypisywaniu zmiennej-referencji odniesienia do obiektu klasy pochodnej;

- przekazywaniu argumentów metodzie, gdy parametr metody jest typu "referencja do obiektu nadklasy argumentu";

- zwrocie wyniku, gdy wynik podstawiamy na zmienną będącą referencją do obiektu nadklasy zwracanego wyniku.

Ta zdolność obiektów Javy do "stawania się" obiektem swojej nadklasy jest niesłychanie użyteczna.

Wyobraźmy sobie, że z klasy Publication oprócz klasy Book wprowadziliśmy jeszcze klasę Journal (czasopisma). Klasa Journal dziedziczy klasę Publication i dodaje do niej - zamiast pola, opisującego autora - pola opisujące wolumin i numer wydania danego czasopisma.

Być może będziemy mieli jeszcze inne rodzaje publikacji, np. muzyczne, wydane na płytach CD (powiedzmy klasę CDisk znowu dziedziczącą klasę Publication i dodającą jakieś informacje właściwe dla muzyki, np. czas odtwarzania).

Możemy teraz np. napisać uniwersalną metodę pokazującą różnicę w dochodach ze sprzedaży wszystkich zapasów dowolnych dwóch publikacji.

public double incomeDiff(Publication p1, Publication p2) {

double income1 = p1.getQuantity() * p1.getPrice();

double income2 = p2.getQuantity() * p2.getPrice();

return income1 - income2;

}

i wywoływać ją dla dowolnych (różnych rodzajów) par publikacji:

Book b1 = new Book(...);

Book b2 = new Book(...);

Journal j = new Journal(...);

CDisk cd1 = new CDisk(...);

CDisk cd2 = new CDisk(...);

double diff = 0;

diff = incomeDiff(b1, b2);

diff = incomeDifg(b1, j);

diff = incomeDiff(cd1, b1);

Gdyby nie było obiektowych konwersji rozszerzających, to dla każdej możliwej kombinacji "rodzajowej" par musielibyśmy napisać inną metodę incomeDiff, np.: double incomeDiff(Book, Book), double incomeDiff(Book, Journal), double incomeDiff(Book, CDisk).

Zwróćmy uwagę, że w przedstawionej metodzie incomeDiff można wobec p1 i p2 użyć metod klasy Publication (bo tak są zadeklarowane parametry), ale nie można używać metod klas pochodnych, nawet wtedy, gdy p1 i p2 wskazują na obiekty klas pochodnych, np.:

....

{

Book b = new Book(...);

jakasMetoda(b);

....

}

void jakasMetoda(Publication p1) {

String autor = p1.getAuthor(); // Błąd kompilacji - niezgodność typów;

... // na rzecz obiektu klasy Publication

... // nie wolno użyć metody getAuthor()

} // bo takiej metody nie ma w klasie Publication

Zaradzić temu możemy, stosując referencyjną konwersję zawężającą, przy czym konieczne jest jawne zastosowanie operatora rzutowania do typu Book:

void jakasMetoda(Publication p1) {

String autor = (Book) p1.getAuthor();

// ...

}

Teraz kompilator nie będzie się skarżył, ponieważ wyraźnie daliśmy do zrozumienia, że chcemy potraktować obiekt p1 jako obiekt klasy Book, a w klasie Book jest metoda getAuthor().

Co by się jednak stało, gdyby tej metodzie przekazać obiekt klasy Journal? Kompilator nie będzie protestował, bo Journal pochodzi od Publication. W fazie kompilacji nie jest też możliwe sprawdzenie, co naprawdę siedzi w zmiennej p1 - czy to jest referencja do obiektu klasy Book, czy też może jakiejś innej klasy pochodnej od Publication.

Prawda wyjdzie na jaw dopiero w fazie wykonania programu: rzutowanie na typ Book spowoduje błąd o nazwie ClassCastException i przerwanie programu, ponieważ w p1 znajduje się referencja do obiektu klasy Journal i nie da się jej rzutować na referencję do obiektu typu Book.

2.3. Stwierdzanie typu

Można się przed takimi sytuacjami zabezpieczyć, używając operatora stwierdzania typu (instanceof).

Wyrażenie:

ref instanceof T

ma wartość true, jeśli referencja ref nie jest null i może być w fazie wykonania programu rzutowana do typu T bez zgłoszenia wyjątku (błędu) ClassCastException.

Przy tym:

- Wyrażenie null instanceof dowolny_typ_refrencyjny zawsze ma wartość false.

- Wyrażenie x instanceof T będzie błędne składniowo (wystąpi błąd w kompilacji), jeśli typ referencji x i typ T nie są związane stosunkiem dziedziczenia.

- Wyrażenie x instanceof T będzie miało wartość false, jeśli faktyczny typ referencji x jest nadtypem typu T.

- Na razie terminy "typ", "podtyp" i "nadtyp" możemy kojarzyć z pojęciami klasy, podklasy i nadklasy.

Dosyć formalną definicję w praktycznym użyciu możemy rozumieć tak: x instanceof T zwróci true, jeśli referencja x jest typu T lub dowolnego podtypu T.

Przykładowy kod 2.3 pokazuje, w jaki sposób można użyć operatora instanceof, aby wyświetlić właściwą informację o książce (ma autora) i o czasopiśmie (ma numer oraz wolumin - zwracane przez getVolNr()).

static void info(Publication p) {

String info = p.getTitle();

if (p instanceof Book) info += '\n' + ((Book) p).getAuthor();

else if (p instanceof Journal) info += '\n' + ((Journal) p).getVolNr();

// ...

System.out.println(info);

}

public static void main(String[] args) {

info(new Book(...));

info(new Journal(...));

}

Kod 2.3. Użycie operatora instanceof

Innym sposobem stwierdzania typu jest zastosowanie metody getClass(). Zwraca ona faktyczny typ obiektu w postaci referencji do obiektu klasy Class. Obiekty tej klasy oznaczają klasy.

Zatem w kontekście:

Book b = new Book(...);

Publication p = b;

Class c = p.getClass();

zmienna c będzie oznaczać klasę Book.

Łatwo możemy się dowiedzieć nazwy klasy:

Class klasa = p.getClass();

String nazwa = klasa.getName(); // będzie zwrócona nazwa klasy

// (w naszym przypadku Book)

i w ten sposób sprawdzić, o jaką klasę chodzi.

W takim sprawdzaniu można też użyć literałów klasowych. Mają one postać:

nazwa_klasy.class

Na przykład:

java.lang.String.class // oznacza klasę String z pakietu java.lang

Book.class // oznacza klasę Book z pakietu domyślnego

Między stwierdzaniem typu przez instanceof i getClass występuje istotna różnica.Zobaczmy to na przykładzie.

Załóżmy, że mamy jeszcze dodatkowo klasę Tabloid dziedzicząca klasę Journal i obiekt tej klasy:

Tabloid t = new Tabloid(...);

Wtedy:

- t instanceof Journal zwróci true, bo brane są pod uwagę wszystkie podtypy Journal, a t jest typu Tabloid, czyli podtypu Journal;

- t.getClass() == Journal.class będzie miało wartość false, bo sprawdzana jest konkretna klasa, którą w tym przypadku jest Tabloid;

- t.getClass().getName() zwróci napis "Tabloid".

2.4. Dziedziczenie w Javie

W Javie każda klasa może bezpośrednio odziedziczyć tylko jedną klasę, ale pośrednio może mieć dowolnie wiele nadklas, co wynika z hierarchii dziedziczenia. Ta hierarchia zawsze zaczyna się od klasy Object (której definicja znajduje się w zestawie stanardowych klas Javy). Zatem w Javie wszystkie klasy pochodzą pośrednio od klasy Object.

Wobec tego hierarchia dziedziczenia omawianych tu klas wygląda jak na rys. 2.2. Z tego wynika, że referencję do obiektu dowolnej klasy można przypisać zmiennej typu Object (zawierającej referencję do obiektu klasy Object). Z właściwości tej korzysta wiele "narzędziowych" metod zawartych w klasach standardu Javy.

Rys. 2.2. Hierarchia dziedziczenia klas publikacji

2.5. Przedefiniowanie metod

Niech teraz naszym zadaniem będzie zdefiniowanie klasy samochodów. Samochody są specjalnym rodzajem pojazdów, zatem obiekty klasy samochod przejmują wszystkie właściwości obiektów klas pojazdów, dodatkowo dostarczając jakichś własnych cech specyficznych.

Projektując klasę dotyczącą samochodów (klasę Car), możemy skorzystać z gotowej klasy Vehicle (nie musimy na nowo pisać metod, definiować pól itd.). Skupiamy się na specyficznych cechach samochodów, ich cechy jako pojazdów "w ogóle" przejmując z klasy Vehicle.

Przyjmijmy, że wyróżniającymi cechami samochodów są:

- numer rejestracyjny,

- użycie paliwa (w razie braku paliwa samochód nie może ruszyć),

a następnie:

- dodajmy odpowiednie pola do klasy Car oraz odpowiedni konstruktor,

- dodajmy metodę fill() pozwalającą tankować paliwo,

- przedefiniujmy metodę start() tak, by bez paliwa samochód nie mógł ruszyć, oraz metodę toString(), by wyświetlały właściwe dla samochodów informacje.

Denicję klasy Car przedstawia kod 2.4.

class Car extends Vehicle {

private String nrRej;

private int tankCapacity;

private int fuel;

public Car(String nr, Person owner, int w, int h, int l, int weight, int tankCap) {

super(owner, w, h, l, weight);

nrRej = nr;

tankCapacity = tankCap;

}

public void fill(int amount) {

fuel += amount;

if (fuel > tankCapacity) fuel = tankCapacity;

}

public void start() {

if (fuel > 0) super.start();

else error("Brak paliwa");

}

public String toString() {

return "Samochód nr rej " + nrRej + " - " + getState();

}

}

Kod 2.4. Klasa Car - przedefiniowanie metod z klasy Vehicle

Przedefiniowanie metody (overriding) nadklasy w klasie pochodnej oznacza dostarczenie w klasie pochodnej definicji nieprywatnej i niestatycznej metody z taką samą sygnaturą (czyli nazwą i listą parametrów) jak sygnatura nieprywatnej i niestatycznej metody nadklasy, ale ewentualnie z inną definicją ciała metody (innym kodem, który jest wykonywany przy wywołaniu metody), przy czym:

- typy wyników tych metod muszą być takie same lub kowariantne (co oznacza m.in., że typ wyniku metody z podklasy może być podtypem wyniku metody nadklasy);

- przedefiniowanie nie może ograniczać dostępu: specyfikator dostępu metody przedefiniowanej w podklasie musi być taki sam lub szerszy (np. public zamiast protected) niż metody przedefiniowywanej;

- metoda przedefiniowana (z podklasy) nie może zgłaszać więcej lub bardziej ogólnych wyjątków kontrolowanych niż metoda przedefiniowywana (z nadklasy), może natomiast - niezależnie od definicji metody nadklasy - zgłaszać dowolne wyjątki niekontrolowane.

W klasie Car przedefiniowano metody start() i toString() z klasy Vehicle. Zwróćmy uwagę, że w metodzie start() klasy Car uruchamiamy samochód, gdy bak nie jest pusty. Nie mogliśmy jednak napisać w metodzie start() klasy Car:

if (fuel > 0) start();

bo to oznaczałoby rekurencyjne wywołanie metody start() klasy Car.

Ogólnie uruchamianiem wszelkich pojazdów zajmuje się metoda start() z klasy Vehicle i właśnie ją chcemy wywołać.

Odwołanie do metody klasy bazowej, która w klasie pochodnej jest przedefiniowana, zapisujemy za pomocą specyfikatora super. Na przykład super.start()użyte w klasie Car wywoła metodę start() z klasy Vehicle, a samo start() wywoła metodę start() z klasy Car.

Obiekt klasy Car składa się z elementów zdefiniowanych przez pola klasy Vehicle oraz elementów zdefiniowanych przez pola klasy Car. Wobec obiektów klasy Car możemy używać:

- wszystkich nieprywatnych (i nieprzedefiniowanych) metod klasy Vehicle (np. crash(...)),

- przedefiniowanych w klasie Car metod klasy Vehicle,

- własnych metod klasy Car.

Pokazuje to fragment programu:

Car c = new Car("WA1090", new Person("Janek", "09090909"), 100, 100, 100, 100, 50),

d = new Car("WB2010", new Person("Ania", "10101010"), 100, 100, 100, 100, 50);

try {

c.start();

} catch (Exception exc) {

System.out.println(c + " - " + exc.getMessage());

}

c.fill(10);

System.out.println(c);

c.start();

System.out.println(c);

c.stop();

System.out.println(c);

d.fill(20);

System.out.println(d);

d.start();

System.out.println(d);

c.start();

System.out.println(c);

c.crash(d);

System.out.println(c + "\n" + d);

c.repair();

d.repair();

System.out.println(c + "\n" + d);

c.start();

d.start();

System.out.println(c + "\n" + d);

który wyświetli:

Samochód nr rej WA1090 - STOPPED - Brak paliwa

Samochód nr rej WA1090 - STOPPED

Samochód nr rej WA1090 - MOVING

Samochód nr rej WA1090 - STOPPED

Samochód nr rej WB2010 - STOPPED

Samochód nr rej WB2010 - MOVING

Samochód nr rej WA1090 - MOVING

Samochód nr rej WA1090 - BROKEN

Samochód nr rej WB2010 - BROKEN

Samochód nr rej WA1090 - STOPPED

Samochód nr rej WB2010 - STOPPED

Samochód nr rej WA1090 - MOVING

Samochód nr rej WB2010 - MOVING

W przedstawionym tu testowym fragmencie jest dużo wierszy. Można skrócić i ułatwić pisanie takich programów, jeśli wybrane metody będą zwracać referencję do obiektu, na którego rzecz zostały wywołane. Jeśli zrobimy to w klasie Vehicle:

public Vehicle start() {

setState(MOVING);

return this;

}

public Vehicle stop() {

setState(STOPPED);

return this;

}

public Vehicle repair() {

// ...

return this;

}

to kompilator zwróci nam uwagę na błąd w przedefiniowaniu metody start() w klasie Car. Istotnie metoda ma tę samą nazwę i parametry co start() z klasy Vehicle, ale inny typ wyniku.

Musimy ten typ zmienić: oczywiście może to być Vehicle, ale lepiej byłoby użyć typu Car, bo wtedy na rzecz wyniku tej metody możemy wołać jakieś inne metody specyficzne dla klasy Car, np. hipotetyczną metodę accelerate() - przyśpiesz.

2.6. Kowariancja typów wyników

I tu właśnie przyda nam się wspomiana w definicji przedefiniowania kowariancja typów wyników przedefiniowanych metod.

W sytuacji:

class A {

S met() {...}

}

class B extends A {

T met() {...}

}

metoda met ma kowariantny typ wyniku, jeśli T jest podtypem S.

Kowariancja to współzmienność - typ wyniku metody zmienia się w tym samym kierunku hierarchii dziedziczenia, co kierunek dziedziczenia klas, w których jest definiowana.

Zatem całkiem spokojnie możemy w klasie Car zapisać:

public Car fill(int amount) {

// ...

return this;

}

public Car start() { // kowariantny typ wyniku przedefiniowanej metody

// ...

return this;

}

public Car stop() {

super.stop();

return this;

}

public Car repair() {

super.repair();

return this;

}

i następnie tworzyć np. takie fragmenty kodu:

c.fill(10).start().crash(d.fill(20).start());

System.out.println(c + "\n" + d);

c.repair().fill(10).start();

d.repair().start();

System.out.println(c + "\n" + d);

d.stop().fill(10);

// ...

co da:

Samochód nr rej WA1090 - BROKEN

Samochód nr rej WB2010 - BROKEN

Samochód nr rej WA1090 - MOVING

Samochód nr rej WB2010 - MOVING

Zauważmy użyteczność kowariancji typów wyniku: przedefiniowaliśmy w klasie Car metody stop() i repair() tylko po to, by zapewnić odpowiedni typ ich wyniku, dzięki czemu możliwe stały się odwołania repair().fil() i stop().fill().

2.7. Przedefiniowanie metod w wyliczeniach

W klasach Vehicle i Car metoda toString() wykorzystywała niejawnie wywołanie metody toString() wyliczenia VehState np. w takich fragmentach:

" jest w stanie " + state;

"Samochód nr rej " + nrRej + " - " + getState();

Tu zarówno state, jak i wynik wywołania getState() jest typu enum VehState, a to wyliczenie, jak już wiemy (por. rodz. 1.2), jest specjalną klasą. Dziedziczy ona klasę Enum z pakietu java.lang i przy dołączaniu wartości elementów wyliczenia do napisów niejawnie wywoływana jest metoda toString()z klasy Enum. W wyniku widzieliśmy takie napisy jak: STOPPED, BROKEN, MOVING.

Skoro z wyliczeniami możemy postępować (prawie) tak jak z normalnymi klasami, to nic nie stoi na przeszkodzie, aby w VehState przedefiniować metodę toString() z klasy Enum (zob. kod 2.5).

public enum VehState {

BROKEN("ZEPSUTY"), STOPPED("STOI"), MOVING("JEDZIE");

private String opis;

VehState(String s) { opis = s; }

public String toString() {

return opis;

}

}

Kod 2.5. Przedefiniowanie metody toString() w enum

Teraz fragmenty ilustrujące wykorzystanie klas Vehicle i Car będą wyświetlać bardziej zrozumiałą (językowo jednolitą) informację, np.:

Samochód nr rej WA1090 - ZEPSUTY

Samochód nr rej WB2010 - ZEPSUTY

Samochód nr rej WA1090 - JEDZIE

Samochód nr rej WB2010 - JEDZIE

2.8. Przedefiniowanie a wyjątki

Jak już wspomniano, przedefiniowanie metody nie może poszerzać zakresu wyjątków kontrolowanych wymienionych w klauzuli throws (przypomnijmy, że wyjątki kontrolowane to te, których klasy pochodzą od klasy Exception, ale nie od RuntimeException).

Oznacza to, że:

- Jeżeli metoda z klasy bazowej nie ma klauzuli throws, to metoda przedefiniowująca ją w klasie pochodnej nie może wymienić w swojej klauzuli throws żadnych wyjątków kontrolowanych.

- Jeżeli metoda z klasy bazowej wymienia w swojej klauzuli throws jakieś wyjątki kontrolowane, to metoda przedefiniowująca ją w klasie pochodnej nie może wymienić żadnej nadklasy tych wyjątków ani żadnych dodatkowych innych klas wyjątków kontrolowanych, może natomiast wymienić dowolne wyjątki pochodzące z podklas wyjątków zgłaszanych przez metodę z klasy bazowej.

- Niezależnie od metody z klasy bazowej metoda przedefiniowana w klasie pochodnej może nie zgłaszać żadnych wyjątków i nie mieć klauzuli throws.

- Metoda przedefiniowana w klasie pochodnej zawsze może zgłaszać wyjątki niekontrolowane i ewentualnie wymieniać je w swojej klauzuli throws (co nie jest obowiązkowe).

Ilustruje to przykładowy kod, w którym:

- wyjątek IOException pochodzi od Exception,

- wyjątek FileNotFoundException pochodzi od IOException,

- wyjątek NumberFormatException jest pochodzi od RuntimeException.

class A {

void met1() {}

void met2() throws FileNotFoundException {}

void met3() throws IOException {}

void met4() throws Exception {}

void met5() {}

}

class B extends A {

void met1() throws Exception {} // błąd: ilustruje pkt. 1

void met2() throws IOExcepetion {} // błąd: ilustruje pkt. 2

void met3() throws FileNotFoundException, IOException {} // ok, ilustruje pkt 2

void met4() {} // ok, ilustruje pkt 3

void met5() throws NumberFormatException { } // ok, ilustruje pkt 4

}

2.9. Przedefiniowanie a przeciążanie, przesłanianie i pokrywanie

Przedefiniowanie metod (overriding) należy odróżniać od ich przeciążania (overloading), a także od dwóch innych pojęć: przesłaniania (shadowing) i pokrywania (hiding) identyfikatorów (zmiennych, metod, klas).

Niewątpliwie najłatwiej odróżnić metody przeciążone. Mają one po prostu te same nazwy, ale inną liczbę i/lub typy parametrów. Zwróćmy uwagę, że:

- Przeciążone metody mogą należeć do tej samej klasy lub różnych klas (z których jedna pośrednio lub bezpośrednio dziedziczy inną).

- Przeciążanie nie wyklucza przedefiniowania: jeśli np. w klasie A zdefiniowano dwie publiczne metody z tą samą nazwą (co oznacza, że są przeciążone, bo sygnatury metod deklarowanych w jednej klasie muszą się różnić), to w klasie B dziedziczącej klasę A możemy jedną z nich dodatkowo przeciążyć (czyli podać w deklaracji inny zestaw parametów), a drugą przedefiniować (pozostawiając jej sygnaturę bez zmian i dostarczając innej definicji kodu metody). W tym względzie Java różni się od C++.

Terminu "przesłanianie" używa się w Javie wtedy, gdy w zasięgu deklaracji identyfikatora zmiennej, metody lub klasy (ogólniej: typu) pojawia się inna deklaracja tego identyfikatora (widzieliśmy przykłady przesłaniania identyfikatorów pól przez identyfikatory zmiennych lokalnych).

W przeciwieństwie do przesłaniania terminów "przedefiniowanie" (metody) i "pokrycie" (metody lub pola) używa się w sytuacji dziedziczenia. Czym różni się pokrycie metody od jej przedefiniowania? Otóż dostarczenie w podklasie definicji metody statycznej o tej samej sygnaturze i tym samym lub kowariantnym typie wyniku co metoda statyczna z nadklasy nazywa się pokryciem metody.

Pokrywanie nie dotyczy metod niestatycznych, co więcej - jeśli w podklasie dostarczymy definicję metody statycznej o tej samej sygnaturze co metoda niestatyczna nadklasy, to wystąpi błąd w kompilacji.

Rozróżnienie pokrycia i przedefiniowania metody związane jest też z pojęciem polimorfizmu (zob. następne punkty): metody przedefiniowane są wywoływane polimorficznie, pokrycie zaś oznacza tylko zastąpienie wywołania metody pokrytej.

Pokrywanie może dotyczyć również pól: oznacza ono wtedy deklarację w podklasie pola o takim samym identyfikatorze jak pole z nadklasy. Pokrycie identyfikatorów pól różni się zarówno od pokrywania identyfikatorów metod, jak i przedefiniowania metod. Pole statyczne może pokryć pole niestatyczne i odwrotnie. Pole pokrywające pole nadklasy może mieć całkiem inny typ niż pokryte pole nadklasy.

Metoda prywatna nigdy nie może być pokryta ani przedefiniowana w podklasie. Deklaracja w podklasie metody o tej samej sygnaturze co metoda prywatna nadklasy oznacza praktycznie wprowadzenie "niezależnego" bytu do naszego programu (zatem możemy mieć np. całkiem inny typ wyniku niż w metodzie prywatnej o tej samej sygnaturze z nadklasy).

2.10. Adnotacja @override

Przedefiniowując metody w podklasach, warto używać adnotacji @Override. Załóżmy, że programista chce przedefiniować metodę toString() w klasach A, B i C, ale gdzieś się pomylił w pisowni, gdzieś podał złą listę parametrów itp.

package tostring;

class A {

public String ToString() {

return "Obiekt klasy A";

}

}

class B {

public String toString() {

return "Obiekt klasy B";

}

}

class C extends B {

public String toString(String ... myMsg) {

String s = "Obiekt klasy C";

if (myMsg.length == 1) s += myMsg[0];

return s;

}

}

Po takim użyciu tych klas:

System.out.println( new A());

System.out.println( new B());

System.out.println( new C());

nasz programista da - zapewne wbrew intencjom - następujące wyniki:

tostring.A@a90653 // błąd w nazwie metody - wołana jest toString() z klasy Object, ktorą dziedziczy A

Obiekt klasy B // tu dobrze

Obiekt klasy B // ale tu metoda toString() jest przeciążona w klasie C, a nie przedefiniowana dlatego dostajemy napis z metody toString z klasy B

Aby uniknąć tego rodzaju błędów w codziennym programowaniu, warto się przyzwyczaić i stosować konwencje nazewnicze Javy (wtedy nigdy nie użyjemy jako nazwy metody ToString). Oprócz tego pomocna jest właśnie adnotacja @Override. Za pomocą tej adnotacji informujemy kompilator, że naszą intencją jest przedefiniowanie metody. Jeśli warunki przedefiniowania nie są spełnione (nie ma metody o identycznej sygnaturze i identycznym lub kowariantnym typie wyniku w nadklasach), kompilator poinformuje o błędzie.

Użycie @Override w poprzednim przykładzie ilustruje rysunek rys. 2.3.

Rys. 2.3. Użycie adnotacji @Override

I na koniec warto podkreślić, że istotą przedefiniowania metod jest modyfikacja lub uszczegółowienie funkcjonalności. Jest to wielka zaleta, bez tego programowanie obiektowe nie byłoby możliwe. Ale równocześnie otwiera pole dla tzw. słabej hermetyzacji klasy bazowej. W klasie Vehicle nie ma metody sing(), więc pojazdy nie śpiewają? Ależ niektóre mogą śpiewać: wystarczy odpowiednio przedefiniować metodę start() w podklasie!

Aby uniknąć popełniania podobnych błędów w przypadkach, gdy zmiany funkcjonalności fragmentów kodu nie są potrzebne czy są nawet niebezpieczne, w deklaracji metod stosuje się słowo kluczowe final.

Słowo kluczowe final użyte w deklaracji metody zabrania jej przedefiniowania.

2.11. Metody wirtualne i polimorfizm

W klasie Car przedefiniowaliśmy metodę start() z klasy Vehicle (dla samochodów sprawdza ona, czy jest paliwo, by ruszyć, nie robi tego dla pojazdów w ogóle). Przedefiniowaliśmy też metodę toString() (dla obiektów klasy Car zwraca ona inne napisy niż dla ogólniejszych obiektów klasy Vehicle). Jeżeli teraz:

Car c = new Car(...); // utworzymy nowy obiekt klasy Car

Vehicle v = c; // dokonamy obiektowej konwersji rozszerzającej

to jaki będzie wynik użycia metod start() i toString() wobec obiektu oznaczanego v:

v.start();

System.out.println(v.toString());

Czy zostaną wywołane metody z klasy Vehicle (formalnie metody te są wywoływane na rzecz obiektu klasy Vehicle), czy z klasy Car (referencja v formalnego typu "referencja do obiektu Vehicle" faktycznie wskazuje na obiekt klasy Car)? Rozważmy przykład "z życia" zapisany w programie, a mianowicie schematyczną symulację wyścigu pojazdów.

Uczestnicy: rowery (obiekty klasy Rower), samochody (obiekty klasy Car), rydwany (obiekty klasy Rydwan). Wszystkie klasy są pochodne od Vehicle. Każda z tych klas inaczej przedefiniowuje metodę start() z klasy Vehicle (np. Rower może w ogóle jej nie przedefiniowywać, Car - tak jak w poprzednich przykładach, Rydwan - w jakiś inny sposób). Sygnał do startu wszystkich pojazdów daje starter.

W programie moglibyśmy to symulować przez:

- uzyskanie tablicy wszystkich pojazdów startujących w wyścigu (np. getAllVehiclesToStart());

- przebiegnięcie przez wszystkie elementy tablicy i posłanie do każdego z obiektów przez nie reprezentowanych komunikatu start().

Na przykład:

Vehicle[] allveh = getAllVehiclesToStart();

for (Vehicle v : allveh) v.start();

Jeżeli nasz program ma odwzorowywać rzeczywistą sytuację wyścigu (sygnał startera, po którym wszystkie pojazdy, jeśli mogą, ruszają), to oczywiście - mimo że v jest formalnego typu Vehicle - powinny być wywołane metody start() z każdej z odpowiednich podklas klasy Vehicle (właściwa metoda start() dla danego rodzaju pojazdu).

I tak jest rzeczywiście w Javie (zob. rys. 2.4).

Rys. 2.4. Polimorficzne wywołanie metod

Ale jak to jest możliwe? Z punktu widzenia łączenia przez kompilator odwołań do metody (np. start()) oraz jej definicji (wykonywalnego kodu) sytuacja jest następująca:

1. Kompilator wie tylko, że start() jest komunikatem do obiektu typu Vehicle.

2. Powinien więc związać odwołanie v.start() z definicją metody start() z klasy Vehicle.

Jakże inaczej? Przecież wartość v może zależeć od jakichś warunków występujących w trakcie wykonania programu (nieznanych kompilatorowi).

Mając na przykład dwie klasy dziedziczące klasę Vehicle, Car i Rydwan, możemy napisać:

public static void main(String args[]) {

Car c = new Car(...);

Rydwan r = new Rydwan(...);

Vehicle v;

if (args[0].equals("Rydwan")) v = r;

else v = c;

v.start();

}

Kompilator nie może wiedzieć, jaki konkretnie jest typ obiektu wskazywanego przez v (czy Car czy Rydwan). I nie wie!

W jaki sposób zatem uzyskujemy efekt opisany wcześniej (zgodny z życiowym doświadczeniem), czyli np. wywołanie metody start() z klasy Car, jeśli v wskazuje na obiekt klasy Car, natomiast wywołanie metody start() z klasy Rydwan, jeśli v wskazuje na obiekt klasy Rydwan? Otóż metoda start() z klasy Vehicle jest metodą wirtualną, a dla takich metod wiązanie odwołań z kodem następuje w fazie wykonania, a nie w fazie kompilacji. Nazywa się to dynamic binding lub late binding (dynamiczne lub późne wiązanie).

Mówi się, że odwołania do metod wirtualnych są polimorficzne, a słowo "polimorficzne" używane jest w tym sensie, iż konkretny rezultat odwołania może przybierać różne kształty w zależności od tego, jaki jest faktyczny typ obiektu, na którego rzecz wywołano metodę wirtualną.

Istotnie, jak widzieliśmy: v.start() raz może oznaczać start samochodu, a innym razem start rydwanu czy roweru.

Wszystkie metody w Javie są wirtualne z wyjątkiem:

- metod statycznych (bo przecież nie dotyczą obiektów),

- metod deklarowanych ze specyfikatorem final (co oznacza, że postać metody jest ostateczna i nie może być ona przedefiniowana w klasie pochodnej, a jak nie ma przedefiniowania, to niepotrzebna jest wirtualność),

- metod prywatnych (do których odwołania z innych metodach danej klasy nie są polimorficzne, bo metody prywatne nie mogą być przedefiniowane).

Znaczenie polimorfizmu można zilustrować prostym przykładem. Rozważmy pewną hierarchię dziedziczenia opisującą takie właściwości różnych zwierząt, jak nazwa gatunku, sposób komunikowania się ze światem oraz imię. Dzięki odpowiedniemu określeniu bazowej klasy Zwierz przy definiowaniu klas pochodnych (takich jak Pies czy Kot) mamy całkiem niewiele roboty (zob. kod 2.6).

(Uwaga. W dalszych przykładach pomijamy specyfikatory dostępu, bo nie mają znaczenia dla omawianych tu treści).

class Zwierz {

String name = "bez imienia";

Zwierz() { }

Zwierz(String s) { name = s; }

String getTyp() { return "Jakis zwierz"; }

String getName() { return name; }

String getVoice() { return "?"; }

// Metoda speak symuluje wydanie głosu przez wypisanie odpowiedniego komunikatu

void speak() {

System.out.println(getTyp()+" "+getName()+" mówi "+getVoice());

}

}

class Pies extends Zwierz {

Pies() { }

Pies(String s) { super(s); }

String getTyp() { return "Pies"; }

String getVoice() { return "HAU, HAU!"; }

}

class Kot extends Zwierz {

Kot() { }

Kot(String s) { super(s); }

String getTyp() { return "Kot"; }

String getVoice() { return "Miauuuu..."; }

}

Kod 2.6. Polimorficzne zwierzęta - wersja wstępna

W klasie Main wypróbujemy naszą hierarchię klas zwierząt przy symulowaniu rozmów między poszczególnymi osobnikami. Rozmowę symuluje statyczna funkcja animalDialog, która ma dwa argumenty - obiekty typu Zwierz oznaczające aktualnych "dyskutantów" (zob. kod 2.7).

public class Main {

public static void main(String[] arg) {

Zwierz z1 = new Zwierz(), z2 = new Zwierz();

Pies pies = new Pies(), kuba = new Pies("Kuba"), reksio = new Pies("Reksio");

Kot kot = new Kot();

animalDialog(z1, z2);

animalDialog(kuba, reksio);

animalDialog(kuba, kot);

animalDialog(reksio, pies);

}

static void animalDialog(Zwierz z1, Zwierz z2) {

z1.speak();

z2.speak();

System.out.println("----------------------------------------");

}

}

Kod 2.7. Znaczenie polimorfizmu - prosty przykład

Wynik działania tej aplikacji pokazuje listing 2.2.

Jakis zwierz bez imienia mówi ?

Jakis zwierz bez imienia mówi ?

----------------------------------------

Pies Kuba mówi HAU, HAU!

Pies Reksio mówi HAU, HAU!

----------------------------------------

Pies Kuba mówi HA HAU!

Listing 2.2. Wynik działania kodu 2.7

Cóż jest ciekawego w tym przykładzie? Otóż dzięki wirtualności metod getTyp() i getVoice() metoda speak(), określona w klasie Zwierz, prawidłowo działa dla różnych zwierząt (obiektów podklas klasy Zwierz). Jest to nie tylko ciekawe, ale i wygodne: jedna definicja metody speak() załatwiła nam wszystkie potrzeby (dotyczące dialogów różnych zwierząt). Co więcej, będzie tak samo użyteczna dla każdej nowej podklasy Zwierza, którą kiedykolwiek w przyszłości wprowadzimy.

Nie jest to kwestia wymyślonych przykładów. Z praktyczną użytecznością polimorfizmu stykamy się w Javie od samego początku. Na przykład metoda println z argumentem Object wyświetla tekst opisujący przekazany obiekt. Tekst ten dostaje przez odwołanie do metody toString() za pośrednictwem statycznej metody valueOf(Object) z klasy String:

W klasie String:

public static String valueOf(Object obj) {return (obj == null) ? "null" : obj.toString();

}

W klasie PrintStream (lub PrintWriter):

public void println(Object obj) {String txt = String.valueOf(x); // wyświetlenie tekstu txt

}

Metoda toString() jest po raz pierwszy zdefiniowana w klasie Object - jej wynikiem jest tam napis w postaci: nazwa_klasy@unikatowy_identyfikator_obiektu. Klasę Object dziedziczą wszystkie klasy (pośrednio lub bezpośrednio). W klasach tych można więc zawsze przedefiniować metodę toString(). A przedefiniowane metody wywoływane są polimorficznie, zawsze więc uzyskamy właściwy opis obiektu (określony w danej klasie), lub - jeśli nie zdefiniowano w niej metody toString - opis z pierwszej nadklasy, w której jest ona zdefiniowana. Na przykład, mając taką hierarchię dziedziczenia jak na schemacie:

public class Object {

// toString() zwraca id obiektu

}

class A { // domyślnie dziedziczy Object

// nieprzedefiniowana metody toString

}

class B {

public String toString() {

return "Obiekt klasy B";

}

}

class C extends B {

public String toString() {

return "Obiekt klasy C";

}

}

class D extends B {

// nieprzedefiniowana metody toString

}

po:

Object[] obArr = { new A(), new B(), new C(), new D() };

for (Object o : obArr) System.out.println(o);

uzyskamy:

A@6e1408 // brak przedefiniowania metody toString() - wywoływane jest toString() z klasy Object

Obiekt klasy B // jest wywoływana toString() z klasy B, a nie Object (choć formalnie o jest typu Object)

Obiekt klasy C // jw., tylko z klasy C

Obiekt klasy B // w klasie D nie ma toString(), polimorficznie wywoływana jest toString() z klasy B.

2.12. Kompozycja

Z koncepcyjnego punktu widzenia kompozycja oznacza, że "obiekt jest zawarty w innym obiekcie". Jest to relacja "całość - część" ( B "zawiera" A). Na przykład obiekty typu Biurko zawierają obiekty typu Szuflada.

Kompozycję uzyskujemy w prosty sposób - przez definiowanie pól obiektowych w klasie.

Robimy to bez przerwy, np. stosując pola typu String.

Nie tylko wykorzystanie obiektów klasy String w naszych klasach stanowi o ich ponownym użyciu. Może ono również znaleźć zastosowanie przy dostarczaniu takich metod klasy, które upraszczają uzyskiwanie informacji, np. w takim kontekście:

class Text {

// ...

String cont;

// ...

String getCont() { return cont; }

}

Text txt = new Text(...);

aby uzyskać długość tekstu musimy teraz napisać:

txt.getCont().length();

"Skrót", który można dostarczyć, polega na zdefiniowaniu w klasie Text metody length():

public int length() {

return cont.length();

}

Taki rodzaj definicji nazywa się delegowaniem wywołań metod.

Teraz możemy pisać prościej: txt.length();

W różnych środowiskach uruchomieniowych delegowanie odwołań przy kompozycji jest łatwe, bo zapewniona jest automatyczna generacja kodu na podstawie naszych wyborów z dialogów.

2.13. Kompozycja a dziedziczenie

Można odnieść wrażenie, że dziedziczenie jest znacznie potężniejszym (dającym większe możliwości) sposobem ponownego wykorzystania klas niż kompozycja. I że - choć bardziej skomplikowane od kompozycji - daje programiście większą elastyczność oraz łatwość tworzenia i wykorzystania kodów "ponownego użytku". Niewątpliwie wrażenie to nie jest mylne.

Z dziedziczeniem są jednak związane pewne problemy. Jednym z nich jest tzw. słaba hermetyzacja kodu klasy bazowej. Rozpatrzmy przykład. Mamy ogólną klasę kontenerów na butelki i chcemy mieć różne specyficzne klasy takich kontenerów (np. skrzynki piwa lub Coca-Coli albo może lodówki sklepowe itd.).

Oczywiście, skrzynka piwa jest kontenerem na butelki, zatem naturalne jest tu dziedziczenie (klasa BeerBox odziedziczy BottleContainer) i wykorzystanie w innej klasie (nazwanej dalej Inhe1) - kod 2.8 (s. 69 -70).

class BottleContainer {

private int bottlesCount;

public BottleContainer(int n) {

bottlesCount = n;

}

public int getCount() {

return bottlesCount;

}

public String toString() {

return "BottleContainer, bottles = " + bottlesCount;

}

}

class BeerBox extends BottleContainer {

public BeerBox(int n) { super(n); }

public String toString() {

return "BeerBox, bottles = " + getCount();

}

}

class Inhe1 {

public static void main(String[] args) {

BeerBox bb = new BeerBox(10);

int n = bb.getCount();

//...

}

}

Tu założyliśmy, że ogólnie kontenery mogą zawierać tylko pełne butelki (stąd typ int w klasie BottleContainer).

Może kiedyś zmienimy interfejs klasy bazowej w taki sposób, że metoda getCount() będzie miała inny typ wyniku - double (niepełne butelki).

class BottleContainer {

private double bottlesCount;

public BottleContainer(double n) {

bottlesCount = n;

}

public double getCount() {

return bottlesCount;

}

}

W tym przykładzie klasa BeerBox nie będzie wymagała żadnych zmian, ale w klasie Inhe1 wykorzystującej BeerBox wystąpią błędy w kompilacji (bo spodziewanym typem wyniku jest int, a nie double i trzeba by użyć konwersji zawężającej, aby to się skompilowało).

Mówi się, że klasa BeerBox "słabo hermetyzuje" kod klasy BottleContainer, gdyż zmiana interfejsu tej klasy uniemożliwia ponowne wykorzystanie hierarchii dziedziczenia w innych klasach. Co gorsza, po takiej ewentualnej zmianie wadliwy staje się kod klas już skompilowanych i być może działających od długiego czasu. Okazuje się, że kompozycja pozwala uniknąć takich problemów. W naszym "butelkowym" przykładzie zastosowanie kompozycji może wyglądać tak, jak przedstwia kod 2.8.

class BottleContainer {

private int bottlesCount;

public BottleContainer(int n) {

bottlesCount = n;

}

public double getCount() {

return bottlesCount;

}

}

class BeerBox {

// wykorzystanie klasy BottleContainer przez kompozycję

private BottleContainer cont;

public BeerBox(int n) {

cont = new BottleContainer(n);

}

public int getCount() {

return cont.getCount();

}

}

class Compos1 {

public static void main(String[] args) {

BeerBox bb = new BeerBox(10);

int n = bb.getCount();

// ...

}

}

Kod 2.8. Kompozycja pozwala uniknąć słabej hermetyzacji klasy bazowej

Mamy tu dwie klasy, które tworzą "zestaw ponownego użycia": klasę BottleContainer i BeerBox. Przez inne klasy (np. Compos1) bezpośrednio używana jest klasa BeerBox. Klasa BottleContainer używana jest pośrednio. Jeśli teraz - podobnie jak w przykładzie z dziedziczeniem - w klasie BottleContainer zmienimy interfejs (np. zmienimy typ wyniku zwracanego przez metodę getCount() na double), to będziemy musieli zmienić coś w implementacji klas bezpośrednio wykorzystywanych przez inne klasy (tu: BeerBox), ale ich interfejsy pozostaną bez zmian i w związku z tym nie trzeba będzie niczego zmieniać w klasach wykorzystujących nasz zestaw klas pojemników na butelki:

class BottleContainer {

private double bottlesCount;

// ...

public double getCount() {

return bottlesCount;

}

}

class BeerBox {

private BottleContainer cont;

// ...

public int getCount() {

return (int) cont.getCount(); // drobna zmiana implementacji

}

}

// nie wymaga zmian po zmianie interfejsu klasy BottleContainer

class Compos1 {

public static void main(String[] args) {

BeerBox bb = new BeerBox(10);

int n = bb.getCount();

}

}

Jest to sytuacja korzystniejsza niż "słaba hermetyzacja" kodu przy dziedziczeniu, bo zwykle klasy takie jak BottleContainer i BeerBox są kontrolowane przez twórców jakiegoś API, użytkownicy API zaś zwykle korzystają z klas bezpośredniego ponownego użycia (w naszym przykładzie BeerBox).

Oczywiście przy dziedziczeniu łatwiejsze niż przy kompozycji jest dodawanie nowych klas jakiegoś API (w naszym przykładzie np. lodówek, półek z wodą mineralną, pojemników z Colą). Łatwiejsze i bardziej naturalne jest też zastosowanie polimorfizmu.

A czy kompozycja w ogóle umożliwia wykorzystanie odwołań polimorficznych? Na pierwszy rzut oka nie bardzo, ale w dalszych rozdziałach okaże się, że dzięki koncepcji interfejsów jest to możliwe.

2.14. Reguły ponownego wykorzystania klas

Po pierwsze, należy dostosowywać sposób ponownego wykorzystania do dziedziny problemu. Dziedziczenia używamy wtedy, kiedy jest spełniona relacja "B jest A", ale czasem okazuje się, że taka relacja jest pozorna lub może podlegać zmianom w cyklu życiowym obiektów. Dobrym testem jest postawienie pytania, czy zawsze, w każdych okolicznościach działania naszego programu można sensownie myśleć o rozszerzającej konwersji referencyjnej typu podklasy na typ nadklasy.

Po drugie, z wyborem sposobu wykorzystania klas związane są kwestie efektywności: kompozycja okazuje się niekiedy bardziej pracochłonna i wolniejsza przy wykonaniu programu, ale z kolei w niektórych okolicznościach pozwala oszczędzać pamięć (tworzenie obiektu definiowanego w ramach kompozycji jako pole klasy może być odroczone w czasie do momentu, gdy będzie on potrzebny, co w danym przebiegu programu może nie nastąpić nigdy; przy dziedziczeniu zawsze wydzielana jest pamięć dla pól nadklasy).

Po trzecie wreszcie, nie należy nadużywać ani dziedziczenia, ani kompozycji. Jak widzieliśmy, dziedziczenie ma swoje wady, związane przede wszystkim ze "słabą hermetyzacją" (a także "kruchością" klasy bazowej - niekiedy zmiana jej interfejsu powoduje konieczność zmian w interfejsach wielu klas dziedziczących, np. jeśli zmienimy typ zwracanego wyniku w metodzie, która w podklasach jest przedefiniowywana). Z kolei kompozycja - która pozwala unikać takich problemów - jest często mniej naturalna i w związku z tym kody klas są trudniejsze do "prowadzenia" (uzupełnień, modyfikacji), a rozbudowa API o nowe klasy dużo trudniejsza niż przy zastosowaniu dziedziczenia.