Dopiski

Z Studia Informatyczne
Przejdź do nawigacjiPrzejdź do wyszukiwania

Dalsze uwagi o widoczności, klasach i dziedziczeniu

Już wiemy jak wyglądają klasy, wiemy też jaką mają strukturę. Przyjrzymy się teraz nieco dokładniej ich budowie, głębiej wnikając w rozwiązania przyjęte w Javie. Ale nie zapominajmy o najważniejszym - o przeznaczeniu klas. Są one narzędziem do wyrażania abstrakcji pojęć występujących w implementowanym (opisywanym) systemie. Udostępniane przez klasę metody stanowią jej interfejs<ref>Słowo interfejs zostało tu użyte w szeroko rozumianym sensie informatycznym, a nie jako jeden z elementów składni Javy.</ref> oferowany reszcie tworzonego systemu. Mówimy o kontrakcie pomiędzy klasą a resztą systemu. Omawiane tu narzędzia pozwalają lepiej ów kontrakt wyrażać i realizować, ale nie zmieniają w zasadniczy sposób naszego rozumienia pojęcia klasy.

Deklarowanie klas

Deklarując klasę możemy podać trzy rodzaje jej składników:

  • pola
  • metody
  • zagnieżdżone klasy i interfejsy

Deklarację klasy możemy poprzedzić tzw. modyfikatorami. Kolejność podawania modyfikatorów jest bez znaczenia (aczkolwiek dla czytelności zaleca się kolejność podaną poniżej). Oto lista możliwych modyfikatorów:

  • adnotacje
  • modyfikatory dostępu
  • abstract
  • final
  • strictfp

Modyfikatory dostępu:
W przypadku klas deklarowanych na najwyższym poziomie struktury programu (na poziomie pakietu) mamy tylko dwie możliwości:

  • możemy nic nie napisać (co oznacza widoczność w całym pakiecie),
  • możemy podać słowo kluczowe public co oznacza widoczność w całym programie.

W jednym pliku (ale nie w jednym pakiecie) można umieścić tylko jedną klasę z modyfikatorem dostępu public, należy też nadać mu nazwę taką jak nazwa klasy (z rozszerzeniem .java). Klas bez jawnego modyfikatora dostępu można umieścić w pliku dowolnie dużo, można też w pliku nie mieć żadnej klasy z modyfikatorem public.

Modyfikator abstract
ten modyfikator oznacza, że definiujemy klasę abstrakcyjną (to jest taką, której obiektów nie można tworzyć). Zwykle oznacza to też, że w naszej klasie pojawią się metody abstrakcyjne (ale nie jest to wymagane). Nie można tego modyfikatora stosować razem z modyfikatorem final.

Modyfikator final
ten modyfikator oznacza, że definiujemy klasę końcową, to jest taką, po której nie będziemy już dziedziczyć. Taka deklaracja ma podwójne znaczenie. Po pierwsze pozwala nam zdecydować, że nasza klasa nie będzie dalej rozwijana. Pozwala to na zastosowanie w niej rozwiązań, które nie byłyby bezpieczne w przypadku np. przedefiniowania metod w podklasach. Po drugie, umożliwia to kompilatorowi przeprowadzenie dodatkowych optymalizacji kodu, takich jak np. bezpośrednie wywołanie metod (ponieważ klasa jest końcowa, to jej metody nie będą już przedefiniowane, więc w tej klasie jesteśmy w stanie wskazać, które konkretnie metody z naszej klasy wywołujemy). Nie można tego modyfikatora stosować razem z modyfikatorem abstract.

Adnotacje omówimy osobno, zaś strictfp oznacza, że w całej klasie stosuje się wyliczanie ściśle zgodne z regułami podanymi dla typów float i double, niezależnie od tego jaka arytmetyka zmiennopozycyjna jest dostępna w procesorze wykonującym obliczenia. Gwarantuje to, że na każdej maszynie wirtualnej Javy obliczenia numeryczne dadzą dokładnie te same wyniki. Jeśli tak bardzo nam na tym nie zależy, a chcemy wykorzystać w większym stopniu możliwości sprzętu, to możemy skorzystać z (domyślnego) trybu obliczeń numerycznych, co może pozwolić uniknąć np. niedomiarów podczas wyliczania wyrażeń z wartościami zmiennopozycyjnymi. Podkreślmy dwie rzeczy:

  • maszyna wirtualna może, ale nie musi, działać inaczej w trybie ścisłego i rozluźnionego wyliczania wartości zmiennopozycyjnych,
  • ten tryb obliczeń dotyczy sposobu wyliczania wartości wyrażeń, a nie sposobu przechowywania wartości w zmiennych.

Modyfikatory klas nie dziedziczą się.

Deklarowanie pól

Deklaracja pola klasy składa się z następujących elementów:

  • modyfikatorów (opcjonalne),
  • typu,
  • nazwy pola,
  • inicjacji.

Modyfikatory pól można podawać w dowolnej kolejności (choć zalecana jest kolejność podana poniżej). Oto lista możliwych modyfikatorów:

  • adnotacje,
  • modyfikatory dostępu,
  • static,
  • final (nie może wystąpić razem z volatile),
  • transient (przemijający),
  • volatile (nieprzewidywalny, nie może wystąpić razem z final).

Adnotacje tu pomijamy, modyfikator transient jest związany z serializacją obiektów (oznacza pole, którego serializacja nie dotyczy), modyfikator volatile jest związany z wątkami i współbieżnością.

Modyfikatory dostępu
Już je wcześniej omawialiśmy, dla przypomnienia tylko je wyliczymy:

  • private (pamiętajmy, że dotyczy klas, a nie obiektów),
  • (nic czyli dostęp na poziomie pakietu),
  • protected (pamiętajmy, że w Javie obejmuje to także cały pakiet),
  • public.

Modyfikator final
Oznaczają pola, których wartości nie mogą się już zmieniać po inicjacji.


Modyfikator static
Oznacza pola (zmienne) klasowe. Zmienna klasowa jest zawsze dokładnie jedna, niezależnie od liczby utworzonych egzemplarzy klasy. Do zmiennych klasowych można się odwołać zarówno poprzez obiekt, jak i za pomocą specjalnej składni: nazwa_klasy.nazwa_pola. Zauważmy, że tego drugiego sposobu nie można stosować w stosunku do zmiennych egzemplarzowych (np. do odwołania się do zmiennej z nadklasy).

class A{
 static int i;
}

// ...
System.out.println("" + new A().i + ", " + A.i);

Zmienne klasowe tworzone są w momencie ładowania klasy, wtedy są też inicjowane.

Przykładem statycznego pola jest zmienna out z klasy java.lang.System.

Inicjacja pól

Jeśli nie podamy jawnej inicjacji pola, to domyślnie będzie przypisana mu wartość odpowiedniego typu odpowiadająca wartości zero (np. false dla typu boolean, a null dla typów referencyjnych). To oznacza, że w Javie nie występują pola niezainicjowane w tradycyjnym tego zwrotu znaczeniu. Jest to bardzo ważne z punktu widzenia semantyki języka, bez domyślnej (lub wymuszanej) inicjacji praktycznie nie dałoby się wprowadzić automatycznego odśmiecania.

Wartość przypisywana w inicjacji jest zadawana wyrażeniem. Tu niestety zaczyna się pojawiać wiele szczególnych sytuacji, które (co najmniej) mogą budzić wątpliwości, przypatrzmy się im więc dokładniej.

Co mogłaby znaczyć następująca deklaracja:

class A{
 int i = i + 1; // ?
}

Oczywiście nie można deklarować kilku pól o tej samej nazwie, nawet jeśli są polami różnych typów czy rodzajów (tj. egzemplarzowe i klasowe):

class A{
 static int i;
 float i; // Błąd kompilacji
}

zatem poprzedni przykład nie może się odnosić do innej zmiennej z tej samej klasy. Ale co wtedy, gdy zmienna o tej samej nazwie istnieje w nadklasie?

class A{
 int i = 13;
}
class B extends A{
 int i = i + 1; // ?
}

Też się nie kompiluje, gdybyśmy chcieli umożliwić kompilację, trzeba by się posłużyć słowem super:

class A{
 int i = 13;
}
class B extends A{
 int i = super.i + 1; // OK
}

Zatem kompilator chroni nas przed odwołaniem się w inicjatorze do właśnie inicjowanej wartości.

A co z wartością innej zmiennej z tej samej klasy? No cóż, to trochę zależy, od tego jak zapiszemy owe inicjacje.

class A{
 int j;
 int i = j;
} 

Powyższe jest poprawne, ale po zamianie kolejności wierszy już nie.

class A{
 int i = j;  // Odwołanie się w przód
 int j;
}

Kompilator stosuje dość prostą i naturalną regułę: można się odwoływać tylko do wartości zmiennych, których deklaracje znajdują się wcześniej w tekście klasy. Ale taka reguła byłaby zbyt prosta, rozważmy poniższy przykład:

class A{
 int i = j;  // Odwołanie się w przód, ale do zmiennej statycznej
 static int j;
}

Tym razem kompilator nie protestuje, czemu? Bo zmienne statyczne są inicjowane w innym momencie wykonywania programu niż zmienne egzemplarzowe, więc powyższy zapis nie grozi powstaniem pętli przy inicjacji. No cóż, w takim razie naturalnym jest pytanie o poniższą sytuację:

class A{
 static int i = j;  // Odwołanie się w przód
 static int j;
}

Tym razem znów kompilator odrzuca naszą deklarację (i słusznie). W zasadzie reguła określająca co może się pojawić w inicjatorze jest naturalna, ale czasami jej efekty są dość zaskakujące. Czy poniższe deklaracje są poprawne?

class A{
 int i; 
}
class B extends A{
  int k = i;
  // ...
}

Odpowiedź brzmi: nie wiadomo. Zależy to od tego, co wpiszemy w miejsce komentarza. Jeśli zostawimy komentarz, to ten przykład się skompiluje, ale jeśli odpowiednio zmienimy komentarz, np. tak jak poniżej:

class A{
  int i; 
}
class B extends A{
 int k = i;
 int i;
}

to już kompilacja się nie powiedzie, a co ciekawsze, błędna będzie deklaracja pola k (choć jeszcze przed zamianą komentarza nie budziła wątpliwości kompilatora). Wyjaśnienie już znamy, tym nie mniej fakt, że dopisanie innych deklaracji może zmienić poprawność deklaracji wcześniejszych nie jest oczywisty.

No dobrze, a co z metodami, czy można je wywoływać w wyrażeniach inicjujących? To zależy, spójrzmy:

class A{
 int i = f(13);
 int f(int k) // ...
   if (k % 2 == 0)
    // ...
   return 0;
 }
 // ...
}

Powyższe się kompiluje, ale nauczeni poprzednimi doświadczeniami przyjrzyjmy się, co można by wpisać zamiast komentarzy z kropkami:

class A{
 int i = f(13);
 int f(int k) throws E{ 
  if (k % 2 == 0)
   throw new E();
  return 3;
 }

 class E extends Exception{
 }
}

Tym razem inicjacja się nie skompiluje, bo nie mamy jak przechwycić wyjątku.

Chyba już wszytko wiemy o inicjowaniu pól i wszystko wydaje się być jasne. To na koniec zastanówmy się nad poniższym przykładem, czy się skompiluje? A jeśli tak, to co będzie wartościami zmiennych k oraz i?

class A{
 static int i = new A().f()+1;
 final int k = i + 1;

 int f(){ 
   return k;
 }
}

Odpowiedź:

k=2, i=3

Bloki inicjujące

Inicjacja pól może dokonywać się także w tzw. blokach inicjujących, czyli zestawach instrukcji umieszczonych bezpośrednio w treści klasy i ujętych w nawiasy klamrowe. Zawarte w nich instrukcje są wykonywane w momencie rozpoczynania wykonywania dowolnego z konstruktorów. Można myśleć o tych blokach jako o wklejanych na początek treści każdego konstruktora - choć oczywiście nie oznacza to, że blok wykonuje się w zakresie widoczności wnętrza konstruktora (w bloku nie można się odwołać do parametrów konstruktora). Użycie w konstruktorach zapisu this(....) oczywiście nie zmieni faktu, że na każde utworzenie obiektu blok inicjujący wykonywany jest tylko jeden raz.

Jeśli blok inicjujący zgłasza wyjątek, to musi on być zadeklarowany w klauzuli throws każdego konstruktora.

Bloki inicjujące mogą zawierać dowolne instrukcje (np. pętle). Można zdefiniować wiele bloków inicjujących (ale nie należy tego robić, ze względu na czytelność).

Przykład użycia bloku inicjującego:

 class A { 
   final static int n = 10;
   int tab[] = new int[n];
   { 
     // Blok inicjujący tablicę, nie muszę tej inicjacji wpisywać 
     // do poszczególnych konstruktorów. Choć mógłbym zdefiniować metodę.	
     for(int i=0; i<n; i++)
       tab[i] = i;
     System.out.println("Inicjalizacja"); // Wywołanie metod też jest dozwolone	 
   }
   A() { 
     //...
   }
   A(int i) { 
     //...
   }
 }

Bloki inicjujące są przydatne z dwu powodów:

  • w klasach anonimowych, bo tam nie da się zdefiniować konstruktora. (Dlaczego się nie da? A jaka by była jego nazwa?)
  • instrukcje w bloku inicjującym mogą inicjować zmienne końcowe (final), czego nie można zrobić w treści metody.

super i this w konstruktorach

