Archiwum kategorii: SOLID

5 podstawowych założeń programowania obiektowego

Zasada odwrócenia zależności

1. Moduły wysokopoziomowe nie powinny zależeć od modułów niskopoziomowych. I jedne, i drugie powinny zależeć od abstrakcji.
2. Abstrakcje nie powinny zależeć od szczegółów. To szczegóły powinny zależeć od abstrakcji.

Robert C. Martin

W tradycyjnej architekturze moduły wysokiego poziomu składają się z modułów niższych poziomów. To tak zwane sztywne powiązania (tight coupling). W takiej architekturze moduły wyższych poziomów zależą od modułów niższych poziomów. Każda zmiana wprowadzona w module niższego poziomy wymusza zmianę w modułach wyższych poziomów. Ta zależność utrudnia ponowne wykorzystanie komponentów wyższego poziomu.

Celem zasady odwrócenia zależności jest zmniejszenie tych powiązań (loose coupling). Obie warstwy komunikują się za pomocą interfejsów które udostępniają metody wymagane przez moduły wyższego poziomu – dodatkowej warstwy abstrakcji. Często robi się to za pomocą wstrzykiwania zależności (dependency injection).

Rozważmy przykładową sytuację. Mamy klasę Displayer (moduł niższego poziomu) odpowiedzialną za wyświetlanie na ekranie:

public class Displayer {
    public void out() {
        System.out.println("Wyświetl na ekranie");
    }
}

Klasa ComputerSystem (moduł wyższego poziomu) składa się między innymi z obiektu klasy Displayer – jest od niego zależna:

public class ComputerSystem {
    private Displayer displayer;

    public ComputerSystem() {
        this.displayer = new Displayer();
    }

    public void out () {
        this.displayer.out();
    }
}

Utwórzmy klasę Printer odpowiedzialną za drukowanie i dodajmy ją do Systemu:

public class Printer{
    public void out() {
        System.out.println("Wydrukuj na drukarce");
    }
}
public class ComputerSystem {
    private Displayer displayer;
    private Printer printer;

    public ComputerSystem() {
        this.displayer = new Displayer();
        this.printer = new Printer();
    }

    public void out (String device) {
        switch (device) {
            case "SCREEN":
                this.displayer.out();
                break;
            case "PRINTER":
                this.printer.out();
                break;
            default:
                break;
        }
    }
}

Możemy stworzyć obiekt klasy ComputerSystem i wywołać na nim metodę out:

public class Runner {
    public static void main (String[] args) {
        ComputerSystem system = new ComputerSystem();

        system.out("PRINTER");
    }
}

Powyższy przykład działa, ale ma 3 wady:

  • Dodanie każdego nowego urządzenia wymaga zmiany w istniejącym kodzie klasy ComputerSystem – łamie zasadę otwarte-zamknięte,
  • Klasa ComputerSystem zależy od klas Displayer i Printer – łamie zasadę odwrócenia zależności,
  • Klasy są ściśle powiązane, klasa ComputerSystem nie może istnieć bez Displayer i Printer.

Poprawiona implementacja

Stwórzmy interfejs z metodą out i zaimplementujmy go w klasach Displayer i Printer:

public interface Device {
    public void out();
}
public class Displayer implements Device{
    @Override
    public void out() {
        System.out.println("Wyświetl na ekranie");
    }
}
public class Printer implements Device{
    @Override
    public void out() {
        System.out.println("Wydrukuj na drukarce");
    }
}

W samych klasach nic się właściwie nie zmieniło. Wcześniej zawierały metodę out, teraz implementują metodę out z interfejsu.

W konstruktorze klasy ComputerSystem dodajmy jako parametr obiekt implementujący interfejs Device – to może być Dispalyer lub Printer ponieważ obie klasy implementują interfejs. To jest wstrzykiwanie zależności (dependency injection):

public class ComputerSystem {
    private Device device;

    public ComputerSystem(Device device) {
        this.device = device;
    }

    public void out() {
        this.device.out();
    }
}

Kiedy przekazujemy obiekt klasy Displayer lub Printer do konstruktora – ta specyficzna implementacja interfejsu jest zapisywana do pola device. Program sam rozpozna klasę obiektu przechowywanego w device i uruchomi metodę z właściwej klasy – polimorfizm:

public class Runner {
    public static void main (String[] args) {
        Printer printer = new Printer();
        ComputerSystem system = new ComputerSystem(printer);

        system.out();
    }
}

Zalety kodu zgodnego z zasadą odwrócenia zależności

Zasada odwrócenia zależności ułatwia rozbudowę aplikacji. Wystarczy, że nowe moduły niższego rzędu będą implementowały interfejs pośredniczący w komunikacji żeby móc je przekazywać jako argumenty.

Zasada odwrócenia zależności daje większą kontrolę użytkownikowi. Nie musi on polegać na sztywnych zależnościach, zamiast tego może łączyć moduły wyższego i niższego rzędu za pomocą interfejsu pośredniczącego.

Moduły wyższego poziomu są bardziej uniwersalne dzięki temu, że nie są zależne od modułów niższego poziomu.

Zasada segregacji interfejsów

Klienci nie powinni być zmuszani do polegania na interfejsach, których nie używają.

Robert C. Martin

Klasy i metody które piszemy powinny odpowiadać potrzebom biznesowym klienta.

W klasie nie powinno być nieużywanych metod. Tym bardziej nie powinno być w niej metod które nic nie robią (są puste) lub ich użycie skutkuje błędem.

Jeśli klasa implementuje interfejs, musi zaimplementować wszystkie jego metody. Dlatego nie należy tworzyć zbyt dużych interfejsów ze zbyt wieloma metodami które nie zostaną użyte. Zamiast tego lepiej stworzyć więcej małych interfejsów i implementować je w razie potrzeby.

Rozważmy przykładowy interfejs i 3 implementującego go klasy:

public interface Vehicle {
    void drive();
    void floatOn();
    void fly();
}
public class Car implements Vehicle{
    @Override
    public void drive() {
        System.out.println("Samochód jedzie.");
    }

    @Override
    public void floatOn() {
        System.err.println("Samochody nie pływają!");
    }

    @Override
    public void fly() {
        System.err.println("Samochody nie latają!");
    }
}
public class Ship implements Vehicle {
    @Override
    public void drive() {
        System.err.println("Statki nie jeżdżą!");
    }

    @Override
    public void floatOn() {
        System.out.println("Statek płynie.");
    }

    @Override
    public void fly() {
        System.err.println("Statki nie latają!");
    }
}
public class Plane implements Vehicle{
    @Override
    public void drive() {
        System.out.println("Samolot jedzie");
    }

    @Override
    public void floatOn() {
        System.out.println("Samolot płynie");
    }

    @Override
    public void fly() {

        System.out.println("Samolot leci.");
    }
}

Utwórzmy jeszcze listę naszych pojazdów i wywołajmy na nich każdą z metod:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Runner {
    public static void main (String[] args) {
        List<Vehicle> vehicles = new ArrayList<>(Arrays.asList(new Car(), new Ship(), new Plane()));

        for (Vehicle vehicle : vehicles) {
            vehicle.drive();
            vehicle.floatOn();
            vehicle.fly();
        }
    }
}

Po uruchomieniu przykładu dostaniemy:

Samochody nie pływają!
Samochody nie latają!
Statki nie jeżdżą!
Statki nie latają!
Samochód jedzie.
Statek płynie.
Samolot jedzie
Samolot płynie
Samolot leci.

Wszystkie 3 klasy implementują interfejs więc muszą zaimplementować wszystkie metody. Ale nie wszystkie klasy używają wszystkich metod.

Poprawiona implementacja

Rozbijmy ten interfejs na 3 oddzielne interfejsy.

public interface Drivable {
    void drive();
}
public interface Floating {
    void floatOn();
}
public interface Flying {
    void fly();
}

i zaimplementujmy je tylko we właściwych klasach:

