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.

public abstract class AbstractCompany {
    
  public boolean canHire() {
    return true;
  }

  public abstract String getMonthlyReport();
}

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

public class SubCompany extends AbstractCompany {

  @Override
  public String getMonthlyReport() {
    return "empty";
  }
}

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

public class SubCompany extends AbstractCompany{
  
  @Override
  public String getMonthlyReport() {
    return "empty";
  }

  @Override
  public boolean canHire() {
    return false;
  }
}

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.

public interface MyPrinter {
    void print();
}

Użycie interfejsu funkcyjnego:

public class Main {

  private static MyPrinter printer = () -> System.out.println("Print something");

  public static void main(String[] args) {
    System.out.println("====");
    printer.print();
    System.out.println("====");
  }
}

Wynik takiego programu:

====
Print something
====

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 (inaczej niż klasy, moga dziedziczyć z wielu interfejsów).

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:

AbstractCompany company = new AbstractCompany() {
  public String getMonthlyReport() {
    return "Report";
  }
};

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ę:

public interface MessageSender {
  void sendMessage(String recipient, String message);
}

i implementacje tego interfejsu:

class EmailSender implements MessageSender {
    @Override
    public void sendMessage(String recipient, String message) {
        // tutaj implementacja wysyłania maila
    }
}

class SmsSender implements MessageSender {
    @Override
    public void sendMessage(String recipient, String message) {
        // tutaj implementacja wysyłania smsa
    }
}

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:

public interface User {
  void canLogin();
  void canHire();
  void canFire();
  void eat();
  void sleep();
}

możemy zastąpić trzema mniejszymi:

public interface User {
    void canLogin();
}

public interface Employee {
    void canHire();
    void canFire();
}

public interface Person {
    void eat();
    void sleep();
}

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.

public class CompanyEmployee implements User, Employee {
    @Override
    public void canHire() {
        // tutaj implementacja
    }

    @Override
    public void canFire() {
        // tutaj implementacja
    }

    @Override
    public void canLogin() {
        // tutaj implementacja
    }
}

 

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

 

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<<

