Czym jest interfejs?

Jak ten czas szybko mija! Zanim się obejrzałem, a już się zaczął grudzień. Skończył się najbardziej przygnębiający miesiąc w roku. A Spotify udostępnił wszystkim podsumowanie muzyki słuchanej w 2020. Wyszło, że słuchałem rocka (w różnych odcieniach), ale moim ulubionym wykonawcą został Adam Skorupa – autor muzyki do drugiej części Wiedźmina. Ogólnie polecam! A co wyszło Tobie? Może podzielisz się tym w komentarzu? Wciąż mam nadzieję, że ktoś zacznie dodawać komentarze do moich artykułów.

Bardzo dawno nic nie napisałem i źle mi z tym. Jako usprawiedliwienie powiem tylko, że poświęciłem ostatnio dużo czasu i energii na zbieranie materiałów do lekcji dla licealistów. Materiałów zebrałem, jak to ja, więcej niż wykorzystałem. Więc w najbliższym czasie przedstawię te materiały szerszemu gronu odbiorców. Być może w jakiejś niestandardowej dla mnie formie.

Zanim przejdę do tematu, muszę się przyznać do dużej porażki. Moja waga znów przekroczyła 100 kg. To była wartość graniczna. Przez ostatnie 2 lata udało mi się ważyć mniej. W związku z tym zrobiło mi się smutno i postanowiłem działać. Już od początku studiów znam program http://www.100pompek.pl/ i już miałem kilka podejść, aby osiągnąć setkę. Ale nigdy mi się nie powiodło. Może publiczne pochwalenie się tym postanowieniem da większą motywację. Zacząłem od 19 pompek – ale niestety od pompek lekkich (dół ciała podtrzymuję na kolanach zamiast na palcach). Będę się chwalił postępami. A z całą pewnością pochwalę się, gdy dojdę do 100 normalnych!

Przejdźmy w końcu do tematu

Słowo interfejs w informatyce nie jest jednoznaczne. Bardzo często mówi się o interfejsie urządzenia, czy też interfejsie aplikacji (również strony internetowej rozumianej jako aplikacja webowa). Mówi się, że interfejs jest czytelny, użyteczny, wygodny…

Ale programiści Javy znają jego drugie znaczenie. Ale czy rzeczywiście jest to drugie znaczenie? A może oba spojrzenia można ze sobą połączyć? Zanim powiemy sobie o interfejsach, zróbmy krótką dygresję o klasach

Klasy abstrakcyjne

W języku C++ nie ma czegoś takiego jak interfejs. Są natomiast klasy, wśród których wyróżnia się klasy abstrakcyjne. W przypadku C++ klasy abstrakcyjnej nie oznacza się w żaden wyrafinowany sposób. Klasa jest abstrakcyjną, gdy z premedytacją pomijamy ciało przynajmniej jednej z metod. Ale musimy zasygnalizować, aby kompilator nie poszukiwał nigdzie tego ciała. W tym celu nagłówkowi metody przypisujemy 0. Czyli na przykład:

class Foo
{
        public:
                virtual void foo() = 0;
};

Próba utworzenia obiektu klasy Foo się nie powiedzie.

W Javie odbywa się to trochę inaczej. Klasa w Javie jest abstrakcyjna, gdy określimy ją jako abstract:

public abstract class Foo {
        public void foo() {
                System.out.println("FOO");
        }
}

To, że klasa jest abstrakcyjna oznacza, że nie możemy stworzyć instancji tej klasy. W przeciwieństwie do C++ nie musimy mieć ani jednej metody abstrakcyjnej. Jednak, jeżeli w klasie zdefiniujemy przynajmniej jedną metodą abstrakcyjną, to klasa z automatu staje się abstrakcyjna – a my jako programiści – musimy ją jaką taką oznaczyć. Czyli kod:

public class Foo {
        public abstract void foo();
}

nie skompiluje się:

$ javac Foo.java
Foo.java:1: error: Foo is not abstract and does not override abstract method foo() in Foo
public class Foo {
       ^
1 error

W kontekście dziedziczenia, klasa abstrakcyjna zachowuje się jak każda inna klasa – czyli może rozszerzać tylko jedną klasę. Oczywiście po klasie abstrakcyjnej może dziedziczyć (czyli po javowemu rozszerzać) wiele klas. Z zastrzeżeniem, że każda klasa może być bezpośrednią podklasą tylko jednej klasy na raz.

Po co klasa abstrakcyjna?

Takie pytanie zawsze się pojawia, gdy mówi się o klasie abstrakcyjnej po raz pierwszy. Więc po co? Zasadniczo używamy jej, gdy szukamy wspólnych cech dla różnych obiektów, a znaleziony zbiór cech wspólny jest na tyle mało konkretny, że nie umiemy zaimplementować którejś z metod. Naturalne pytanie po takim wytłumaczeniu to: no to po co taka klasa, w której nie umiemy zdefiniować jakiegoś zachowania? Odpowiedź na to nie jest może oczywista, ale nie jest trudna. Z reguły chcemy w ten sposób wymusić zdefiniowanie jakiegoś określonego zachowania, dla bardzo szerokiej gamy obiektów. Wiem, bez konkretów brzmi to bardzo mętnie. Przejdźmy więc do przykładu.

Rozważmy prostą aplikację, która na ekranie ma rysować różne kształty geometryczne. Na początek chcemy obsłużyć koła i prostokąty. Definiujemy więc takie klasy:

class Color {
    int red;
    int green;
    int blue;
}

class Point {
    int x;
    int y;
}

class Rectangle {
    Color color;
    Point position;
    int height;
    int width;
    
    void draw() {
        // logic to draw rectangle
    }
}

class Circle {
    Color color;
    Point position;
    int radius;
    
    void draw() {
        // logic to draw circle
    }
}

I dla takich klas możemy napisać prosty program:

public class Shapes {
    public static void main(String[] args) {
        List<Rectangle> rectangles = null; // there should be a creation of collection of rectangles here
        List<Circle> circles = null; // there should be a creation of collection of circles here
        
        for (Rectangle r : rectangles) {
            r.draw();
        }
        
        for (Circle c : circles) {
            c.draw();
        }
    }
}

Jeżeli ktoś słyszał o dziedziczeniu, to tutaj nasuwa się od razu następujący pomysł:

class Shape {
    Color color;
    Point position;
}

class Rectangle extends Shape {
    int height;
    int width;
    
    void draw() {
        // logic to draw rectangle
    }
}

class Circle extends Shape {
    int radius;
    
    void draw() {
        // logic to draw circle
    }
}

I takie rozwiązanie pozwala zaoszczędzić trochę czasu na pisanie kodu, który powtórzyłby się w wielu klasach. Ale nie pokazuje w pełni możliwości, które daje nam dziedziczenie…

Spójrzmy na program. Z dwóch list możemy zrobić jedną listę:

List<Shape> shapes;

W której będziemy przechowywać zarówno prostokąty jak i koła (trudne słowo: taką kolekcję nazywamy heterogoniczną – czyli zawierającą elementy różnych typów). Więc super, ale niestety nie możemy zastąpić dwóch pętli jedną… No bo w klasie Shape nie mamy zdefiniowanej żadnej wspólnej akcji. Widzimy, że w obu klasach jest zdefiniowana metoda draw(). Moglibyśmy ją umieścić w klasie Shape. Ale nie wiemy, jak rysować dowolny kształt… Więc nie wiemy jak zaimplementować metodę draw() w klasie Shape.

Możemy zastosować bardzo łopatologiczne rozwiązanie:

  1. Zdefiniuj w klasie Shape metodę draw()
  2. Metoda będzie rzucała wyjątkiem przy każdej próbie uruchomienia
  3. W klasach Circle i Rectangle metoda zostanie nadpisana, więc wyjątek nie zostanie rzucony
  4. Oczywiście wyjątek powinien być typu unchecked –  moim zdaniem, rzucanie wyjątkami unchecked powinno być bardzo sensownie uzasadnione – to nie łapie się pod takie

Mamy rozwiązanie, które działa, ale nie jest zgodne z duchem programowania obiektowego (pozdrowienia dla programistów Pythona)! Przede wszystkim o wyjątku dowiemy się w momencie działania programu, a to może być za późno.

No ale wcześniej już sobie powiedzieliśmy, że jak nie wiem jak zdefiniować jakąś metodę, ale bardzo mi zależy na tym, żeby ta metoda była zdefiniowana w podklasach to należy zdefiniować metodę jako abstrakcyjną:

abstract class Shape {
    Color color;
    Point position;
    abstract void draw();
}

class Rectangle extends Shape {
    int height;
    int width;
    
