Klasa abstrakcyjna vs interfejs

Pytania rekrutacyjne – czym różni się klasa abstrakcyjna od interfejsu?

Na to z pozoru proste pytanie możesz odpowiedzieć na kilka sposobów. Możesz skupić się tylko na różnicach technicznych pomiędzy klasą abstrakcyjną i interfejsem. To jest ta prosta część. Możesz też zagłębić się bardziej w temat i opowiedzieć o tym, kiedy stosować klasę abstrakcyjną, a kiedy interfejs. Ale to nie wszystko, bo możesz także powiedzieć o różnych regułach, które można naruszyć korzystając z jednej lub z drugiej konstrukcji. O tym wszystkim przeczytasz w poniższym artykule.

Klasa abstrakcyjna

Jest to klasa, z której nie można utworzyć obiektu (w tradycyjny sposób), można po niej jedynie dziedziczyć. Klasa taka może mieć metody, które posiadają implementację, ale także metody abstrakcyjne czyli takie, które są jedynie deklaracją metody, która powinna zostać zaimplementowana w klasie potomnej.

Klasa ta może być podstawą dla innych klas.  Potomne klasy dziedziczą wszystkie zachowania klasy abstrakcyjnej (zachowania w kodzie reprezentowane są przez metody, abstrakcyjne i nie abstrakcyjne).

W Javie jest ograniczenie dziedziczenia do jednej klasy, więc każda klasa potomna może dziedziczyć tylko raz. Jeżeli chcemy obejść to ograniczenie, musimy ułożyć klasy w postaci hierarchii, co zwykle nie jest za bardzo wygodne. I praca z taką hierarchią jest coraz trudniejsza wraz ze wzrostem ilości klas, które umieścimy w takiej hierarchii.

Podklasa, która dziedziczy po klasie abstrakcyjnej, może w dowolny sposób implementować metody abstrakcyjne:

Każda podklasa może nadpisywać zachowania, czyli nadpisywać publiczne i chronione (public i protected)  metody odziedziczone z klasy bazowej:

Klasa abstrakcyjna, podobnie jak normalna klasa, może przechowywać stan (może mieć pola), który może być dowolnie modyfikowany.

 

Interfejs

Interfejsy to typy, które są bardzo podobne do klas, ale mogą zawierać jedynie stałe i deklaracje metod. Od Javy 8 mogą zawierać także metody default i metody statyczne (które mogą zawierać implementację). A od Javy 9 też metody prywatne. Interfejsy nie mogą mieć stanu.

Służą one do definiowania zachowania dla obiektów. Ale inaczej niż w przypadku klasy abstrakcyjnej nie definiują tego zachowania (metody nie mają implementacji). Każda klasa implementująca interfejs musi mieć implementację metod zawartych w interfejsie.

Metody default i statyczne zostały wprowadzone w Javie 8 w celu poprawienia kompatybilności wstecznej. Wprowadzenie ich pozwala zmieniać interfejsy, które zostały już zaimplementowane przez różne klasy, ale nie wymusza automatycznie zmian implementacji tych klas. Dzięki temu, twórcy Javy mogli zmieniać interfejsy takie jak List, czy Map bez zmiany wszystkich implementacji list i map w bibliotece standardowej.

 

Jeśli w swoim interfejsach implementujesz metody default i statyczne, to zastanów się, czy faktycznie używasz ich zgodnie z przeznaczeniem!

 

Dodatkowo jeżeli interfejs posiada tylko jedną metodę abstrakcyjną, to możemy uznać go za interfejs funkcyjny. Interfejsy funkcyjne służą do implementowania wyrażeń lambda od Javy 8.

Użycie interfejsu funkcyjnego:

Wynik takiego programu:

Interfejs funkcyjny możemy oznaczyć adnotacją @FunctionalInterface, ale jest to adnotacja informacyjna (i bez niej taki interfejs też może być używany jako lambda).

Kolejną cechą interfejsów jest to, że klasy mogą implementować wiele interfejsów (jest to niekiedy nazywane wielo-dziedziczeniem w Javie, ale nie jest to klasyczne wielo-dziedziczenie, tak jak np. w C++, gdzie klasa może dziedziczyć z wielu klas). Dodatkowo interfejsy mogą także dziedziczyć z innych interfejsów (tak jak klasy, każdy może dziedziczyć tylko z jednego interfejsu).

