Klasy i programowanie


1. Anatomia

Rozważymy  nieco inną (niż wykorzystywana dotąd w przykładach) konstrukcję klasy Para, tak by na jej przykładzie móc dokładnie prześledzić co dzieje się przy tworzeniu obiektów i  wywoływaniu metod.  


public class Para {

 private int a;   // To są "dane" (zwane polami klasy).
                  // Określają one z jakich elementów składać się
 private int b;   // będą obiekty tej klasy.
                  // a = pierwszy składnik pary, b - drugi


 public Para(int x, int y) { // konstruktor: nadaje wartość parze
   a = x ;                   // na podstawie przekazanych wartości x i y
   b = y;
 }


 public void set(Para p) { // metoda ustalenia wartości pary
   a = p.a;                // na podstawie składników przekazanej pary
   b = p.b;
 }


 public Para add(Para p) { // metoda dodawania dwóch par
   Para wynik = new Para(a, b);
   wynik.a += p.a;
   wynik.b += p.b;
   return wynik;
 }

 // metoda pokazująca parę
 public void show(String s) {
   System.out.println(s + " ( " + a + " , " + b + " )" );
 }

}

W innej klasie możemy użyć klasy Para, np. tak:

class ParaTest {

  public static void main(String[] args) {
    Para para1 = new Para(1, 5);
    Para para2 = new Para(2, 4);
    para1.show("Para 1 =");
    para2.show("Para 2 =");
    Para sumaPar = para1.add(para2);
    sumaPar.show("Suma par =");
    para1.set(para2);
    para1.show("Teraz para 1 = ");

  }

}

Powyższy program wyprowadzi na konsolę następujące wyniki.
Para 1 = ( 1 , 5 )
Para 2 = ( 2 , 4 )
Suma par = ( 3 , 9 )
Teraz para 1 =  ( 2 , 4 )

Zobaczmy co się naprawdę dzieje.

Gdy piszemy:

    Para para1 = new Para(1, 5);

wyrażenie new tworzy obiekt, tzn.:

Podobnie możemy napisać:

Para para2 = new Para(2,4);

Mamy teraz dwa obiekty para1 i para2.
para1 "wygląda" tak: 		para2 "wygląda" tak:

Pola: 				Pola:

 int a; ( = 1) 			int a; ( = 2)
 int b; ( = 5) 			int b; ( = 4)
----------------------------------------------------
Metody: Metody:

 void set(...) 			void set(...)
 Para add(...) 			Para add(...)
 void show(...) 		void show(...)


Identyfikatory pól i metod są takie same!
Zatem trzeba ich używać "na rzecz" konkretnego obiektu (para1 albo para2).

Do tego rozróżniania służy kropka (nareszcie naocznie widać jej użyteczność):

para1.a - oznacza element a obiektu para1
para2.a - oznacza element a obiektu para2

To samo z metodami:

para1.show();  // obiektowi oznaczonemu para1 wysyłamy komunikat show (pokaż się)
                      // co oznacza wywołanie metody show na rzecz obiektu para1

para2.show();  // obiektowi oznaczonemu para2 wysyłamy komunikat show (pokaż się)
                      // co oznacza wywołanie metody show na rzecz obiektu para2

Uwagi:


Zajrzyjmy teraz do wnętrza klasy. Skąd wiadomo co konkretnie oznacza a i b w konstruktorze albo w metodzie set?

Rozważmy konstruktor

class Para {
   int a, b;

public Para(int x, int y) {   
   a = x  ;                            
   b = y;
}
....
}

Słowo this jest słowem kluczowym języka
Wyrażenie new najpierw tworzy obiekt, a później wywoływany jest konstruktor. Zatem w momencie rozpoczęcia działania konstruktora obiekt już istnieje (jest mu przydzielona pamięć na przechowanie dwóch liczb całkowitych,  ich wartości  zostały inicjalnie określone, w naszym przypadku jako zera). Wykonanie konstruktora dotyczy właśnie tego nowoutworzonego  obiektu. W konstruktorze dostępna jest referencja do tego obiektu w postaci niejawnie zdefiniowanej zmiennej o nazwie this. (this = TEN).
Zatem this.a i this.b - zgodnie z interpretacją znaczenia kropki to pola a i  b tego obiektu,  którego dotyczą inicjacje wykonywane przez konstruktor.

r



Ponieważ i tak wiadomo, że samo a i b dotyczy pól (elementów) tego obiektu, dla którego akurat wołany jest konstruktor, to słowo this możemy pominąć.


To samo dotyczy metod wywoływanych na rzecz obiektów.
Wyobraźmy sobie, że na rzecz obiektu para1 wywołano metodę set z argumentem  para2.
Działanie metody set ma polegać na przepisaniu zawartości pary para2 do pary para1.

"Algorytm" metody set jest taki:
  1. polu a tego obiektu na rzecz którego wywołano metodę przypisz wartość pola a obiektu przekazanego jako argument,
  2. polu b tego obiektu na rzecz którego wywołano metodę przypisz wartość pola b obiektu przekazanego jako argument.
TEN obiekt na rzecz którego wywołano metodę jest wewnątrz metody reprezentowany słowem kluczowym this.

r

I znowu możemy pominąć słówko this, bo tu jasne jest z kontekstu.

void set(Para p) {
   a = p.a;
   b = p.b;
}

Zobaczmy teraz jak działa metoda dodawania dwóch par.

Po pierwsze: mamy dwie pary, które chcemy dodać – wobec tego silna jest pokusa by użyć metody z dwoma argumentami. Ale przecież programujemy obiektowo: pierwsza z par do której dodajemy drugą będzie obiektem do którego poślemy polecenie add:

    para1.add(para2);


Po drugie: co zrobić z wynikiem dodawania?
W rezultacie dodawania powinna powstać nowa para – suma dwóch dodanych par.
Ta nowa para winna być stworzona w metodzie add, a referencja do niej zwrócona jako wynik tej metody. Dlatego:

r

Zauważmy też, że wszystko co analizowaliśmy dotąd na przykładzie pól - dotyczy również metod.
Gdybyśmy w klasie Para mieli na przykład dwie metody getA() i getB(), które zwracają odpowiednie składniki pary, to w innej metodzie klasy (np. metodzie sumującej składniki pary) moglibyśmy się do nich odwołać:

class Para {
    int a, b;
    ...
    int getA() { return a; }
    int getB() { return b; }

    int sum() {
        int pierwsza = this.getA();
        int druga = this.getB();
        return pierwsza + druga;
    }
}

co oznaczałoby, że wywołujemy metody getA i getB na rzecz tego obiektu, na rzecz którego została wywywołana metoda sum.
Ponieważ jest to jasne z kontekstu, możemy napisac prościej:

int sum() {
    return getA() + getB();
}
   

Są przypadki, kiedy użycie slowa kluczowego this jest istotne. Na przykład, gdy identyfikatory parametrów przesłaniają (są takie same jak) identyfikatory pól klasy.

class Para {
    int a, b;

Para (int a,  int b) {
     a = ... // o które a chodzi: parametr czy pole???
...
}

void set(int a, int b) {
    a = ... // o które a chodzi: parametr czy pole???
}
}

Dla rozróżnienia należy użyć słowa this.
Zapis this.a zawsze oznacza pole a obiektu inicjowanego przez konstruktor lub tego na rzecz którego wywołano metodę. Zatem w powyższym kontekście piszemy:
class Para {
 int a;
 int b;

 Para(int a, int b) {
   this.a = a; // polu a obiektu przypisz wartość parametru a
   this.b = b; // polu b obiektu przypisz wartość parametru b
 }

 void set(int a, int b) {
   this.a = a;
   this.b = b;
 }
}

Słowa this użyjemy również w sytuacji, gdy metoda powinna zwrócić referencję do obiektu na rzecz którego została wywołana.
Np. metodę add(,,,) dodającą pary moglibyśmy zdefiniować w taki sposób, by para podana jako argument była "dosumowywana" do pary, na rzecz której wywołano metodę (zmieniając wartość tej pary, tak by stanowiła ona sumę par), a wynik był zwracany:
 
Para add(Para p) {
 a += p.a;
 b += p.b;
 return this;
}
Dzięki temu możemy łatwo zapisywać "kumulatywne" sumowanie. Np. w kontekście:

Para suma = new Para(0,0);
Para p1 = new Para(1, 2);
Para p2 = new Para(3, 4);
Para p3 = new Para(5, 6);

zamiast:

suma.add(p1);
suma.add(p2);
suma.add(p3);

możemy napisać:

suma.add(p1).add(p2).add(p3);

2. Zasięg identyfikatorów. Zmienne lokalne. Czas życia danych.

Gdy definiujemy jakąś klasę  niebagatelną kwestią okazuje się pytanie o możliwości działania na określonych zmiennych.  Zwykle sprawia to początkującym w Javie programistom wiele trudności.

Zacznijmy od pewnego banalnego (ale wcale nie tak oczywistego dla  kogoś kto zaczyna przygodę z Javą) stwierdzenia.

Wszystkie instrukcje (oprócz deklaracji pól) można umieszczać wyłącznie w metodach klasy

(jest od tego wyjątek: blok statyczny, ale w tym momencie go pominiemy)

W samych metodach możemy mieć jednak pewne niejasności: kiedy  możemy się odwoływać do określonych zmiennych i innych metod?

Jak pamiętamy - nazwy zmiennych, stałych, metod, klas są identyfikatorami.

Zasięgiem identyfikatora jest fragment programu, w którym może on być używany (w którym identyfikator jest rozpoznawany przez kompilator).


W każdej metodzie klasy możemy zawsze odwołać się do identyfikatorów składowych klasy (pól i metod), niezależnie od tego w którym miejscu klasy występuje deklaracja tych pól i metod.


Przypomnijmy: cialo metody czyli jej kod ujmowany jest w nawiasy klamrowe. Ujęty w nawiasy klamrowe kod nazywa się również blokiem.
W każdej metodzie możemy deklarować nowe zmienne (lub stałe). Zasięg ich identyfikatorów jest lokalny - rozciąga się od miejsca deklaracji do końca metody (końca bloku stanowiącego ciało metody), w której zostały zadeklarowane. Mówimy o nich zmienne (stałe) lokalne . Dotyczy to również parametrów (których deklaracje występują w nagłówku metody). Tak naprawdę, parametry są zmiennymi lokalnymi o zasięgu od miejsca deklaracji do końca bloku obejmującego cialo metody.
Również wewnatrz bloków lokalnych (zestawu instrukcji ujętych w nawiasy klamrowe wewnątrz metody) możemy wprowadzać deklaracje zmiennych. Ich zasięg obejmuje obszar od miejca deklaracji do końca bloku w którym zostały zadeklarowane.

Zatem, jeśli mamy następującą klasę:
class A {

 int a;

 void metoda1() {
   int b;
 ... 
 }

 void metoda2() {
   int c;
 ...
 }
 }

to w metodzie metoda1 możemy odwoływać się do zmiennej a, zmiennej b, oraz metody metoda2(), a w metodzie metoda2() możemy odwoływac się do zmiennej a, zmiennej c i metody metoda1. Blędem natomiast będzie próba odwołania się z metody1 do zmiennej c i z metody2 do zmiennej b.

Jak już widzieliśmy, W konstruktorach i metodach możemy przesłaniać identyfikatory pól klasy.
Np.

class A {

 int a;

 void metoda() {
   int a = 0;  // przesłonięcie identyfikatora pola
   a = a + 10; // dotyczy zmiennej lokalnej;
   this.a++;   // dotyczy pola
 }

}

Tutaj w metodzie metoda() wprowadziliśmy zmiennę lokalną o tej samej nazwie co pole klasy (przesłonięcie identyfikatora). Samo odwołanie a będzie dotyczyć tej zmiennej lokalnej. Jak pamiętamy, przy takim przesłonięciu możemy odwolać się do pola używając zmiennej this.

Natomiast:
W Javie nie wolno przesłaniać zmiennych lokalnych w blokach wewnętrznych.


Np. konstrukcja:
class A {
 ....
  void metoda() {
     int a;
     {
       int a;
         ...
     }
  }
}

jest niedopuszczalna.


Ważna kwestia dotyczy inicjacji zmiennych lokalnych. Otóż w przeciwieństwie do pól klasy, zmienne lokalne nie mają zagwarantowanej inicjacji i jeśli nie nadamy im wartości (czy to w jawnej inicjacji, czy za pomocą przypisania) ich wartość jest nieokreślona.
 
Zmienne lokalne muszą mieć na pewno nadane wartości. W przeciwnym razie wystąpi błąd w kompilacji, związany z naruszeniem tzw. "definite assignment rule"


Rozważmy przykład kolejnych kroków w pisaniu programu.

public class DefAsgn {

 public static void main(String[] args) { 
   int a = 3, b = 4, c;    // zmienna c jest niezainicjowana
   if (a < b) c = 7;       // ale tu zapewne dostanie wartość
 }

}
W tym momecie kompilator jeszcze nie będzie się skarżył, ponieważ nie używamy zmiennej c w programie (nie odwołujemy się do jej wartości w innych instrukcjach).

Jednak po sięgnięciu do wartości zmiennej c:
public class DefAsgn {

 public static void main(String[] args) {
   int a = 3, b = 4, c;
   if (a < b) c = 7;
   System.out.println(c); // tu będzie błąd w kompilacji
 }

}
kompilator wykryje błąd i zgłosie, że:

The local variable c may not have been initialized   

(warto zwrócić uwagę, że kompilator nie analizuje treści programu, tak naprawdę, przy konkretnych wartościsch a i b zmienna c uzyska przecież wartość).

Rozwiązaniem problemu jest albo inicjacja zmiennej c przy deklaracji np.
int a = 3, b = 4, c = 0;
albo w inny sposób składniowe gwarantowanie jej inicjacji:
  public static void main(String[] args) {
    int a = 3, b = 4, c;
    if (a < b) c = 7;
    else c = 8              // użycie else gwarantuje nadanie wartosci zmiennej c
    System.out.println(c); 
}

Z zasięgiem identyfikatorów wiąże się w pewnym sensie czas życia danych, ale nie są to pojęcia tożsame.

Czas życia danych to okres od momentu wydzielenia pamięci dla ich przechowywania do momentu zwolnienia tej pamięci.

Zmienne lokalne są powoływane do życia w momencie deklaracji (automatyczne wydzielenie pamięci na stosie) i likwidowane przy wyjściu sterowania z bloku w którym zostału zadeklarowane (automatyczne zwolnienie pamięci). Dotyczy to również tych zmiennych, które są referencjami do obiektów.

Wartości zmiennych lokalnych są tracone po wyjściu sterowania z bloku - np. zakończeniu działania metody


Ta oczywista prawda niekiedy jest niedostrzegana i niektórzy starają się np. za pomocą zmiennych lokalnych zliczać liczbę wywołań jakiejś metody (usiłują wymyślić sposob na to, a przecież to niemożliwe).

Natomiast niestatyczne pola klasy zachowują się inaczej. Stanowią one przecież elementy obiektów. Obiekty zaś są tworzone w momencie wykonania operacji new. Pamięć dla nich wydzielana jest dynamicznie – na stercie i zostanie zwolniona automatycznie tylko wtedy gdy żadna referencja nie odnosi się już do danego obiektu.
Zatem niestatyczne pola klasy zachowują swoje wartości pomiędzy wywołaniami metod (stanowią swoistą globalną pamięć jednego obiektu, dzieloną pomiędzy różnymi metodami). Zawsze jednak przy tworzeniu obiektów są one inicjowane od nowa, zatem nie mogą przechować informacji wspólnej dla wszystkich obiektów klasy. Tę ostatnią rolę (globalnej pamięci, dzielonej pomiędzy różne obiekty) spełniają natomiast pola statyczne.
Pokazuje to następujący program:
public class Counter {
  
  private static int objectCount;
  private int counter;
  
  public Counter() {
    objectCount++;
  }
  
  private void increase() {
    counter++;
  }
  
  public void report() {
    System.out.println("Stworzono obiektów " + objectCount);
    System.out.println("Licznik w obiekcie " + counter);
  }

  public static void main(String[] args) {
    Counter s1 = new Counter();
    s1.increase();
    s1.increase();
    s1.report();
    
    Counter s2 = new Counter();
    s2.report();
  }

}
który wyprowadzi w wyniku:

Stworzono obiektów 1
Licznik w obiekcie 2
Stworzono obiektów 2
Licznik w obiekcie 0

Warto zwrócić uwagę, że zaraz po stworzeniu drugiego obiektu (s2) pole counter w tym obiekcie (licznik w obiekcie) ma wartość 0 i nie ma nic wspólnego z wartością tego pola z pierwszego obiektu (s1), która jest równa 2 na skutek dwóch wywołań metody increase. Natomiast pole statyczne objectCount przy tworzeniu obiektu s1 uzyskało wartość 1 i wartość ta była zachowana i dostępna w trakcie tworzeniu obiektu s2. Dlatego przy tworzeniu obiektu s2, w konstruktorze udało się zwiększyć tę wartość z 1 na 2. W ten sposób policzyliśmy stworzone obiekty klasy Counter.

3. Pakiety i importy

3.1. Pojęcie pakietu


Pakiety są swoistymi bibliotekami klas. Każda taka biblioteka (pakiet) grupuje klasy, które mają jakąś wspólną funkcjonalność np. służą do operacji we-wy, budowy aplikacji sieciowych itp.

Każda klasa należy do jakiegoś pakietu.
Klasy kompilowane z deklaracją pakietu package ... należą do pakietu o nazwie podanej w deklaracji.
Klasy kompilowane bez deklaracji pakietu należę do pakietu "bez nazwy" (domyślnego).
Pakiet bez nazwy jest definiowany przez środowisku w którym działa Java (zwykle jest to bieżący katalog, ale może to być też jakaś tablica w bazie danych).
 
Pakiety mają hierarchiczną strukturę:
Pakiet
  podpakiet
    podpakiet

Np. pakiet o nazwie java.awt zawiera podpakiet o nazwie java.awt.event.

Pakiet jest pojęciem logicznym. Hierarchiczna struktura pakietów może być odwzorowywana w różny fizyczny sposób. Typowym jest struktura katalogów (kolejne poziomy hierarchii katalogów odpowiadają kolejnym poziomom hierarchii pakietów). W katalogach "terminalnych" znajdują się pliki klasowe (.class) stanowiące użytkowe klasy danej "biblioteki". Ponieważ struktury katalogowe dają się łatwo archiwizować, to pakiety klas są zwykle przechowywane w plikach JAR (Java Archive) lub ZIP.

Java wyposażona jest  w  setki  pakietów (o których możemy myśleć jak o bibliotekach standardowych), które z kolei zawierają tysiące klas. Pakiety są  umieszczone w plikach JAR, zawartych w dystrybucjach Javy.

Po co są pakiety?

Zadaniem pakietów jest nie tylko grupowanie klas według ich funkcjonalności.

Rozważmy przykład:


    File f = new File("test.txt");  // tworzymy obiekt klasy File

Możliwe są dwa przypadki: klasa File jest zdefiniowana w tym samym pliku lub w pliku z katalogu bieżącego ALBO klasa File jest zewnętrzna.
W pierwszym przypadku kompilator (i JVM) mogą odnaleźć potrzebną klasę.
W drugim – powstaje problem gdzie jej szukać.
Co więcej, użycie nazwy File może być niejednoznaczne (może chodzi o File z bieżącego pliku, a może o File skądś z zewnątrz).

Zatem pakiety, których zadaniem jest grupowanie klas pełnią rolę porządkującą  względem przestrzeni nazw klas i chronią przed kolizjami nazw.
 
Nazwy kwalifikowane

Kwalifikowana nazwa klasy (typu) znajdującej się w nazwanym pakiecie ma postać:

        nazwa_pakietu.nazwa_klasy


np.   java.io.File


 java.io.File f = new java.io.File("test.txt");

Klasy zdefiniowane w nienazwanym pakiecie (tj. bez użycia dyrektywy package) mają swoje proste nazwy (czyli np. Para).

Uwaga: przy tworzeniu choć trochę większych projektów należy umieszczać klasy w nazwanych pakietach, stosując dyrektywę package.
 
W środowisku Eclipse jest to bardzo łatwe - wystarczy podać nazwę pakietu w dialogu definiowania klasy:
r

Struktura katalogowa projektu PrzykladowyProjekt będzie teraz miała następująca postać:
r

a automatycznie wygenerowany plik źródłowy MojaKlasa.java będzie zawierał odpowiednią dyrektywę package:
package mojpakiet;

public class MojaKlasa {

  public static void main(String[] args) {

  }

}

3.2. Import


Generalnie wszystkie nazwy klas użytych w programie winny być kwalifikowane.
Na szczęście, deklaracja importu pozwala na użycie nazw uproszczonych
import java.io.File;  // importuje nazwę klasy java.io.File

class A {
 File f = new File("test.txt");
 java.io.FileReader fr = java.io.new FileReader(f);   
// ...
}
Powyżej po importowaniu nazwy klasy java.io.File możemy użyć jej uproszczonej nazwy File. Ale ponieważ nie importowaliśmy nazwy klasy java.io.FileReadre jesteśmy zmuszeni używać jej pełnej kwalifikowanej nazwy.
Oczywiście, można importować dowolnie wiele nazw klas za pomocą kolejnych deklaracji importu np.:

import  java.io.File;
import  java.io.FileReader;
import  java.io.FileWriter;

Wygodna forma deklaracji importu pozwala importować nazwy wszystkich klas danego pakietu. Do tego służy gwiazdka:
import java.io.*;  // importuje wszystkie nazwy klas pakietu java.io.*

class A {
 File f = new File("test.txt");
 FileReader fr = new FileReader(f);   // teraz możemy też użyć prostej nazwy klasy java.io.FileReader
// ...
}

A co z klasami String i System, których nazw często używamy w przykladach?
One też stanowią standardowe klasy Javy. I znajdują się w określonym pakiecie (java.lang).

Pakiet java.lang nie wymaga importu (m.in zawiera klasy String i System)
Nazwy klas tego pakietu są importowane domyślnie.


Uwaga. Importowanie nazw klas nie oznacza "wstawiania" ich definicji do naszego programu źródłowego. Wielkość programu nie zależy od liczby importowanych nazw, a import służy tylko i wyłącznie umożliwieniu posługiwania się skróconymi nazwami klas i stanowi sygnał dla kompilatora i JVM gdzie, w jakich pakietach szukać definicji tych klas.

3.3. Importy statyczne

Statyczny import umożliwia odwołania do składowych statycznych bez kwalifikowania ich nazwą klasy.



Import składowej statycznej:

import static TypeName.Identifier;


Import wszystkich składowych statycznych:

import static TypeName.*;



Przykładowo, możemy importować nazwę System.out i posługiwać się skróconym zapisem: zamiast System.out.println(...)- out.println(...):

import static java.lang.System.out;

public class MojaKlasa {

  public static void main(String[] args) {
    out.println("Trochę krócej");
  }

}
albo importować wszystkie nazwy składowych statycznych klasy System i wygodnie się do nich odwoływać:
import static java.lang.System.*;

public class MojaKlasa {

  public static void main(String[] args) {
    long time = nanoTime();  // wywołanie statycznej metody System.nanoTime()
    out.println(time);       // uzycie statycznej stałej System.out
    exit(1);                 // wywołanie statycznej metody System.exit(..)
  }

}



4. Struktura programu

Jak wiemy, program w Javie jest zestawem definicji klas.

Poza ciałem klasy nie może być żadnego kodu programu - oprócz dyrektywy package, dyrektyw importu oraz komentarzy.


Strukturę programu obrazuje poniższy schemat.
package ... // deklaracja pakietu (niekonieczna)
import ...  // deklaracje importu; zwykle, ale nie zawsze potrzebne
import ...


// To jest klasa A

public class A {
...
}


// To jest klasa B

class B {
...
}
...



Dyrektywa package służy do "umieszczenia" kompilowanych klas w nazwanym pakiecie.
Znaczenie importów - poznaliśmy przed chwilą.

Program może być zapisany w jednym lub wielu plikach źródłowych (.java)
(w szczególności: wszystkie klasy składające się na program można umieścić w jednym pliku albo każdą klasę można umieścić w odrębnym pliku). Działając w środowiskach uruchomieniowych (IDE) warto umieszczać każdą klasę w odrębnym pliku, tym bardziej, że w jednym pliku może być tylko jedna klasa publiczna.

Nie ma kodu poza klasą... A z drugiej strony wiemy też, że klasa jest swoistym wzorcem, szablonem określającym właściwości obiektów.

Jak zatem możliwe jest w ogóle działanie programu napisanego w Javie? Gdzie i jak zaczyna się wykonanie?
Powtórzmy sobie najpierw to wszystko co już wiemy

Oczywiście, najpierw jest kompilacja do B-kodu.
Wszystkie wybrane pliki źródłowe .java podlegają kompilacji za pomocą kompilatora Javy (javac.exe). Z każdej klasy w pliku(ach) źródłowym powstaje plik B-kodu o rozszerzeniu .class

Jeżeli nasz program jest aplikacją (a nie apletem lub serwletem), to jedna z  klas musi zawierać metodę:

    public static void main(String[] args)

Np. spójrzmy na klasę Work i klasę Inna zdefiniowane w pliku Work.java

public class Work {
 public static void main(String[] args) {
    ...
  }
...
}
class Inna {
...
}
 
// Koniec pliku Work.java

Po kompilacji (javac Work.java) powstają dwa pliki:
Work.class
Inna.class
 
Maszyna wirtualna Javy jest wywołana za pomocą polecenia java z argumentami:
Czyli:
java Work [ew. argumenty przekazywane do main jako String[] args] 

Klasa Work zostaje załadowana przez JVM i sterowanie zostaje przekazane do metody main. W tej metodzie zaczyna się "życie": tworzenie obiektów, odwołania do innych klas aplikacji.

Zauważmy jednak szczególnie jedną ważną rzecz: metoda main jest metodą statyczną. Wobec tego - działanie (jakiegoś) programu nie wymaga wcale by istniały lub były tworzone obiekty (o ile posługujemy się w metodzie main tylko zmiennymi typów prostych).
Nawet jeśli posługujemy się obiektami (takimi jak łańcuchy znakowe) - nie musimy tworzyć obiektu klasy, w której zawarta jest metoda main.

Takie podejście jest jednak możliwe tylko wtedy, gdy nasz program jest stosunkowo niewielki. Większe programy powinniśmy co najmniej dobrze strukturyzować (rozbić na funkcje - metody).

Zobaczmy bardzo prosty przyklad. Mamy trzy liczby i powinniśmy policzyć i wyprowadzić ich sumę oraz średnią, po czym wszystkie trzy liczby dwa razy zwiększyć o 1, za każdym razem wyprowadzając nową sumę i średnią.

Bardzo zle rozwiązanie tego problemu może wyglądac tak.
public class Main1 {

  public static void main(String[] args) {
    double a = 12.0,
           b = 14.0,
           c =  4.0;
    double sum = a + b + c;
    double avg = (a + b + c)/3;
    System.out.println("Suma " + sum);
    System.out.println("Srednia " + avg);
    a++;
    b++;
    c++;
    sum = a + b + c;
    avg = (a + b + c)/3;
    System.out.println("Suma " + sum);
    System.out.println("Srednia " + avg);
    a++;
    b++;
    c++;
    sum = a + b + c;
    avg = (a + b + c)/3;
    System.out.println("Suma " + sum);
    System.out.println("Srednia " + avg);

  }

Oczywiście na myśl przychodzi od razu podział programu na metody.
Być może, długo nie zastanawiając się napiszemy tak:
public class Main2 {

 double sum(double a, double b, double c) {
   return a + b + c;
 }

 double average(double a, double b, double c) {
   return (a + b + c)/3;
 }

 void report(double a, double b, double c) {
    System.out.println("Suma " + sum(a, b, c));
    System.out.println("Srednia " + average(a, b, c));
 }


  public static void main(String[] args) {
    double a = 12.0,
           b = 14.0,
           c =  4.0;
    report(a, b, c);
    a++;
    b++;
    c++;
    report(a, b, c);
    a++;
    b++;
    c++;
    report(a, b, c);
  }
} 
Chociaż wielkość programu zmieniła się nieznacznie, to jednak klarowne wyodrębnienie pewnych powtarzalnych czynności w postaci metod czyni go bardziej czytelnym i elastycznym (latwiej modyfikowalnym).
Niestety, spotka nas rozczarowanie...
Kompilator zgłosi:

Main2.java:21: non-static method report(double,double,double) cannot be referenced from a static context
    report(a, b, c);
    ^
Main2.java:25: non-static method report(double,double,double) cannot be referenced from a static context
    report(a, b, c);
    ^
Main2.java:29: non-static method report(double,double,double) cannot be referenced from a static context
    report(a, b, c);
    ^
3 errors


Aha, przecież metoda main jest statyczna! Nie możemy odwoływac się z jej wnętrza do niestatycznych składowych klasy Main2 (a taką jest metoda report).

Gdy uczynimy ją statyczną:

    static void report(...)

problem przeniesie się w inne miejsce: ze statycznej metody report nie możemy wywołać niestatycznych metod sum i average.
Możemy uczynić je wszystkie statycznymi - i wtedy nasz program zadziała.
Albo - pozostawiając wszystkie metody niestatycznymi  - w metodzie main utworzyć obiekt klasy i na jego rzecz wywołać metodę report().
Może to wyglądać tak:
public class Main3 {

 double sum(double a, double b, double c) {
   return a + b + c;
 }

 double average(double a, double b, double c) {
   return (a + b + c)/3;
 }

 void report(double a, double b, double c) {
    System.out.println("Suma " + sum(a, b, c));
    System.out.println("Srednia " + average(a, b, c));
  }


  public static void main(String[] args) {
    double a = 12.0,
           b = 14.0,
           c =  4.0;

    Main3 m = new Main3(); // utworzenie obiektu
    m.report(a, b, c);
    a++;
    b++;
    c++;
    m.report(a, b, c);
    a++;
    b++;
    c++;
    m.report(a, b, c);

  }

}

Ale skoro już tworzymy obiekt klasy, to nadajmy jej jakiś istotny sens. Na przyklad - niech jej obiekty będą trójkami liczb rzeczywistych.

public class Trojka {

 private double a, b, c;

 public Trojka(double x, double y, double z) {
   a = x;
   b = y;
   c = z;
 }

 public double sum() {
   return a + b + c;
 }

 public double average() {
   return (a + b + c)/3;
 }

 public void increase() {
   a++;
   b++;
   c++;
 }

 public void report() {
    System.out.println("Suma " + sum());
    System.out.println("Srednia " + average());
  }


  public static void main(String[] args) {
    Trojka t = new Trojka(12, 14, 4);
    t.report();
    t.increase();
    t.report();
    t.increase();
    t.report();
  }

}

Główna "procedura" main (zauważmy zresztą, że umieszczona w klasie Trojka; metoda main może przecież znajdowac się w dowolnej klasie) stała sie dzięki temu jeszcze bardziej klarowna, a cały program jeszcze łatwiejszy do modyfikacji i uzupełnień, bowiem dane (zmienne a, b, c - zdefiniowane jako pola) stały się teraz dostępne dla wszystkich metod klasy i jednocześnie zachowują swoje wartości pomiędzy wywołaniami metod, co wykorzystaliśmy definiując metodę increase(), zwiększającą o 1 wszystkie trzy dane.

Naturalnie, tę metodę main można też umieścić w innej klasie. Np.
public class Test { 
  public static void main(String[] args) {
    Trojka t = new Trojka(12, 14, 4);
    t.report();
    t.increase();
    t.report();
    t.increase();
    t.report();
  }

}
Rozpatrując różne warianty strukturyzacji program warto podkreślić, że bardzo często obiekt klasy programu nie jest nam wcale potrzebny. "Praca" zapisywana jest w konstruktorze i w metodzie main (rozpoczynającej dzialanie programu) wystarczy samo wywolanie konstruktora. Czyni się tak szczególnie często, gdy konstruktor tworzy graficzny interfejs użytkownika.
W naszym przypadku sumowania i uśredniania trzech liczb takie rozwiązanie jest raczej sztuczne, ale dla porządku można je podać.
public class Main4 {

