Zasady SOLID

Zasady SOLID z przykładami

Czym jest SOLID?

Zasady SOLID (ang. solid principles) to zbiór reguł, które z pewnością pomogą nam pisać lepszy kod. Zostały one zebrane w całość przez Roberta C. Martina (znanego jako Uncle Bob). Sam akronim SOLID został wymyślony przez Michaela Feathersa. S.O.L.I.D. to skrót od pierwszych liter tych zasad, w którym:

  • S - Single Responsibility Principle (zasada pojedynczej odpowiedzialności)
  • O - Open Closed Principle (zasada otwarte-zamknięte)
  • L - Liskov Substitution Principle (zasada podstawiania Liskov)
  • I - Interface Segregation Principle (zasada segregacji interfejsów)
  • D - Dependency Inversion Principle (zasada odwracania zależności)

Spójrzmy po kolei na opis każdej z zasad SOLID aby zrozumieć ich działanie i zastosowanie.

Single Responsibility Principle

Obrazek ze scyzorykiem - metafora zasady pojedynczej odpowiedzialności

Pierwsza z zasad SOLID była już znana wcześniej jako zasada jedności. Oto jej definicja:

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

Każdy system w trakcie swego życia rozwija się na skutek nowych wymagań. Nie pozostaje to bez wpływu na kod programu. Bardzo często prowadzi to do zmian w jednej lub wielu klasach. Jeśli dana klasa ma więcej niż jedno zadanie do wykonania, to tym bardziej te zmiany będą jej dotyczyć. Dlatego każda klasa powinna wykonywać jedno zadanie. Poniższy przykład pomoże zrozumieć idee tej zasady.

Przykład klasy naruszający zasadę SRP

Klasa Employee reprezentuje pracownika.

 
 package pl.javadeveloper.solid.srp.bad;
 
 import java.math.BigDecimal;
 
 public class Employee {
 
     private String firstName;
     private String lastName;
     private BigDecimal salary;
 
     public String getFirstName() {
         return firstName;
     }
 
     public void setFirstName(String firstName) {
         this.firstName = firstName;
     }
 
     public String getLastName() {
         return lastName;
     }
 
     public void setLastName(String lastName) {
         this.lastName = lastName;
     }
 
     public BigDecimal getSalary() {
         return salary;
     }
 
     public void setSalary(BigDecimal salary) {
         this.salary = salary;
     }
 
     public void save() {
         // make some validation
         // open database connection
         // store Employee in database
         // close database connection
     }
 }

Klasa Employee narusza zasadę SRP. Ma ona więcej niż jeden powód do zmiany.

  • Klasa może się zmienić, gdy chcemy dodać e-mail pracownika (zmiana danych osobowych)

  • Klasa może się zmienić, gdy chcemy zmienić sposób zapisu danych pracownika (zmienia struktury bazy danych)

Co należy więc zrobić?

Należy usunąć logikę zapisu pracownika i umieścić ją w nowej klasie. Nowa klasa będzie mieć tylko jedną odpowiedzialność. Będzie nią zapis pracownika w bazie danych. Oto wygląd obu klas po zmianach.

Klasa Employee.java

 package pl.javadeveloper.solid.srp.ok;
 
 import java.math.BigDecimal;
 
 public class Employee {
     private String firstName;
     private String lastName;
     private BigDecimal salary;
 
     public String getFirstName() {
         return firstName;
     }
 
     public void setFirstName(String firstName) {
         this.firstName = firstName;
     }
 
     public String getLastName() {
         return lastName;
     }
 
     public void setLastName(String lastName) {
         this.lastName = lastName;
     }
 
     public BigDecimal getSalary() {
         return salary;
     }
 
     public void setSalary(BigDecimal salary) {
         this.salary = salary;
     }
 }

Klasa EmployeeRepository.java

 package pl.javadeveloper.solid.srp.ok;
 
 public class EmployeeRepository {
     
     public void save(Employee employee) {
         // make some validation
         // open database connection
         // store employee in the database
         // close database connection
     }
 }