Implementacja wielu interfejsów w klasie daje nam bardzo duże możliwości. Jeśli mamy jakąś klasę, to zawsze możemy do nie dodać jakieś zachowanie, poprzez zaimplementowanie kolejnego interfejsu. Dzięki interfejsom nie musimy dziedziczyć implementacji, a także interfejsy nie wymuszają na programiście konkretnej implementacji danego zachowania. Interfejs daje tylko szkielet tego, co programista powinien zaimplementować, ale nie narzuca, jak ma być to zrobione.

 

Klasy anonimowe

Dla obu tych konstrukcji nie możemy utworzyć obiektów w klasyczny sposób poprzez new MyType(); Ale z klasy abstrakcyjnej i interfejsu możemy stworzyć klasę anonimową, czyli właściwie utworzyć klasę i w tym samym miejscy obiekt, np:

Ale nie jest to konstrukcja zalecana. Lepiej jest utworzyć konkretną podklasę dziedzicząca z danej klasy abstrakcyjnej lub zaimplementować dany interfejs w klasie konkretnej i dopiero utworzyć obiekt.

 

Klasa Abstrakcyjna – kiedy stosować?

Klasy abstrakcyjne stosuje się dużo rzadziej niż interfejsy, ale jest kilka przypadków gdzie sprawdzają się bardziej niż interfejsy. Jeśli chcemy współdzielić część kodu pomiędzy klasami, mamy jakąś określoną hierarchię, którą chcemy odwzorować w kodzie np. hierarchię pracowników w przedsiębiorstwie, gdzie każdy kolejny wyższy poziom z hierarchii dziedziczy zachowania z niższego lub dziedziczy je z niewielkimi modyfikacjami. Problemem może być tutaj to, że taka hierarchia może zmieniać się w czasie i zmiany w takiej hierarchii mogą być trudne do odwzorowania w kodzie.

Natomiast zaleta klasy abstrakcyjnej to łatwiejsza rozbudowa. Jeśli mamy klasę bazową i podklasy, które po niej dziedziczą, to bardzo łatwo dodać kolejną metodę w klasie abstrakcyjnej. Dzięki czemu każda klasa potomna ma od razu dostępną daną funkcjonalność.

Dodawanie kolejnych metod w interfejsach, które implementują wiele klas wprowadza konieczność zmieniania tych wszystkich klas.

Wzorzec Template Metod

Jednym z dobrych przykładów wykorzystania klasy abstrakcyjnej jest wzorzec projektowy metoda szablonowa. Jest to wzorzec trochę podobny do wzorca strategia, z ta różnicą, że w tym przypadku używamy interfejsu do implementacji różnych algorytmów. W metodzie szablonowej natomiast, mamy jeden algorytm, a tylko pewne jego części są zaimplementowane w różny sposób w poszczególnych podklasach.

 

Interfejs – kiedy stosować?

Gdy potrzebujesz dodać jakieś zachowania do danej klasy wystarczy, że zaimplementujesz kolejny interfejs. Unikasz wtedy problemu z skomplikowanym kodem w hierarchii klas i z nadpisywaniem metod z klasy bazowej, na kolejnych poziomach hierarchii. Prowadzi to często do wielu niejasności. Na pierwszy rzut oka nie widać co i jak zostało nadpisane.

Interfejsy możemy stosować w zasadzie wszędzie tam, gdzie potrzebujemy różnych implementacji tego samego interfejsu. Przykładowo, jeżeli mamy interfejs MessageSender, to w zależności od kanału dystrybucji jaki wybierzemy, będziemy mieli zupełnie inną implementację:

i implementacje tego interfejsu:

Dziedziczenie implementacji bywa trudne zwłaszcza, że każdy kod i każda hierarchia klas ma tendencję do rozrastania się. Dlatego zaleca się stosowanie interfejsów zamiast klas abstrakcyjnych (Effective Java, Item 18).

Klasa abstrakcyjne – reguły powiązane

Jedną z najbardziej przydatnych reguł przy stosowaniu klas abstrakcyjnych jest Liskov substitution principle, czyli zasada podstawienia sformułowana przez Barbarę Liskov. Pochodzi ona ze zbioru reguł SOLID spopularyzowanych przez Roberta C. Martina (Wujka Boba). Zasada ta wygląda tak: „Funkcje, które używają wskaźników lub referencji do klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów”. Co oznacza, że Twój program powinien działać poprawnie, niezależnie od tego, czy używasz typu bazowego, czy jego podtypów.