public class Car implements Drivable {
    @Override
    public void drive() {
        System.out.println("Samochód jedzie.");
    }
}
public class Ship implements Floating {
    @Override
    public void floatOn() {
        System.out.println("Statek płynie.");
    }
}
public class Plane implements Drivable, Floating, Flying {
    @Override
    public void drive() {
        System.out.println("Samolot jedzie");
    }

    @Override
    public void floatOn() {
        System.out.println("Samolot płynie");
    }

    @Override
    public void fly() {

        System.out.println("Samolot leci.");
    }
}

Ponownie wywołajmy na nich dostępne metody:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Runner {
    public static void main (String[] args) {
        Car car = new Car();
        Ship ship = new Ship();
        Plane plane = new Plane();

        car.drive();
        ship.floatOn();
        plane.drive();
        plane.floatOn();
        plane.fly();
    }
}

Po uruchomieniu programu tym razem dostaniemy:

Samochód jedzie.
Statek płynie.
Samolot jedzie
Samolot płynie
Samolot leci.

W żadnej z klas nie ma już nieużywanych, niepotrzebnych metod.

Zalety kodu zgodnego z zasadą segregacji interfejsów

Kod napisany zgodnie z zasadą segregacji interfejsów jest łatwiejszy w rozwijaniu. W momencie tworzenia kolejnej klasy można zaimplementować tylko potrzebne metody.

Interfejsów napisanych zgodnie z zasadą segregacji interfejsów łatwiej ponownie użyć. Mając mniej specyficznych metod są bardziej uniwersalne.

Zasada podstawienia Liskov

Obiekty klasy pochodnej powinny dawać się używać w miejscach, gdzie oczekiwane są obiekty klasy bazowej, bez wpływu na poprawność działania programu.

Barbara Liskov

Jeżeli występuje dziedziczenie, powinno być możliwe użycie obiektu klasy potomnej zamiast klasy bazowej bez wpływu na poprawność działania programu (zarówno od strony językowej jak i biznesowej). Czyli jeśli klasa Potomna dziedziczy z klasy Bazowej, to obiekt klasy Bazowej powinno dać się zastąpić obiektem klasy Potomnej w każdej funkcji używającej wskaźnika lub referencji do klasy Bazowej.

Oznacza to, że klasa potomna musi implementować wszystkie metody klasy rodzica. Klasa potomna powinna rozszerzać możliwości klasy bazowej.

Rozważmy przykładową sytuację. Mamy klasę bazową IRobotRoomba i 2 dziedziczące z niej klasy reprezentujące poszczególne modele:

public class IRobotRoomba {
    public void vacuum () {
        System.out.println("IRobot Roomba odkurza");
    }

    public void mop () throws Exception {
        System.out.println("IRobot Roomba mopuje");
    }
}
public class Plus405Combo extends IRobotRoomba{
    final int height = 106;

    @Override
    public void vacuum() {
        System.out.println("IRobot Roomba Plus 405 Combo odkurza");
    }

    @Override
    public void mop() {
        System.out.println("IRobot Roomba Plus 405 Combo mopuje");
    }
}
public class I5 extends IRobotRoomba {
    final int height = 92;

    @Override
    public void vacuum() {
        System.out.println("IRobot Roomba I5 odkurza");
    }

    @Override
    public void mop() throws Exception {
        // TEN MODEL NIE MA FUNKCJI MOPOWANIA
        throw new Exception();
    }
}

Klasa SmartHomeSystem odpowiada za sprzątanie domu: odkurzanie i mopowanie:

public class SmartHomeSystem {
    public void vacuumHome (IRobotRoomba iRobot) {
        iRobot.vacuum();
    }
    public void mopHome (IRobotRoomba iRobot) {
        try {
            iRobot.mop();
        } catch (Exception e) {
            System.err.println("Wystąpił błąd!");
        }
    }
}

Utwórzmy jeszcze listę naszych robotów i dodajmy do niej 3 urządzenia:

  • iRobotRoomba,
  • iRobotRoomba Plus 405 Combo,
  • iRobotRoomba i5