25 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.

      1. Interfejs, może posiadać własne pola. Wszystkie pola interfejsu muszą być publiczne, statyczne i finalne, ale oczywiście nie trzeba ich w ten sposób deklarować 🙂

        1. Wszystkie pola w interfejsie są domyślnie public static final. Jak przypiszesz raz coś do takiego pola, to nie możesz tego już zmienić (to daje final), jest tylko jedna wartość takiego pola w całej maszynie wirtualnej (to daje static). W klasach jest zupełnie inaczej, po utworzeniu obiektu z klasy każde pole może mieć specyficzną wartość dla danego obiektu, poza tym pola w klasie nie muszą być final, więc można je zmieniać.

      2. W takim razie jeśli mamy stałe pola w interfejsach, to posiadają one stan?
        Co rozumiesz przez „Stan” obiektu, stan definiują chyba zmienne- pola klasy? Skoro ma pola, co prawda statyczne, to jakiś stan posiada ? Czy coś nie kumam?

        1. Stałe to pola statycznie, nie przynależą do instancji obiektu, zawsze możesz się do nich odwołać poprzez TwoJaKlasa.polestatyczne (nie potrzebujesz tutaj instancji obiektu). Pola statyczne można traktować jako takie „dodatkowe miejsce w pamięci”. W Javie jest tak, że musisz je definiować w jakiejś klasie lub interfejsie i jak są prywatne (w klasie), to są widoczne tylko w danej klasie, ale tak jak napisałem wyżej, nie przynależą do instancji obiektu, który utworzysz z takiej klasy, bo możesz się do nich odwoływać bezpośrednio, bez utworzenia instancji danego obiektu (chyba, że są prywatne, wtedy ich nie widać na zewnątrz).

          „Stan obiektu”, to stan obiektu w momencie jego utworzenia (poprzez new) i przez cały czas istnienia tego obiektu. Jeśli obiekt jest zmienny (mutable), to taki stan może się zmieniać.

          Zmieniać w obiekcie możesz tylko jego pola. Np public int counter; Zmienne statyczne, stałe, to nie jest stan obiektu (to zmienne statyczne, albo składniki statyczne klasy i egzystują na poziomie klasy a nie obiektu, gdzy robisz new TwojaKlasa() to nie inicjalizują się, żadne składniki statyczne, tylko składniki dynamiczne, czyli pola).

          Nie wiem, czy udało mi się to dobrze wyjaśnić, więc jak jeszcze coś jest niejasne to pisz 😉

  3. Dzięki. Jest czytelnie nawet dla laika obcującego z interfejsami od tygodnia 😉 Pozdrawiam.

  4. Świetnie, że udało się wpleść reguły solid i przy okazji je bardzo sensownie wytłumaczyć!

  5. Dzięki za świetny artykuł! 🙂 Będę wdzięczny za informację, dlaczego stworzenie klasy anonimowej nie jest zalecane? Skoro np wyrażenia lambda są często dobrym rozwiązaniem, a w zasadzie robią coś bardzo podobnego do klasy anonimowej, tylko zwięźlej… Nie zrozumiałem tego 🙂

    1. Dzięki Łukasz za komentarz. Wyrażenia lambda to nie to samo co klasa anonimowa, chociaż na poziomie implementacji można te konstrukcje stosować wymiennie. Różnica tkwi w tym jak te konstrukcje są zaimplementowane w języku (jak są kompilowane). Klasa anonimowa jest kompilowana do osobnego pliku. Np. jeśli masz jakąś klasę np. Application.java i w niej użyjesz kilka razy klas anonimowych, to kompilator po skompilowaniu stworzy skompilowaną klasę Application.class i do tego dla każdego użycia klasy anonimowej stworzy dodatkowe pliki: Application$1.class, Application$2.class itd. w małej aplikacji nie jest to problemem, będziesz miał kilka dodatkowych plików. Natomiast jeśli chcesz używać klas anonimowych w dużej aplikacji, to zaczyna to stwarzać problemy wydajnościowe (w skrócie taka klasa musi zostać załadowana przez classLoader). Natomiast wyrażenie lambda jest kompilowane do tej samej klasy. W byte kodzie lambda jest kompilowana do metody statycznej (i tak samo jest ładowana). Lambdy zostały tak skonstruowane właśnie ze względów wydajnościowych i dlatego w Javie 8 i wyższych należy używać wyrażeń lambda, a nie klas anonimowych (oczywiście tam, gdzie jest to możliwe – jeśli interfejs ma więcej niż jedna metodę abstrakcyjną, to już nie użyjesz lambdy, tylko klasy anonimowej lub normalnej).

  6. A mam takie pytanie:
    Co jeśli interfejs dziedziczy po innym interfejsie i w obu tworach jest metoda o takiej samej sygnaturze? Która metoda ma pierwszeństwo?
    To samo odnośnie klasy abstrakcyjnej. Miałem takie pytanie na rozmowie kwalifikacyjnej.

    1. Jeśli chodzi o interfejsy, to w takim przypadku w klasie możesz zaimplementować tylko jedną metodę, ponieważ w interfejsie dziedziczącym sygnatura metody zostaje nadpisana przez tę samą sygnaturę (Override). I tak naprawdę nie ma to wpływy na działanie aplikacji.

      Co do klas abstrakcyjnych i metod abstrakcyjnych, to jest tak samo jak w przypadku interfejsów i ich metod.

      Co do metod normalnych (public, protected), to też jest tak samo z tą różnicą, że tu też masz implementację i tym razem nadpisujesz metodę wraz z implementacją, więc może to zmieniać zachowanie twojej aplikacji.

  7. Czemu jeżeli Interfejs może dziedziczyć po jednym interfejsie, to czemu kompilator w takim przypadku:
    @Repository
    public interface AuthorRepository extends JpaRepository, CrudRepository
    nie krzyczy mi że coś jest źle? (Java 15)

    1. Hej, w artykule był błąd (już poprawiłem). Oczywiście interfejsy mogą dziedziczyć z wielu interfejsów. Aż sam się zdziwiłem, że takiego byka walnąłem 😉

  8. Świetny artykuł! Fajnie rozwinięte wszystkie informacje. Łatwe do zrozumienia przez początkujących programistów i duży plus za powiązanie z regułami SOLID
    Dzięki!

Komentarze są zamknięte.