Przykład metody naruszającej zasadę SRP

Zasada SRP nie odnosi się tylko do klas. Spójrzmy na metodę print klasy ReportNonSRP.

 package pl.javadeveloper.solid.srp.bad;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.List;
 
 public class ReportNonSRP {
 
     public void print() {
         List<Employee> employees = getEmployees();
         System.out.println("Report | Date: " + LocalDateTime.now());
         System.out.println("#####################################");
         for (Employee e : employees) {
             System.out.println("First name: " + e.getFirstName());
             System.out.println("Last name: " + e.getLastName());
             System.out.println("Salary: " + e.getSalary());
         }
         System.out.println("#####################################");
     }
 
     private List<Employee> getEmployees() {
         Employee employee = new Employee();
         employee.setFirstName("John");
         employee.setLastName("Smith");
         employee.setSalary(new BigDecimal(500));
         return Arrays.asList(employee);
     }
 }

Metoda print narusza zasadę SRP, ponieważ ma więcej niż jeden powód do zmiany. Takimi powodami mogą być zmiany, które obejmują:

  • wygląd nagłówka

  • wygląd danych osobowych pracownika

  • znak separatora

Zobaczmy jak można zmienić kod aby być zgodnym z zasadą SRP.

 package pl.javadeveloper.solid.srp.ok;
 
 import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.Arrays;
 import java.util.List;
 
 public class Report {
 
     public void print() {
         List<Employee> employees = getEmployees();
         header();
         separator();
         printEmployees(employees);
         separator();
     }
 
     private void header() {
         System.out.println("Report | Date: " + LocalDateTime.now());
     }
 
     private void separator() {
         System.out.println("######################################");
     }
 
     private void printEmployees(List<Employee> employees) {
         for (Employee e : employees) {
             System.out.println("First name: " + e.getFirstName());
             System.out.println("Last name: " + e.getLastName());
             System.out.println("Salary: " + e.getSalary());
         }
     }
 
     private List<Employee> getEmployees() {
         Employee employee = new Employee();
         employee.setFirstName("John");
         employee.setLastName("Smith");
         employee.setSalary(new BigDecimal(500));
         return Arrays.asList(employee);
     }
 
 }

Zasada SRP dotyczy nie tylko metod i klas. Okazuje się, że można jej także używać na innych poziomach. Na poziomie komponentów jest regułą wspólnego domknięcia (ang. Common Closure Principle). Na poziomie architektury jest osią zmiany (ang. Axis of Change).

Open Closed Principle

Open Closed Principle (OCP)

Drugą z zasad SOLID jest zasada otwarte zamknięte. Zasada ta została opisana przez Bertranda Meyera w 1988 roku. W książce Object-Oriented Software Construction ujął ją w taki oto sposób:

Elementy systemu powinny być otwarte na rozbudowę, ale zamknięte na modyfikacje.

Elementami systemu mogą być moduły, klasy, metody itp. Celem zasady jest możliwość zmiany zachowania elementu systemu bez wprowadzania zmian w jego kodzie źródłowym. Jest to bardzo ważna zasada zwłaszcza gdy w systemie często pojawiają zmiany. Z jednej strony przestrzeganie tej zasady pozwala na tworzenie elastycznego systemu, który jest łatwy do rozbudowy. Z drugiej strony ograniczamy ryzyko wprowadzenie do systemu nowych błędów.

Tylko jak kod może spełniać obydwa warunki zasady OCP? Jak może być otwarty na rozbudowę, a zarazem zamknięty na zmiany?

Poprzez użycie abstrakcji, która będzie wyrażać stałe wspólne zachowanie. To co będzie się zmieniać trafi do klas konkretnych. W Javie abstrakcję możemy wyrazić albo przez klasę abstrakcyjną albo przez interfejs.

Przykład naruszenia zasady otwarte-zamknięte

