Blog
Dependency Inversion Principle
Powrót do Artykułów Dodał(a) Bartłomiej Zagórski w dniu 29.01.2022 w kategorii Zasady SOLID
Zasada odwrócenia zależności jest ostatnią a zarazem najbardziej wyrafinowaną i według mnie najważniejszą z zasad projektowania obiektowego zawartych w skrócie SOLID. Polega ona na tym, żeby nie uzależniać modułów wysokiego ani niskiego poziomu od konkretnych implementacji ale od abstrakcji. Należy wykorzystywać w tym celu mechanizm polimorfizmu. Zapewnia to naszym aplikacjom dużą elastyczność i łatwość utrzymania i rozwoju. W artykule postaram się pokazać jak odwrócenie zależności wygląda w praktyce.
Spis treści
- Definicja
- Przykład z życia
- Aplikacja niezgodna z DIP
- Aplikacja zgodna z DIP
- Wstrzykiwanie zależności
- Korzyści płynące ze stosowania zasady Odwrócenia Zależności
- Podsumowanie
Definicja
Moduły wysokopoziomowe nie powinny zależeć od modułów niskopoziomowych. I jedne i drugie powinny zależeć od abstrakcji. Abstrakcje nie powinny zależeć od szczegółów, to szczegóły powinny zależeć od abstrakcji.
Przykład z życia
Bardzo dobrym przykładem zastosowania zasady odwrócenia zależności jest wykorzystywanie gniazdek elektrycznych w domach. Możemy dzięki nim podłączać wszystkie urządzenia, które mają dostosowaną wtyczkę do gniazdka. Robimy to zamiast podłączać je na stałe. Dzięki temu nie jesteśmy zmuszeni do prasowania w danym miejscu, tylko możemy przenieść żelazko, a w to miejsce podpiąć coś innego np. ładowarkę do telefonu. W tym przykładzie dom jest modułem wysokiego poziomu, zależy on od gniazdek elektrycznych, które pełnią rolę klas abstrakcyjnych lub interfejsów, które są rozszerzane lub implementowane przez moduły niskiego poziomu, których role pełnią urządzenia np. żelazko, ładowarka, lampka nocna itp.
Aplikacja niezgodna z DIP
W artykule o pojedynczej odpowiedzialności doprowadziliśmy aplikację do momentu kiedy każda klasa ma tylko jeden powód do zmiany. Okazuje się jednak, że nie wystarczy to, by aplikacja działała zgodnie z zasadą odwrócenia zależności. Zobaczmy kod programu:
public class Calculator {
private final CalculationMaker calculationMaker = new CalculationMaker();
private final ResultPrinter resultPrinter = new ResultPrinter();
private final SoundMaker soundMaker = new SoundMaker();
public void calculate(int a, int b, MathOperation mathOperation) {
String calculationResult = calculationMaker.makeCalculations(a, b, mathOperation);
resultPrinter.printResult(calculationResult);
soundMaker.makeSound();
}
}
public class CalculationMaker {
public String makeCalculations(int a, int b, MathOperation mathOperation) {
switch (mathOperation) {
case ADDITION:
return add(a, b);
case SUBTRACTION:
return subtract(a, b);
case MULTIPLICATION:
return multiply(a, b);
case DIVISION:
return divide(a, b);
default:
return "";
}
}
private String divide(int a, int b) {
try {
return String.valueOf(a/b);
} catch (ArithmeticException e) {
return "Yoy mustn't divide by 0 !!!";
}
}
private String multiply(int a, int b) {
return String.valueOf(a * b);
}
private String subtract(int a, int b) {
return String.valueOf(a - b);
}
private String add(int a, int b) {
return String.valueOf(a + b);
}
}
public enum MathOperation {
ADDITION,
SUBTRACTION,
MULTIPLICATION,
DIVISION
}
public class ResultPrinter {
public void printResult(String result) {
System.out.println(result);
}
}
public class SoundMaker {
public void makeSound() {
System.out.println("imagine amazing sound here\n");
}
}
Klasy CalculationMaker, SoundMaker oraz ResultPrinter są to moduły niskiego poziomu. Każda z nich ma swoje określone zadanie zatem jest tu spełniona reguła pojedynczej odpowiedzialności. Klasa Calculator jest modułem wysokiego poziomu, która odpowiada za delegowanie zadań do modułów niższego poziomu. Oznacza to, że odpowiada ona za proces obliczenia. Mimo to, ten projekt aplikacji nie jest jeszcze dobry. Calculator bowiem, tworzy instancje klas niższych modułów. Jest ściśle z nimi powiązany. Każda zmiana w module niskiego poziomu, będzie odzwierciedlona w module wysokiego poziomu i będzie trzeba na to uważać. Istnieje duże prawdopodobieństwo, że zmiana wprowadzona w module niskiego poziomu będzie powodowała konieczność zmian w module wysokiego poziomu, a zatem złamie zasadę otwarte zamknięte. Dzieje się tak, ponieważ nie zastosowano zasady odwrócenia zależności. Klasa wysokiego poziomu jest ściśle zależna od klas niższego poziomu, ponieważ zależy bezpośrednio od rzeczywistej implementacji. Widać to dokładnie na schemacie UML aplikacji.