    @Override
    void draw() {
        // logic to draw rectangle
    }
}

class Circle extends Shape {
    int radius;
    
    @Override
    void draw() {
        // logic to draw circle
    }
}

I teraz możemy bardzo uprościć nasz program:

public class Shapes {
    public static void main(String[] args) {
        List<Shape> shapes = null; // there should be a creation of collection of circles and rectangled here
        for (Shape s : shapes) {
            s.draw();
        }
    }
}

Ale co z interfejsami?

W każdej standardowej lekcji programowania następuje teraz moment, w którym mówi się o tak abstrakcyjnej abstrakcji, że nie da się w niej zdefiniować niczego konkretnego. Ale szczerze mówiąc, nie potrafię teraz wymyślić tak wysokiej abstrakcji dla tego przykładu. Wiem, że profesor Mrozek, gdy byłem na pierwszym roku, przedstawiał tę ideę na przykładzie kształtów, ale nie mogę sobie jej przypomnieć (to było już jakieś 12 lat temu).

Mówiąc bardzo formalnie: interfejs w Javie to taka klasa abstrakcyjna, w której wszystkie metody są abstrakcyjne oraz dodatkowo nie definiujemy w tej klasie żadnych pól. Ten formalizm został trochę nadwyrężony w ósmej wersji Javy, ale do tego jeszcze wrócę.

Jak już jesteśmy przy formalizmach, to interfejs może rozszerzać dowolnie wiele innych interfejsów (właśnie rozszerzać, używamy do tego słowa kluczowego extends, a kolejne interfejsy oddzielamy od siebie przecinkami). Więcej, każda klasa oprócz tego że rozszerza tylko jedną klasę (jeżeli nie napiszemy tego wprost, to dziedziczy po klasie Object) może implementować dowolnie wiele interfejsów (różnica jest istotna, bo tutaj używamy słowa kluczowego implements).

No to jak formalizmy już omówiliśmy, to przejdźmy do kwestii: po co?

Wyobraźmy sobie, że tworzymy program, który będzie rysował różne obiekty. Czyli będzie rysował koła, kwadraty, trójkąty i inne podstawowe figury geometryczne, które sobie zdefiniujemy. Każda z tych klas będzie rozszerzała klasę Shape. To jest oczywiste, że każdy kształt ma kolor oraz położenie. To nie podlega dyskusji. Ale chcemy pójść krok dalej. Chcemy stworzyć obiekt zawierający jakiś określony zbiór kształtów. W tym celu stwórzmy klasę:

class Picture {
    List<Shape> shapes;
    
    void draw() {
        for (Shape s : shapes) {
            s.draw();
        }
    }
}

Widzisz, że tak naprawdę klasa Picture może mieć listę dowolnych obiektów, które da się narysować. Już widzisz, do czego zmierzam?

Obrazek może się składać z wielu mniejszych obrazków. Albo możemy sobie zdefiniować jakiś fragment obrazka, który będziemy wstawiać jako element składowy do wielu innych obrazków. Skoro od listy shapes wymagamy, żeby jej elementy dało się narysować (wywołać metodą draw()) to moglibyśmy do niej dodać również jakiś inny obiekt klasy Picture. Albo dowolny inny obiekt, który ma zaimplementowaną metodę draw().

Moglibyśmy zdefiniować klasę abstrakcyjną z jedną metodą abstrakcyjną draw(). I teraz obiekt dowolnej podklasy tej klasy będziemy mogli dodać do obrazka. Dlaczego tak się nie robi? Bo jednak wymóg dziedziczenia po jednej klasie, jest bardzo ograniczający.

Dlaczego w Javie nie ma wielodziedziczenia?

Zanim przejdziemy dalej, pozwól że spróbuję wyjaśnić, dlaczego w Javie nie ma wielodziedziczenia – czyli jedna klasa nie może rozszerzać więcej niż jednej klasy. Popatrz na przykład:

class Person {
    String name;
}

class Soldier extends Person {
    String rank;
}

class Priest extends Person {
    String church;
}

class Chaplain extends Soldier, Priest {
    void introduce() {
        System.out.println(name);
    }
}

Gdyby wielodziedziczenie było możliwe, to mógłby się pojawić problem diamentu zaprezentowany w powyższym przykładzie. Klasa Chaplain miałaby pole name odziedziczone po klasie Soldier, oraz drugie pole name odziedziczone po klasie Priest. Twórcy Javy zabronili takiego mechanizmu i przez to ułatwili sobie pracę – implementacja takiego rozwiązania byłaby nietrywialna. Przy próbie kompilacji takiego kodu dostaniemy błąd:

$ javac People.java
People.java:14: error: '{' expected
class Chaplain extends Soldier, Priest {
                              ^
1 error

Ale zastanów się nad tym przez chwilę. Ile takich zawiłych przypadków przychodzi Ci do głowy?

Interfejsy to the rescue!

Wróćmy teraz do przykładu z klasą Picture:

class Picture {
    List<Shape> shapes;
    
