Dziedziczenie (programowanie)
Dziedziczenie (ang. inheritance) – mechanizm współdzielenia funkcjonalności między klasami. Klasa może dziedziczyć po innej klasie, co oznacza, że oprócz deklaracji swoich własnych atrybutów oraz zachowań, uzyskuje także te pochodzące z klasy, z której dziedziczy. Klasa dziedzicząca jest nazywana klasą pochodną lub potomną (w j. angielskim: subclass lub derived class), zaś klasa, z której następuje dziedziczenie — klasą bazową (w ang. superclass). Z jednej klasy bazowej można uzyskać dowolną liczbę klas pochodnych. Klasy pochodne posiadają obok swoich własnych metod i deklaracji pól, również kompletny interfejs klasy bazowej.
W językach programowania z prototypowaniem (np. JavaScript) nie występuje pojęcie klasy, dlatego dziedziczenie zachodzi tam pomiędzy poszczególnymi obiektami.
Pojęcie dziedziczenia zostało wprowadzone po raz pierwszy przez twórców języka Simula[1].
Klasy bazowe i pochodne
Zależności między klasami bazowymi i pochodnymi tworzą tzw. hierarchię klas. Klasy pochodne otrzymują wszystkie metody i deklaracje atrybutów ze swoich klas bazowych oraz mogą dodawać nowe. Dopuszczalne jest także nadpisywanie istniejących metod, przy czym poszczególne języki programowania mogą żądać spełnienia dodatkowych warunków, np. pozostawienia niezmienionej listy argumentów wejściowych i typu wyniku.
Wiele języków programowania umożliwia deklarowanie klas jako abstrakcyjnych. Nie można tworzyć obiektu klasy abstrakcyjnej, lecz można po takiej klasie dziedziczyć. Klasa abstrakcyjna może zawierać metody czysto wirtualne, które muszą zostać zaimplementowane przez klasy pochodne. Mechanizmu tego używa się, jeśli twórca klasy chce dostarczyć jedynie części funkcjonalności, tworząc szkielet dla innych, bardziej wyspecjalizowanych klas.
W części języków programowania istnieje możliwość ograniczania widoczności dziedziczonych pól i metod:
- elementy publiczne — nieograniczony dostęp, można je wywoływać zarówno z wnętrza klas, jak i spoza nich.
- elementy chronione — można wywoływać jedynie z wnętrza klasy oraz z wnętrza wszystkich klas pochodnych.
- elementy prywatne — można wywoływać jedynie z wnętrza bieżącej klasy, natomiast nie ma do nich dostępu również w klasach pochodnych.
Rodzaje dziedziczenia
W programowaniu obiektowym wyróżniane jest dziedziczenie pojedyncze oraz dziedziczenie wielokrotne. Z dziedziczeniem pojedynczym mamy do czynienia, gdy klasa pochodna dziedziczy po dokładnie jednej klasie bazowej (oczywiście klasa bazowa wciąż może dziedziczyć z jakiejś innej klasy), natomiast w dziedziczeniu wielokrotnym klas bazowych może być więcej.
Wielokrotne dziedziczenie jest obsługiwane w takich językach, jak C++, Common Lisp czy Perl. Zwiększa możliwości ponownego wykorzystania kodu, lecz jednocześnie jest krytykowane za:
- niejednoznaczność semantyczną (tzw. Diamond problem),
- problemy z łańcuchowym wywoływaniem konstruktorów,
- trudności implementacyjne.
Powyższe problemy dotyczą przede wszystkim konfliktów implementacji. Dlatego nawet jeśli w danym języku programowania wielokrotne dziedziczenie klas jest niedozwolone, można je stosować w przypadku interfejsów, które mogą być traktowane, jak klasy abstrakcyjne zawierające wyłącznie metody czysto wirtualne.
Zastosowania
Podstawowym zastosowaniem dziedziczenia jest ponowne wykorzystanie kodu. Jeśli dwie klasy wykonują podobne zadania, możemy utworzyć dla nich wspólną klasę bazową, do której przeniesiemy definicje identycznych metod oraz deklaracje identycznych atrybutów. Ułatwi to testowanie oraz potencjalnie zwiększy niezawodność aplikacji w przypadku zmian. W razie ewentualnych problemów łatwiej będzie również odnaleźć przyczynę błędu.
Dziedziczenie a polimorfizm (podtypowanie)
Hierarchia klas może przekładać się na hierarchię typów. Możliwe jest wtedy podstawienie pod zmienną (lub atrybut funkcji) typu T obiektu typu S będącego podtypem T i dalsze używanie go jakby był typu T. Jest to możliwe dzięki temu, że podklasa posiada kompletny interfejs swojej nadklasy.
W podklasie może być zdefiniowana metoda już istniejąca w nadklasie. Konstrukcja taka umożliwia wykonywanie operacji na obiektach bez informacji, z jakim właściwie obiektem mamy do czynienia. Rozpatrzmy typową aplikację GUI wyświetlającą na ekranie różne komponenty (np. przycisk, pole tekstowe czy listę rozwijaną). Reagują one na te same zdarzenia: kliknięcie myszką, naciśnięcie klawisza, lecz każdy z nich reaguje inaczej, stosownie do tego czym jest. System obsługi zdarzeń najpierw określa, który z komponentów powinien obsłużyć zdarzenie, a następnie przekazuje mu je. Dzięki podtypowaniu opartym na dziedziczeniu możemy utworzyć wspólną klasę Komponent
z metodą obsluzKlikniecieMyszka()
, którą będą rozszerzać wszystkie rodzaje komponentów. Pobierając aktywny obiekt, możemy wywołać tę metodę bez zastanawiania się czy dany obiekt jest przyciskiem czy polem tekstowym.
Decyzja o tym, która wersja zachowania zostanie wywołana w konkretnym miejscu, zależy od języka programowania i sposobu zdefiniowania metod. Rozpatrzmy następującą sytuację:
class A {
method foo();
}
class B extends A {
method foo();
}
A obiektBazowy = new A();
B obiektPochodny = new B();
obiektBazowy.foo(); // 1
obiektBazowy = obiektPochodny;
obiektBazowy.foo(); // 2
Mamy klasę bazową A
oraz dziedziczącą z niej klasę B
. Klasa bazowa definiuje metodę foo()
, która jest nadpisywana przez klasę pochodną. Przypadek pierwszy (1) nie budzi żadnych wątpliwości: mamy utworzony obiekt klasy A, dlatego wywołujemy wersję metody foo()
zdefiniowaną w tej klasie. W przypadku drugim pod zmienną obiektBazowy
podstawiony jest obiekt klasy pochodnej. Jednak wtedy w linijce oznaczonej przez 2 możemy:
- wywołać wersję metody
foo()
z klasyA
, mimo iż zmienna wskazuje na obiekt klasy pochodnej posiadającej zmodyfikowaną wersję, - wywołać wersję metody
foo()
z klasy pochodnejB
, mimo iż zmiennaobiektBazowy
jest typuA
.
Jeśli zachodzi sytuacja druga, powiemy, że metoda foo()
jest metodą wirtualną. W niektórych językach (np. C++) metody muszą być jawnie deklarowane jako wirtualne przez programistę. W innych (np. Java) wszystkie metody są z definicji wirtualne.
W ogólnym ujęciu podtypowanie i dziedziczenie to dwa różne pojęcia. Dziedziczenie dotyczy powtórnego wykorzystania klasy bazowej, natomiast podtypowanie (polimorfizm) możliwości wykorzystania podtypu w miejscu nadtypu.
Ograniczenia
Dziedziczenie posiada kilka ograniczeń wynikających z faktu, że hierarchia klas jest ustalana w momencie kompilacji programu i nie może podlegać późniejszym zmianom. Wyobraźmy sobie klasę Osoba
, z której dziedziczą klasy Pracownik
oraz Student
. Napotykamy tutaj na istotne problemy:
- Pojedynczość — w językach z pojedynczym dziedziczeniem osoba może być albo pracownikiem, albo studentem.
- Niezmienność — nawet jeśli skorzystamy z wielokrotnego dziedziczenia i utworzymy klasę
StudentPracownik
, klasę wybieramy w momencie tworzenia obiektu i nie możemy jej później zmienić. Oznacza to, że system nie może poprawnie reagować na sytuacje, gdy dotychczasowy student zostaje dodatkowo pracownikiem, gdyż konieczne jest wtedy utworzenie całkowicie nowego obiektu.
Innym istotnym ograniczeniem jest uzależnienie kodu od konkretnej implementacji klasy, które może doprowadzić do błędów przy jej zmianie. Tego typu problem pojawia się zwłaszcza gdy dziedziczymy między klasami znajdującymi się w różnych komponentach (tzw. Problem kruchości klasy podstawowej)[2].
Rozwiązania alternatywne
Istnieje kilka rozwiązań alternatywnych eliminujących poszczególne ograniczenia dziedziczenia.
Kompozycja
Kompozycja polega na zastąpieniu dziedziczenia składaniem mniejszych obiektów. Posługując się dalej powyższym przykładem, możemy zostawić klasę Osoba
, która posiada listę ról takich, jak Pracownik
czy Student
. Obiektowi można przydzielać nowe oraz usuwać stare role w dowolnym momencie wykonania programu, eliminując tym samym problemy pojedynczości oraz niezmienności. Wadą kompozycji jest większe zużycie pamięci (dużo małych obiektów) oraz niewielki spadek wydajności podczas dostępu do metod.
Kompozycję można stosować w każdym języku obsługującym programowanie obiektowe.
Domieszki
Domieszka pozwala uzyskać funkcjonalność podobną do wielokrotnego dziedziczenia, unikając jednocześnie trapiących je paradoksów. Jest to rodzaj klasy abstrakcyjnej, którą można „dodać” do właściwych klas. Klasa uzyskuje wszystkie deklaracje atrybutów oraz metody zdefiniowane w dodanych do niej domieszkach. Oddzielenie klas od domieszek pozwala wprowadzić jasne reguły rozwiązywania konfliktów. Przykładem języka wykorzystującego domieszki jest Ruby.
Interfejsy i cechy
Interfejs to rodzaj klasy abstrakcyjnej, która może zawierać wyłącznie metody czysto wirtualne oraz stałe. Ponieważ paradoksy dotyczą wyłącznie implementacji, której tu nie ma, w interfejsach można bezpiecznie korzystać z wielokrotnego dziedziczenia. Również klasy mogą implementować więcej niż jeden interfejs jednocześnie. Przykładami języków wykorzystujących interfejsy są Java oraz C#.
Cechy umożliwiają wielokrotne wykorzystanie tego samego kawałka kodu w różnych klasach. W przeciwieństwie do domieszek, kod ten zachowuje się tak, jakby był zapisany w tych klasach bezpośrednio, a w momencie wykonania programu nie ma możliwości stwierdzenia czy dana metoda została zaimplementowana bezpośrednio w klasie czy dodana przez cechę.
Zobacz też
Przypisy
- ↑ Simula IBM System 360/370 Compiler and Historical Documentation. [dostęp 2011-07-13]. (ang.).
- ↑ Allen Holub: Why extends is evil. JavaWorld.com, 2003-08-01. [dostęp 2011-07-18]. [zarchiwizowane z tego adresu (2010-03-28)]. (ang.).