1. Obiekty i referencje
Za wyjątkiem operacji na liczbach, programowanie w Javie - tak naprawdę -
polega na posługiwaniu się obiektami. Jak pamiętamy z wykładu 4 obiekty są
egzemplarzami klas. Np. łańcuchy znakowe (napisy) są egzemplarzami klasy String, a klasa ta definiuje
wspólne właściwości tego rodzaju obiektów (napisów). Klasa String jest jedną
z wielu klas "standardowych", dostarczonych wraz ze środowiskiem Java 2 SDK,
już gotową do wykorzystania przez programistę. Klasy możemy definiować sami
(za chwilę zobaczymy jak), możemy także korzystać z klas, które przygotowali
jacyś inni programiści (i które nie wchodzą w skład klas "standardowych").
Przypomnijmy sobie również, że na obiektach operujemy wydając im polecenia
(komunikaty do obiektów), a wydawanie poleceń nazywane jest wywoływaniem
metod (odpowiednika pojęcia funkcji) na rzecz obiektów.
Wyobraźmy sobie teraz, że ktoś przygotował dla nas klasę o nazwie Para i
że 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).
Wyobraźmy sobie dalej, że w klasie Para są zdefiniowane następujące polecenia
(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)
Ta informacja wystarczy nam teraz by posługiwać się obiektami klasy Para (jej budowę poznamy w następnych wykładach).
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ądać 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
}
}
Wydruk działania programu:
( 1 , 2 )
Po kolei:
- w wierszu oznaczonym 1 deklarujemy zmienną o nazwie para1 i tworzymy obiekt parę za pomocą wyrażenia new (jak widać wyrażenie new składa się ze słowa kluczowego new
i następującej po nim nazwie klasy obiektu oraz nawiasów okrągłych, w których
- jak się można domyslić - podawane mogą być - jako argumenty - informacje
niezbędne do tworzenia obiektu. Tu nie podajemy takich informacji. Po następnym
wykładzie będziemy dobrze rozumieć dlaczego wyrażenie new ma taką formę);
- 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 (.), nazywana selektorem
. 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. W nawiasach okrągłych podajemy jego argumenty (dodatkową informację,
która ma być uwzględniona przy wykonaniu polecenia). 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 do obiektu, oznaczanego przez para1 posylamy komunikat
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?
Widzimy wyraźnie, że została ona zadeklarowana, ale jednocześnie pojawił się nieco dziwny punkt o tworzeniu obiektu.
Co by się stało gdybyśmy po prostu napisali:
Para para1;
para1.set( 1, 2 );
para1.show();
Jak już było powiedziane, deklaracje zmiennych, które oznaczają obiekty
zapisujemy w analogiczny sposób jak deklaracje zmiennych typów pierwotnych.
int x; // deklaracja zmiennej typu int
Para p; // deklaracja zmiennej p, 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:
W innych językach programowania (np. C++) mówi się, że jest to deklaracja wraz z definicją
Zatem sama deklaracja zmiennej calkowitoliczbowej x tworzy "obiekt" - liczbę
całkowitą (przed ustaleniem wartości zmiennej liczba ta ma jakąś domyślną
wartość, być może 0).
W przypadku deklaracji zmiennej, która może 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 wyrażenia 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.
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();
Dokładnego wyjaśnienia dostarcza poniższy ideowy schemat
W literaturze polskiej rozróżnia się czasem dwa pojęcia:
- odniesienie - to co tutaj nazywamy referencją do obiektu, lokalizacją, adresem obiektu.
- odnośnik - zmienną, której wartością jest odniesienie
Nota bene, w niniejszych wykladach będziemy posługiwać się czasem terminem "referencja" na
oznaczenie obu tych pojęć.. Różnice będą zawsze jasne z kontekstu.
gdzie:
- Przydzielenie pamięci zmiennej p do przechowania referencji do obiektu.
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 skladniki 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 przydzialono 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ą.
I: "referencja wskazuje na obiekt".
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 lub wartość 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 drugą daną wartości zmienej oznaczającej pierwszą 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 pierwotnych
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.");
}
}
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 (obok) może wyglądać zaskakująco dla kogoś, kto nie uświadomi sobie braku
różnicy pomiędzy operacjami na zmiennych typów pierwotnych 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 py = px powoduje skopiowanie referencji (wskazującej na obiekt-parę o składnikach (3, 3)) do zmiennej
py (dotąd wskazującej na obiekt-parę o wartościach (4,4). Od tej chwili
px i py oznaczają ten sam obiekt (który jest parą o wartościach (3,3)). Do
obiektu-pary (4,4) 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 zmnenna py jak i px, to odwolania show wobec tych
zmiennych pokażą identyczne wartości (skladniki 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
(4,4) na którą wskazywała najpierw referencja py. Obiekt ten został utworzony
na stercie (py = new Para()), a więc zajmuje jakiś obszar pamięci. Następnie
ustalono wartości jego elementów (składników pary) - py.set(4,4) - a więc
te wartości zostały wpisane do tego obszaru. Po czym zmiennej py przypisano
wartość zmiennej px i w ten sposób w programie nie mamy już żadnej referencji
do tego obiektu. A ponieważ na obiektach możemy dzialać 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 spowodowoł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 py = 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
Programując w Javie, w wielu przypadkach, można nawet nie zdawać sobie z
tego wszystkiego sprawy. Można pokazać wiele programów obiektowych w Javie,
które działają poprawnie i wykonują przewidziane dla nich zadania, a zostały
napisane bez uświadomienia sobie różnicy pomiędzy referencjami i obiektami.
Dobrym przykładem są aplikacje powitalne z poprzedniego wykładu: można je
napisać nic nie wiedząc o referencjach i myśląc (błędnie), że zmienne zadeklarowane
tam jako Frame czy Label zawierają obiekty.
Jednak w wielu nawet prostych przypadkach (np. poprzedni program porównujący
działania na liczbach i na obiektach-parach) brak wiedzy o referencjach może
doprowadzić do poważnych błędów w programie. Na pewno zaś do napisania nieco
bardziej skomplikowanych programów uświadomienie sobie różnicy pomiędzy obiektami
i referencjami jest bardzo istotne. Dlatego poświęciliśmy tej kluczowej sprawie
sporo miejsca już na początku: byłoby chyba niedobrze zaczynać od prostszej,
może bardziej zrozumiałej (nie wspominającej o referencjach), ale z gruntu
fałszywej interpretacji języka, aby potem - ze zdziwieniem - musieć przestawiać
swoje myślenie na całkiem inne tory.
Dlatego - mimo, że podany tu materiał może wydać się nieco skomplikowany
- warto mu poświęcić i czas i wysiłek. Nie odkładając tego na poźniej.
Powiedzmy też szczerze, że model przyjęty przez twórców Javy, 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 jednej prostej instrukcji:
Color x = y.getBackground();
powinien wyglądac tak:
"zmiennej x przypisujemy referencję do obiektu klasy Color zwróconą przez
metodę getBackground(), wywołaną na rzecz obiektu, do którego referencję
zawiera zmienna y".
Sytuacji w niczym nie poprawia użycie terminologii odnośnik i odniesienie:
"odnośnikowi x przypisujemy odniesienie do obiektu klasy Color, zwrócone
przez metodę getBackground(), wywołaną na rzecz obiektu, do którego odniesienie
zawiera odnośnik y".
Dlatego w dalszej części wykładów 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:
JakasKlasa x;
...
powiemy czasem "referencja x" (gdy będziemy chcieli uwypuklić "wskaźnikowy" charakter zmiennej x)
a czasem: "obiekt x" (gdy będziemy chcieli podkreślić operowanie na obiekcie, wskazywanym przez referencję zawartą w x)
W kontekście :
String txt;
...
powiemy czasem: "łańcuch znakowy txt".
a w kontekście:
Frame v;
...
powiemy czasem "okno v ..." .
Albo w kontekście:
Color x = y.getBackground();
powiemy czasem: "uzyskanie koloru tła obiektu y"
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.
I - mimo tych skrótów - zawsze należy pamiętać o różnicy pomiędzy referencjami i obiektami.
|