Dzięki zasadzie otwarte-zamknięte nasze systemy mogą być kompatybilne wstecz. Reguła ta jest szczególnie ważna, jeżeli chcemy tworzyć systemy, które będą miały więcej niż jedną wersję. Jeżeli skupimy się na tej zasadzie, to wydając kolejne wersje, nie będziemy psuli poprzednich. Jeżeli wprowadzimy zmiany, przez które zostanie wygenerowane mnóstwo błędów w modułach zależnych, to znak że prawdopodobnie nie stosujemy się do zasady otwarte-zamknięte.
SOLID - Open-Closed Principle (OCP) - Wszystko Co Powinieneś Wiedzieć o Zasadzie Otwarte-Zamknięte
Klasa powinna być zamknięta na modyfikacje tego, co już istnieje, ale otwarta na rozszerzenia, czyli mamy jakąś metodę, która jest używana w innych częściach systemu, więc nie powinniśmy jej już modyfikować, bo może coś zepsuć w innych miejscach systemu. Jeżeli mamy jakichś konsumentów naszych systemów, to taka zmiana może im trochę namieszać, ponieważ jeżeli będziemy sobie zmieniać metody to może brakować kompatybilności wstecznej z naszymi metodami, które już udostępniamy.
Także, zasada ta jest szczególnie istotna dla programistów, którzy tworzą biblioteki programistyczne, z których korzystają inni programiści. Wyobraź sobie sytuację, że stworzyłeś klasę na przykład Calculator, która coś oblicza i zapakowałeś to w dll'ke. Następnie udostępniłeś ją innym programistom. Ci programiści zaczęli używać klasy Calculator i mają setki wywołań tej klasy w swoich programach. W kolejnej wersji do konstruktora klasy Calculator dodałeś parametr. Wtedy programiści, którzy używają Twojej biblioteki, chcąc pobrać najnowszą wersję, muszę zmienić wszystkie swoje wywołania tej klasy. Na pewno będzie to dla nich bardzo czasochłonna i niezbyt przyjemna praca.
Chcąc stosować się na tej regule, musisz często polegać na abstrakcji, dzięki której możesz zachować większą elastyczność swoich systemów.
Podobnie jak wcześniej opisywana zasada pojedynczej odpowiedzialności, tak samo i zasada otwarte-zamknięte dotyczy klas, modułów i metod. Zasada OCP jest powiązana po części z SRP. Zazwyczaj, jeżeli piszemy kod zgodny z zasadą SRP, piszemy też kod zgodny z OCP.
Tyle teorii, przejdźmy do przykładów. Wyobraź sobie, że masz za zadanie zrobienie prostego logera, który może zapisywać wiadomości do plików tekstowych, a także do bazy danych.
Przykładowy kod może wyglądać w taki sposób:
Kod #1 niestosujący się do zasady otwarte-zamknięte:
public enum LogType
{
File, Database
}
public class Logger
{
private readonly LogType _logType;
public Logger(LogType logType)
{
_logType = logType;
}
public void Log(string message)
{
switch (_logType)
{
case LogType.File:
throw new NotImplementedException();
case LogType.Database:
throw new NotImplementedException();
default:
throw new Exception("Unexpected log type!");
}
}
}
Stworzony został typ wyliczeniowy - enum, który zostaje przekazywany przy tworzeniu nowej instancji klasy Logger. W metodzie Log na podstawie typu logera określamy, gdzie faktycznie jest logowana wiadomość.
Na potrzeby tego przykładu, nie potrzebujemy implementacji tego, jak faktycznie wygląda logowanie. Niby wydaje się wszystko ok, przekazujemy argument i w zależności od tego, czy chcemy zapisać wiadomość w pliku tekstowym, czy w bazie danych, tak się faktycznie dzieje. Jednak metoda Log nie spełnia zasady OCP, ponieważ nie można jej zamknąć dla nowych typów logowania. Jeżeli teraz chciałbyś dodać na przykład logowanie wiadomości do excela, to musiałbyś dodać nowy typ, oraz zmienić właśnie metodę Log i dodać kolejną instrukcję do switcha. Ten przykład jest dość prosty, a co jeśli w tej klasie byłoby więcej metod, których działanie było zależne od typu logera - wtedy zmian byłoby jeszcze więcej.
Jak możesz napisać tę klasę zgodnie z regułą OCP? Musisz polegać na abstrakcji.
Kod #1 stosujący zasadę otwarte-zamknięte:
public interface IMessageLogger
{
void Log(string message);
}
public class FileLogger : IMessageLogger
{
public void Log(string message)
{
throw new NotImplementedException();
}
}
public class DatabaseLogger : IMessageLogger
{
public void Log(string message)
{
throw new NotImplementedException();
}
}
public class Logger
{
private readonly IMessageLogger _messageLogger;
public Logger(IMessageLogger messageLogger)
{
_messageLogger = messageLogger;
}
public void Log(string message)
{
_messageLogger.Log(message);
}
}
Jak widzisz w powyższym przykładzie, wszystkie typy to klasy implementujące interfejs IMessageLogger. Dzięki temu, jeżeli teraz będziemy potrzebować, dodać nowy typ logowania, to wystarczy stworzyć nową klasę, która będzie implementowała ten interfejs. Sama klasa Logger nie wymaga żadnych zmian. Klasa spełnia założenia OCP.
Przejdźmy do kolejnego przykładu. Tym razem mamy stworzyć klasę do wysyłanie SMS-ów, która ma wysyłać SMS-a za pomocą api wystawionego przez usługę smsapi.
Kod #2 niestosujący się do zasady otwarte-zamknięte:
public class SmsSender
{
private SmsApiService _smsApiService;
public SmsSender(SmsApiService smsApiService)
{
_smsApiService = smsApiService;
}
public void Send(Sms sms)
{
_smsApiService.Send(sms);
}
}
Powyższa klasa SmsSender została tak zaprojektowana, że wymaga podania instancji klasy SmsApiService i na podstawie tego argumentu zostaje wysyłany SMS. Kod działa prawidłowo, został zaimplementowany w dll'ce. Inni programiści zaczęli używać tej klasy, mają oni dużo wywołań w swoich aplikacjach. Pewnego dnia nie wnikając w szczegóły doszli do wniosku, żeby zastąpić api smsapi na serwersms.
Programista musiał dokonać zmian w klasie SmsSender i zmienił ją na poniższą.
public class SmsSender
{
private SerwerSmsService _serwerSmsService;
public SmsSender(SerwerSmsService serwerSmsService)
{
_serwerSmsService= serwerSmsService;
}
public void Send(Sms sms)
{
_serwerSmsService.Send(sms);
}
}
Po czym wydał kolejną wersję swojej dll'ki. Niestety konsumenci tej dll'ki po zaktualizowaniu otrzymali błędy kompilacji przy każdym wywołaniu tej klasy, ponieważ konstruktor teraz przyjmuje obiekt klasy SerwerSmsService, a nie jak wcześniej SmsApiService. Stało się tak, ponieważ nie zastosowano tutaj reguły otwarte-zamknięte. Jak powinien w takim przypadku wyglądać kod początkowy tej klasy?
Kod #2 stosujący zasadę otwarte-zamknięte:
public class SmsSender
{
private ISmsService _smsService;
public SmsSender(ISmsService smsService)
{
_smsService = smsService;
}
public void Send(Sms sms)
{
_smsService.Send(sms);
}
}
Mając taką klasę chcąc zmienić dostawcę usługi wysyłającej SMS-y wystarczy dodać klasę implementującą interfejs ISmsService i klasa SmsSender nie zostanie w żaden sposób zmieniona. Konsumenci tej biblioteki nie muszą zmieniać swoich wywołań tej klasy. Po aktualizacji nasza biblioteka jest dalej kompatybilna wstecz.
PODSUMOWANIE
Dzisiaj przedstawiłem Ci wraz z przykładami kolejną literę w skrócie SOLID, a mianowicie O jak Open-Closed Principle, czyli OCP. Mówi ona o tym, że klasy powinny być zamknięte na modyfikację, ale otwarte na rozszerzenia. Jeżeli dokonujemy jakichś zmian, rozwijamy funkcjonalność kodu, który pracuje w środowisku produkcyjnym, to powinno to nastąpić poprzez rozszerzenie kodu, a nie jego modyfikację. Powinniśmy polegać na abstrakcji, poprzez zastosowanie interfejsów lub klas abstrakcyjnych i polimorfizmu. Dzięki zastosowaniu się do reguły otwarte-zamknięte nasz system jest bardziej stabilny oraz jeżeli rozwijamy nasz system, minimalizujemy potrzeby modyfikacji istniejącego kodu w wielu miejscach. Nie zawsze uda się w pełni trzymać zasady OCP, warto jednak się o to postarać.
Poprzedni artykuł - SOLID - Single Responsibility Principle (SRP) - Wszystko Co Powinieneś Wiedzieć o Zasadzie Pojedynczej Odpowiedzialności.
Następny artykuł - SOLID - Liskov Substitution Principle (LSP) - Wszystko Co Powinieneś Wiedzieć o Zasadzie Podstawienia Liskov.