Personal tools

PO Dziedziczenie i interfejsy

From Studia Informatyczne

<<< Powrót

Spis treści

Wprowadzenie

Wiemy już, że przy pomocy klas możemy modelować pojęcia z dziedziny obliczeń naszego programu. Bardzo często podczas takiego modelowania zauważamy, że pewne pojęcia są mocno ze sobą związane. Podejście obiektowe dostarcza mechanizmu umożliwiającego bardzo łatwe wyrażanie związków pojęć polegających na tym, że jedno pojęcie jest uszczegółowieniem (lub uogólnieniem) drugiego. Ten mechanizm nazywa się dziedziczeniem. Stosujemy go zawsze tam, gdzie chcemy wskazać, że dwa pojęcia są do siebie podobne.

Zwróćmy uwagę, że zastosowanie dziedziczenia jest związane ze znaczeniem klas powiązanych tym związkiem, a nie z ich implementacją. To że dwie klasy mają podobną implementację w żadnym stopniu nie upoważnia do wyrażenia tej zależności za pomocą dziedziczenia. Implementacja ma być ukryta w klasach (więc jej podobieństwa między klasami nie powinny być eksponowane za pomocą dziedziczenia). Implementacja poszczególnych pojęć może się zmieniać, co nie powinno mieć wpływu na relację dziedziczenia pomiędzy klasami w programie.

Dziedziczenie jest jedną z fundamentalnych cech podejścia obiektowego. Pozwala kojarzyć klasy obiektów w hierarchie klas. Te hierarchie, w zależności od użytego języka programowania, mogą przyjmować postać drzew, lasów drzew, bądź skierowanych grafów acyklicznych. W Javie hierarchia dziedziczenia dla klas ma postać drzewa. Jej korzeniem jest klasa Object. Jak wkrótce się przekonamy jest niesłychanie wygodnie mieć taką jedną wspólną nadklasę dla wszystkich klas występujących w programie.

Realizacja dziedziczenia polega na tym, że klasa dziedzicząca dziedziczy po swojej nadklasie wszystkie jej atrybuty i metody (i nie ma znaczenia, czy te atrybuty i metody były zadeklarowane bezpośrednio w tej nadklasie, czy ona też odziedziczyła je po swojej z kolei nadklasie).

W różnych językach programowania można natknąć się na różną terminologię. Klasę po której inna klasa dziedziczy nazywa się nadklasą lub klasą bazową, zaś klasę dziedziczącą podklasą lub klasą pochodną.

Dziedziczenie odzwierciedla relację is-a (jest czymś). Oznacza to, że każdy obiekt podklasy jest także obiektem nadklasy. Na przykład hierarchia klas zbudowana z nadklasy Owoc i dwu podklas Jabłko i Gruszka jest prawidłowo zbudowana, bo każde jabłko i każda gruszka jest owocem. Niestety często relacja is-a jest mylona z relacją has-a (ma coś) dotyczącą składania obiektów.

Zasada podstawialności[1]: ponieważ obiekty podklas są też obiektami nadklas, to oczywiście zawsze można podstawiać obiekty podklas w miejsce obiektów nadklas. Na przykład metoda, która ma parametr typu Owoc, prawidłowo zadziała z argumentem będącym jabłkiem albo gruszką.

Oto typowe zastosowania dziedziczenia:

  • opisanie specjalizacji jakiegoś pojęcia (np. klasa Prostokąt dziedzicząca po klasie Czworokąt),
  • specyfikowanie pożądanego interfejsu (klasy abstrakcyjne, interfejsy),
  • opisywanie rozszerzania pojęć (np. klasa KoloroweOkienko dziedzicząca po klasie CzarnoBiałeOkienko, potrafiąca wyświetlać nagłówek, tło, tekst itp. nie tylko w kolorze białym lub czarnym, ale także np. w zielonym).

Nie należy natomiast stosować dziedziczenia do wyrażania ograniczania (np. użycie do zdefiniowania klasy Stos dziedziczenia po uniwersalnej implementacji sekwencji wartości z operacjami dostępu do dowolnego elementu sekwencji byłoby bardzo poważnym błędem - tu należy zastosować składanie).

Często podklasy różnią się pod względem realizacji zobowiązań określonych w nadklasach (na przykład zobowiązanie do śpiewania przyjęte w klasie Ptak będzie inaczej realizowana w podklasie Słowik, a inaczej w podklasie Wrona). Czasami chcemy wyrazić w hierarchii klas wyjątki (np. Pingwin choć jest niezwykle sympatyczny to jak na przedstawiciela klasy Ptak słabo lata). Takie sytuacje możemy wyrazić dzięki przedefiniowywaniu metod (ang. method overriding).

W niektórych językach obiektowych występuje wielodziedziczenie, czyli możliwość jednoczesnego dziedziczenia po dwu (lub więcej) klasach. Choć sama idea wielodziedziczenia jest bardzo prosta, to jej implementacja przysparza wielu problemów. Co gorsza, samo rozumienie co powinno oznaczać wielodziedziczenie okazuje się bardzo złożone już dla dość prostych przykładów. Wystarczy rozważyć cztery klasy ułożone w hierarchię o kształcie rombu stojącego na jednym z wierzchołków i zastanowić się, co powinno zdarzyć się z atrybutem z górnej klasy odziedziczonym przez klasę dolną. Powstaje pytanie ile egzemplarzy tego atrybutu ta dolna klasa powinna odziedziczyć. Czy jeden (bo w górnej klasie był tylko jeden zdefiniowany), czy dwa (bo dolna klasa dziedziczy po jednym atrybucie z obu środkowych klas, a klasy górnej w ogóle nie musi znać). Nie ma jedynej dobrej odpowiedzi na to pytanie). Z tego powodu wielodziedziczenie w większości języków obiektowych nie występuje w ogóle lub tylko w ograniczonej postaci. W Javie klasa może dziedziczyć tylko po jednej klasie, ale za to dodatkowo może dziedziczyć po dowolnej liczbie interfejsów.

Dziedziczenie pozwala pogodzić ze sobą dwie sprzeczne tendencje w tworzeniu oprogramowania:

  • chcemy żeby stworzone programy były zamknięte: gdy program już kompiluje się, działa i przeszedł przez wszystkie testy, chcielibyśmy go zapieczętować tak, by nikt już go nie modyfikował, bo modyfikacje często sprawiają, że programy przestają działać.
  • chcemy żeby stworzone programy były otwarte: jeśli napisaliśmy dobry program, taki który jest używany przez użytkowników, to na pewno trzeba go będzie modyfikować. Nie dlatego że jest zły i zawiera błędy, tylko właśnie dlatego, że jest dobry i użytkownicy chcą go używać w ciągle zmieniającym się świecie. Skoro zmienia się sprzęt, na którym jest uruchamiany program, system operacyjny, upodobania użytkownika, przepisy prawne, to oczywiście i sam program musi być zmieniany. Przeznaczeniem dobrych programów jest częste ich modyfikowanie.

Dziedziczenie pozwala wtedy, gdy potrzebujemy zmian, nie modyfikować klas już istniejących, lecz na ich podstawie (przez dziedziczenie właśnie) stworzyć nowe dostosowane do zmienionych wymagań. Czyli dotychczasowy kod pozostaje bez zmian, a jednocześnie możemy rozwijać nasz program.

Realizacja w Javie

W Javie można dziedziczyć zarówno po klasach jak i po interfejsach (o tych drugich będziemy jeszcze mówić dalej). W ramach tego wykładu będziemy obie te formy dziedziczenia nazywali po prostu dziedziczeniem lub będziemy wyraźnie zaznaczać, że w danym miejscu chodzi nam o dziedziczenie po klasach lub o dziedziczenie po interfejsach. Co więcej, żeby co chwila nie pisać o "nadklasie" lub "nadinterfejsie", pozwolimy sobie mówić tylko o nadklasach i traktować interfejsy jako szczególny, bardzo uproszczony, rodzaj klas.

Oba te sposoby dziedziczenia są w Javie odmiennie wyrażane składniowo. Przy dziedziczeniu po klasach używa się słowa kluczowego extends, zaś przy dziedziczeniu po interfejsach słowa implements. Stąd często zamiast mówić o dziedziczeniu mówi się w Javie o rozszerzaniu klas lub implementowaniu interfejsów, odpowiednio. Ponadto w Javie dziedziczenie po klasach jest ograniczone do tylko jednej bezpośredniej nadklasy (nie ma w Javie wielodziedziczenia takiego jak np. w C++, gdzie z kolei nie ma interfejsów), można natomiast dziedziczyć (bezpośrednio) po dowolnej liczbie interfejsów.

Składnia dziedziczenia jest bardzo prosta. Po nazwie klasy można dopisać słowo kluczowe extends a po nim podać nazwę klasy, po której tworzona klasa ma dziedziczyć. Dalej można podać słowo kluczowe implements z następującymi po nim nazwami interfejsów, po których tworzona klasa ma dziedziczyć (używając terminologii Javy powiedzielibyśmy, że jest to lista interfejsów, które tworzona klasa ma implementować). Poniżej podajemy kilka przykładów deklaracji dziedziczenia:

 class Pracownik extends Osoba 
         // dziedziczenie po klasie
 class Samochód implements Pojazd, Towar 
         // dziedziczenie po kilku interfesjach
 class Chomik extends Ssak implements Puchate, DoGłaskania 
         // dziedziczenie po klasie i kilku interfejsach

Jeśli klauzula extends jest pominięta, to domyślnie przyjmuje się, że klasa dziedziczy po klasie Object[2]

Już powiedzieliśmy wcześniej, że klasa dziedzicząca otrzymuje ze swojej nadklasy komplet jej atrybutów i metod. O ile nazwy atrybutów i metod z klasy dziedziczącej i tej, po której następuje dziedziczenie, są różne, to sytuacja jest dość jasna. Spójrzmy na poniższy fragment programu:

 class A{
   int iA=1;
   void infoA(){
      System.out.println(
         "Jestem infoA() z klasy A\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName() + "\n" +
         "  iA="+iA);
   }
  }
 class B extends A{
   int iB=2;
   void infoB(){
      infoA();
      System.out.println(
         "Jestem infoB() z klasy B\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName() + "\n" +
         "  iA="+iA + ", iB=" + iB);
   }
  }
  
 class C extends B{
   int iC=3;
   void infoC(){
      infoA();
      infoB();
      System.out.println(
         "Jestem infoC() z klasy C\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName()+ "\n" +
         "  iA="+iA + ", iB=" + iB + ", iC=" + iC);
   }
  }
 
  C c = new C();
  c.infoC();

spowoduje wypisanie:

Jestem infoA() z klasy A
  wywołano mnie w obiekcie klasy C
  iA=1
Jestem infoA() z klasy A
  wywołano mnie w obiekcie klasy C
  iA=1
Jestem infoB() z klasy B
  wywołano mnie w obiekcie klasy C
  iA=1, iB=2
Jestem infoC() z klasy C
  wywołano mnie w obiekcie klasy C
  iA=1, iB=2, iC=3

Wyrażenie this.getClass().getSimpleName() powoduje wypisanie nazwy klasy obiektu, w którym to wyrażenie wyliczamy.

Jak widać z tego przykładu, klasa C odziedziczyła po swoich przodkach wszystkie atrybuty i metody (podobnie klasa B). Możemy więc myśleć o obiektach klasy C jak o kawałkach tortu składających się z wielu warstw, gdzie każda warstwa odpowiada kolejnej nadklasie.

Na razie nic szczególnego się nie wydarzyło, spróbujmy nieco skomplikować nasz przykład. Najpierw skorzystamy z zasady podstawialności i zadeklarujemy obiekt klasy C jako obiekt klasy A (precyzyjniej: na zmienną typu A przypiszemy referencję do obiektu klasy C). Dopisujemy więc na końcu naszego programu:

 A a = new C();
 System.out.println("a.iA=" + a.iA + ", c.iA=" + c.iA);

Oczywiście na wyjściu naszego programu dodatkowo pojawi się poniższy wiersz:

 a.iA=1, c.iA=1

A teraz złośliwie zmienimy nazwy wszystkich atrybutów w naszej hierarchii na takie same. Oczywiście tworząc hierarchie klas w praktyce nie dążymy do tego, żeby wszystkie występujące w nich atrybuty nazywać tak samo. Problem tkwi jednak w tym, że często dziedzicząc po jakiejś klasie nie znamy jej implementacji i nie wiemy jak nazywają się występujące w niej atrybuty. A tym bardziej jak nazywają się atrybuty jej nadklasy. Zobaczmy więc co się wydarzy.

 class A{
   int i=1;
   void infoA(){
      System.out.println(
         "Jestem infoA() z klasy A\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName() + "\n" +
         "  i z A="+i);
   }
  }
 
 class B extends A{
   int i=2;
   void infoB(){
      infoA();
      System.out.println(
         "Jestem infoB() z klasy B\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName() + "\n" +
         "  i z A="+((A) this).i + ", i z B=" + i);  // albo super.i
   }
  }
  
 class C extends B{
   int i=3;
   void infoC(){
      infoA();
      infoB();
      System.out.println(
         "Jestem infoC() z klasy C\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName()+ "\n" +
         "  i z A="+ ((A) this).i + ", i z B=" + ((B) this).i + ", i z C=" + i);
   }
  }
  
  C c = new C();
  c.infoC();
  
  A a = new C();
  System.out.println("a.i=" + a.i + ", c.i=" + c.i);

Dla powyższego programu otrzymamy następujące wyniki:

Jestem infoA() z klasy A
  wywołano mnie w obiekcie klasy C
  i z A=1
Jestem infoA() z klasy A
  wywołano mnie w obiekcie klasy C
  i z A=1
Jestem infoB() z klasy B
  wywołano mnie w obiekcie klasy C
  i z A=1, i z B=2
Jestem infoC() z klasy C
  wywołano mnie w obiekcie klasy C
  i z A=1, i z B=2, i z C=3
a.i=1, c.i=3

Po pierwsze wyjaśnijmy znaczenie konstrukcji ((A) this). Używamy tu bardzo nieeleganckiego zabiegu, a mianowicie rzutowania typów. Prosimy kompilator, żeby uwierzył, że obiekt this jest typu A. W tym przypadku jest to prawda (pamiętamy o zasadzie tworzenia hierarchii klas: każdy obiekt podklasy jest obiektem nadklasy, tu mamy obiekt klasy C, który jest także obiektem klasy A). Rzutowanie typów oznacza, że rezygnujemy z bezpieczeństwa dawanego przez kompilator i przez silny system typów Javy. Przechodzimy do świata dynamicznego sprawdzania typów - kompilator przy rzutowaniu generuje dodatkowe instrukcje, które sprawdzą podczas wykonywania programu, czy rzeczywiście this jest typu A (my to wiemy, ale kompilator nie). Gdyby się okazało, że typy się nie zgadzają, to podczas działania programu zostałby zgłoszony wyjątek. Dynamiczne sprawdzanie typów jest oczywiście znacznie gorsze od statycznego, nie tylko dlatego, że spowalnia wykonywanie programu, ale przede wszystkim dlatego, że komunikaty o błędach wypisuje użytkownikowi (a nie twórcy) programu, który zwykle ich nie rozumie i nie ma jak poprawić programu.

Jak widać z tego przykładu, nawet gdy nazwy atrybutów się pokrywają, dalej obiekt klasy C ma warstwy pochodzące z nadklas, tyle że teraz trudniej dostać się do odziedziczonych atrybutów. Zastosowana notacja z rzutowaniem daje wprawdzie dostęp do każdego atrybutu ale jest bardzo nieelegancka i nie należy jej używać w programach (z różnych powodów, jeszcze o nich więcej powiemy). Do dostępu do atrybutów (i ogólnie składowych) z bezpośredniej nadklasy służy bezpieczna notacja ze słowem kluczowym super (zasygnalizowana w komentarzu z powyższego przykładu). Tak jak słowo kluczowe this pozwala się odnieść do bieżącego obiektu (tego, którego metodę obecnie wykonujemy), tak super również odnosi się do tego obiektu, ale traktowanego jako obiekt nadklasy, to znaczy przestają być widoczne wszystkie te deklaracje, które wprowadziła klasa tego obiektu, odsłaniając w ten sposób przesłonięte deklaracje z nadklasy. Z definicji notacja super.atrybut oznacza tyle samo co ((Nadklasa) this).atrybut[3]. Z notacji super będziemy często korzystać w programach pisanych w Javie, choć właśnie nie w przypadku atrybutów (na przykład przy przedefiniowaniu metod lub definiowaniu konstruktorów).

Czemu zatem w przykładzie nie użyto tej notacji? Dlatego, że pozwala ona sięgnąć tylko jeden poziom wyżej w hierarchii dziedziczenia, a nam zależało także na dostaniu się z klasy C do atrybutów z A.

Ale najważniejsza uwaga dotycząca tego przykładu jest inna: pamiętając o tym, że obiekt podklasy składa się z warstw i zawiera wszystkie atrybuty zadeklarowane w nadklasach, unikajmy sięgania do atrybutów z nadklas! Dlaczego? Bo są one częścią opisu tego jak działają obiekty nadklasy, a nie tego co one robią. A zgodnie z regułami kapsułkowania nie chcemy nigdy znać wewnętrznej reprezentacji obiektów. Chcemy być niezależni od jej zmian. Oczywiście sytuacja, w której odwołujemy się do atrybutów zupełnie innego obiektu (absolutnie nigdy nie należy tego robić) jest inna od sytuacji, gdy odwołujemy się do atrybutów własnych, tyle że odziedziczonych po nadklasie. Można sobie wyobrazić sytuację, gdy definiujemy jednocześnie całą hierarchię własnych klas i zależy nam na rozdzieleniu poszczególnych części implementacji między klasy w tworzonej hierarchii i co więcej chcemy, by klasy były świadome tego podziału. Takie rozwiązanie jest akceptowalne, do jego realizacji zwykle stosuje się modyfikator widoczności protected (choć pamiętajmy, że niestety w Javie daje on zbyt szeroko prawa dostępu - także klasom nie znajdującym się w tworzonej hierarchii, a tylko znajdującym się w tym samym pakiecie). Jeśli jednak przewidujemy, że poszczególne części naszej hierarchii mogą być modyfikowane niezależnie od siebie, to dużo bezpieczniej jest odwoływać się tylko do operacji definiowanych przez klasy, a nie ich wewnętrznej implementacji.

Zwróćmy jeszcze uwagę na wartość wyrażenia a.i. Wartością zmiennej a jest obiekt klasy C. W obiektach tej klasy odwołanie się do atrybutu i daje wartość 3, bo odnosi się do atrybutu wprowadzonego w tej właśnie klasie. Tu jednak odwołaliśmy się do atrybutu z klasy A. Wynika to wprawdzie z deklaracji zmiennej a jako zmiennej klasy A, ale sygnalizuje bardzo ważny problem:

 Czy przy odwoływaniu się do składowych obiektu powinien być brany pod uwagę jego typ statyczny (to jest wynikający z deklaracji w programie), czy typ dynamiczny (czyli faktyczny)?

Widzieliśmy już, że w przypadku atrybutów decyduje typ statyczny, zobaczmy teraz co się dzieje w przypadku metod - doprowadzi nas to do pojęcia polimorfizmu w programowaniu obiektowym.

Polimorfizm

Tym razem będziemy jeszcze bardziej złośliwi niż poprzednio i także wszystkie metody nazwiemy tak samo.

 class A{
   int i=1;
   void info(){
      System.out.println(
         "Jestem info() z klasy A\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName() + "\n" +
         "  i z A>>i = "+i);
   }
  }
  
 class B extends A{
   int i=2;
   void info(){
      super.info();
      System.out.println(
         "Jestem info() z klasy B\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName() + "\n" +
         "  i z A>>i = "+((A) this).i + ", i z B>>i = " + i);  // albo super.i
   }
  }
  
 class C extends B{
   int i=3;
   void info(){
      super.info();
      System.out.println(
         "Jestem info() z klasy C\n"+
         "  wywołano mnie w obiekcie klasy " + this.getClass().getSimpleName()+ "\n" +
         "  i z A>>i = "+ ((A) this).i + ", i z B>>i = " + ((B) this).i + ", i z C>>i = " + i);
   }
  }

  C c = new C();
  c.info();
  
  A a = new C();
  System.out.println("\na.i=" + a.i + ", c.i=" + c.i + "\n");
  a.info();

W wyniku wykonania tego programu na standardowym wyjściu zostanie wypisane:

Jestem info() z klasy A
  wywołano mnie w obiekcie klasy C
  i z A>>i = 1
Jestem info() z klasy B
  wywołano mnie w obiekcie klasy C
  i z A>>i = 1, i z B>>i = 2
Jestem info() z klasy C
  wywołano mnie w obiekcie klasy C
  i z A>>i = 1, i z B>>i = 2, i z C>>i = 3

a.i=1, c.i=3 

Jestem info() z klasy A
  wywołano mnie w obiekcie klasy C
  i z A>>i = 1
Jestem info() z klasy B
  wywołano mnie w obiekcie klasy C
  i z A>>i = 1, i z B>>i = 2
Jestem info() z klasy C
  wywołano mnie w obiekcie klasy C
  i z A>>i = 1, i z B>>i = 2, i z C>>i = 3

Zwróćmy uwagę, że o ile w pierwszym przypadku wywołanie się metody z klasy C (która wywołała metodę z klasy B, a ta metodę z klasy A) było zupełnie oczywiste, o tyle drugie wywołanie - choć dało ten sam efekt - wymaga już wyjaśnień. Przecież zgodnie z zadeklarowanym typem, zmienna a jest typu A, czemu więc wywołała się metoda z klasy C? Oczywiście dlatego, że faktycznym typem obiektu będącego wartością tej zmiennej był typ C. Tyle, że tym razem kompilator o tym nie wiedział. Wygenerował natomiast przy wywołaniu metody kod, który w czasie działania programu wybrał właściwą dla obiektu pamiętanego w zmiennej metodę. Takie zjawisko w programowaniu obiektowym nazywamy polimorfizmem. Polimorfizm mówi o tym, że to jakie metody zostaną wywołane zależy od faktycznego, a nie zadeklarowanego, typu obiektu. Ale jednocześnie system typów zapewnia nam, że faktyczny typ obiektu na pewno ma wszystkie wymagane przez typ zadeklarowany metody i atrybuty. Innymi słowy, mamy zagwarantowane, że podczas działania programu zaakceptowanego przez kompilator nie wystąpią błędy typów (błędy wynikające z niezgodności typów). To bardzo ważna i pożyteczna cecha języków obiektowych ze statyczną kontrolą typów.

Słowo kluczowe super

Warto jeszcze przyjrzeć się zastosowaniu słowa kluczowego super w odniesieniu do metod. Znaczenie konstrukcji super.metoda(parametry) jest następujące: metoda zostanie wywołana na rzecz obiektu this, ale wyszukiwanie jej implementacji rozpocznie się od nadklasy klasy, która (tekstowo) zawiera wywołanie metody. Taka konstrukcja pozwala wywołać pochodzącą z nadklasy wersję metody przedefiniowanej w danej klasie. Często jest to użyteczne - pozwala opisać wykonanie czynności specyficznych dla danej klasy oraz wykonać czynności opisane w nadklasie.

Popatrzmy na przykład. Załóżmy, że mamy dwie klasy: Osoba i Student. Ponieważ każdy student jest osobą, zastosowanie dziedziczenia jest oczywiste. Przyjmijmy, że interesują nas jedynie takie cechy osób jak imię i nazwisko, a w przypadku studentów jeszcze numer indeksu. Jedyną operacją, którą rozważymy w tym przykładzie będzie dostarczanie tekstowego opisu obiektów (czyli metoda toString). Implementacja tych klas może wyglądać następująco:

class Osoba{
  private String imię, nazwisko;
  // ... 
  @Override
  public String toString(){
    return "imię = " + imię + ", nazwisko = " + nazwisko;
  }
}
   
class Student extends Osoba{
  private String nrIndeksu;  // Typ String, tak by numer indeksu mógł zawierać nie tylko cyfry (np. I-123456)
  //...
  @Override
  public String toString(){
    return super.toString() + ", nr indeksu = " + nrIndeksu;
  }
}

Zwróćmy uwagę na metodę toString w klasie Student. Chcemy wypisać tam opis studenta, ale nie ma sensu w tym opisie jawnie odwoływać się do imienia i nazwiska (nawet nie dałoby się tego zrobić, bo te atrybuty są prywatne w klasie Osoba). Zamiast powtarzać kod wywołujemy metodę z nadklasy. Niestety pojawia się tu pewna trudność, gdybyśmy to wywołanie zapisali return toString() ... co byłoby równoważne zapisowi return this.toString() ..., to metoda toString wywoływałaby się rekurencyjnie (aż do przepełnienia stosu). Dzięki słowu super można wskazać, że chodzi o wywołanie metody z nadklasy, czyli w tym przypadku z klasy Osoba.

Kolejne zastosowanie słowa kluczowego super jest związane z konstruktorami. W klasie Osoba konstruktor mógłby wyglądać tak:

public Osoba(String imię, String nazwisko){
  this.imię = imię;
  this.nazwisko = nazwisko;
}

Zauważmy, że w klasie Student większość instrukcji tego konstruktora trzeba by powielić, czego oczywiście nie chcemy robić. Podobnie jak w rozważanym wcześniej przypadku metody toString chcielibyśmy wywołać w klasie Student konstruktor z klasy Osoba. Znów możemy to zrobić i znów za pomocą słowa kluczowego super:

public Student(String imię, String nazwisko, String nrIndeksu){
  super(imię, nazwisko);
  this.nrIndeksu = nrIndeksu;
}

Warto jednak zwrócić uwagę, że o ile metody z nadklasy można wywoływać ile tylko razy chcemy i w dowolnym miejscu metod podklasy, o tyle konstruktor nadklasy można wywołać tylko i wyłącznie jako pierwszą instrukcję konstruktora podklasy. Wynika to stąd, że treść konstruktora podklasy może zacząć się wykonywać dopiero po zainicjowaniu wszystkich składowych odziedziczonych z nadklasy (a więc po zakończeniu wykonywania się konstruktora z nadklasy).

Dwa powyższe użycia słowa kluczowego super są bardzo intuicyjne. Z nieco mniej intuicyjną sytuacją mamy do czynienia, gdy metoda zawierająca słowo super jest dziedziczona lub poprzez super wywoływana w podklasie. Dodajmy do naszej hierarchii klas jeszcze studenta mającego stypendium.

class StudentStypendysta extends Student{
  private int stypendium;  
  public StudentStypendysta(String imię, String nazwisko, String nrIndeksu, int stypedium){
    super(imię, nazwisko, nrIndeksu);
    this.stypendium = stypedium;
  }
  @Override
  public String toString(){
    return super.toString() + ", stypendium = " + stypendium;
  }
}

Przypatrzmy się metodzie toString. Wywołujemy w niej metodę o tej samej nazwie z nadklasy (czyli z klasy Student). I tu pojawia się wątpliwość - co oznacza w niej (tj. w metodzie toString z klasy Student) wywołanie postaci:

return super.toString() + ", nr indeksu = " + nrIndeksu;

Chodzi o fragment super.toStrig(). Dotąd wystarczała nam wiedza, że słowo kluczowe super oznacza rozpoczęcie wyszukiwania odpowiedniej metody od nadklasy, teraz jednak pojawia się problem: czyjej nadklasy? Analizowana wersja metody toString pochodzi z klasy Student, ale jest wywoływana na rzecz obiektu klasy StudentStypendysta! Przypomnijmy definicję semantyki słowa kluczowego super:

Znaczenie konstrukcji super.metoda(parametry) jest następujące: metoda zostanie wywołana na rzecz obiektu this, ale wyszukiwanie jej implementacji rozpocznie się od nadklasy klasy, która (tekstowo) zawiera wywołanie metody.

Zatem mimo tego, że wołanie metody następuje dla obiektu z klasy StudentStypendysta, i tak wyszukiwanie definicji metody toString wołanej w metodzie wziętej z klasy Student rozpocznie się od nadklasy klasy Student (czyli od klasy Osoba). I całe szczęście! W przeciwnym wypadku doszłoby do rekurencyjnego wywoływania się tej metody z klasy Student zakończonego przepełnieniem stosu.

Klasy abstrakcyjne i interfejsy

Zwykle po to tworzymy klasy by tworzyć ich egzemplarze. Okazuje się jednak często, że definiujemy klasy, które z założenia nie będą nigdy miały swoich egzemplarzy. Takie klasy nazywamy klasami abstrakcyjnymi. Wbrew temu, co mogłoby się wydawać na pierwszy rzut oka, te klasy pełnią bardzo ważną rolę przy projektowaniu hierarchii klas. Pozwalają bowiem na wyabstrahowanie wspólnych cech wielu definiowanych pojęć (klas) i jawne wskazanie, że wszystkie dziedziczące klasy muszą te cechy posiadać.

Czasami w klasie abstrakcyjnej chcemy opisać tylko interfejs, bez żadnych danych ani implementacji metod. Ponieważ takie wyabstrahowanie samego interfejsu jest bardzo ważne w Javie nadano mu specjalną postać składniową i nazwano ją interfejsem.

Podsumowanie

Dziedziczenie jest bardzo silnym narzędziem pozwalającym lepiej strukturalizować programy. Jest charakterystyczne dla podejścia obiektowego. Tak jak każde silne narzędzie ma zarówno zalety jak i wady. Poniżej krótko je charakteryzujemy.


Zalety:

  • Możliwość jawnego zapisywania w programie związków ogólniania/uszczegóławiania pomiędzy opisywanymi pojęciami.
  • Możliwość ponownego wykorzystywania (nie trzeba od nowa pisać odziedziczonych metod i deklaracji odziedziczonych zmiennych). Ponowne wykorzystywanie zwiększa niezawodność (szybciej wykrywa się błędy w częściej używanych fragmentach programów) i pozwala szybciej tworzyć nowe systemy (budować je z gotowych klocków).
  • Zgodność interfejsów (osiągana przez tworzenie hierarchii klas dziedziczących po wspólnej nadklasie lub interfejsie).


Problemy:

  • Problem jo-jo (nadużywanie dziedziczenia może uczynić czytanie programu bardzo żmudnym procesem).
  • Modyfikacje kodu w nadklasach mają wpływ na podklasy i na odwrót (wirtualność metod, która jednocześnie stanowi o sile dziedziczenia).


Należy na koniec podkreślić, że dziedziczenie nie jest jedyną techniką wyrażania związków pomiędzy klasami. Innym ważnym mechanizmem wyrażania związków jest składanie. Oba te mechanizmy wzajemnie się uzupełniają i nigdy nie należy dążyć na siłę do zastąpienia jednego z nich drugim.



Dopiski

Przypisy

  1. Jako pierwsza sformułowała ją Barbara Liskov (Barbara Liskov, “Data Abstraction and Hierarchy,” SIGPLAN Notices, 23,5, May, 1988).
  2. Nie dotyczy to samej klasy Object, bo ona nie ma żadnej nadklasy, ale akurat tej klasy sami nie możemy zadeklarować (mówiąc precyzyjniej, nie możemy zadeklarować tej klasy Object, możemy zadeklarować własną klasę, która będzie się nazywała Object, ale nie będzie ona miała oczywiście żadnych specjalnych własności).
  3. Uwaga: dla metod tak już nie jest!