i każdego użyjmy do odkurzania i mopowania domu:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Runner {
    public static void main (String[] args) {
        SmartHomeSystem system = new SmartHomeSystem();

        List<IRobotRoomba> robots = new ArrayList<>(Arrays.asList(new IRobotRoomba(), new Plus405Combo(), new I5()));

        for (IRobotRoomba robot : robots) {
            system.vacuumHome(robot);
            system.mopHome(robot);
        };
    }
}

Po uruchomieniu przykładu dostaniemy:

IRobot Roomba odkurza
IRobot Roomba mopuje
IRobot Roomba Plus 405 Combo odkurza
IRobot Roomba Plus 405 Combo mopuje
IRobot Roomba I5 odkurza
Wystąpił błąd!

Model i5 nie ma funkcji mopowania. Użycie obiektu klasy I5 zamiast IRobotRoomba psuje działanie programu.

Poprawiona implementacja

Wydzielmy mopowanie z klasy bazową IRobotRoomba do oddzielnego interfejsu:

public class IRobotRoomba {
    public void vacuum () {
        System.out.println("IRobot Roomba odkurza");
    }
}
public interface moppingDevice {
    void mop ();
}

i zaimplementujmy go w iRobot Roomba Plus 405 Combo:

public class Plus405Combo extends IRobotRoomba implements MoppingDevice {
    final int height = 106;

    @Override
    public void vacuum() {
        System.out.println("IRobot Roomba Plus 405 Combo odkurza");
    }

    @Override
    public void mop() {
        System.out.println("IRobot Roomba Plus 405 Combo mopuje");
    }
}

klasa I5 musi teraz zaimplementować tylko 1 metodę:

public class I5 extends IRobotRoomba {
    final int height = 92;

    @Override
    public void vacuum() {
        System.out.println("IRobot Roomba I5 odkurza");
    }
}

Musimy jeszcze zmodyfikować typ parametru w klasie SmartHomeSystem. Mopowanie musi przyjmować obiekty implementujące interfejs MoppingDevice :

public class SmartHomeSystem {
    public void vacuumHome(IRobotRoomba iRobot) {
        iRobot.vacuum();
    }

    public void mopHome(MoppingDevice iRobot) {
        iRobot.mop();

    }
}

Tym razem musimy utworzyć 2 listy:

  • roboty zawierają wszystkie urządzenia typu IRobotRoomba,
  • ale urządzenia mopujące zawierają tylko modele implementujące moppingDevice czyli tylko iRobot Roomba Plus 405 Combo

i ponownie użyjmy ich do odkurzania i mopowania domu:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Runner {
    public static void main (String[] args) {
        SmartHomeSystem system = new SmartHomeSystem();

        List<IRobotRoomba> robots = new ArrayList<>(Arrays.asList(new IRobotRoomba(), new Plus405Combo(), new I5()));
        List<MoppingDevice> moppingDevices = new ArrayList<>(Arrays.asList(new Plus405Combo()));

        for (IRobotRoomba robot : robots) {
            system.vacuumHome(robot);
        };

        for (MoppingDevice moppingDevice : moppingDevices) {
            system.mopHome(moppingDevice);
        };
    }
}

Po uruchomieniu programu tym razem dostaniemy:

IRobot Roomba odkurza
IRobot Roomba Plus 405 Combo odkurza
IRobot Roomba I5 odkurza
IRobot Roomba Plus 405 Combo mopuje

Zmniejszyliśmy naszą klasę bazową i przenieśliśmy problematyczną metodę do interfejsu. Dzięki temu tylko model z funkcją mopowania musi ją zaimplementować. Możemy bezpiecznie zastępować obiekty klasy bazowej obiektami klas potomnych.

Dodatkowe zasady

Z zasady podstawienia Liskov wynikają dodatkowe 2 zasady:

  • Jeśli klasa bazowa zawiera jakieś warunki wstępne, to w klasach potomnych warunki wstępne nie mogą być wzmacniane.
    Można osłabiać warunki wstępne ponieważ to nie zepsuje programu. Jeśli w klasie bazowej warunki wstępne są trudniejsze do spełnienia, to program i tak będzie je musiał spełnić.
  • Jeśli klasa bazowa zawiera jakieś warunki końcowe, to w klasach potomnych warunki końcowe nie mogą być osłabiane.
    Można wzmacniać warunki końcowe ponieważ to nie zepsuje programu. Jeśli w klasie bazowej warunki końcowe są łatwiejsze do spełnienia, to program będzie na nie przygotowany.

Zasada otwarte-zamknięte

Elementy programu (klasy, moduły, funkcje itp.) powinny być otwarte na rozbudowę ale zamknięte na modyfikację.

Mayer Bertrand

Modyfikacja zachowań elementów programu (funkcji, klas, modułów itp.) nie powinna wymagać modyfikacji kodu. Elementy powinny być zamknięte na modyfikacje ale otwarte na rozszerzenia.

Kod programu powinien być napisany w taki sposób, żeby umożliwić jego rozbudowę, dodania nowych funkcjonalności, bez modyfikacji istniejącego kodu. Należy rozwijać program przez dodawanie nowych elementów, nie przez modyfikowanie istniejących.

Zasada otwarte-zamknięte wymaga stosowania abstrakcji.

Rozważmy przykładową sytuację. Mamy 2 klasy reprezentujące 2 rodzaje klientów:

public class RegularCustomer {
    final double discountLevel = 0.95;

    // metoda zwraca cenę jednostkową z uwzględnieniem rabatu
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PremiumCustomer {
    final double discountLevel = 0.8;

    // metoda zwraca cenę jednostkową z uwzględnieniem rabatu
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

Klasa PriceCalculator oblicza cenę całkowitą w zależności od rodzaju klienta i ilości zamówionych produktów:

public class PriceCalculator {
    // metoda zwraca koszt całkowity z rabatem w zależności od rodzaju klienta
    public double calculateFinalPrice (double basePrice, int quantity, String customerType) {
        switch (customerType) {
            case "REGULAR":
                RegularCustomer regularCustomer = new RegularCustomer();
                return quantity * regularCustomer.calculateDiscountedPrice(basePrice);
            case "PREMIUM":
                PremiumCustomer premiumCustomer = new PremiumCustomer();
                return quantity * premiumCustomer.calculateDiscountedPrice(basePrice);
            default:
                return quantity * basePrice;
        }
    }
}
public class Runner {
    public static void main (String [] args) {
        System.out.println(PriceCalculator.calculateFinalPrice(100, 100, "PREMIUM"));
}

Powyższy przykład działa poprawnie ale ma zasadniczą wadę:

  • dodanie nowego rodzaju klienta wymaga zmiany istniejącego kodu w klasie PriceCalculator.
public class VipCustomer {
    final double discountLevel = 0.5;

    // metoda zwraca cenę jednostkową z uwzględnieniem rabatu
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PriceCalculator {
    // metoda zwraca koszt całkowity z rabatem w zależności od rodzaju klienta
    public double calculateFinalPrice (double basePrice, int quantity, String customerType) {
        switch (customerType) {
            case "REGULAR":
                RegularCustomer regularCustomer = new RegularCustomer();
                return quantity * regularCustomer.calculateDiscountedPrice(basePrice);
            case "PREMIUM":
                PremiumCustomer premiumCustomer = new PremiumCustomer();
                return quantity * premiumCustomer.calculateDiscountedPrice(basePrice);
            case "VIP":
                VipCustomer vipCustomer = new VipCustomer();
                return quantity * vipCustomer.calculateDiscountedPrice(basePrice);
            default:
                return quantity * basePrice;
        }
    }
}

Poprawiona implementacja

Wprowadźmy interfejs.

public interface Customer {
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

i zaimplementujmy go w każdej klasie reprezentującej rodzaj klienta:

public class RegularCustomer implements Customer{
    final double discountLevel = 0.95;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PremiumCustomer implements Customer{
    final double discountLevel = 0.8;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class VipCustomer implements Customer{
    final double discountLevel = 0.5;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

Klasa calculateFinalPrice bardzo się upraszcza:

public class PriceCalculator {
    public double calculateFinalPrice (double basePrice, int quantity, Customer customer) {
        return quantity * customer.calculateDiscountedPrice(basePrice);
    }
}

Żądamy przekazania jako argumentu dowolnego obiektu implementującego interfejs Customer. Wszystkie klasy reprezentujące rodzaj klienta implementują ten interfejs więc muszą zaimplementować metodę calculateDiscountedPrice.

Dzięki wprowadzeniu dodatkowej warstwy abstrakcji nie trzeba już sprawdzać rodzaju klienta. Program sam wywoła metodę z konkretnej implementacji interfejsu.

public class Runner {
    public static void main (String [] args) {
        System.out.println(PriceCalculator.calculateFinalPrice(100, 100, new RegularCustomer()));
}

Zalety kodu zgodnego z zasadą otwarte-zamknięte

Kod napisany zgodnie z zasadą jednej odpowiedzialności jest łatwiej rozwijać. Dzięki wprowadzeniu warstwy abstrakcji – interfejsu zawierającego zachowania wspólne dla wszystkich klas:

  • wystarczy, że nowa klasa będzie implementować interfejs żeby zmodyfikować zachowanie programu bez konieczności zmieniania istniejącego, działającego i przetestowanego kodu
  • sam kod stał się znacznie prostszy.

Zasada jednej odpowiedzialności

Zbierz razem rzeczy, które zmieniają się z tych samych powodów. Oddziel te, które zmieniają się z różnych powodów.

Robert C. Martin

Każda funkcja, klasa lub moduł powinna mieć jedną odpowiedzialność i ta odpowiedzialność powinna być w niej zamknięta.

Kod programu powinien być podzielony na klasy i każda z tych klas powinna istnieć w jednym celu. Każda klasa powinna zawierać metody wykonujące podobne zadania. Oznacza to, że podobne funkcje (wykonujące podobne zadania) należy zebrać w jednej klasie.

Rozważmy przykładową klasę której zadaniem jest wygenerowanie raportu:

public class ReportGenerator {
    // metoda wyświetla raport
    public void printReport (String reportId) {
        System.out.println(generateReport(reportId));
    }
    
    // metoda generuje raport na podstawie przekazanego Id
    private String generateReport (String reportId) {

        // logika odpowiedzialna za wygenerowanie raportu

        return wygenerowanyRaport;
    }
}
public class Runner {
    public static void main (String [] args) {
        ReportGenerator generator = new ReportGenerator();
        generator.printReport("123");
}

Powyższa klasa ma 2 odpowiedzialności, co oznacza, że mogą istnieć 2 powody do modyfikacji tej klasy:

  • metoda generateReport generuje raport na podstawie przekazanego ID,
  • a metoda printReport wyświetla wygenerowany raport.

Klasa powinna mieć tylko jeden powód do zmiany.

Robert C. Martin

Poprawiona implementacja

Rozbijmy tę klasę na 2 oddzielne klasy.

Klasa ReportGenerator odpowiada tylko za generowanie raportów:

public class ReportGenerator {   
    // metoda generuje raport na podstawie przekazanego Id
    public String generateReport (String reportId) {

        // logika odpowiedzialna za wygenerowanie raportu

        return generatedRaport;
    }
}

a ReportPrinter za wyświetlanie raportów:

public class ReportPrinter {   
    // metoda wyświetla raport
    public void printReport (String reportToPrint) {
        System.out.println(reportToPrint);
    }
}
public class Runner {
    public static void main (String [] args) {
        ReportGenerator generator = new ReportGenerator();
        ReportPrinter printer = new ReportPrinter();
        
        String generatedReport = generator.printReport("123");
        printer.printReport(generatedReport);        
}

Zalety kodu zgodnego z zasadą jednej odpowiedzialności

Kod napisany zgodnie z zasadą jednej odpowiedzialności jest łatwiejszy w utrzymaniu. Funkcje, klasy i moduły napisane zgodnie z tą zasadą są mniejsze, bardziej spójne. W przypadku wprowadzania modyfikacji łatwiej uniknąć błędów.

Elementów napisanych zgodnie z zasadą jednej odpowiedzialności łatwiej ponownie użyć. Są prostsze, mniej specyficzne, bardziej czytelne. Kod jest lepiej zorganizowany i bardziej modularny.