Ścisłe powiązania między modułami wysokiego i niskiego poziomu uniemożliwia łatwe podmienianie implementacji, a także utrudnia rozszerzanie aplikacji. Gdybyśmy np. mieli kilka klas wydających dźwięki to bylibyśmy zmuszeni do dodania wszystkich i korzystania z nich na zasadzie przełącznika. W związku z tym łamana byłaby z automatu zasada otwarte zamknięte. Zobaczmy jak przearanżować naszą aplikację by była super elastyczna.
Aplikacja zgodna z DIP
W celu poprawienia kodu w taki sposób, by spełniał założenia zasady odwrócenia zależności wprowadzimy do aplikacji interfejsy. Dzięki temu moduł wysokiego poziomu będzie zależny od abstrakcji. Kontrakt pomiędzy klasą wysokiego poziomu a klasami niskiego poziomu będzie polegał na tym by te drugie implementowały określone metody. W ten sposób klasa nadrzędna dba o to co ma być zrobione a nie jak. Tym zajmą się klasy niskiego poziomu. Zobaczmy UML oraz kod programu po zmianach.

W tym momencie moduł wysokiego poziomu zależy od abstrakcji, mianowicie interfejsów. Wiąże ich relacja agregacji, tzn, że klasa Calculator musi zawierać 3 obiekty implementujące 3 zadane interfejsy. Nie interesuje jej natomiast jaki konkretnie obiekt i w jaki sposób zrealizuje swoje zadanie, ważne by otrzymać poprawny wynik. Klasy niskiego poziomu również są zależne od abstrakcji. W tym jednak przypadku relacja, która je wiąże to dziedziczenie, a w zasadzie implementacja interfejsu. Oznacza to, że obiekty klas niskiego poziomu są połączone z abstrakcją relacją typu „jest”. To te klasy są odpowiedzialne za realizację swoich zadań i zwrócenie klientowi (klasie Calculator) prawidłowych wyników. Ważne jest to, że możemy mieć różne klasy, które implementują wymagane interfejsy i można bez problemu korzystać z każdej z nich, bez konieczności zmian w klasie wysokiego poziomu. Zobaczmy jak wygląda kod.
package interfaces;
public interface ICalculationMaker<T, S> {
String makeCalculations(T a, T b , S calcType);
}
package interfaces;
public interface IResultPrinter {
void printResult(String result);
}
package interfaces;
public interface ISoundMaker {
void makeSound();
}
Powyżej widać wprowadzone interfejsy, z których teraz będzie korzystać klasa Calculator. Kod klas implementujących właściwie pozostaje bez zmian, gdyż mają one jedynie zaimplementować odpowiednie abstrakcyjne metody.
import interfaces.ICalculationMaker;
import interfaces.IResultPrinter;
import interfaces.ISoundMaker;
public class Calculator {
private ICalculationMaker<Integer, MathOperation> iCalculationMaker;
private IResultPrinter iResultPrinter;
private ISoundMaker iSoundMaker;
public Calculator(ICalculationMaker<Integer, MathOperation> iCalculationMaker, ISoundMaker iSoundMaker,
IResultPrinter iResultPrinter) {
this.iCalculationMaker = iCalculationMaker;
this.iResultPrinter = iResultPrinter;
this.iSoundMaker = iSoundMaker;
}
public void calculate(Integer a, Integer b, MathOperation mathOperation) {
String calculationResult = iCalculationMaker.makeCalculations(a, b, mathOperation);
iResultPrinter.printResult(calculationResult);
iSoundMaker.makeSound();
}
}
Zauważmy, że w chwili obecnej aplikacja działa dokładnie tak jak wcześniej, jednak teraz powiązanie klas wysokiego i niskiego poziomu są luźne. Klasa Calculator zależy od interfejsów. Nie tworzy ich jednak w swoim ciele, gdyż nie można tworzyć obiektów interfejsów. Korzysta zaś z mechanizmu polimorfizmu i dynamicznie przypisuje obiekty klas implementujących interfejs do właściwości zadeklarowanych w Calculator. W tym przypadku przypisanie następuje w konstruktorze. Jest to tzw. wstrzykiwanie zależności.
Wstrzykiwanie zależności
Najpopularniejszą metodą realizacji zasady odwrócenia zależności jest właśnie wstrzykiwanie zależności. Mechanizm ten polega na tym, że wykorzystujemy polimorfizm do dynamicznego przypisania konkretnych implementacji (modułów niskiego pozimu) do abstrakcji zadeklarowanych w ciele klasy – klienta (modułu wysokiego poziomu). Zauważmy, że w momencie analizowania klasy Calculator nie wiadomo jeszcze, jakie konkretnie klasy oraz w jaki sposób będą zaspokajać potrzeby zadeklarowane poprzez interfejsy. Dopiero na etapie tworzenia obiektu przekazujemy do niego (wstrzykujemy!) przez konstruktor konkretne instancje. Można to robić również przez settery, albo w bardziej wyrafinowany sposób poprzez kontener Dependency Injection, ale nie stanowi to tematu tego artykułu.
Dzięki temu, że możemy dynamicznie wstrzykiwać zależności a program sam wie, dzięki wykorzystaniu mechanizmu polimorfizmu, metodę którego obiektu wywołać, możemy stworzyć kilka różnych implementacji danego interfejsu i stosować je wymiennie w zależności od potrzeb. Sprawia to, że nasza aplikacja staje się bardzo elastyczna, łatwa w rozszerzaniu i utrzymaniu.
Korzyści płynące ze stosowania zasady Odwrócenia Zależności
Dzięki zasadzie odwrócenia zależności program staje się dużo łatwiejszy do rozszerzania. Możemy wprowadzać nowe implementacje interfejsów (klas abstrakcyjnych) i korzystać z nich bez konieczności wprowadzania jakichkolwiek zmian w istniejącym kodzie. Dbamy zatem o to by zasada otwarte zamknięte był przestrzegana. Poza tym wszelkie zmiany w istniejących implementacjach nie wpływają na zmianę istniejącego kodu, gdyż jeżeli interfejs jest poprawnie zaimplementowany to nie wpływa to na kod klienta.
Podsumowanie
Zasada Odwrócenia Zależności jest to ostatnia ale zdecydowanie nie najmniej ważna zasada spośród zasad SOLID. Polega ona na tym, by rozdzielić zależności modułów wysokiego poziomu od modułów niskiego poziomu poprzez wprowadzenie abstrakcji. Dzięki temu aplikacja staje się elastyczna i łatwa w utrzymaniu i rozszerzaniu. Wykorzystujemy tu wstrzykiwanie zależności, a dzięki magii polimorfizmu wykonywane są odpowiednie metody obiektów właściwych klas. Jest to według mnie najważniejsza z zasad SOLID i zdecydowanie warto się do niej stosować.