 private double a = 12, b = 14, c = 4;

 Main4() {
   report();
   increase();
   report();
   increase();
   report();
 }

 double sum() {
   return a + b + c;
 }

 double average() {
   return (a + b + c)/3;
 }

 void increase() {
   a++;   b++;   c++;
 }

 void report() {
    System.out.println("Suma " + sum());
    System.out.println("Srednia " + average());
  }

  public static void main(String[] args) {
    new Main4();
  }
}
Zauważmy:


5. Pojęcie o dziedziczeniu

Zajmiemy się teraz krótko pojęciem dziedziczenia. Pełna dyskusja tej tematyki zawarta jest w dalszej częsci tekstu. Tutaj zwrócimy uwagę na te elementy, które będą nam potrzebne w najbliższych wykladach.

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 one bardziej wyspecjalizowane.

Omawiana wcześniej klasa Publication opisuje właściwości publikacji, które kupuje i sprzedaje księgarnia. Zauważmy, że za pomocą tej klasy nie możemy w pełni opisać książek. Książki są szczególną, "wyspecjalizowaną" wersją publikacji, oprócz tytułu, wydawcy, ceny itd - mają jeszcze jedną właściwość - autora (lub autorów).
Gdybyśmy w programie chcieli opisywać zakupy i sprzedaż książek - to powinniśmy stworzyć nową klasę opisującą książki, o nazwie np. 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:

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 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:
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 ilości - 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ć konstruktor nadklasy, po to by zainicjować jej pola, a następnie zainicjować pole author.

 // Konstruktor klasy Book
 // argumenty: aut - autor, tit - tytuł, pub - wydawca, y - rok wydania
 // id - ISBN, price - cena, quant - ilość   
 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;
 }


Teraz można podać już pelną definicję klasy Book.

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;
 }

}

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

Przy tak zdefiniowanej klasie Book możemy utworzyć jej obiekt:

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


r

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 to obiekty klasy Book mają również wszelkie właściwości obiektów klasy Publication, a zatem możemy na ich rzecz używać również metod zdefiniowanych w klasie Publication.
.

Nic zatem nie stoi na przeszkodzie, by napisać taki program:
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");
 }

}

który skompiluje się i wykona poprawnie, dając w wyniku pokazany listing.
Na zakup 100 książek:
James Gossling
Moja Java
WNT
2002
ISBN6893
---------------
wydano: 5100.0
---------------
Po sprzedaży zostało 10 pozycji


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)

Dzięki temu 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ą (ang. widening reference conversion). Słowo konwersja oznacza, że dochodzi do przekształcenia  z jednego typu do innego typu (np. z typu Book do typu Publication). Konwersja jest rozszerzająca, bowiem, przekształcamy typ "pochodny" (referencja do obiektu podklasy) do typu "wyższego" (referencja do obiektu nadklasy). A ponieważ chodzi o typy referencyjne - mówimy o referencyjnej konwersji rozszerzającej.

Nieco mniej precyzyjnie, ale za to podkreślając, że chodzi o operowanie na obiektach, będziemy mówić o takich konwersjach jako o obiektowych konwersjach rozszerzających (ang. "upcasting" - up - bo w górę hierarchii dziedziczenia).


Obiektowe konwersje rozszerzające dokonywane są automatycznie przy:

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

Wyobraźmy sobie np. że oprócz klasy Book - z klasy Publication wyprowadziliś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ś właściwe dla muzyki informacje, 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 mozliwej kombinacji "rodzajowej" par - musielibyśmy napisać inną metodę incomeDiff np.
double incomeDiff(Book, Book), double incomeDiff(Book, Journal), double incomeDiff(Book, CDisk) itd.


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 b1 = new Book(...);  
  Book b2 = new Book(...);
  jakasMetoda(b1,b2);
....
}

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().


Na koniec krótkiego, wstępnego, mającego raczej instrumentalny charakter, wprowadzenia do dziedziczenia, należy podkreślić bardzo ważną właściwość Javy.

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ę na klasie Object (której definicja znajduje się w zestawie stanardowych klas Javy).
Zatem w Javie wszystkie klasy pochodzą pośrednio od klasy Object.
Jeśli definiując klasę nie użyjemy słowa extends (nie zażądamy jawnie dziedziczenia), to i tak nasza klasa domyślnie będzie dziedziczyć klasę Object (tak jakbyśmy napisali class A extends Object).

Wobec tego hierarchia dziedziczenia omawianych tu klas wygląda następująco:

r


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.