Blog
Open Close Principle
Powrót do Artykułów Dodał(a) Bartłomiej Zagórski w dniu 02.01.2022 w kategorii Zasady SOLID
Zasada Otwarte Zamknięte jest to druga z zasad projektowania obiektowego zawarta w skrócie SOLID. Mówi ona, że klasy, moduły bądź funkcje powinny być otwarte na rozszerzanie, a zamknięte na modyfikacje. Oznacza to, że należy tak projektować aplikacje, żeby każda zmiana lub poszerzenie programu nie wymagało zmian w już istniejącym kodzie. W tym artykule postaram się pokazać jak to wygląda w praktyce.
Spis treści
- Definicja
- Przykład z życia
- Aplikacja niezgodna z OCP
- Dodawanie nowego narzędzia (przed refactoringiem)
- Tymczasowa naprawa kodu
- Aplikacja zgodna z OCP
- Dodawanie nowego narzędzia (po refactoringu)
- Korzyści płynące ze stosowania Open Close Principle
- Podsumowanie
Definicja
Zasada Otwarte Zamknięte stanowi, że jednostki oprogramowania (moduły, klasy, funkcje) powinny być otwarte na rozszerzenia a zamknięte na modyfikacje.
Przykład z życia
Przykładów z życia jest dużo jak np. wymiana żarówki na mocniejszą. Nie wymaga to żadnej ingerencji w żyrandol lub lampkę nocną, Po prostu możemy wkręcić nową żarówkę pod warunkiem, że gwint pasuje. Może być to również wpięcie do przedłużacza kolejnego urządzenia. Nie wymaga to żadnej modyfikacji instalacji elektrycznej, po prostu jesteśmy w stanie włączyć np. kolejną ładowarkę do telefonu. Jedynym warunkiem jest odpowiednia wtyczka.
Aplikacja niezgodna z OCP
Aby pokazać jak należy projektować i programować aplikacje zgodnie z zasadą Open Close Principle przedstawię prosty program napisany niezgodnie z OCP a następnie zaprezentuję jakie problemy spowoduje niewielkie rozszerzenie funkcjonalności. Następnie zrefactoryzuję kod tak, aby zasada otwarte zamknięte była zachowana i wtedy również rozszerzę działanie aplikacji. A oto wspomniana aplikacja.
package pl.zagora.model;
public class LawnMower {
public void start() {
System.out.println("cut the grass");
}
public void repair() {
System.out.println("sharpen the knives, adjust knives height, fix the engine");
}
}
package pl.zagora.model;
public class Scissors {
public void start() {
System.out.println("cut the bush");
}
public void repair() {
System.out.println("sharpen blades and screw the screws");
}
}
Powyżej znajduje się kod dwóch klas będących narzędziami wykorzystywanymi w ogrodzie. Mamy tutaj kosiarkę (LawnMower) oraz nożyce (Scissors). Obydwie klasy mają dwie metody publiczne start() oraz repair() odpowiedzialne za wykonanie właściwej czynności oraz naprawę.
package pl.zagora;
import pl.zagora.model.LawnMower;
import pl.zagora.model.Scissors;
public class Gardener {
public void handleTheGarden(Object[] tools ) {
for (Object tool : tools) {
if (tool instanceof LawnMower) {
((LawnMower) tool).start();
} else if(tool instanceof Scissors) {
((Scissors) tool).start();
}
}
}
}
package pl.zagora;
import pl.zagora.model.LawnMower;
import pl.zagora.model.Scissors;
public class ServiceTechnician {
public void repairTools(Object[] tools) {
for (Object tool: tools) {
if (tool instanceof LawnMower) {
((LawnMower) tool).repair();
} else if (tool instanceof Scissors) {
((Scissors) tool).repair();
}
}
}
}
A tutaj mamy dwie klasy, których obiekty będą wykorzystywały narzędzia, a więc ogrodnik (Gardener) oraz serwisant (ServiceTechnician). Obydwie klasy mają po jednej metodzie, do której należy przekazać tablicę narzędzi. Jako, że w języku Java wszystkie klasy domyślnie dziedziczą po klasie Object to tablica jest właśnie tej klasy. Następnie w ciele metody sprawdzane jest do jakiej klasy należą poszczególne narządzia i po rzutowaniu wykonywane są metody odpowiedniej klasy. Na tym etapie mamy 2 narzędzia, więc blok if else nie jest jeszcze duży, ale da się wyczuć, że za chwilę zacznie sprawiać problemy.
package pl.zagora;
import pl.zagora.model.LawnMower;
import pl.zagora.model.Scissors;
public class Main {
public static void main(String[] args) {
Gardener gardener = new Gardener();
ServiceTechnician serviceTechnician = new ServiceTechnician();
Object[] tools = new Object[] {new LawnMower(), new Scissors()};
gardener.handleTheGarden(tools);
serviceTechnician.repairTools(tools);
}
}
No i finalnie klasa Main, która tworzy obiekty Gardener i ServiceTechnician oraz tablicę narzedzi, a nastepnie wywołuje odpowiednie metody na obiektach gardener i serviceTechnician. A oto wynik działania programu:

Prawda że niesamowity ? 🙂 Zerknijmy teraz na schemat UML:

Gardener, i ServiceTechnician używają LawnMower oraz Scissors. Na razie sytuacja jest opanowana. Ale na horyzoncie zaczyna pojawiać się coś co prędzej czy później nastąpi w niemal każdej komputerowej aplikacji, ZMIANY !!! Zobaczmy następną sekcję.
Dodawanie nowego narzędzia (przed refactoringiem)
Wyobraźmy sobie, że właściciel posesji ma na niej kilka drzew liściastych, które jesienią stały się utrapieniem. Należy zatem zakupić grabie by utrzymać ogród w porządku. Klasa Rake wygląda następująco:
package pl.zagora.model;
public class Rake {
public void start() {
System.out.println("rake leaves from lawn");
}
public void repair() {
System.out.println("weld broken rake teeth and fix rake handle");
}
}
Zaś w klasie Main należy dorzucić kolejne narzędzie do listy:
Object[] tools = new Object[] {new LawnMower(), new Scissors(), new Rake()};
Teraz należy jeszcze uzupełnić klasę Gardener o dodatkowy warunek uwzględniający obecność nowego narzędzia:
public void handleTheGarden(Object[] tools ) {
for (Object tool : tools) {
if (tool instanceof LawnMower) {
((LawnMower) tool).start();
} else if(tool instanceof Scissors) {
((Scissors) tool).start();
} else if (tool instanceof Rake) {
((Rake) tool).start();
}
}
}
Można teraz uruchomić program i zobaczyć jakie są rezultaty:

Ku naszemu zdumieniu można zauważyć, że chociaż ogrodnik wykonał dobrze swoją pracę, to jednak kiedy narzędzia trafiły do serwisu, grabie nie zostały naprawione. Aby zrozumieć dlaczego tak się stało zerknijmy na schemat UML aplikacji:

Widać teraz jak na dłoni, że serwisant nie mógł naprawić grabii, ponieważ nie wiedział jak ma to zrobić. Między klasami Rake a ServiceTechnician nie ma żadnego połączenia. „Zapomniałem” wprowadzić modyfikacji w klasie ServiceTechnician.
Tymczasowa naprawa kodu
Żeby aplikacja działała poprawnie należy wprowadzić do klasy ServiceTechnician podobną poprawkę jak w klasie Gardener:
public void repairTools(Object[] tools) {
for (Object tool: tools) {
if (tool instanceof LawnMower) {
((LawnMower) tool).repair();
} else if (tool instanceof Scissors) {
((Scissors) tool).repair();
} else if (tool instanceof Rake) {
((Rake) tool).repair();
}
}
}
Po tej modyfikacji program działa poprawnie:

A schemat UML przedstawia się następująco:

Teraz wszystko jest na swoim miejscu. Zarówno Gardener jak i ServiceTechnician korzystają ze wszystkich narzędzi. Zauważmy jednak jak wiele jest relacji na powyższym schemacie. Ponadto musiałem zmodyfikować istniejące klasy, aby rozszerzyć program o nową funkcjonalność. Sporo pracy jak na tak niewielką aplikację. Wyobraźmy sobie sytuację w prawdziwej pracy, przy jakiejś ogromnej aplikacji, gdzie takich miejsc jak powyższe może być tysiące. Nie ma możliwości w takim natłoku kodu zapamiętać i znaleźć wszystkie możliwe wykorzystania klasy, którą później zmodyfikujemy. Można przez to popsuć coś w zupełnie innym miejscu, a zauważyć dopiero po upływie kilku dni czy tygodni. Sytuacja taka jest niedopuszczalna, dlatego właśnie istnieje zasada otwarte zamknięte. Zobaczmy jak poprawić kod, aby był zgodny z tą zasadą.
Aplikacja zgodna z OCP
W celu poprawienia aplikacji i dostosowania jej do zasady Open Close należy wprowadzić do kodu element abstrakcji. W tym przypadku będzie to interfejs ITool zawierający metody start() i repair() a każde narzędzie będzie implementowało ten interfejs. Będzie zatem zmuszone przez IDE do nadpisania metody, w przeciwnym razie program w ogóle się nie skompiluje. Natomiast klasy korzystające będą od tej pory zależne od interfejsu a nie konkretnej implementacji. Zamiast tablicy Object [ ] będą od tej pory dostawały listę List<ITool>. Zobaczmy kod:
package pl.zagora.model;
public interface ITool {
void start();
void repair();
}
Powyżej wspomniany interfejs, a poniżej klasy implementujące go:
package pl.zagora.model;
public class LawnMower implements ITool {
public void start() {
System.out.println("cut the grass");
}
public void repair() {
System.out.println("sharpen the knives, adjust knives height, fix the engine");
}
}
package pl.zagora.model;
public class Scissors implements ITool {
public void start() {
System.out.println("cut the bush");
}
public void repair() {
System.out.println("sharpen blades and screw the screws");
}
}
package pl.zagora.model;
public class Rake implements ITool {
public void start() {
System.out.println("rake leaves from lawn");
}
public void repair() {
System.out.println("weld broken rake teeth and fix rake handle");
}
}
Jak widać oprócz deklaracji implements ITool nic się nie zmieniło, prawdziwe zmiany zaszły jednak u klientów. Zobaczmy:
package pl.zagora;
import pl.zagora.model.ITool;
import java.util.List;
public class Gardener {
public void handleTheGarden( List<ITool> tools ) {
for (ITool tool : tools) {
tool.start();
}
}
}
package pl.zagora;
import pl.zagora.model.ITool;
import java.util.List;
public class ServiceTechnician {
public void repairTools( List<ITool> tools) {
for (ITool tool: tools) {
tool.repair();
}
}
}
Kod klas Gardener i ServiceTechnician został znacznie zredukowany i stał się bardzo prosty i przejrzysty. Nie potrzeba już bloku „if else” do decydowania o tym, która metoda, z której klasy zostanie wykonana. W tej chwili dzieje się to automatycznie za pomocą polimorfizmu. Klasa Gardener podobnie jak ServiceTechnician wie tylko tyle, że dostanie do przetworzenia obiektu implementujące interfejs ITool. Wywołają one jednak metody z odowiedniej klasy implementującej tenże interfejs. Kod klasy Main wygląda teraz następująco:
package pl.zagora;
import pl.zagora.model.ITool;
import pl.zagora.model.LawnMower;
import pl.zagora.model.Rake;
import pl.zagora.model.Scissors;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class Main {
public static void main(String[] args) {
Gardener gardener = new Gardener();
ServiceTechnician serviceTechnician = new ServiceTechnician();
List<ITool> tools = new ArrayList<>(Arrays.asList(new LawnMower(), new Scissors(), new Rake()));
gardener.handleTheGarden(tools);
serviceTechnician.repairTools(tools);
}
}
Jak widać zmieniła się jedynie kolekcja przechowująca narzędzia. Działanie programu pozostało niezmienione. Znacząco zmienił się za to schemat aplikacji.

Teraz wszystko wygląda bardzo przejrzyście. Klasy Gardener i ServiceTechnician wykorzystują interfejs ITool. Ten jest implementowany przez klasy rzeczywiste, ale dzięki temu, że klienci są zależni od abstrakcji a nie faktycznej implementacji to bardzo łatwo będzie można rozszerzyć aplikację. Zobaczmy jak w tej chwili będzie wyglądało dodanie nowego narzędzia.
Dodawanie nowego narzędzia (po refactoringu)
Na horyzoncie pojawiła się kolejna potrzeba – podlewanie kwiatów. W tym celu dodamy do programu kolejne narzędzie, a mianowicie wąż ogrodowy. Klasa prezentuje się następująco:
package pl.zagora.model;
public class GardenHose implements ITool {
@Override
public void start() {
System.out.println("water the flowers");
}
@Override
public void repair() {
System.out.println("patch the hole");
}
}
Pozostaje tylko dodać obiekt GardenHose do listy…
List<ITool> tools = new ArrayList<>(Arrays.asList(new LawnMower(), new Scissors(), new Rake(), new GardenHose()));
I to wszystko !!! Program działa następująco:

A schemat UML zmienił się o dołączoną klasę GardenHose.

Jak widać było to banalnie proste i właśnie o to chodziło.
Korzyści płynące ze stosowania Open Close Principle
Przede wszystkim zasada OCP jest niemal niezbędna w profesjonalnym programowaniu. Jest niemal niemożliwe utrzymanie dużych rozmiarów aplikacji jeżeli mamy w kodzie mnóstwo drabinek z bloków „if else”. Każda zmiana lub rozszerzenie wymaga wtedy bardzo dużo wysiłku, gdyż należy modyfikować już istniejący kod, a może być go potwornie dużo. A poza tym to jak łatwo wykonuje się zmiany i rozszerzenia chyba przekonuje każdego, że OCP pozwala zaoszczędzić mnóstwo czasu i zapewnić jakość aplikacjom, które się do niej stosują.
Podsumowanie
Zasada otwarte zamknięte mówi, że należy tak projektować aplikację komputerową, aby jej poszczególne składniki były zamknięte na modyfikacje a otwarte na rozszerzenia. Stosowanie jej w praktyce pozwala zmieniać lub rozwijać program jedynie przez dodawanie nowych klas czy metod. To co zostało napisane wcześniej jest właściwie niezmienne. Daje to programistom ogromną elastyczność, pozwala zaoszczędzić mnóstwo czasu, a jako że nie trzeba bez przerwy grzebać w kodzie, żeby dokonać nawet najmniejszej zmiany można mieć pewność, że nie zepsuje się czegoś innego.