· rszmit · 14 min
Wyzwanie Java #5: Interfejsy i dziedziczenie
W trzecim wyzwaniu wprowadziliśmy Was w świat programowania obiektowego. Zrozumienie tego paradygmatu jest niezwykle ważne, by biegle posługiwać się językiem Java a także wieloma innymi językami obiektowymi. Niestety, zrozumienie go i prawidłowe pisanie naszych programów obiektowych nie jest wcale takie proste, dlatego wymaga większego zaangażowania i czasu by prawidłowo przyswoić niezbędną wiedzę.
W kolejnym naszym wyzwaniu, chcielibyśmy powtórzyć wiedzę, którą poznaliśmy na temat programowania obiektowego i ją rozszerzyć.
Powiemy sobie o najważniejszych założeniach paradygmatu obiektowego, czyli:
- Dziedziczeniu
- Polimorfiźmie
- Hermetyzacji
- Abstrakcji
Dziedziczenie
Wyobraźmy sobie, że chcemy stworzyć aplikację dla pobliskiego ZOO. W tym systemie pracownicy wprowadzali by informacje o wszystkich zwierzętach którymi się opiekują. Dla przykładu, wśród zwierząt mielibyśmy lwy, tygrysy, słonie, żyrafy, papugi, orły i wiele wiele innych zwierząt. Przystępując do pracy na pewno stworzylibyśmy takie klasy:
public class Lion {
}
public class Elephant {
}
public class Eagle {
}
public class Parrot {
}
Każde z naszych zwierząt ma jakieś cechy, dla przykładu wagę, wiek oraz imię. Żeby móc w naszym programie zapisać te cechy, nasze klasy muszą mieć do tego odpowiednie pola, zatem nasz kod zacznie wyglądać jak poniżej:
public class Lion {
public String name;
public int age;
public double weight;
}
public class Elephant {
public String name;
public int age;
public double weight;
}
public class Eagle {
public String name;
public int age;
public double weight;
}
public class Parrot {
public String name;
public int age;
public double weight;
}
Dla ułatwienia, wszystkie zmienne oznaczymy jako “public”, choć docelowo dowiemy się, że nie jest to zbyt dobry pomysł. Jak widać, każdemu z naszym zwierząt możemy przypisać wszystkie trzy powyższe cechy, na przykład tak:
package pl.kodolamacz.zoo;
public class ZooApplication {
public static void main(String[] args) {
Elephant elephant1 = new Elephant();
elephant1.name = "Hadoop";
elephant1.weight = 7000;
elephant1.age = 11;
Elephant elephant2 = new Elephant();
elephant2.name = "Dumboo";
elephant2.weight = 6000;
elephant2.age = 77;
}
}
Jednak każdy od razu widzi, że w powyższym kodzie, powielamy w każdym obiekcie te same klasy! Od razu się nasuwa pytanie, czy nie można tego zrobić sprawniej, czyli poprzez zapisanie tego raz i współdzielenie z innymi klasami? Właśnie do tego służy dziedziczenie. Dzięki niemu możemy stworzyć oddzielną klasę, np. zwierzę i odziedziczyć po niej zestaw cech które ma każde zwierzę!
Nasz kod dzięki temu może wyglądać tak:
public class Animal {
public String name;
public int age;
public double weight;
}
public class Lion extends Animal {
}
public class Elephant extends Animal {
}
public class Eagle extends Animal {
}
public class Parrot extends Animal {
}
Polimorfizm
Dzięki stworzeniu klasy Animal i odziedziczeniu po niech zestawu cech dzięki słowu kluczowemu extends, wszystkie klasy zwierząt także mają te cechy. Dlatego nasz program w klasie ZooApplication może pozostać bez zmian.
Dzieje się tak dlatego, że obiekty elephant1 i elephant2 z klasy ZooApplication są jednocześnie słoniami (klasa Elephant) ale także zwierzętami (klasa Animal). Inaczej mówiąc, mają cechy z każdej z tych klas i można je w naszym programie traktować zarówno jako obiekty typu Elephant jak i Animal. Pokazuje to poniższy kod, gdzie obydwa obiekty zostały dodane do teoretycznie dwóch innych zbiorów.
package pl.kodolamacz.zoo;
import java.util.ArrayList;
import java.util.List;
public class ZooApplication {
public static void main(String[] args) {
Elephant elephant1 = new Elephant();
elephant1.name = "Hadoop";
elephant1.weight = 7000;
elephant1.age = 11;
Elephant elephant2 = new Elephant();
elephant2.name = "Dumboo";
elephant2.weight = 6000;
elephant2.age = 77;
Lion lion1 = new Lion();
lion1.name = "Simba";
lion1.weight = 100;
lion1.age = 24;
List<Elephant> elephants = new ArrayList<>();
elephants.add(elephant1);
elephants.add(elephant2);
// elephants.add(lion1); // Tak nie możemy zrobić, lew nie jest słoniem!
List<Animal> animals = new ArrayList<>();
animals.add(elephant1);
animals.add(elephant2);
animals.add(lion1); // Tak już możemy, lew jest zwierzęciem!
System.out.println("Liczba słoni: " + elephants.size());
System.out.println("Liczba zwierząt: " + animals.size());
}
}
Takie zachowanie nazywamy polimorfizmem czyli wielopostaciowością.
W świecie Java każdy obiekt dziedziczy po wspólnym “przodku”, jest nim klasa java.lang.Object. Mimo, że tworząc swoją klasę, nie piszemy tego wprost, każda klasa która nie ma słowa extend rozszerza właśnie klasę Object. Oczywiście, jak chcemy, możemy napisać, że nasza klasa rozszerza klasę Object, ale jest to po prostu zbędne.
Trzeba także pamiętać, że dziedziczenie w Javie odbywa się zawsze zgodnie z zasadą IS-A, czyli jest, co oznacza, że słoń jest zwierzęciem, ale nie każde zwierze jest słoniem. Nie ma zatem w Javie takiego “dziedziczenia”, jakie znamy z biologii, czyli dziecko “dziedziczy” po swoich rodzicach, bo dziecko nie jest swoją mamą ani nie jest swoim ojcem, jednak wszyscy są ludźmi.
Hermetyzacja
Podczas dziedziczenia nie tylko cechy, czytaj pola klasy, są dziedziczone z innej klasy. Dotyczy to także metod. Jeśli nasza klasa Animal miałaby jakieś metody, wszystkie klasy “potomne”, także będą mieć te metody (z dokładnością do widoczności o czym powiemy za chwilę).
Nasze “zwierze” ma trzy cechy, imię, wiek i wagę. Imię jest cechą którą nadajemy, zaś wiek zmienia się wraz ze starzeniem się a waga jedząc. Nie wnikając w szczegóły i biologię ssaków, nasz program uwzględniający powyższe zasady powinien wyglądać tak:
package pl.kodolamacz.zoo;
public class Animal {
private String name;
private int age;
private double weight;
/**
* Nazwyam zwierzę
*/
public void setName(String name) {
this.name = name;
}
/**
* Starzeje się, więc mój wiek wzrasta...
*/
public void growOld(int age) {
this.age += age;
}
/**
* Jem więc staję się cięższy
*/
public void eat(double weight) {
this.weight += weight;
}
}
oraz tak:
package pl.kodolamacz.zoo;
import java.util.ArrayList;
import java.util.List;
public class ZooApplication {
public static void main(String[] args) {
Elephant elephant1 = new Elephant();
elephant1.setName("Hadoop");
elephant1.eat(7000);
elephant1.growOld(11);
Elephant elephant2 = new Elephant();
elephant2.setName("Dumboo");
elephant2.eat(6000);
elephant2.growOld(77);
Lion lion1 = new Lion();
lion1.setName("Simba");
lion1.eat(100);
lion1.growOld(24);
// tutaj ciąg dalszy ...
}
}
Zauważmy, że oprócz dodanie trzech nowych metod, nasze pola zmieniły się z public na private, czyli teraz nikt poza metodami z tej klasy, nie będzie miał dostępu do tych cech. Jakakolwiek zmiana ich wartości będzie wymagała dostępu przez metody setName, eat i growOld. Takie “ukrycie” implementacji, czyli wewnętrznego zachowania, nazywamy hermetyzacją. W praktyce będziemy bardzo często spotykać się z hermetyzacją w naszych programach, pozwala ona na uniknięcie wielu potencjalnych błędów i ułatwia pisanie dużych programów, o czym sami się kiedyś przekonacie.
Abstrakcja
W naszym programie możemy stworzyć obiekty czterech klas: Lion, Elephant, Eagle oraz Parrot. W celu ułatwienia sobie pisania kodu, stworzyliśmy dodatkową klasę Animal po której dziedziczą wszystkie inne i która służy do przechowania wszystkich “wspólnych” metod i cech obiektów. Jednak nikt nikomu nie zabroni napisania czegoś takiego w naszym programie:
Animal nieWiemCoTo = new Animal();
czyli stworzenia obiektu klasy Animal. Nie powinno tak być, gdyż nie ma w przyrodzie po prostu “zwierząt”, są za to lwy, słonie, orły i papugi. W języku polskim pojęcia nie mające odpowiednika w konkretnym przedmiocie, coś bardziej ogólnego nazywamy abstrakcją. Tak samo jest i w Javie, to znaczy, klasy które są bytami ogólnymi, używanymi do celów dziedziczenia, ale nie służą do tworzenia żadnych konkretnych bytów, czyli obiektów, nazywamy klasami abstrakcyjnymi. By stworzyć klasę abstrakcyjną, wystarczy użyć słowa abstract w kodzie jak pokazano poniżej:
package pl.kodolamacz.zoo;
public abstract class Animal {
private String name;
private int age;
private double weight;
/**
* Nazwyam zwierzę
*/
public void setName(String name) {
this.name = name;
}
/**
* Starzeje się, więc mój wiek wzrasta...
*/
public void growOld(int age) {
this.age += age;
}
/**
* Jem więc staję się cięższy
*/
public void eat(double weight) {
this.weight += weight;
}
}
Od teraz już nikt w naszym programie, nie będzie mógł stworzyć obiektu z klasy Animal.
Klasy abstrakcyjne mogę mieć także metody abstrakcyjne, czyli takie które muszą być stworzone w klasie dziedziczącej po niej, lecz tutaj jedynie stwierdzamy, że taka metoda ma powstać. Gdyby chcieć nasze metody zmienić w abstrakcyjne, wystarczyłoby napisać tak:
package pl.kodolamacz.zoo;
public abstract class Animal {
private String name;
private int age;
private double weight;
/**
* Nazwyam zwierzę
*/
public abstract void setName(String name);
/**
* Starzeje się, więc mój wiek wzrasta...
*/
public abstract void growOld(int age);
/**
* Jem więc staję się cięższy
*/
public abstract void eat(double weight);
}
Za chwilę poznamy inny mechanizm “zmuszenia” programisty do napisania jakichś metod, mianowicie interfejsy. Zauważmy, że metody abstrakcyjne, nie mają ciała, jedynie definiujemy nazwę, parametry, zwracany typ oraz widoczność metody.
Widoczność pól i metod
W dziedziczeniu pól i metod jest jednak pewne ograniczenie, mianowicie, nie można odziedziczyć pól i metod oznaczonych jako private, użycie tego słowa zamiast public spowodowałoby, że tylko metody znajdujące się w klasie Animal miałyby dostęp do tego pola. Tak stało się z polami, gdy wprowadziliśmy trzy metody dostępu do pól, czyli eat, setName i growOld.
Dla przypomnienia, jeszcze raz tabela modyfikatorów dostępu wraz z oznaczeniem kto ma dostęp do danego pola lub metody:
Klasa | Pakiet | Podklasa | Wszyscy | |
---|---|---|---|---|
public | + | + | + | + |
protected | + | + | + | |
default | + | + | ||
private | + |
Dziedziczenie i przesłanianie metod
Z dziedziczeniem metod wiąże się jeszcze jedna bardzo ważna cecha, metody można nadpisywać. Wszystkie zwierzęta jedzą, jednak różne zwierzęta, mogą jeść w inny sposób. Wyobraźmy sobie, że u słoni tylko połowa jedzenia jest przetwarzana na masę (waga), zaś u wszystkich innych zwierząt będzie to całość. Nasz kod mógłby wyglądać na przykład tak:
package pl.kodolamacz.zoo;
public abstract class Animal {
private String name;
private int age;
private double weight;
/**
* Nazwyam zwierzę
*/
public void setName(String name) {
this.name = name;
}
/**
* Starzeje się, więc mój wiek wzrasta...
*/
public void growOld(int age) {
this.age += age;
}
/**
* Jem więc staję się cięższy
*/
public void eat(double weight) {
this.weight += 0,75 * weight;
}
}
oraz:
package pl.kodolamacz.zoo;
public class Elephant extends Animal {
/**
* Słoń je trochę inaczej
*/
@Override
public void eat(double weight) {
this.weight += 0,5 * weight;
}
}
Jak widzimy, słoń, czyli klasa Elephant ma własną wersję metody eat, mimo że w klasie Animal już jest ona stworzona. Mówimy tutaj o nadpisaniu metody, co dodatkowo zaznaczone jest nieobowiązkową adnotację @Override. W tym momencie, gdy wykonamy metodę eat na obiektach klasy Elephant, zostanie użyta nowa implementacja.
Z racji tego, że przy wielokrotnym dziedziczeniu, tworzy się coś w rodzaju drzewa klas, zawsze używana jest implementacja z klasy będącej najdalej od klasy Object w tym drzewie. Przypomina to trochę stos kart, gdzie zawsze bierzemy to co najbliżej góry i zawsze kolejna karta nadpisuje te pod spodem.
Powyższy kod będzie jednak działał pod warunkiem, że pole weight w klasie Elephant nie będzie prywatne (patrz tabela modyfikatorów dostępu). Oznaczać to wtedy będzie, że nie możemy z tego pola skorzystać w klasie Elephant. Możemy jednak wtedy wywołać metodę eat z klasy po której dziedziczymy. Można to zrobić za pomocą słowa super. Poniżej przykład użycia tego słowa:
package pl.kodolamacz.zoo;
public class Elephant extends Animal {
/**
* Słoń je trochę inaczej
*/
@Override
public void eat(double weight) {
super.eat(0,5 * weight);
}
}
Jak widać w metodzie eat klasy Elephant odwołujemy się do metody eat klasy Animal. Oczywiście, w tej metodzie możemy odwołać się także do każdej innej metody z klasy Animal lub Elephant, jednak gdy w obydwu klasach będą istniały metody o tej samej nazwie, czytaj nadpisane, wtedy słowo super pozwala nam wskazać, że chcemy wykonać metodę z klasy po której dziedziczymy lub innej niżej w hierarchii klas w drzewie dziedziczenia.
Konstruktory
Wiemy, że do stworzenia obiektu, używana jest “specjalna” metoda zwana konstruktorem. Okazuje się, że one także podlegają zasadą dziedziczenia. Tworząc nowy obiekt, najpierw wykonywany jest konstruktor klasy Object a następnie po kolei wszystkie konstruktory powyżej, czyli w naszym przypadku najpierw konstruktor klasy Animal a następnie konstruktor klasy Elephant.
Także w przypadku konstruktorów, można skorzystać ze słowa super, z tym, że nie używamy wtedy znaku kropki. Poniżej przykład użycia dwóch różnych konstruktorów:
package pl.kodolamacz.zoo;
public abstract class Animal {
private String name;
private int age;
private double weight;
public Animal() {
System.out.println("Tworzę zwierzę bez imienia...");
}
public Animal(String name) {
this.name = name;
System.out.println("Tworzę zwierzę o imieniu " + name);
}
/**
* Nazwyam zwierzę
*/
public void setName(String name) {
this.name = name;
}
/**
* Starzeje się, więc mój wiek wzrasta...
*/
public void growOld(int age) {
this.age += age;
}
/**
* Jem więc staję się cięższy
*/
public void eat(double weight) {
this.weight += 0,75 * weight;
}
}
package pl.kodolamacz.zoo;
public class Elephant extends Animal {
public Elephant() {
super(); // tą linijkę można pominąć, jest domyślna
System.out.println("Tworzę słonia bez imienia...");
}
public Elephant(String name) {
super(name); // wybieramy konstruktor z parametrem
System.out.println("Tworzę słonia o imieniu " + name);
}
/**
* Słoń je trochę inaczej
*/
@Override
public void eat(double weight) {
super.eat(0,75 * weight);
}
}
package pl.kodolamacz.zoo;
import java.util.ArrayList;
import java.util.List;
public class ZooApplication {
public static void main(String[] args) {
Elephant elephant1 = new Elephant();
elephant1.setName("Hadoop");
elephant1.eat(7000);
elephant1.growOld(11);
Elephant elephant2 = new Elephant("Dumboo");
// elephant2.setName("Dumboo"); // już niepotrzebne, mamy konstruktor
elephant2.eat(6000);
elephant2.growOld(77);
}
}
Po uruchomieniu programu dostaniemy:
Tworzę zwierzę bez imienia...
Tworzę słonia bez imienia...
Tworzę zwierzę o imieniu Dumboo
Tworzę słonia o imieniu Dumboo
Interfejsy
A teraz sobie skomplikujmy sytuację. Mamy cztery zwierzęta: słoń, lew, papuga, orzeł. Dwa z nich to ssaki, dwa ptaki. Dwa są mięsożerne, dwa nie. Pojawia się problem, jak zbudować drzewo dziedziczenia przy takich relacjach. Można by spróbować wprowadzić dodatkowe klasy dla ssaków i patków, wtedy słoń dziedziczył by po ssaku, zaś ssak po zwierzęciu, czyli klasie Animal. Jednak to nie rozwiąże problemu z jedzeniem mięsa, bo nie każdy ssak jest mięsożerny i nie każdy ptak również. Dlatego by rozwiązać ten problem, wymyślono zupełnie inny mechanizm zwany interfejsami.
Interfejs to zestaw wymagań, które stawiamy przed danym obiektem/klasą. W praktyce przekłada to się na zestaw metod które ta klasa musi mieć, by mogła implementować ten interfejs. Dlatego mówimy o interfejsach i ich implementacjach. Każdy interfejs może mieć wiele implementacji oraz dowolna klasa może implementować wiele interfejsów.
Poniżej przykład takiego interfejsu dla mięsożercy i roślinożercy:
package pl.kodolamacz.zoo;
public interface Carnivore {
void eatMeat(double weight);
}
package pl.kodolamacz.zoo;
public interface Herbivore {
void eatPlant(double weight);
}
Ich użycie zaś będzie wyglądać tak:
package pl.kodolamacz.zoo;
public class Elephant extends Animal implements Herbivore {
/**
* Słoń je trochę inaczej
*/
@Override
public void eat(double weight) {
super.eat(0,5 * weight);
}
@Override
public void eatPlant(double weight) {
eat(weight); // Nie ma słowa super, używamy metody eat z klasy Elephant!!!
}
}
package pl.kodolamacz.zoo;
public class Lion extends Animal implements Carnivore{
@Override
public void eatMeat(double weight) {
eat(weight); // tutaj także słowo super nie jest potrzebne bo nie ma konfliktu nazw
}
}
package pl.kodolamacz.zoo;
public class Parrot extends Animal implements Herbivore {
@Override
public void eatPlant(double weight) {
eat(weight);
}
}
Dzięki temu zabiegowi słoń będący obiektem klasy Elephant jest zwierzęciem ale także roślinożercą. Odbywa się to dzięki użyciu słowa specjalnego implements w klasie implementującej dany interfejs.
Poniżej przykład użycia interfejsów w naszym programie:
package pl.kodolamacz.zoo;
import java.util.ArrayList;
import java.util.List;
public class ZooApplication {
public static void main(String[] args) {
Elephant elephant1 = new Elephant();
elephant1.setName("Hadoop");
elephant1.eat(7000);
elephant1.growOld(11);
Elephant elephant2 = new Elephant();
elephant2.setName("Dumboo");
elephant2.eat(6000);
elephant2.growOld(77);
Lion lion1 = new Lion();
lion1.setName("Simba");
lion1.eat(100); // możemy tak
lion1.eatMeat(100); // lub tak
// lion1.eatPlant(100); // ale nie tak...
lion1.growOld(24);
Parrot parrot1 = new Parrot();
parrot1.setName("Ara");
parrot1.eatPlant(1);
parrot1.growOld(5);
List<Elephant> elephants = new ArrayList<>();
elephants.add(elephant1);
elephants.add(elephant2);
List<Animal> animals = new ArrayList<>();
animals.add(elephant1);
animals.add(elephant2);
animals.add(lion1);
animals.add(parrot1);
List<Herbivore> herbivores = new ArrayList<>();
herbivores.add(elephant1);
herbivores.add(elephant2);
herbivores.add(parrot1); // papuga jest rożlinożercą!
// herbivores.add(lion1); // tak nie można!
System.out.println("Liczba słoni: " + elephants.size());
System.out.println("Liczba roślinożerców: " + herbivores.size());
System.out.println("Liczba zwierząt: " + animals.size());
}
}
Od wersji ósmej języka Java interfejsy mogą mieć także domyślne implementacje metod, nie tylko jak powyżej, samą definicję, że takie metody mają istnieć:
package pl.kodolamacz.zoo;
public interface Carnivore {
void eatMeat(double weight);
default void roar() {
System.out.println("ROARRRRR....");
}
}
Dzięki temu każda klasa implementująca interfejs Carnivore będzie potrafiła “ryczeć”. Jednak nic nie stoi na przeszkodzie, by nadpisać tą implementację własną. Interfejsy mogą także mieć pola i metody statyczne.
Wyzwanie
A teraz czas na wyzwanie. Tym razem zadanie będzie projektowe a nie algorytmiczne, jak było to w przypadku wyzwania dotyczącego algorytmów i struktur danych w Javie.
Celem wyzwania jest stworzenie klas do systemu wypożyczalni pojazdów. Każdy pojazd ma swój numer rejestracyjny, numer vin, kolor, cenę, spalanie, stan zbiornika paliwa oraz licznik przejechanych kilometrów. Samochody dzielimy na osobowe, dostawcze, motocykle i maszyny robocze. W zależności od typu, pojazdy mogą mieć dodatkowe cechy jak np. liczba drzwi w przypadku aut osobowych, której to cechy nie będą mieć motocykle. Pojazdy będą mieć silnik diesla, silnik na benzynę lub elektryczny. Należy korzystając z klas i interfejsów stworzyć model obiektowy tego systemu. Całość należy przetestować analogicznie do klasy ZooApplication, tworząc po kilka przykładowych samochodów. Klasy powinny mieć odpowiednie konstruktory oraz metody jedź oraz tankuj, zmieniające pola licznika kilometrów oraz stanu zbiornika paliwa.
Rozwiązanie wyzwania #5 opublikujemy w piątek na stronie naszego wydarzenia na Facebooku.
Jak uda się Wam poprawnie wykonać zadanie - pochwalcie się tym koniecznie w przeznaczonym do tego poście na FB!
Kolejna część wyzwania za tydzień, 21 maja o godz. 10:00.
Powodzenia!
Gotowe rozwiązanie zadania 5 znajdziecie tutaj.
Wszystkie wspisy z serii #javowewyzwanie:
Wszystkie wspisy z serii #javowewyzwanie:
Wyzwanie 2 - Podstawowe instrukcje
Wyzwanie 3 - Programowanie obiektowe
Wyzwanie 4 - Algorytmy i struktury danych w języku Java
Wyzwanie 5 - Interfejsy i dziedziczenie
Wyzwanie 6 - Operacje wejścia - wyjścia
Wyzwanie 7 - Programowanie funkcyjne
Materiały dodatkowe do wyzwania:
[Wprowadzenie do świata języka Java
Czego się uczyć by zostać programistą?
Java od środka, czyli jak to wszystko działa?
Wprowadzenie do Apache Maven, czyli jak tworzy się projekty w świecie Java