    void draw() {
        for (Shape s : shapes) {
            s.draw();
        }
    }
}

Stwierdziliśmy, że dobrym pomysłem byłoby stworzenie klasy abstrakcyjnej:

abstract class Drawable {
    abstract void draw();
}

Ale zauważyliśmy, że wtedy, gdy jakaś klasa rozszerza klasę Drawable, to nie będzie mogła rozszerzać żadnej innej klasy. Takie podejście, może okazać się problematyczne. Bo w praktyce takich klas abstrakcyjnych, które wymuszają jakąś określoną metodą (albo metody) jest bardzo wiele.

Ale zastanów się teraz. Skoro problem diamentu wiąże się z wieloma polami o tej samej nazwie, to jeżeli z klas wyeliminujemy pola, to problem diamentu przestanie istnieć, prawda? No więc właśnie wyróżniono w Javie klasy abstrakcyjne bez pól i nazwano je interfejsami. Nie dotyczy ich problem diamentu, więc dopuszczalne jest wielodziedziczenie. Więc naszą klasę Drawable możemy teraz przerobić na interfejs:

interface Drawable {
    void draw();
}

a pozostałe klasy w naszym programie będą wyglądały następująco:

abstract class Shape implements Drawable {
    Color color;
    Point position;
}

class Rectangle extends Shape {
    int height;
    int width;
    
    @Override
    void draw() {
        // logic to draw rectangle
    }
}

class Circle extends Shape {
    int radius;
    
    @Override
    void draw() {
        // logic to draw circle
    }
}

class Picture implements Drawable {
    List<Drawable> drawables;
    
    @Override
    void draw() {
        for (Drawable d : drawables) {
            d.draw();
        }
    }
}

Ale czy to wystarczy? No nie. Bo nie powiedziałem jednej, bardzo ważnej rzeczy o interfejsach. To że wszystkie metody w interfejsie są abstrakcyjne, jest oczywiste, więc nie musimy o tym pisać – w sensie nie musimy tego zaznaczać w programie – kompilator sam to rozkmini. Ale kompilator założy jeszcze jedną rzecz na temat metod w interfejsach. Co takiego? Rzuć okiem na wynik kompilacji powyższych klas:

$ javac Shapes.java
Shapes.java:24: error: draw() in Rectangle cannot implement draw() in Drawable
        void draw() {
             ^
  attempting to assign weaker access privileges; was public
Shapes.java:33: error: draw() in Circle cannot implement draw() in Drawable
        void draw() {
             ^
  attempting to assign weaker access privileges; was public
Shapes.java:42: error: draw() in Picture cannot implement draw() in Drawable
        void draw() {
             ^
  attempting to assign weaker access privileges; was public
3 errors

Tak, wszystkie metody w interfejsie są automatycznie publiczne. A to oznacza, że metoda nadpisująca taką metodą abstrakcyjną musi być publiczna.

Poza tym formalizmem Javy można odszukać w tym większy sens. I tu wracamy do znaczenia słowa interfejs. Gdy piszemy bibliotekę, to ustalamy jej interfejs – czyli to jak można z biblioteki korzystać – używając interfejsów – czyli klas abstrakcyjnych, które definiują publiczne metody udostępniane przez naszą bibliotekę. Więc w pewnym sensie, obie rzeczy się ze sobą łączą. Chętnie o tym podyskutuję w komentarzach.

Popatrz na standardowe Javowe kolekcje – one opierają się właśnie na interfejsach: Collection, Iterable, List, Set itd. Temat kolekcji jest bardzo ciekawy, więc o nich powstanie seria kolejnych wpisów. Zwróć uwagę, że w większości wypadków piszesz kod z wykorzystaniem interfejsu List, a nie wykorzystując jego implementację ArrayList. A jeżeli tak nie robisz, to jak najszybciej zacznij;) Nie zrozum mnie źle, jak trzeba stworzyć obiekt dla zmiennej typu List to najprawdopodobniej wybierzesz klasę ArrayList, ale jak definiujesz argument metody lub typ zwracany przez metodę powinieneś użyć typu List. No chyba, że bardzo Ci zależy na jakiejś metodzie z konkretnej implementacji – ale z taką sytuacją nie spotkałem się ani razu w czasie mojej kilkuletniej już kariery programisty Javy.

Tak było do Javy 1.7 włącznie, ale potem nadeszła era Javy 1.8

Co zepsuła Java 1.8?

Nie chcę żebyś zrozumiał mnie źle. Bardzo dobrze oceniam rozwój Javy. Naprawdę kolejne wersje idą z duchem czasu. I od czasu Javy 1.8 dużo wygodniej się programuje. Ale jeżeli chodzi o kwestię interfejsów, to twórcy języka nieźle namieszali.

Największą zmianą jest to, że od tej wersji, w interfejsie można definiować ciała metod. Takie metody nazywają się metodami domyślnymi i należy je implementować z użyciem słowa kluczowego default. Popatrz na poniższy przykład:

interface Person {
    default void greetings() {
        System.out.println("Witaj, jestem czlowiekiem");
    }
}

class Wanderer implements Person {
}

public class Creatures {
    public static void main(String[] args) {
        Wanderer w = new Wanderer();
        w.greetings();
    }
}

W moim odczuciu zaprzecza to trochę idei interfejsu (ale trzeba przyznać, że udało się dzięki temu znacząco rozbudować pakiet z Javowymi kolekcjami zachowując wsteczną kompatybilność programów do starszych wersji). Widzimy, że mamy klasę implementującą interfejs, w której nie zdefiniowaliśmy żadnej metody, a jednak metodę możemy wywołać. Całe szczęście wciąż w interfejsach nie możemy definiować pól.

No to zastanów się, co będzie, gdy klasa będzie implementować dwa interfejsy, w których będzie zdefiniowana metoda domyślna o takiej samej sygnaturze:

interface Animal {
    default void greetings() {
        System.out.println("Whhhhrrrrrr");
    }
}

interface Person {
    default void greetings() {
        System.out.println("Witaj, jestem czlowiekiem");
    }
}

class Werwolf implements Person, Animal {
}

Wielodziedziczenie po interfejsach jest jak najbardziej poprawne. Ale kompilacja tych klas się nie powiedzie. Kompilator, przy tworzeniu klasy Werwofl, nie wie którą z domyślnych implementacji ma wybrać:

$ javac Creatures.java
Creatures.java:14: error: types Person and Animal are incompatible;
class Werwolf implements Person, Animal {
^
class Werwolf inherits unrelated defaults for greetings() from types Person and Animal
1 error

W tym wypadku powinniśmy zaimplementować metodę greetings w klasie Werwolf. Całe szczęście możemy się odwołać do obu domyślnych implementacji:

class Werwolf implements Person, Animal {
    @Override
    public void greetings() {
        Animal.super.greetings();
        Person.super.greetings();
    }
}

Składnia dość nieoczywista – podobna do przypadku klas wewnętrznych.

Podsumowanie

Jak mogłeś się przekonać, twórcom Javy przyświecała idea tworzenia prostych aplikacji, bez wielodziedziczenia, które powoduje dużo błędów. Ale jednak w toku rozwoju Javy, zapomnieli o tej prostocie i wprowadzili składnię, która prowadzi do problemu diamentu. Namieszali przez to w teorii dotyczącej klas abstrakcyjnych i interfejsów. Ale z drugiej strony daje to niesamowite możliwości przy programowaniu funkcyjnym. A o tym napiszę już niedługo przy okazji artykułu o interfejsach funkcyjnych.

Dziękuję, że przeczytałeś mój kolejny wpis. Daj mi znać, co sądzisz o mojej pracy. Pamiętaj, że konstruktywna krytyka jest zawsze w cenie!

 

Aspirujący twórca internetowy, który zna się na programowaniu i chce się dzielić wiedzą

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Scroll to top