Oto przykład klasy logującej komunikaty, która narusza zasadę OCP.

 
 package pl.javadeveloper.solid.ocp.bad;
 
 import java.nio.file.Files;
 import java.nio.file.Paths;
 import java.nio.file.StandardOpenOption;
 import java.util.Arrays;
 
 public class LoggerNonOCP {
     private LogTarget logTarget;
 
     public LoggerNonOCP(LogTarget logTarget) {
         this.logTarget = logTarget;
     }
 
     public void log(String message) throws Exception {
         switch (logTarget) {
             case CONSOLE:
                 System.out.println(message);
                 break;
             case FILE:
                 Files.write(Paths.get("file.log"),
                         Arrays.asList(message),
                         StandardOpenOption.APPEND);
                 break;
             default:
                 throw new IllegalArgumentException("Unsupported logging type!");
         }
     }
 }

Konstruktor klasy LoggerNonOCP przyjmuje typ wyliczeniowy LogTarget, który ma dwie wartości (CONSOLE, FILE).

 package pl.javadeveloper.solid.ocp.bad;
 
 public enum LogTarget {
     CONSOLE, FILE
 }

Typ ten określa miejsce logowania komunikatów i jest używany w metodzie log. Metoda ta posiada jeden parametr i jest nim logowany komunikat. W zależności od wartości typu wyliczeniowego trafia on albo na konsole albo do pliku.

Jednak wymagania klienta mogą się zmienić i tym samym mogą pojawiać się nowe sposoby logowania. Na przykład komunikaty mogą być zapisywane w bazie danych. Aby dodać nowy sposób logowania trzeba “otworzyć” kod czyli go zmienić. Ponadto, będzie trzeba dodać nowy typ logowania w LogTarget. Taki kod narusza zasadę OCP i w konsekwencji nie pozwala na rozbudowę bez jego zmiany.

Jeśli chcemy poprawić ten kod i zapewnić jego zgodność z zasadą OCP musimy wydzielić abstrakcję. Będzie nią interfejs MessageLogger, który będzie wspólny dla wszystkich sposobów logowania:

 package pl.javadeveloper.solid.ocp.good;
 
 public interface MessageLogger {
     void log(String message) throws Exception;
 }

Każdy ze sposobów logowania umieścimy w odrębnej klasie. Pierwszą z klas będzie klasa ConsoleLogger, która będzie logować komunikaty na konsole.

 package pl.javadeveloper.solid.ocp.good;
 
 public class ConsoleLogger implements MessageLogger {
     @Override
     public void log(String message) {
         System.out.println(message);
     }
 }

Druga z klas czyli FileLogger będzie logowała dane do pliku.

 package pl.javadeveloper.solid.ocp.good;
 
 import java.nio.file.Files;
 import java.nio.file.Paths;
 import java.nio.file.StandardOpenOption;
 import java.util.Arrays;
 
 public class FileLogger implements MessageLogger {
     @Override
     public void log(String message) throws Exception {
         Files.write(Paths.get("file.log"),
                 Arrays.asList(message),
                 StandardOpenOption.APPEND);
     }
 }

Po zmianach główna klasa będzie wyglądała następująco:

 package pl.javadeveloper.solid.ocp.good;
 
 public class LoggerOCP {
     private MessageLogger messageLogger;
 
     public LoggerOCP(MessageLogger messageLogger) {
         this.messageLogger = messageLogger;
     }
 
     public void log(String message) throws Exception {
         messageLogger.log(message);
     }
 }

Klasa ma pole typu MessageLogger. Podczas jej tworzenia w konstruktorze do tego pola przypisujemy konkretną implementacje sposobu logowania. Metoda log stała się teraz bardzo prosta. Pozostało w niej tylko wywołanie metody log z obiektu klasy przekazanej w konstruktorze.

Teraz możemy bardzo łatwo dodać kolejne sposoby logowania i to bez wpływu na główną klasę programu. W tym przypadku zachowujemy zgodność z zasadą OCP. Kod jest otwarty na rozbudowę, ale zamknięty na zmiany.

Liskov Substitution Principle

Comparison regular file and readonly file

Trzecia z zasad SOLID to zasada podstawień Liskov. Została ona opisana przez Barbarę Liskov. W oryginale brzmi ona dość naukowo, ale możemy ją opisać w bardziej prosty sposób:

Jeśli używasz typu bazowego, powinieneś mieć możliwość postawienia w jego miejsce typów pochodnych.

Czyli nie powinieneś zauważyć różnicy między używaniem podtypu i typu bazowego. Spójrzmy na przykład, który pomoże zrozumieć kiedy naruszamy zasadę LSP.

Przykład naruszenia zasady podstawień Liskov

Załóżmy, że mamy interfejs, który opisuje operacje wykonywane na pliku. Tymi operacjami jest zapis danych do pliku oraz ich odczyt. Oto definicja interfejsu:

 package pl.javadeveloper.solid.liskov.bad;
 
 public interface File {
     byte[] read();
     void write(byte[] data);
 }

Prosta implementacja, który realizuje powyższy interfejs wygląda następująco:

 package pl.javadeveloper.solid.liskov.bad;
 
 import pl.javadeveloper.solid.liskov.bad.File;
 
 public class RegularFile implements File {<br>
     @Override
     public byte[] read() {
         // reads data
         return new byte[0];
     }
 
     @Override
     public void write(byte[] data) {
         // writes data
     }
 }

Program, który będzie odczytywał i zapisywał dane do plików bazuje na kontrakcie zdefiniowanym przez interfejs File. Oto prosta implementacja takiego programu.

 package pl.javadeveloper.solid.liskov.bad;
 
 import java.util.ArrayList;
 import java.util.List;
 
 public class Program {
 
     public static void main(String[] args) {
         List<File> files = new ArrayList<>();
         files.add(new RegularFile());<br>        files.add(new RegularFile());
 
         files.forEach(file -> file.read());
         files.forEach(file -> file.write(new byte[0]));
     }
 }

Wszystko działa poprawnie. Jednak system się rozwija i powstają nowe implementacje interfejsu File. Jedna z nich ma zadanie obsługiwać pliki tylko do odczytu. Spójrzmy jak wygląda klasa, która pozwala tylko na odczyt pliku.

 
 package pl.javadeveloper.solid.liskov.bad;
 
 import pl.javadeveloper.solid.liskov.bad.File;
 
 public class ReadOnlyFile implements File {
 
     @Override
     public byte[] read() {
         // reads data
         return new byte[0];
     }
 
     @Override
     public void write(byte[] data) {
         throw new UnsupportedOperationException();
     }
 }

Metoda, która zapisuje dane wyrzuca wyjątek, aby zapobiec zapisowi do pliku. Spójrzmy jak teraz zachowa się program o nazwie Program, gdy dodamy do niego obiekt klasy ReadOnlyFile.

 package pl.javadeveloper.solid.liskov.bad;
 
 import java.util.ArrayList;
 import java.util.List;
 
 public class Program {
 
     public static void main(String[] args) {
         List<File> files = new ArrayList<>();
         files.add(new RegularFile());
         files.add(new ReadOnlyFile()); //read only file
 
         files.forEach(file -> file.read());
         files.forEach(file -> file.write(new byte[0]));
     }
 }

W wyniku wykonania programu pojawi się wyjątek przy próbie zapisu danych do pliku tylko do odczytu.

 
 Exception in thread "main" java.lang.UnsupportedOperationException
   at pl.javadeveloper.solid.liskov.violated.ReadOnlyFile.write(ReadOnlyFile.java:15)
   at pl.javadeveloper.solid.liskov.violated.Program.lambda$main$1(Program.java:14)
   at java.util.ArrayList.forEach(ArrayList.java:1257)
   at pl.javadeveloper.solid.liskov.violated.Program.main(Program.java:14)

Klasa ReadOnlyFile narusza zatem zasadę podstawień Liskov. Mimo, że jest ona podtypem interfejsu File to nie spełnia jego kontraktu. Swym zachowaniem wpływa także na program Program. Zgodnie zasadą Liskov zachowanie to nie powinno się zmienić gdy w miejsce typu bazowego podstawianie są typy pochodne.

Aby poprawić powyższy kod należy rozdzielić interfejs File na dwa interfejsy. Pierwszy z nich będzie definiował kontrakt dla plików tylko do odczytu. Drugi zaś dla plików do których dane można zapisywać.

 package pl.javadeveloper.solid.liskov.ok;
 
 public interface Readable {
     byte[] read();
 }
 package pl.javadeveloper.solid.liskov.ok;
 
 public interface Writable {
     void write(byte[] data);
 }

Teraz klasa RegularFile, która umożliwia odczyt i zapis danych będzie wyglądać jak poniżej.

 package pl.javadeveloper.solid.liskov.ok;
 
 public class RegularFile implements Readable, Writable {
 
     @Override
     public byte[] read() {
         // reads data
         return new byte[0];
     }
 
     @Override
     public void write(byte[] data) {
         // writes data
     }
 }

Natomiast klasa ReadOnlyFile będzie po zmianach wyglądać tak:

 package pl.javadeveloper.solid.liskov.ok;
 
 public class ReadOnlyFile implements Readable {
 
     @Override
     public byte[] read() {
         // reads data
         return new byte[0];
     }
 }

Kod klasy Program zmieni się i będzie wyglądał jak poniżej.

 package pl.javadeveloper.solid.liskov.ok;
 
 import java.util.ArrayList;
 import java.util.List;
 
 public class Program {
 
     public static void main(String[] args) {
         List<Readable> readablesFiles = new ArrayList<>();
         readablesFiles.add(new ReadOnlyFile());
         readablesFiles.add(new RegularFile());
 
         List<Writable> writableFiles = new ArrayList<>();
         writableFiles.add(new RegularFile());
         // writableFiles.add(new ReadOnlyFile());  compilation error
     }
 }

W powyższym programie mamy teraz dwa rodzaj list. Pierwsza z nich pozwala na umieszczanie obiektów klas implementujących interfejs Readable. Druga lista akceptuje obiekty klas, które implementują interfejs Writable. Dzięki rozdzieleniu interfejsu File już na etapie kompilacji nie pozwalamy na dodanie do drugiej listy obiektów klasy ReadOnlyFile.

Inne przykłady naruszenia zasady podstawień Liskov

Najbardziej znanym przykładem naruszenia LSP, jest problem prostokąta i kwadratu. Pod wymienionym wcześniej adresem znajduje się opis zagadnienia przygotowany przez samego Roberta C. Martina.

Podobny przykład naruszenia LSP dotyczy koła i elipsy. Jego opis znajdziemy na Wikipedii pod adresem https://en.wikipedia.org/wiki/Circle-ellipse_problem.

Zasada LSP i technika Design By Contract

Istnieje pomocna technika, która pomaga egzekwować zasadę LSP. Jest nią technika projektowania według kontraktu (ang. Design By Contract - DBC). Technika ta została wprowadzona przez Bertranda Meyera. Pozwala ona autorowi klasy rozszerzyć definicję metody o takie elementy jak:

  • warunki wstępne,

  • warunki końcowe,

  • niezmienniki klasy.

Warunek wstępny określa warunek jaki musi być spełniony aby można było wykonać metodę. W typie pochodnym warunek wstępny nie może być mocniejszy niż w typie bazowym.

Warunek końcowy określa warunek jaki musi być spełniony po zakończeniu działania metody. Nie może być słabszy w typie pochodnym niż w typie bazowym.

Natomiast niezmiennik klasy to warunek, który musi być zawsze spełniony.

Przykład z kontami bankowymi

Rozpatrzmy jeszcze jeden przykład naruszenia zasady LSP. Dotyczący on różnych rodzajów kont bankowych. Pierwsze z kont jest zwykłym kontem, które można założyć w banku. Zakłada się, że można je zamknąć tylko wtedy, gdy środki na koncie są większe od zera. Poniżej znajduje się prosta implementacja klasy, która realizuje powyższe założenia.

 package pl.javadeveloper.solid.liskov;
 
 public class Account {
     private double balance = 0.0;
 
     public boolean close() {
         if (balance > 0.0) {
             return true;
         }
         return false;
     }
 
     public double getBalance() {
         return balance;
     }
 
     public void setBalance(double balance) {
         this.balance = balance;
     }
 }

W metodzie close() znajduje się logika odpowiedzialna za zamykanie konta. Metoda ta sprawdza czy kwota na koncie (pole balance) jest większa od zera. Jeśli tak, to konto jest zamykane. Jeśli nie to konto pozostaje nadal otwarte.

Drugie z kont to konto promocyjne. Można je zamknąć gdy kwota na koncie jest większa od zera i było aktywne więcej niż 3 miesiące. Kod klasy dla tego konta znajdziemy poniżej.

 package pl.javadeveloper.solid.liskov;
 
 public class PromoAccount extends Account {
     private int months = 0;
 
     @Override
     public boolean close() {
         if (getBalance() > 0.0 && months > 3) { // violation
             return true;
         }
         return false;
     }
 
     public void setMonths(int months) {
         this.months = months;
     }
 }

Klasą bazową dla klasy konta promocyjnego PromoAccount jest klasa Account. Metoda close() w klasie PromoAccount przesłania metodę z klasy bazowej i implementuje wyżej opisaną logikę. Klasa PromoAccount nie jest prawdziwym podtypem klasy Account ponieważ narusza zasadę podstawień Liskov i nie spełnia reguł techniki DBC. Wynika to z faktu, że w klasie pochodnej mamy silniejszy warunek wstępny niż w klasie bazowej. Oprócz sprawdzania bilansu konta sprawdzamy też okres jego aktywności. Zatem naruszamy zasadę LSP i nie możemy użyć obiektu klasy PromoAccount w miejsce obiektu klasy Account.

Część języków programowania posiada wbudowane mechanizmy pisania klas z uwzględnieniem techniki DBC. W Javie możemy częściowo realizować ten mechanizm za pomocą asercji. Jednak lepszą metodą jest użycie dodatkowych bibliotek. Wśród nich można polecić:

Interface Segregation Principle

Interface Segregation Principle

Czwarta z zasad SOLID to zasada segregacji interfejsów. Zasada ta brzmi następująco:

Klasa nie powinna być zależna od metod, których nie używa.

Aby zrozumieć zasadę ISP rozpatrzmy przykład mechanizmu logowania komunikatów. Interfejsem bazowym tego mechanizmu jest interfejs Logger przestawiony poniżej.

 package pl.javadeveloper.solid.isp.bad;
 
 import java.util.Collection;
 
 public interface Logger {
     void writeMessage(String message);
     Collection<String> getMessages();
 }

Rozważmy możliwe implementacje tego interfejsu. Pierwszą z nich jest klasa FileLogger logująca komunikaty do pliku.

 package pl.javadeveloper.solid.isp.bad;
 
 import java.util.ArrayList;
 import java.util.Collection;
 
 public class FileLogger implements Logger {
 
     @Override
     public void writeMessage(String message) {
         // write message to file
     }
 
     @Override
     public Collection<String> getMessages() {
         // get messages from logger file
         return new ArrayList<>();
     }
 }

Drugą jest klasa DatabaseLogger. Odpowiada ona za zapis komunikatów w bazie danych.

 package pl.javadeveloper.solid.isp.bad;
 
 import java.util.ArrayList;
 import java.util.Collection;
 
 public class DatabaseLogger implements Logger {
 
     @Override
     public void writeMessage(String message) {
         // write message to database
     }
 
     @Override
     public Collection<String> getMessages() {
         // get all messages from database
         return new ArrayList();
     }
 }