Jak rozpoznać w kodzie, czy ta reguła została złamana? Jeśli w klasie potomnej nadpiszemy metody z klasy bazowej, to naturalnie doprowadzi może doprowadzić to do sytuacji, że dana klasa potomna będzie zachowywała się inaczej niż jej klasa bazowa. I wtedy konieczna jest znajomość tej implementacji, żeby ocenić jak ta dana klasa pochodna będzie się zachowywać. A reguła mówi wyraźnie „bez dokładnej znajomości tych obiektów”.

 

Interfejs – reguły powiązane

Jedną z ważniejszych reguł, której powinniśmy przestrzegać przy stosowaniu interfejsów jest Interfejs segregation principal, czyli reguła segregacji interfejsów. Także pochodzi ona ze zbioru reguł SOLID. I polega na tym, że powinniśmy stosować podział interfejsów. Zamiast jednego interfejsu, który definiuje bardzo dużo zachowań, powinniśmy podzielić dany interfejs na mniejsze, które zawierałyby wydzielone, powiązane ze sobą zachowania.

Przykładowo taki interfejs:

możemy zastąpić trzema mniejszymi:

Wtedy możemy używać tych interfejsów w bardziej elastyczny sposób. Nie każdy User to pracownik. Także, nie zawsze będziemy potrzebować zachowań, które definiuje interfejs Person.

 

Zawsze powinieneś stosować interfejsy.

Jeśli nie wiesz co zastosować, to w 99% przypadków powinieneś stosować interfejsy. Interfejsy są dużo prostsze w utrzymaniu, w stosunku do np. hierarchii klas, która bardzo szybko potrafi się rozrastać i stwarzać bardzo dużo problemów. Dodatkowo, możliwość nadpisywania metod w całej hierarchii może znacznie skomplikować i doprowadzić do wielu trudno wykrywalnych błędów.

Takie podejście jest też zalecane przez wielu ekspertów, między innymi przez Joshue Blocha – autora kultowej moim zdaniem książki Effective Java.

 

Podsumowanie

Tak jak w przypadku każdego pytania rekrutacyjnego, nie wystarczy nauczyć się odpowiedzi. Trzeba przede wszystkim rozumieć omawiane zagadnienie. Jeśli nie zrozumiesz danego zagadnienia, to osoba rekrutująca Cię bardzo szybko zorientuje się, że wyuczyłeś się tej odpowiedzi. Dlatego warto znać szerszy kontekst danego problemu. I ten szerszy kontekst starałem się przedstawić właśnie w tym artykule. Daj znać w komentarzach, czy mi się udało ?

 

Źródła:

https://docs.oracle.com/javase/specs/jls/se14/html/jls-8.html#jls-8.1.1.1

https://docs.oracle.com/javase/specs/jls/se14/html/jls-9.html

 

Mini kurs testy jednostkowe

Mateusz Dąbrowski

Cześć jestem Mateusz, zajmuję się programowaniem już ponad 12 lat z czego ponad 8 programuję w Javie. Zapraszam Cię do lektury mojego bloga. Możesz przeczytać więcej o mnie >>TUTAJ<<

10 thoughts to “Pytania rekrutacyjne – czym różni się klasa abstrakcyjna od interfejsu?”

  1. > Jak rozpoznać w kodzie, czy ta reguła została złamana? Jeśli w klasie potomnej nadpiszemy metody z klasy bazowej, to naturalnie doprowadzi to do sytuacji, że dana klasa potomna będzie zachowywała się inaczej niż jej klasa bazowa. I wtedy konieczna jest znajomość tej implementacji, żeby ocenić jak ta dana klasa pochodna będzie się zachowywać. A reguła mówi wyraźnie „bez dokładnej znajomości tych obiektów”.

    Czy mówisz, że dziedziczenie z nadpisaniem metody łamie LSP?

    1. Może i w wielu przypadkach tak się dzieje. Ale każdy przypadek trzeba rozpatrywać osobno. Jeśli zmienia się zachowanie klasy bazowej, to reguła LSP może być złamana.

  2. W Javie chyba było jeszcze można deklarować zmienne w interfacach.
    A tak idealna odpowiedz podczas rozmowy o pracę 🙂

    1. Dzięki. W interfejsach możesz definiować tylko stałe i metody. Oczywiście jak implementujesz metody default czy statyczne to możesz mieć w nich zmienne lokalne. Ale nie możesz mieć modyfikowalnych pól tak jak w klasie.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *