Languages

  • Polski

» Open close principle (OCP) & Dependency Inversion Principle (DIP)

Wstęp teoretyczny

W pierwszym artykule chciałbym od razu zawrzeć dwie zasady, gdyż zauważyłem, że stosując jedną często mimowolnie zaczynam używać i drugiej.

OCP

„Software entities should be opened for extension, but closed for modification.” – Meyer Bertrand. Jednostki oprogramowania (klasy, moduły, metody, itp.) powinny być otwarte na rozszerzanie (rozwijanie), ale zamknięte na modyfikacje. Co to znaczy „otwarte na rozszerzanie, ale zamknięte na modyfikacje”? Otóż każdy projekt ma tę tendencję, że po napisaniu wersji 1.0.0 developerzy dalej chcą go rozwijać. A to wymyślą nową funkcjonalność, a to użytkownicy zasygnalizują potrzebę dodania innej funkcjonalności. I szkoda by było, gdyby każdorazowa (nawet najdrobniejsza) zmiana ciągnęła za sobą potrzebę „rozbebeszania” całego kodu aplikacji i pisania wielu fragmentów od nowa, bo w takiej postaci nie da się nic nowego wcisnąć… Już widzę z jakim entuzjazmem podchodziłbym do tego typu zadań… Sam podczas pisania pluginów do Minecrafta trafiłem moment, w którym stwierdziłem: „Usuwam wszystko i piszę od nowa!”, bo każda zmiana pociągała za sobą godziny pracy nad modyfikowaniem całej funkcjonalności uprzednio już napisanych linijek kodu… Na szczęście wtedy już zacząłem próbować korzystać z powyższej zasady, więc teraz nie jest aż tak źle. 🙂

DIP

  1. „High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g interfaces).”
  2. „Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.

Autorem tych słów jest powszechnie znany Robert C. Martin (Uncle Bob). Tłumacząc na język polski:

  1. Moduły wysokopoziomowe nie powinny zależeć od niskopoziomowych. Obydwa rodzaje powinny zależeć od abstrakcji (np. interfejsów).
  2. Abstrakcje nie powinny zależeć od szczegółów. Szczegóły (konkretne implementacje) powinny zależeć od abstrakcji.

Te zasady idą często w parze razem z OCP. Bo gdy każda najdrobniejsza zmiana w klasach podrzędnych (niskopoziomowych) wymaga poprawy istniejącego już kodu w klasach nadrzędnych (wysokopoziomowych), to pokazuje to, że aplikacja słabo opiera się na abstrakcjach, a raczej na konkretnych implementacjach. Powoduje to, że w wielu miejscach tworzymy klasy podrzędne, które pasują tylko i wyłącznie do jednego konkretnego użycia. To tak jakbyśmy mieli w każdym PC dedykowany jedyny w swoim rodzaju port USB i tylko jeden rodzaj kabla by do niego pasował. Strasznie niewygodne.

Trochę bardziej praktycznie…

Wyobraźmy sobie, że tworzymy grę z gatunku MMORPG , czyli standardowo są gracze, którzy powinni posiadać jakąś klasę umiejętności typu: wojownik, mag, itp. Weźmy sobie dla uproszczenia tylko jedną. Załóżmy, że mag będzie ciskał jakimiś zaklęciami (nic nowego). Stwórzmy sobie zatem oddzielną klasę dla maga (Mage), ze dwie klasy na dwa zaklęcia (Fireball, Slow) i klasę główną, gdzie będziemy bawić się naszym świeżo napisanym kodem. 🙂

public class Mage {

    public void castSpell(Object spell) {
        if (spell instanceof Fireball) {
            Fireball fireball = (Fireball) spell;
            fireball.cast();
        } else if (spell instanceof Slow) {
            Slow slow = (Slow) spell;
            slow.cast();
        } else {
            System.out.println("No such spell!");
        }
    }
}

public class Fireball {
    public void cast() {
        System.out.println("FIREBALL!");
    }
}

public class Slow {
    public void cast() {
        System.out.println("SLOW!");
    }
}
Show/Hide

Klasy „zaklęciowe” posiadają tylko jedną metodę „cast()”, która dla uproszczenia wyświetla tylko odpowiedni komunikat w konsoli. Klasa Mage steruje wywołaniami „cast()” – z której klasy metoda ma być wywołana. Można to osiągnąć za pomocą drabiny warunków if (jak powyżej), bądź za pomocą przeładowania metod:

public class Mage {

    public void castSpell(Fireball fireball) {
        fireball.cast();
    }

    public void castSpell(Slow slow) {
        slow.cast();
    }
}
Show/Hide

Wracając na chwilę do drabinki… W metodzie „castSpell” argument przekazywany jest typu Object. W Javie jest to typ po którym dziedziczy każdy utworzony obiekt. Zatem każdy obiekt naszej klasy może być również castowany do typu Object i potem z powrotem do naszego wyjściowego typu, np. Fireball. Dzięki temu metoda może przyjąć obiekt dowolnego typu i to powoduje, że może być ona jedna dla wielu wywołań metody „cast” dla różnych obiektów.

W klasie Main możemy sprawdzić jak to wszystko działa:

public class Main {
    public static void main(String[] args) {
        Mage mage = new Mage();

        mage.castSpell(new Fireball());
        mage.castSpell(new Slow());
    }
}
Show/Hide

Powinniśmy otrzymać na wyjściu komunikat:

FIREBALL!
SLOW!
Show/Hide

Dodajmy nowe zaklęcie!

Wszystko fajnie, ale co, gdy będzie potrzeba dodać nowe zaklęcie? Dodajmy je zatem wg aktualnej architektury projektu…

// Nowa klasa "zaklęciowa"
public class DragonShout {
    public void cast() {
        System.out.println("FUS RO DAH!");
    }
}

// Zaktualizowana klasa Mage (drabinkowa)
public class Mage {

    public void castSpell(Object spell) {
        if (spell instanceof Fireball) {
            Fireball fireball = (Fireball) spell;
            fireball.cast();
        } else if (spell instanceof Slow) {
            Slow slow = (Slow) spell;
            slow.cast();
        } else if (spell instanceof DragonShout) {
            DragonShout dragonShout = (DragonShout) spell;
            dragonShout.cast();
        } else {
            System.out.println("No such spell!");
        }
    }
}

// Zaktualizowana klasa Mage (przeładowanie metod)
public class Mage {

    public void castSpell(Fireball fireball) {
        fireball.cast();
    }

    public void castSpell(Slow slow) {
        slow.cast();
    }

    public void castSpell(DragonShout dragonShout) {
        dragonShout.cast();
    }
}

// Zaktualizowana klasa Main
public class Main {
    public static void main(String[] args) {
        Mage mage = new Mage();

        mage.castSpell(new Fireball());
        mage.castSpell(new Slow());
        mage.castSpell(new DragonShout());
    }
}
Show/Hide

No, nawet nie było przy tym tak dużo pracy. Ale co jeśli klasa Mage miałaby jeszcze jedną, dwie metody podobne do „castSpell”, które w taki sam sposób zarządzałyby swoimi klasami? Na przykład mechanizm przywoływania stworów. Każde stworzenie miałoby przypisaną do siebie klasę (jak zaklęcia) i tymi klasami trzeba by było jakoś zarządzać. Albo w drugą stronę… Co jeśli klasy „zaklęciowe” miałyby jeszcze jedną bądź więcej funkcji jak „cast()”? Przecież do każdej funkcji aktualnie jest potrzebna osobna metoda zarządzająca (np. castSpell). Z dodaniem każdego nowego zaklęcia trzeba ogarniać wszystkie instrukcje if (lub dodatkowe metody w przypadku przeładowania metod) dla każdej z metod klasy „zaklęciowej”! Na przykład w grze The Elder Scrolls IV: Oblivion możemy używać zaklęć rzucając do celu, dotykając go bądź możemy go użyć na sobie. Gdybyśmy chcieli również zaimplementować ten sposób, ale z naszą aktualną architekturą kodu, to wyglądałoby to jakoś tak:

// Nowa klasa "zaklęciowa"
public class DragonShout {
    public void cast() {
        System.out.println("Cast: FUS RO DAH!");
    }

    public void useOnYourself() {
        System.out.println("Use on Yourself: FUS RO DAH!");
    }

    public void touch() {
        System.out.println("Touch: FUS RO DAH!");
    }
}

// Zaktualizowana klasa Mage (drabinkowa)
public class Mage {

    public void castSpell(Object spell) {
        if (spell instanceof Fireball) {
            Fireball fireball = (Fireball) spell;
            fireball.cast();
        } else if (spell instanceof Slow) {
            Slow slow = (Slow) spell;
            slow.cast();
        } else if (spell instanceof DragonShout) { // nowy kod
            DragonShout dragonShout = (DragonShout) spell;
            dragonShout.cast();
        } else {
            System.out.println("No such spell!");
        }
    }

    public void useOnYourself(Object spell) {
        if (spell instanceof Fireball) {
            Fireball fireball = (Fireball) spell;
            fireball.useOnYourself();
        } else if (spell instanceof Slow) {
            Slow slow = (Slow) spell;
            slow.useOnYourself();
        } else if (spell instanceof DragonShout) { // nowy kod
            DragonShout dragonShout = (DragonShout) spell;
            dragonShout.useOnYourself();
        } else {
            System.out.println("No such spell!");
        }
    }

    public void touch(Object spell) {
        if (spell instanceof Fireball) {
            Fireball fireball = (Fireball) spell;
            fireball.touch();
        } else if (spell instanceof Slow) {
            Slow slow = (Slow) spell;
            slow.touch();
        } else if (spell instanceof DragonShout) { // nowy kod
            DragonShout dragonShout = (DragonShout) spell;
            dragonShout.touch();
        } else {
            System.out.println("No such spell!");
        }
    }
}

// Zaktualizowana klasa Mage (przeładowanie metod)
public class Mage {

    public void castSpell(Fireball fireball) {
        fireball.cast();
    }

    public void castSpell(Slow slow) {
        slow.cast();
    }

    public void castSpell(DragonShout dragonShout) { // nowy kod
        dragonShout.cast();
    }

    public void useOnYourself(Fireball fireball) {
        fireball.cast();
    }

    public void useOnYourself(Slow slow) {
        slow.cast();
    }

    public void useOnYourself(DragonShout dragonShout) { // nowy kod
        dragonShout.cast();
    }

    public void touch(Fireball fireball) {
        fireball.cast();
    }

    public void touch(Slow slow) {
        slow.cast();
    }

    public void touch(DragonShout dragonShout) { // nowy kod
        dragonShout.cast();
    }
}
Show/Hide

Zatem dodanie kolejnego zaklęcia, gdy taka jedna klasa „zaklęciowa” miałaby trzy metody podobne do metody „cast” wiązałoby się z poszerzeniem trzech instrukcji if (dodaniem trzech nowych metod w przypadku przeładowania) w klasie Mage. A jeśli takich metod byłoby jeszcze więcej? Mnożenie w tym przypadku staje się zabójcze… Zbyt dużo pracy, zbyt dużo możliwości popełnienia błędu. Spróbujmy to naprawić…

Naprawiamy kod!

Fajnie, żeby nasza metoda mogła przyjąć dowolny argument, jeżeli tylko chodzi o zaklęcie, ale również, żeby nie musiała korzystać z drabiny warunków if. Hmm… W takim razie, powinno to być jakieś połączenie naszych dwóch sposobów:

public void castSpell(Object spell) {
    if (spell instanceof Fireball) {
        Fireball fireball = (Fireball) spell;
        fireball.cast();
    } else if (spell instanceof Slow) {
        Slow slow = (Slow) spell;
        slow.cast();
    } else if (spell instanceof DragonShout) {
        DragonShout dragonShout = (DragonShout) spell;
        dragonShout.cast();
    } else {
        System.out.println("No such spell!");
    }
}

public void castSpell(Fireball fireball) {
    fireball.cast();
}
Show/Hide

Potrzebujemy zatem typu wspólnego dla wszystkich naszych zaklęć, ale nie może to być Object, bo jest on wspólny dla wszystkich obiektów, a potrzebujemy czegoś tylko dla zaklęć. Z pomocą przychodzi albo nowa klasa i mechanizm dziedziczenia, albo interfejs, który zawiera deklaracje samych metod. I jeśli nasze klasy „zaklęciowe” mają współdziedziczyć tylko same metody, to interfejs nada się w sam raz:

public interface Spell {
    void cast();
}

public class Fireball implements Spell {

    @Override
    public void cast() {
        System.out.println("FIREBALL!");
    }
}

public class Slow implements Spell {

    @Override
    public void cast() {
        System.out.println("SLOW!");
    }
}

// Na razie pominąłem klasę DragonShout, żeby móc ją potem dodać xd
Show/Hide

Każda z klas musi mieć też go zaimplementowanego. Nie da się inaczej. IntelliJ będzie szukał metody „cast” w każdej klasie z interfejsem „Spell” i jeśli nie znajdzie to wykrzyczy nam to w twarz. 🙂 Dzięki takiemu zabiegowi nasza klasa Mage może przybrać bardzo przystępną postać:

public class Mage {

    public void castSpell(Spell spell) {
        spell.cast();
    }
}
Show/Hide

I nic więcej nie potrzeba. 🙂 Klasa Main nie zmienia nawet swojej budowy, bo wszystko w klasie Mage jest wywoływane tak samo jak wcześniej. Dodanie DragonShout w tym przypadku będzie wymagało tylko i wyłącznie napisania klasy, zaimplementowania interfejsu i zaimplementowania metody „cast”, która będzie działała tak, jak to napiszemy.

public class DragonShout implements Spell {

    @Override
    public void cast() {
        System.out.println("FUS RO DAH!");
    }
}
Show/Hide

Sprawiliśmy w tym przykładzie, że dodanie nowej klasy podrzędnej (poszerzenie funkcjonalności aplikacji) nie wymaga żadnej modyfikacji klas nadrzędnych. Wystarczy napisać nową klasę podrzędną (np. nowe zaklęcie), zaimplementować w niej odpowiedni interfejs (w tym wymagane metody) i gotowe. OCP spełniona. Z drugiej strony to interfejs dyktuje nam jak ma ma wyglądać klasa podrzędna (szczegóły implementacji), a nie na odwrót (odwrócenie zależności). DIP również spełniona!

Linki do repozytorium na GitHubie:

Dzięki za Twoją uwagę i do następnego!

It is much more difficult to judge oneself than to judge others. If you succeed in judging yourself rightly, then you are indeed a man of true wisdom.

-- Little Prince --