Kolejna z klas loguje komunikaty na konsolę. Jest nią klasa ConsoleLogger przedstawiona na poniższym listingu.

 package pl.javadeveloper.solid.isp.bad;
 
 import java.util.Collection;
 
 public class ConsoleLogger implements Logger {
 
     @Override
     public void writeMessage(String message) {
         System.out.println(message);
     }
 
     @Override
     public Collection<String> getMessages() {
        throw new UnsupportedOperationException("You can't get logged messages for console logger.");
     }
 }

Czy widzisz problem w powyższym kodzie?

Pojawia się on w metodzie getMessages(). Jako, że nie można pobierać komunikatów z konsoli to zastosowano “obejście” w postaci wyrzucania wyjątku (przy okazji naruszona została zasada podstawień Liskov). Klasa ConsoleLogger implementuje zbyt “gruby” interfejs. Metoda pobierająca komunikaty jest użyteczna z punktu widzenia innych klas. Jednak w przypadku klasy ConsoleLogger nie ma ona sensu. Znaczy to, że klasa posiada metodę której nie potrzebuje i tym samym narusza zasadę ISP.

Powyższe rozważania wskazują, że interfejs Logger nie powinien mieć metody do odczytu. W związku z tym interfejs ten musi zostać zmieniony. Oto jego wygląd po zmianie:

 package pl.javadeveloper.solid.isp.good;
 
 public interface Logger {
     void writeMessage(String message);
 }

Odczyt zapisanych komunikatów powinien opisywać osobny interfejs, który przedstawia poniższy kod.

 package pl.javadeveloper.solid.isp.good;
 
 import java.util.Collection;
 
 public interface PersistenceLogger extends Logger {
     Collection<String> getMessages();
 }

Interfejs PersistenceLogger rozszerza interfejs Logger. Zawiera deklaracje metody do pobierania zapisanych komunikatów. Teraz klasy, które pozwalają na zapis i odczyt komunikatów będą go implementowały. Po zmianach klasa FileLogger będzie wyglądała jak na poniższym listingu.

 package pl.javadeveloper.solid.isp.good;
 
 import java.util.ArrayList;
 import java.util.Collection;
 
 public class FileLogger implements PersistenceLogger {
 
     @Override
     public void writeMessage(String message) {
         // write message to file
     }
 
     @Override
     public Collection<String> getMessages() {
         // get messages from logger file
         return new ArrayList<>();
     }
 }

Również klasa, która pozwala na zapis i odczyt komunikatów w bazie zostanie zmieniona.

 package pl.javadeveloper.solid.isp.good;
 
 import java.util.ArrayList;
 import java.util.Collection;
 
 public class DatabaseLogger implements PersistenceLogger {
 
     @Override
     public void writeMessage(String message) {
         // write message to database
     }
 
     @Override
     public Collection<String> getMessages() {
         // get all messages from database
         return new ArrayList();
     }
 }

Nowa wersja klasy logującej komunikaty na konsolę będzie teraz zależna tylko od interfejsu Logger i tym samym tylko od metody zapisującej komunikaty.

 package pl.javadeveloper.solid.isp.good;
 
 public class ConsoleLogger implements Logger {
 
     @Override
     public void writeMessage(String message) {
         System.out.println(message);
     }
 }

Dependency Inversion Principle

Dependency Inversion Principle

Ostatnią z zasad SOLID jest zasada odwracania zależności. Zasada ta składa się z dwóch punktów:

  1. Moduły wysokiego poziomu nie powinny zależeć od modułów niższego poziomu. Obydwa rodzaje modułów powinny zależeć od abstrakcji.
  2. Abstrakcje nie powinny zależeć od detali. To detale powinny zależeć od abstrakcji.

Główną celem zasady DIP jest zmniejszenie zależności od konkretnych implementacji. Możemy to uzyskać za pomocą abstrakcji (interfejsów). Jeśli kod zależy od interfejsu to mamy małą zależność. Dzięki temu nasz kod nie zmienia się lub zmienia się bardzo rzadko.

Przykład naruszenia zasady odwracania zależności

Rozważmy przykład klasy do zarządzanie zadaniami (dla uproszczenia pomijam inne metody).

 package pl.javadeveloper.solid.dip.bad;
 
 public class TaskService {
 
     private FileRepository repository = new FileRepository();
 
     public void addTask(Task task) {
         repository.saveTask(task);
     }
 
     public void removeTask(String taskId) {
         repository.deleteTask(taskId);
     }
 }

Klasa TaskService używa konkretnej klasy FileRepository, która zapisuje lub usuwa zadania z pliku. W tym przykładzie klasa TaskService jest “modułem wysokiego poziomu”. Klasa FileRepository pełni rolę “modułu niższego poziomu”. Mamy tutaj bezpośrednią zależność między klasami. W ten sposób naruszamy zasadę DIP.

DIP violation

Aby rozwiązać powyższy problem powinniśmy sprawić by klasa TaskService nie była zależna od klasy FileRepository. Ponadto obie klasy muszą zależeć od abstrakcji. Stwórzmy więc abstrakcję w postaci interfejsu Repository. Będzie on miał metody związane z zapisem i odczytem zadań.

 package pl.javadeveloper.solid.dip.good;
 
 public interface Repository {
 
     void saveTask(Task task);
 
     void deleteTask(String taskId);
 }

Zmieńmy klasę TaskService aby używała interfejsu Repository i w ten sposób zależała od abstrakcji.

 package pl.javadeveloper.solid.dip.good;
 
 public class TaskService {
 
     private Repository repository;
 
     public TaskService(Repository repository) {
         this.repository = repository;
     }
 
     public void addTask(Task task) {
         repository.saveTask(task);
     }
 
     public void removeTask(String taskId) {
         repository.deleteTask(taskId);
     }
 }

Również klasa FileRepository będzie zależała od abstrakcji i spełniała założenia interfejsu Repository.

 package pl.javadeveloper.solid.dip.good;
 
 public class FileRepository implements Repository {
 
     @Override
     public void saveTask(Task task) {
         // logic responsible for saving task to file
     }
 
     @Override
     public void deleteTask(String taskId) {
         // logic responsible for deleting task from file
     }
 }

Poniższy rysunek pokazuje jak zmieniły się zależności między klasami.

Obrazek obrazujący zgodność z zasadą DIP

Zależności zostały odwrócone (stąd nazwa tej zasady). Teraz “moduł wysokiego poziomu” nie zależy od “modułu niskiego poziomu”. Moduł warstwy niższej zależy od abstrakcyjnego interfejsu z warstwy wyższej. Zatem zmiany w module na niższym poziomie nie wpływają na moduł na wyższym poziomie. Jeśli na przykład pojawi się potrzeba zapisu zadań w bazie danych zamiast w pliku to czeka nas proste zadanie. Wystarczy dodanie odpowiedniej klasy na niższym poziomie. Oto przykład zarysu takiej klasy dla bazy MySQL.

 package pl.javadeveloper.solid.dip.good;
 
 public class MySqlRepository implements Repository {
 
     @Override
     public void saveTask(Task task) {
         // store task in TASK table
     }
 
     @Override
     public void deleteTask(String taskId) {
         // delete task from TASK table
     }
 }

Podsumowanie

Zasady SOLID warto znać i stosować ponieważ pozwolą nam pisać kod, który będzie można łatwo rozszerzać i testować. Myślę, że jeśli zapoznałeś się ze wszystkimi przykładami to zasady te stały się dla Ciebie jasne.

Zachęcam również do zapoznania się z artykułem o zasadach SOLID autorstwa samego Roberta C. Martina. Znajdziesz je na http://butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod.

Kody źródłowe opisane w tym artykule znajdują się na https://github.com/javadeveloperpl/solid