Pierwsza instrukcja konstruktora może mieć postać słowa super lub this, po którym następuje w nawiasach lista parametrów, co oznacza wywołanie konstruktora, odpowiednio, z bezpośredniej nadklasy lub z tej samej klasy. Oczywiście w tym drugim przypadku nie można podać zacyklonych odwołań konstruktorów tej samej klasy.

Często spotykane konstruktory

Konstruktor bezparametrowy

Jak sama nazwa wskazuje, ten konstruktor nie ma parametrów. Czemu warto o nim mówić? Dlatego, że jako jedyny może być wywoływany niejawnie, to znaczy jego wywołanie może być wygenerowane automatycznie przez kompilator. Dzieje się tak wtedy, gdy w konstruktorze podklasy nie wskażemy jawnie (używając słowa super), który konstruktor nadklasy ma być wywołany. Wywołania innych konstruktorów kompilator sam nie wymyśli, bo musiałby podać jakieś wartości ich parametrów, a to wymagałoby zrozumienia co robi program - czego, jak wiemy, kompilator nie potrafi. Ponadto omawiany dalej konstruktor domyślny też jest konstruktorem bezargumentowym.

Konstruktor domyślny

To bezargumentowy konstruktor, który jest automatycznie generowany przez kompilator, o ile w klasie nie ma żadnego innego konstruktora. Jego treścią jest pusta lista instrukcji. Jak pamiętamy z wcześniejszych naszych rozważań oznacza to, że konstruktor domyślny wywoła bezparametrowy konstruktor nadklasy. Oczywiście jak zwykle przy wywołaniu konstruktora także i w przypadku konstruktora domyślnego wykonają się egzemplarzowe bloki inicjalizujące (o ile są w tej klasie zdefiniowane) oraz inicjalizacje zmiennych egzemplarzowych zapisane przy ich deklaracjach.

Konstruktor kopiujący

W przeciwieństwie do np. C++, w Javie ten konstruktor nie ma dużego znaczenia. Ponadto często stosuje się tu zamiast niego metodę klonującą.

Konstruktor prywatny

Czasami stosowana technika (np. dla realizacji wzorca Singleton).

Podsumowanie

Istnieje wiele sposobów inicjowania pól w Javie. Wybieramy najbardziej nam pasujące. Kolejność inicjacji:

  • konstruktor z nadklasy,
  • inicjacja pól zapisana w ich deklaracjach,
  • inicjacja wyrażona blokiem (blokami) inicjującymi,
  • instrukcje zawarte w konstruktorze.

Klasowe bloki inicjujące

Można też definiować bloki inicjujące wykonujące się przy tworzeniu klasy (a nie obiektu). Czyli dokonujące inicjacji na rzecz całej klasy. Ich deklaracja wygląda tak samo jak w przypadku zwykłych (egzemplarzowych) bloków inicjujących, tyle że cały blok poprzedza słowo static. Oczywiście nie ma w nim dostępu do zmiennych egzemplarzowych. W tych blokach nie można także zgłaszać sprawdzalnych wyjątków (bo nie ma gdzie ich przechwytywać).

Interfejsy

  • definiują interfejs klasy,
  • można je uważać za abstrakcyjne klasy bez żadnej implementacji,
  • klasa może implementować wiele interfejsów,
  • interfejs nie może dziedziczyć po klasie,
  • metody zadeklarowane w interfejsie musi zaimplementować każda nieabstrakcyjna klasa go implementująca,
  • pola deklarowane w interfejsie są (niejawnie) opatrywane modyfikatorami public, static i final,
  • metody deklarowane w interfejsie są (niejawnie) opatrywane modyfikatorami abstract i public,
  • jawnie nie podaje się żadnych modyfikatorów,<ref>Specyfikacja Javy zdecydowanie odradza nadmiarowe podawanie w interfejsach domyślnie przyjmowanych specyfikatorów dla metod. W przypadku pól nie ma takiej uwagi, ale wydaje się rozsądne, by przyjąć, że żadnych domyślnie przyjętych modyfikatorów nie powinno się jawnie podawać.</ref>
  • metody w interfejsie nie mogą być klasowe (static), bo klasowe metody nie mogą być abstrakcyjne, pozostałe modyfikatory (native, synchronized, strictfp) odwołują się do implementacji, a tego nie wolno robić w interfejsie,
  • interfejs dziedziczący po interfejsie używa słowa extends,
  • jeśli klasa implementuje dwa (lub więcej) interfejsów z taką samą metodą, to jest to dozwolone przez język, ale w praktyce oznacza, że nie da się podać sensownej implementacji tej metody,
  • bardzo często przy deklarowaniu zmiennych podaje się jako ich typ interfejs.

Przypisy

<references/>