1. Obiekty i referencje
Rozważmy jedną z możliwych wersji klasy Para. Jej obiekty są parami
liczb całkowitych tzn. każdy obiekt składa się z
dwóch elementów - liczb całkowitych (pierwszego składnika pary i
drugiego
składnika pary).
To co możemy robić z obiektami klasy Para określone jest przez zestaw
metod
tej klasy (poleceń, które można wydawać obiektom-parom liczb
całkowitych). W tej wersji klasy Para zdefiniujemy
następujące metody:
- o nazwie set - ustalająca wartość pary (obu
składników pary),
- i o nazwie show - pokazująca parę
(wyprowadzająca na konsolę oba składniki pary).
public class Para {
private int a;
private int b;
// konstruktor bezparametrowy
public Para() {
}
// konstruktor z dwoma argumentami = elementami pary
public Para(int x, int y) {
a = x;
b = y;
}
// Metoda ustalająca składniki pary
public void set(int x, int y) {
a = x;
b = y;
}
// Metoda pokazujaca parę
public void show() {
System.out.println("( " + a + "," + b + " )");
}
}
Co trzeba zrobić, żeby ustalić wartość pary i wyprowadzić ją na konsolę?
- Po pierwsze, musimy mieć zmienną, która będzie oznaczać
obiekt- parę.
-
Po drugie, musimy utworzyć obiekt-parę.
-
Po trzecie, temu obiektowi trzeba wydać polecenia set i show.
Może to wyglądac w następujący sposób:
public class ParaSetAndShow {
public static void main(String[] args) {
Para para1 = new Para(); // 1
para1.set( 1, 2 ); // 2
para1.show(); // 3
}
}
Zobacz demonstrację
działania programu.
Wydruk dzialania programu:
( 1 , 2 )
Po kolei:
- w wierszu oznaczonym 1 deklarujemy zmienną o nazwie
para1 i tworzymy obiekt parę za pomocą wyrażenia
new (wywołując konstruktor bezparametrowy);
- teraz
mamy już (jeden) obiekt-parę i zmienną (o nazwie para1) za pomocą
której będziemy na tym obiekcie operować;
- w wierszu
// 2 do obiektu oznaczanego przez zmienną para1 posyłamy
polecenie set. Aby posłać polecenie musimy wybrać jakiego konkretnie
obiektu
ma ono dotyczyć. Temu wyborowi służy kropka (.).
Gdy piszemy para1. - znaczy to, że następujące po kropce polecenie ma
być
wysłane do obiektu oznaczanego przez zmienną para1.
Poleceniem
tym jest
set. Jak już wiemy jest to wywołanie metody set na rzecz obiektu,
oznaczanego przez zmienną para1. W nawiasach okrągłych podajemy
argumenty wywołania metody. W tym przypadku pierwszy
argument jest wartością, która ma być nadana pierwszemu składnikowi
pary,
a drugi - drugiemu. W sumie para1.set( 1, 2); znaczy: ustalić wartość
pierwszego
elementu obiektu (składnika pary) oznaczanego przez zmienną para1 na 1,
a
drugiego elementu (składnika pary) - na 2;
- w
wierszu // 3 na rzecz obiektu, oznaczanego przez para1 wywołujemy
metodę
show ("pokaż się") i w rezultacie na konsolę wyprowadzana jest "wartość
pary",
w tym przypadku podana jako ( wartość_pierwszego_skladnika ,
wartość_drugiego_skladnika
) czyli ( 1 , 2 );
Nieco niepokojące jest w tym opisie ciagłe używanie sformułowania
"obiektu
oznaczanego przez (zmienną) para1". Czyż nie łatwiej byłoby mowić:
obiektu para1, tak
jak mówimy np liczby calkowitej x? I w ogóle czym jest, tak naprawdę,
zmienna
para1?
Co by się stało gdybyśmy po prostu napisali:
Para p;
p.set( 1, 2 );
p.show();
Widzimy tu deklarację zmiennej p, mówiąca o tym,
że jest ona typu Para.
To jest bardzo podobne do deklaracji
zmiennych typów prostych.
int x; // deklaracja zmiennej typu int
Para p; // deklaracja zmiennej para1, za pomocą której możemy operować
na obiektach klasy Para
Pomiędzy tymi deklaracjami występuje jednak subtelna różnica
znaczeniowa.
Otóż, deklaracja zmiennej x wydziela pamięć dla
przechowywania liczby całkowitej
(cztery bajty). W tym momencie x jest synonimem jednostki danych -
liczby
całkowitej.
Piszemy np. x = 4; i do miejsca pamięci oznaczanego przez zmienną x
wpisywana jest liczba 4. Wygląda to mniej więcej tak:
Zatem sama
deklaracja zmiennej calkowitoliczbowej x tworzy "obiekt" - liczbę
całkowitą.
W przypadku deklaracji zmiennej, która będzie oznaczać obiekt
jakiejś klasy
sytuacja jest zupełnie inna. Deklaracja nie tworzy obiektu (nie
wydziela
pamięci do przechowywania obiektu klasy).
Sam obiekt musi być dopiero utworzony - za pomocą
wyrażenia new.
Jego zastosowanie powoduje przydzielenie pamięci dla obiektu w
dynamicznej
(zmieniającej się w trakcie działania programu) części pamięci, zwanej stertą.
Wynikiem wyrazenia new jest adres (lokalizacja) miejsca w pamięci,
przydzielonego
obiektowi. Ten adres możemy przypisać zmiennej za pomocą której chcemy
na
danym obiekcie operować.
Np. deklaracja:
Para p;
nie tworzy obiektu klasy Para.
A jeśli nie ma obiektu, to nie możemy posłać do niego
żadnego
komunikatu
(wydać polecenia, wywołać na jego rzecz metody). Zatem, w tym
kontekście,
p.set(...) i p.show() będą niepoprawnymi odwołaniami i spowodują błąd
wykonania programu tzw. NullPointerException (odwołanie do
nieistniejącego obiektu)
Zatem zmienna p nie zawiera obiektu Para.
Może natomiast zawierać jego lokalizację (adres w pamięci) - inaczej
nazywaną referencją do obiektu.
Referencja
to wartość, która oznacza lokalizację (adres) obiektu w pamięci
Obiekt klasy Para możemy utworzyć używając wyrażenia new
Para(), a przypisując
wartość tego wyrażenia zmiennej p, uzyskujemy możliwość operowania na
tym obiekcie:
Para p;
p = new Para();
co wcześniej, w skrócie, zapisywaliśmy stosując inicjację
przy deklaracji:
Para p = new Para();
Dokladnego wyjaśnienia dostarcza poniższy ideowy schemat.
gdzie:
- Przydzielenie pamięci zmiennej p do przechowania
referencji do obiektu (4 bajty w pamięci).
Referencja jest nieustalona, ma wartość null,
co oznacza, że nie odnosi się
do żadnego obiektu.
- Opracowanie wyrażenia new powoduje
przydzielenie pamięci dla obiektu
klasy Para na stercie pod jakimś wolnym adresem (tu symbolicznie 1304).
Wielkość
przydzielonego obszaru jest wystarczająca, by zmieścić dwie liczby
całkowite
(składniki pary). W tym momencie oba składniki pary równe są 0.
- Wartością
wyrażenia new jest referencja (adres 1304). Jest ona umieszczana w
uprzednio przydzielonym zmiennej p obszarze pamięci
- Zmienna
p ma teraz wartość = referencji do obiektu klasy Para, któremu w kroku
2 przydzielono pamięć na stercie (adres 1304).
Zatem
zmienna p w naszym przykładzie zawiera (w końcu) referencję do obiektu
klasy Para. Powiemy też "wskazuje na obiekt".
Powiemy też czasem w skrócie: jest referencją.
Mówiliśmy wcześniej: "zmienna p może oznaczać
obiekt klasy Para" w tym właśnie sensie, iż może zawierać referencję do
obiektu
klasy Para (zatem jakoś go "oznaczać", ale na pewno nie zawierać). A
dlaczego
może? Bo nie zawsze zawiera
referencję do obiektu, czasami
(np. zaraz po deklaracji bez inicjacji) nie zawiera referencji do
żadnego
obiektu (bo żaden nie został jeszcze utworzony).
Powstaje pytanie - jakiego typu jest zmienna p i wszystkie
podobne zmienne, te o których mówiliśmy, że mogą oznaczać obiekty?
Otóż w Javie oprócz typów numerycznych i typu boolowskiego
istnieje jeszcze tylko jeden typ - typ referencyjny.
Wszystkie zmienne
deklarowane z nazwą klasy w miejscu nazwy typu są zmiennymi
typu referencyjnego. Zmienne te mogą zawierać referencje do obiektów
lub
nie zawierać żadnej referencji (nie wskazywać na żaden obiekt).
Wartość zmiennej typu referencyjnego, która nie zawiera
referencji do obiektu równa jest null. Słowo null
jest słowem kluczowym języka
Zatem dopuszczalne wartości zmiennych typu referencyjnego -
to referencje
do obiektów danej klasy lub wartośc null.
Tak samo jak 1 jest literałem
typu int - null
jest literałem typu referencyjnego.
Referencje są bardzo
podobne do wskaźników w C, z tą istotną
różnicą, że nie ma w Javie arytmetyki "na referencjach". Dzięki temu
programowanie
w Javie jest bardziej odporne na błędy. Arytmetyka wskaźnikowa w C jest
częstą przyczyną błędów, gdyż pozwala sięgać do dowolnego miejsca w
pamięci
(np. poprzez zwiększanie wskaźnika, który wskazuje na obszar
przydzielony
jakiejś zmiennej).
Dla wartości typów referencyjnych
(które to wartości
w istocie są liczbami, bo adresy obiektów są liczbami) nie są
dopuszczalne
operacje arytmetyczne. Możemy natomiast:
- porównywać referencje na równość (==) lub
nierówność (!=),
- przypisywać im wartości innych
referencji oraz wartość null
Musimy zawsze pamiętać, że operacje te (wykonywane na zmiennych,
oznaczających
obiekty) dotyczą referencji, a nie obiektów (na obiektach, ich wnętrzu
operujemy
za pomocą metod, poleceń posyłanych do obiektów za pośrednictwem
referencji
i za pomocą "operatora" kropka).
Wyobraźmy sobie, że na dwóch "danych" - liczbach całkowitych
i na dwóch "danych"
- obiektach klasy Para wykonujemy podobne operacje:
- Nadanie wartości pierwszej danej, nadanie wartości
drugiej danej.
- Przypisanie zmiennej
oznaczającej pierwszą daną wartości zmienej
oznaczającej drugą daną
- Zmianę wartości drugiej
danej.
- Porównanie zmiennych,
oznaczających obie dane
A dodatkowo (w obu przypadkach) wprowadzimy trzecią daną, której
wartość ustalimy na wartość drugiej i porównamy zmienne
oznaczające te dane (drugą i trzecią).
Program mógłby wyglądać tak:
public class Roznica {
public static void main(String[] args) {
// Operacje na zmiennych typów prostych
int x, y, z;
x = 3;
y = 4;
x = y;
y = 5;
z = 5;
System.out.println("x = " + x);
System.out.println("y = " + y);
System.out.println("z = " + z);
if (x == y) System.out.println ("x i y równe.");
else System.out.println ("x i y nierówne.");
if (y == z) System.out.println ("y i z równe.");
else System.out.println ("y i z nierówne.");
// Podobne operacje na zmiennych typu referencyjnego
Para px = new Para(), py = new Para(), pz = new Para();
px.set( 3, 3 );
py.set( 4, 4 );
pz.set( 5, 5 );
px = py;
py.set( 5, 5 );
System.out.print("Para px: "); px.show();
System.out.print("Para py: "); py.show();
System.out.print("Para pz: "); pz.show();
if (px == py) System.out.println ("px i py równe.");
else System.out.println ("px i py nierówne.");
if (py == pz) System.out.println ("py i pz równe.");
else System.out.println ("py i pz nierówne.");
}
}
Zobacz demonstrację działania
programu.
Uwaga: w
programie zastosowano instrukcję if-else. W instrukcji tej sprawdzany
jest warunek w nawiasach i jeśli jest prawdziwy wykonywana jest część
po if; jeśli nie - część po słowie else. Więcej na ten temat w
następnej części tekstu.
Wynik
działania programu pokazuje następujący wydruk:
x = 4
y = 5
z = 5
x i y nierówne.
y i z równe.
Para px: ( 5 , 5 )
Para py: ( 5 , 5 )
Para pz: ( 5 , 5 )
px i py równe.
py i pz nierówne.
Wynik działania programu może wyglądac
zaskakująco dla kogoś, kto nie uświadomi sobie braku
różnicy pomiędzy operacjami na zmiennych typów prostych i
referencyjnych
(myśląc że zmienne typów referencyjnych zawierają obiekty). Otrzymany
rezultat
wynika z następujących faktów:
- wyrażenie new Para() zwraca referencję do
nowoutworzonego obiektu klasy
Para; uzyskiwane referencje przypisywane są zmiennym typu Para (który
jest
typem referencyjnym),
- użycie metody
(polecenia) set ustala wartości danych w obiekcie klasy
Para; do obiektu odwołujemy się przez referencję, która na niego
wskazuje
(np. px.set(...)
- przypisanie px = py powoduje
skopiowanie referencji (wskazującej na obiekt-parę o składnikach (4,
4)) do zmiennej px (dotąd wskazującej na obiekt-parę o wartościach
(3,3). Od tej chwili
px i py oznaczają ten sam obiekt (który jest parą o wartościach (4,4)).
Do
obiektu-pary (3,3) nie mamy już w tej chwili żadnego dostępu.
- ponieważ
za pomocą referencji py ustalamy nowe wartości składników pary
(py.set(5,5)),
na którą wskazuje zarówna zmienna py jak i px, to odwołania show wobec
tych
zmiennych pokażą identyczne wartości (składniki pary o wartościach 5, 5)
-
dalej porównanie referencji px i py da wartość true (bo referencje
wskazują
na ten sam obiekt, a nie dlatego, że wskazują na dwa obiekty o tych
samych
wartościach elementów - składników pary)
- o czym
dobitnie się przekonujemy porównując zmienne py i pz. Zmienne te
wskazują na dwa różne obiekty (zatem wartości tych zmiennych są różne)
i
dlatego wynik porównania jest false, mimo, że wartości elementów obu
tych obiektów (składniki pary) są takie same (5, 5).
Przy okazji warto zastanowić się, co dzieje się obiektem-parą o
wartościach
(3,3) na którą wskazywała najpierw referencja px. Obiekt ten został
utworzony
na stercie (px = new Para()), a więc zajmuje jakiś obszar pamięci.
Następnie
ustalono wartości jego elementów (składników pary) - px.set(3,3) - a
więc
te wartości zostały wpisane do tego obszaru. Po czym zmiennej px
przypisano
wartość zmiennej py i w ten sposób w programie nie mamy już żadnej
referencji
do tego obiektu. A ponieważ na obiektach możemy działać tylko za pomocą
referencji,
to jest on już dla nas bezużyteczny i wyłącznie "zaśmieca pamięć". Czy
musimy
się tym martwić? Gdyby np. takich zaśmiecających pamięć obiektów
pojawiło
się w naszym programie tysiące, to czy nie spowodowałoby to
przepełnienia
pamięci?
Jest to istotne
ułatwienie w porównaniu z takimi językami jak C czy
C++, gdzie dynamicznie alokowane przez programistę (za pomocą
operatorów
lub funkcji) obszary pamięci muszą być przez programistę świadomie
zwalniane
Na szczęście - nie. Bezużyteczne obiekty są automatycznie usuwane z
pamięci
(bez konieczności żadnej ingerencji programisty), mimo, że powstały one
na
skutek wykonania odpowiednich instrukcji zapisanych przez programistę w
programie
(np. Para px = new Para();).
Obiekty, na które w programie
nie wskazuje już żadna referencja są automatycznie usuwane z pamięci.
Nazywa się to automatycznym odśmiecaniem (garbage
collecting)
Podsumujmy najważniejsze fakty.
- obiekty
musimy tworzyć za pomocą wyrażenia new ...
- na obiektach operujemy za pomocą referencji (które
na nie wskazują) i poleceń (metod) zdefiniowanych w klasie obiektów
- referencje nie są obiektami - są adresami obiektów
- zmienne typów referencyjnych musimy (tak samo jak
zmienne innych typów) deklarować przed ich użyciem w programie
- deklaracja zmiennej-referencji nie tworzy obiektu
Zaletą takiego modelu jest to, że
przypisania referencji nie kopiują danych. Cóż by się stało, gdyby było
inaczej?
Np.
niech x wskazuje na obiekt-obraz o rozmiarze 10MB.
Przypisanie: y
= x (zakładając, że zmienne x i y są tego samego typu) powieliłoby
obraz i mielibyśmy dodatkowe 10MB zajętej pamięci. W Javie nic takiego
się nie dzieje: przypisanie y=x kopiuje referencję (adres), a 10 MB
obraz jest tylko jeden.
Jednak model jest trudny
w opisie. Precyzyjne opisy działania programu (ściśle zgodne z modelem)
muszą
roić się od słabo czytelnych zbitek.
Na przykład, opis takiego fragmentu:
JButton b = new
JButton("Ok");
Color c = b.getBackground();
powinien wyglądac tak:
"zmiennej c przypisujemy referencję do obiektu klasy Color
zwróconą przez
metodę getBackground(), wywołaną na rzecz obiektu klasy JButton, do
którego referencję
zawiera zmienna b".
Dlatego w dalszych częściach tekstu będziemy się
starali używać uproszczonego języka, stosując swoiste skróty myślowe.
Zawsze pamiętając o
różnicy między referencją, zmienną zawierającą referencję
oraz obiektem będziemy czasem (dla uproszczenia opisów) mówić:
- referencja - nazywając tak zarówno adres obiektu,
jak i zmienną go zawierającą
- obiekt - mając na
myśli referencję do obiektu
Na przykład w kontekście:
String txt;
...
powiemy czasem: "łańcuch znakowy txt" lub "napis txt"
a w kontekście :
Color c =
b.getBackground();
powiemy czasem: "uzyskanie koloru tła przycisku b"
Należy pamiętać, że będą to wszystko skróty myślowe, służące
do bardziej
klarownego przedstawienia treści, istoty, wysokopoziomowej semantyki
programów.
2. Napisy
Oczywiście, napisy (łańcuchy znakowe) są obiektami klasy String, zatem
wszystko co
dotąd powiedziano o obiektach i referencjach dotyczy także obiektów
klasy
String.
Dodatkowo jednak, ponieważ operacje na łańcuchach znakowych są w
programowaniu
dość częste, kompilator dostarcza nam tu pewnych udogodnień.
Powtórzmy sobie znowu.
Gdy napiszemy
String napis;
to w tym momencie nie będzie jeszcze żadnego łańcucha
znakowego, jedynie
zmienna napis,
która może zawierać referencję do obiektu (ale jeszcze nie
zawiera).
Tak jak w przypadku każdej innej klasy obiekty klasy String
musimy
bezpośrednio tworzyć.
Pierwsza udogodnienie polega na tym,
że zamiast:
String s = new String("Ala ma kota");
możemy napisać:
String s = "Ala ma kota";
Zapis ten spowoduje:
- stworzenie obiektu klasy String z zawartością "Ala
ma kota"
- przypisanie referencji do tego obiektu
zmiennej s
Zatem, wyjątkowo, tworząc obiekty klasy String nie
musimy używac wyrażenia new.
Innym (wyjątkowym) udogodnieniem przy korzystaniu z
łańcuchów znakowych
jest
możliwość użycia operatora + w znaczeniu konkatenacji (łączenia
łańcuchów
znakowych).
Np.
String s1 = "Ala ma kota";
String s2 = " szaroburego";
String s3;
s3 = s1 + s2;
spowoduje,
że:
- wyrażenie s1 + s2 stworzy (nowy) obiekt klasy
String, który jest połączeniem napisów oznaczanych przez zmienne s1 i
s2,
- referencja do nowoutworzonego obiektu zostanie
przypisana zmiennej s3,
- s3 będzie teraz oznaczać
napis "Ala ma kota szaroburego".
Takie zastosowanie operatora + jest wyjątkowe, gdyż
(wizualnie) stosujemy
go wobec referencji (np. s1 + s2), co ogólnie jest niedozwolone.
Co więcej, za pomocą operatora + do łańcuchów znakowych
możemy dołączać innego
rodzaju dane, np. liczby (a także dane reprezentujące obiekty dowolnych
klas).
Na przykład:
String s1 = "Ala ma kota";
String s2 = " szaroburego";
String s3;
s3 = s1 + s2 + " w wieku " + 3 + " lat ";
Teraz
zmienna s3 będzie oznaczać napis "Ala ma kota szaroburego w wieku
3 lat".
Oczywiście, nic nie stoi na przeszkodzie, by w konkatenacji zamiast
literału 3 pojawiła się zmienna typu int o wartości 3. Np.
int lata = 3;
...
s3 = s1 + s2 + " w wieku " + lata + " lat ";
Zwróćmy uwagę, zmienna lata
lub literał 3 w wyrażeniu konkatenacji
łańcuchów znakowych są typu int. Przy opracowaniu wyrażenia (wyliczeniu
jego
wyniku) następuje przekształcenie wartości zmiennej lub literału
(dziesiętne
3, binarne 00000011 - to jest tzw. wewnętrzna reprezentacja wartości) w
kod
znaku Unicodu (dziesiętnie 33) i dzięki temu znak cyfry ('3') pojawi
się
w łańcuchu znakowym (znak cyfry 3 jest znakową reprezentacją wartości
3).
To samo dotyczy innych wartości numerycznych (typów float, double, itp.)
Jeśli w wyrażeniu konkatenacji łańcuchow znakowych wystąpi referencja
do
obiektu jakiejś klasy, to obiekt (dane zawarte w obiekcie)
zostaną
za pomocą metody
toString() przekształcone do postaci znakowej i dołączone
do łańcucha.
Na przykład jeśli napiszemy:
Para p = new Para(10,11);
String s = "Ta para jest równa" + p;
to w wyrażeniu konkatenacji zostanie automatycznie
wywołana metoda toString() z klasy Para i dostaniemy w wyniku napis:
Ta
para jest równa ( 10, 11)
Należy także zwrócić uwagę na dwie ważne kwestie:
Po pierwsze,
operator + jest traktowany jako operator konkatenacji
łańcuchów znakowych tylko wtedy, gdy jeden z jego argumentów jest typu
String
Zatem np. takie fragmenty będą niepoprawne:
String s = 1 + 3;
wynikiem prawej strony operatora przypisania jest liczba 4 (typ int), a
danej
typu int nie można podstawić na zmienną typu referencyjnego (którą jest
s)
int a = 1, b = 3;
String s = a + b;
j.w.
Po
drugie, przy konkatenacji należy baczną uwagę zwracać na
kolejność opracowywania wyrażeń
Np.
String s = "Nr " + 1 + 2;
da napis "Nr 12", bo: najpierw zostanie wyliczone wyrażenie
"Nr " + 1, co
w wyniku da napis "Nr 1", po czym drugi operator + dołączy do tego
napisu
napis "2" (znakową wartość liczby 2).
Natomiast:
String s = 100 + " Nr " + (1 +2);
da napis "100 Nr 3", bo:
- najpierw będzie opracowane wyrażenie 100 + " Nr "
(jego wynik - napis " 100 Nr ")
- następnie zostanie
opracowane wyrażenie 1 + 2 (ponieważ nawiasy zmieniają
kolejność opracowania wyrażeń), a jego wynikiem będzie liczba 3
-
w końcu zostanie zastosowany drugi operator +, który do wyniku
pierwszego
wyrażenia (napisu "100 Nr ") dołączy przeksztalconą do postaci znakowej
wartość
drugiego wyrazenia (liczbę 3)
Przy operowaniu na łańcuchach znakowych trzeba szczególnie
pamiętać, że dostęp
do nich uzyskujemy za pomocą referencji, co ma swoje konsekwencje przy
operacjach
porównania na równość - nierówność.
Jeszcze raz:
Operatory równości (==)
i nierówności (!=) zastosowane wobec zmiennych
oznaczających obiekty , porównują referencje do obiektów, a nie
zawartość
obiektów
Zatem poniższy fragment:
String s1 = "Al";
String s2 = "a";
String s3 = s1 + s2;
String s4 = "Ala";
System.out.println(s3 + " " + s4);
if (s3 == s4) System.out.println("To jest Ala");
else System.out.println("To nie Ala"
wyprowadzi
(wbrew intuicyjnym oczekiwaniom):
Ala Ala
To nie Ala
Zwróćmy uwagę: zawartością obiektu oznaczanego przez s3 jest
napis "Ala".
Również - zawartością obiektu oznaczanego przez s4 jest taki sam napis
"Ala".
Ale porównanie obu zmiennych da wartość false, bo s3 wskazuje na inny
obiekt niż s4.
Porównanie byłoby prawdziwe tylko wtedy, gdyby s3 wskazywało na ten sam
obiekt co s4.
Do porównywania łańcuchów
znakowych (ich zawartości) nie należy używać
operatorów == i !=
Ktoś mógłby powiedzieć: czyżby?
A co z takim
fragmentem programu?
String s1 = "Ala";
String s2 = "Ala";
if (s1 == s2) System.out.println("To jest Ala");
Cóż
- wyprowadzi on napis "To jest Ala", jakby wbrew
sformułowanej przed chwilą regule.
Ale
dzieje się tak tylko dlatego, że wszystkie literały
łańcuchowe
mające ten sam tekst,
są jednym i tym samym obiektem. Obiekty-literały są tworzone w fazie
kompilacji i dodawane do puli literałów. W instrukcji: s1 =
"Ala"; zmiennej s1 jest przypisywana referencja do literału
"Ala".
To samo dzieje się w instrukcji s2 = "Ala"; zatem obie zmienne wskazują
ten sam obiekt.
Dlatego porównanie da wartośc true.
Jednak nie należy
wykorzystywać tej właściwości języka i zawsze, zamiast operatora ==
należy stosować polecenie equals
3. Interakcja: wprowadzanie napisów i liczb, komunikaty
W
realnych sytuacjach programy komunikują się z użytkownikiem,
umożliwiając wprowadzanie danych i wyprowadzając wyniki. Wiemy już jak
wypisać wyniki na konsoli.
Ale jak wprowadzić dane do
programu? I jak pokazać wyniki w trochę wygodniejszej i
ładniejszej od konsolowego wyjścia formie?
Do wprowadzania
danych możemy użyć:
- skanera (klasy Scanner
z pakietu java.util)
- dialogów wejściowych
(statycznych metod showInputDialog z klasy JOptionPane z pakietu
javax.swing).
Do pokazywania wyników w okienku
komunikatów możemy użyć statycznej metody
showMessageDialog klasy JOptionPane z pakietu javax. swing;
Zapis w programie:
String s =
JOptionPane.showInputDialog("Komunikat");
spowoduje otwarcie okienka dialogowego z komunikatem
"Komunikat", w którym
będzie można wprowadzić jakiś tekst. Po kliknięciu w przycisk Ok
wprowadzony
łańcuch znakowy będzie dostępny za pomocą zmiennej s. Zamknięcie
dialogu (np.
przyciskiem Cancel) spowoduje, że zmienna s będzie miała wartośc null.
Zapis:
JOptionPane.showMessageDialog(null,
"Komunikat");
pokaże okno dialogowe z komunikatem "Komunikat" (w tym
zapisie null
mówi m.in. o tym, że okno dialogowe ma być wycentrowany w obszarze
pulpitu graficznego
interfejsu systemu operacyjnego; zamiast null można podać referencję do
obiektu-okna
innego od pulpitu)
Przykład wykorzystania dialogów
w programie:
import javax.swing.JOptionPane;
public class GreetMsg {
public static void main(String[] args) {
String name = JOptionPane.showInputDialog("Podaj swoje imię");
if (name == null) name = "";
JOptionPane.showMessageDialog( null, "Witaj " + name + "!");
}
}
Demo:
Program uwidoczni dialog
wejściowy, w którym będziemy mogli wprowadzić tekst.
Gdy po wpisaniu tekstu klikniemy "Ok", to zmienna name będzie wskazywać
na
napis, który wprowadziliśmy w dialogu. Jeśli natomiast
zamkniemy dialog,
albo klikniemy "Cancel", to wynikiem odwołania
JOptionPane.showInputDialog(...)
będzie null i tę właśnie wartość będzie miala zmienna name (co oznacza,
że
nie wskazuje na żaden łańcuch znakowy).
W takim przypadku przypiszemy jej referencję do pustego łańcucha
znakowego "".
Poniższe
rysunki ilustrują dzialanie programu.
Pytanie:
Wynik w okienku
komunikatów:
Dane
możemy także wprowadzać z konsoli (standardowego wejścia). W Javie
oznaczane jest ono przez statyczną publiczną stałą z klasy System: System.in. Wygodnym
sposobem czytania ze standardowego wejścia jest użycie klasy Scanner.
Pokazuje to przykładowy program:
import java.util.*;
public class ScanString {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
System.out.println("Podaj imię");
String name = scan.next();
System.out.println("Witaj " + name);
}
}
Tworząc
obiekt klasy Scanner podajemy w konstruktorze źródło danych. Tutaj jest
nim standardowe wejście (System.in). Metoda next() wczytuje ze
standardowego wejścia kolejny symbol (napis nie zawierający tzw. białych
znaków, czyli spacji, tabulacji, znaku nowego wiersza itp.).
Uzyskamy
podobny efekt, jak w poprzednim programie, tyle, że za pośrednictwem
konsoli.
A jak wprowadzić liczby?
W
przypadku skanera jest to dość proste, ma on bowiem metody do
wprowadzania liczb, przekształcające ich znakową postać (napisy
oznaczające liczby) w postać binarną.
Obrazuje to poniższy
program:
import java.util.*;
public class ScanNums {
public static void main(String[] args) {
Scanner scan = new Scanner(System.in);
scan.useLocale(new Locale("en"));
System.out.println("Wprowadż liczbę całkowitą:");
int i = scan.nextInt();
System.out.println("Wprowadż liczbę rzeczywistą:");
double d = scan.nextDouble();
System.out.println("Wprowadzono: " + i + " " + d);
}
}
Metoda
nextInt() pobiera ze źródła danych kolejną liczbę całkowitą,
nextDouble() - rzeczywistą.
Skaner jest wrażliwy na ustawienia
regionalne -tzw. locale (m.in. to czy separatorem miejsc dzisiętnych w liczbach
rzeczywistych jest kropka czy przecinek). Dlatego w programie użyto
metody useLocale(...) zapewniającej użycie separatora właściwego dla
ustawień (lokalizacji) języka angielskiego ("en"), czyli
kropki. W lokalizacji polskiej, wprowadzając dane, musielibyśmy używać przecinka.
A jak wprowadzić do programu liczby przy zastosowaniu dialogów
wejściowych?
Gdy w polu tekstowym okna dialogowego wpiszemy jakąś liczbę, to będzie
ona
w programie dostępna jako napis (łańcuch znakowy, String). Oczywiście
nie
możemy na takim napisie wykonywać operacji arytmetycznych. Do tego
potrzebne
jest przekształcenie znakowej (napisowej) reprezentacji liczby do
postaci
binarnej (takiej, w jakiej liczby są zapisywane w pamięci komputera).
Skaner robi to automatycznie, stosując dialogi sami musimy zadbać o
odpowiednią konwersję.
Do przekształcania napisów
na liczby całkowite można wykorzystać następujące odwołanie:
liczba = Integer.parseInt(napis);
gdzie:
liczba -
jest dowolną zmienną typu int,
napis
- jest wyrażeniem typu String.
Oczywiście, nie każdy napis reprezentuje liczbę całkowitą.
Jeśli np. w napisie
znajdzie się znak litery lub kropka dziesiętna, to przy próbie
przekształcenia
napisu na liczbę całkowitą wystąpi błąd. W Javie taki błąd
sygnalizowany
jest za pomoca wyjątku, który nazywa się NumberFormatException.
Dopóki nie poznamy mechanizmu obsługi błędów (wyjątków)
musimy się liczyć z tym, że w takim przypadku wykonanie programu
zostanie
przerwane. To samo dotyczy skanera, z tym, że zgłaszany jest błąd - wyjątek InpuMismatchException.
Przykład wprowadzania liczb calkowitych w dialogach wejściowych: program sumujący dwie wprowadzone
liczby.
import javax.swing.*;
public class ParseInt {
public static void main(String[] args) {
String s1 = JOptionPane.showInputDialog("Podaj pierwszą liczbę");
if (s1 != null) {
String s2 = JOptionPane.showInputDialog("Podaj drugą liczbę");
if (s2 != null) {
int l1 = Integer.parseInt(s1);
int l2 = Integer.parseInt(s2);
JOptionPane.showMessageDialog(null, "Suma: " + (l1 + l2));
}
}
}
}
Zobacz
demo działania programu:
Podobnie, stosując metodę double Double.parseDouble(String s) możemy przekształcać znakową reprezentację liczb rzeczywistych w ich postać binarną.
Skaner
możemy łączyć z dialogami wejściowymi. Może on bowiem pobierać dane z
różnych źródeł m.in. z napisów (obiektów klasy String).
Ilustruje to poniższy program - odpowiednik poprzedniego sumowania.
import java.util.*;
import javax.swing.*;
public class ScanNumsFromString {
public static void main(String[] args) {
String in = JOptionPane.showInputDialog("Podaj dwie liczby całkoitew rozdzielone spacjami");
if (in != null) {
Scanner scan = new Scanner(in);
if (scan.hasNextInt()) {
int n1 = scan.nextInt();
if (scan.hasNextInt()) {
int n2 = scan.nextInt();
JOptionPane.showMessageDialog(null, "Suma: "+ (n1 + n2));
}
}
}
}
}
Tutaj jako źródło danych, przy wywołaniu konstruktora skanera ustalamy napis reprezentowany przez zmienną in
(wynik wprowadzenia informacji w dialogu wejściowym). Metoda
hasNextInt() zwraca true, jeżeli w źródle danych jest jeszcze "nie
zeskanowana" liczba całkowita. Napis może być pusty, lub zawierać
symbole nie dające się zinterpretować jako liczba całkowita. Wtedy
metoda hasNextInt() zwróci false i nie będziemy wykonywać żadnych
operacji.
Skaner dysponuje wieloma innymi (bogatymi)
możliwościami interpretacji i skanowania danych źródłowych. Będzie o nich mowa później.
4. O metodach equals() i toString()
Skoro
operator == użyty wobec referencji porównuje tożsamość obiektu (czy
referencje wskazują na ten sam obiekt), to jak porównać zawartość
obiektów (czy obiekty wskazywane przez referencje reprezentują
identyczne dane?).
Otóż to zależy od definicji klasy.
Do porównywania zawartości (treści) obiektów
służy metoda:
public boolean equals(Object o)
która
zwraca wartość true, jeżeli ten obiekt, na rzecz którego wywołano
metodę ma taką samą zawartość jak obiekt, przekazany jako argument.
W
większości standardowych klas Javy (takich jak np. String) metoda
equals() jest odpowiednio zdefiniowana. W naszych własnych klasach
musimy sami dostarczyć jej definicji.
W klasie Para może ona
wyglądać następująco:
public class Para {
private int a, b;
// ...
public boolean equals(Object obj) {
if (this == obj) return true; // 1
if (obj == null) return false; // 2
if (getClass() != obj.getClass()) return false; // 3
Para other = (Para) obj; // 4
if (a != other.a || b != other.b) return false; // 5
return true; // 6
}
// ...
}
Znaczenie
poszczególnych wierszy kodu (wg numerów podanych w
komentarzach) jest następujące:
- Jeżeli to
jest ten sam obiekt - zwracamy true (para jest równa samej sobie).
- Jeżeli
jako argument przekazano null - pary nie są takie same (null nie jest
parą).
- Jeżeli
klasy tego obiektu i argumentu są różne - pary nie są takie same
(argument nie jest referencją do obiektu klasy Para); proszę zwrócić
uwagę, że argument jest formalnie typu Object, więc można tu podać
referencję do obiektu dowolnej klasy; metoda getClass() pozwala uzyskać
klasę obiektu i w ten sposób możemy sprawdzić czy obiekt wskazywany
przez argument należy do tej samej klasy co TEN obiekt (czyli klasy
Para).
- Po to, by móc odwoływać się do składowych
pary-argumentu musieliśmy dokonać rzutowania (konwersji zawężającej) do
klasy Para.
- Jeżeli
którykolwiek ze składników pary w tym obiekcie nie jest taki sam jak
odpowiedni składnik pary w obiekcie-argumencie - pary nie są takie same.
- W
przeciwnym razie (oba składniki par są odpowiednio takie same) zwracamy
true (pary są takie same)
Poniższy program testowy
pokazuje wyniki porównań:
public class Test {
static void test(String jakiePary, Para p1, Para p2) {
System.out.println(jakiePary + " == - daje wynik: " + (p1 == p2));
System.out.println(jakiePary + " equals - daje wynik: " + p1.equals(p2));
}
public static void main(String[] args) {
Para para1 = new Para(1,2);
Para para2 = new Para(3,4);
Para para3 = new Para(1,2);
test("para1 i para2", para1, para2);
test("para1 i para3", para1, para3);
para2 = para1;
System.out.println("Po podstawieniu para2 = para1");
test("para1 i para2", para1, para2);
test("para1 i para3", para1, para3);
}
}
Wydruk:
para1 i para2
== - daje wynik: false
para1 i para2
equals - daje wynik: false
para1 i para3
== - daje wynik: false
para1 i para3
equals - daje wynik: true
Po podstawieniu para2 = para1
para1
i para2 == - daje wynik: true
para1
i para2 equals - daje wynik: true
para1 i
para3 == - daje wynik: false
para1
i para3 equals - daje wynik: true
Do porównywania zawartości obiektów na
równość - nierówność nie wolno stosować operatora ==.
Należy
zawsze stosować metodę equals(...), o ile jest zdefiniowana w klasie
obiektów.
Dodatkowe uwagi:
- sygnatura (nazwa i parametr) metody equals oraz jej typ wyniku musi być zawsze taka sama: boolean equals(Object),
- definiując
metodę equals() warto też zdefiniować metodę hashCode(); o tym dlaczego
tak jest dowiemy się z materiału o kolekcjach.
Szczególną rolę w definicji
klas pełni metoda public String toString().
Metoda:
public String toString()
służy
do przedstawiania zawartości obiektów w postaci napisów.
Powinna
zwracać napis, reprezentujący zawartość obiektu.
W naszych
klasach sami decydujemy o tym, jak ma wyglądać ten napis.
Metoda
toString() jest ważna, bowiem jest wykorzystywana w wielu standardowych
klasach Javy do pokazywania napisowej reprezentacji obiektu. W
szczególności, jeżeli metodzie println(...) przekażemy jako argument
referencję do obiektu, to zostanie automatycznie wywołana metoda
toString() i jej wynik wyprowadzony na konsolę.
Możemy
teraz w
klasie Para zrezygnować z metody show() (wypisującej zawartośc pary na
konsolę) i zastąpić ją metodą toString(). Kod będzie bardziej naturalny.
public class Para {
private int a;
private int b;
// ...
public String toString() {
return " ( " + a + " , " + b + " )";
}
public static void main(String[] args) {
Para p1 = new Para(1,2);
Para p2 = new Para(3,4);
System.out.println(p1);
System.out.println(p2);
System.out.println(p1.add(p2));
}
}
wypisze
na konsoli:
(
1 , 2 )
( 3 , 4 )
( 4 , 6 )
Co
równie ważne, w klasach o uniwersalnym przeznaczeniu (takich
jak
klasa Para) nie powinniśmy przesądzać o różnych szczegółach ich użycia
(np. czy informacja o składnikach pary ma być wypisywana na konsoli czy
może pokazywana w jakimś dialogu). Dlatego należało
zrezygnować z
metody show() i wprowadzić metodę toString(). Uzyskany od niej wynik
(zawartość pary) może być pokazywany dowolny sposób.
5. Wyliczenia (enumeracje)
Ogólnie, 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:
- nie ma 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
ulegną modyfikacji 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 (konwersji String - int), proste wypisanie 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
Np.
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 przyjmowac 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 refrencyjnego Pora (czyli ani liczby ani napisy).
Wobec
danych typów wyliczeniowych możemy stosować różne metody
(obiektowość! - są zdefiniowane operacje). Jak to możliwe i jak można
rozszerzać funkcjonalność typów wyliczeniowych dowiemy się w części
poświęconej programowaniu obiektowemu.
Teraz wystarczy wiedza całkiem praktyczna. M.in.:
- zastosowanie
metody toString() wobec zmiennej typu wyliczeniowego zwróci jej wartość
w postaci napisu (w naszym przykładowym przypadku np. "ZIMA");
umożliwia to eleganckie i łatwe pokazywanie wartości typu
wyliczeniowego np. p = Pora.ZIMA; System.out.println(p); wypisze
na konsoli napis ZIMA,
- zastosowanie statycznej metody
valueOf(String s) zwróci wartość typu wyliczeniowego odpowiadająca
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 (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 - zob. rozdział o tablicach),
- dla
porównania wartości typów wyliczeniowych na równość
wystarczy operator == (tu przy porównaniach zawsze wchodzi w grę tylko
identyczność obiektów na które wskazują referencje).
Przykładowy
program pozwala na podanie nazwy pory roku i opisuje ją w okienku komunikatów.
import javax.swing.*;
public class PoryRoku {
enum Pora { ZIMA, WIOSNA, LATO, JESIEŃ };
public static String opisz(Pora p) {
if (p == Pora.ZIMA) return p + " - pada śnieg.";
if (p == Pora.WIOSNA) return p + " - kwitnie bez.";
if (p == Pora.LATO) return p + " - jest ciepło.";
if (p == Pora.JESIEŃ) return p + " - pada deszcz.";
return "Pora roku nie została wybrana";
}
public static void main(String[] args) {
String nazwa = JOptionPane.showInputDialog("Podaj porę roku:");
Pora p = Pora.valueOf(nazwa);
JOptionPane.showMessageDialog(null, opisz(p) );
}
}
Zobacz demonstrację działania programu
Np. po podaniu w dialogu wejściowym napisu "LATO" uzyskamy następujący wynik: