Zasada otwarte-zamknięte, czyli jak wprowadzać zmiany w kodzie
Podczas tworzenia oprogramowania nieustannie mamy do czynienia ze zmianami. Kiedy aplikacja zostanie napisana, jest później udoskonalana. Wprowadzane są do niej nowe funkcjonalności wymagane przez klientów. Poprawiane są także wykryte i zgłoszone błędy, by kod był coraz bardziej niezawodny. Nie da się tego wszystkiego uniknąć i długo utrzymać systemu w niezmienionym stanie. Jeśli nie zastosujemy się do odpowiednich standardów, w miarę rozwoju programu może być coraz trudniej dokonywać w nim modyfikacji. Właśnie tego problemu dotyczy druga z zasad wyrażonych akronimem SOLID, której nazwa to open-closed principle.
Geneza
Została ona opracowana w 1988 roku przez francuskiego wykładowcę naukowego Bertranda Meyera, który przedstawił ją w książce pt. „Object-Oriented Software Construction”. Jej definicja mówi, że klasy, moduły, funkcje itd. powinny być otwarte na rozszerzanie, a zamknięte na modyfikacje. Nowe funkcjonalności należy wprowadzać do aplikacji przez dodawanie nowych jednostek kodu, a nie przekształcanie istniejących. Może się to wydawać na pierwszy rzut oka niemożliwe. Jak program może zachować się inaczej, jeśli według omawianej zasady nie powinniśmy niczego zmieniać w kodzie? Poniższy przykład pokaże, w jaki sposób można to osiągnąć.
Przykład
Wyobraźmy sobie, że mamy do czynienia z aplikacją, która wykonuje różne operacje na figurach geometrycznych. Jedna z klas może zawierać metodę na przykład do obliczania sumy pól obsługiwanych kształtów. Poniżej znajduje się przykład, jak taki kod może wyglądać.
public function calculateTotalArea(array $shapes): float
{
$totalArea = 0;
foreach ($shapes as $shape) {
if ($shape instanceof Rectangle) {
$totalArea += $shape->getWidth() * $shape->getHeight();
} elseif ($shape instanceof Triangle) {
$totalArea += $shape->getBase() * $shape->getHeight() / 2;
}
}
return $totalArea;
}
Działanie metody jest bardzo proste. Jako argument otrzymuje tablicę zawierającą obiekty figur geometrycznych, a następnie w czasie kolejnych kroków iteracji oblicza pole każdej z nich. Potem otrzymaną wartość dodaje do wyniku, który później zwraca. Niżej znajdują się proste implementacje użytych kształtów: prostokąta i trójkąta.
class Rectangle
{
private float $width;
private float $height;
public function __construct(float $width, float $height)
{
$this->width = $width;
$this->height = $height;
}
public function getWidth(): float
{
return $this->width;
}
public function getHeight(): float
{
return $this->height;
}
}
class Triangle
{
private float $base;
private float $height;
public function __construct(float $base, float $height)
{
$this->base = $base;
$this->height = $height;
}
public function getBase(): float
{
return $this->base;
}
public function getHeight(): float
{
return $this->height;
}
}
Taki kod działa i spełnia swoje zadanie, czyli oblicza sumę otrzymanych figur. Niestety, nie jest on dobrze napisany. Wszystko jest w porządku do momentu, kiedy zachodzi potrzeba dodania nowego kształtu do aplikacji np. trapezu. Zobaczmy, co się stanie w takiej sytuacji. Implementacja kolejnej klasy w programie może wyglądać w następujący sposób.
class Trapezoid
{
private float $baseA;
private float $baseB;
private float $height;
public function __construct(float $baseA, float $baseB, float $height)
{
$this->baseA = $baseA;
$this->baseB = $baseB;
$this->height = $height;
}
public function getBaseA(): float
{
return $this->baseA;
}
public function getBaseB(): float
{
return $this->baseB;
}
public function getHeight(): float
{
return $this->height;
}
}
Konstruktor nowej klasy przyjmuje jako argumenty długości podstaw oraz wysokość trapezu, a gettery zwracają poszczególne rozmiary figury. Niestety nie jest to jedyna zmiana, którą należy wykonać w kodzie. Konieczne jest zmodyfikowanie metody calculateTotalArea, tak aby obliczała pole trapezu i dodawała do zwracanego wyniku.
public function calculateTotalArea(array $shapes): float
{
$totalArea = 0;
foreach ($shapes as $shape) {
if ($shape instanceof Rectangle) {
$totalArea += $shape->getWidth() * $shape->getHeight();
} elseif ($shape instanceof Triangle) {
$totalArea += $shape->getBase() * $shape->getHeight() / 2;
} elseif ($shape instanceof Trapezoid) {
$totalArea += ($shape->getBaseA() + $shape->getBaseB()) * $shape->getHeight() / 2;
}
}
return $totalArea;
}
Konieczność zmodyfikowania powyższej metody oznacza, że nie spełnia ona omawianej zasady. Dzieje się tak, ponieważ dodanie nowej funkcjonalności do programu – w tym wypadku kolejnej figury geometrycznej – dokonuje się poprzez zmianę, a nie wyłącznie przez napisanie nowego kodu, czyli klasy Trapezoid. Metoda calculateTotalArea jest zatem otwarta na dodanie następnego kształtu, a powinna być na to zamknięta. Poniżej znajduje się poprawny kod, który spełnia zasadę open-closed principle.
interface ShapeInterface
{
public function getArea(): float;
}
class Rectangle implements ShapeInterface
{
private float $width;
private float $height;
public function __construct(float $width, float $height)
{
$this->width = $width;
$this->height = $height;
}
public function getWidth(): float
{
return $this->width;
}
public function getHeight(): float
{
return $this->height;
}
public function getArea(): float
{
return $this->width * $this->height;
}
}
class Triangle implements ShapeInterface
{
private float $base;
private float $height;
public function __construct(float $base, float $height)
{
$this->base = $base;
$this->height = $height;
}
public function getBase(): float
{
return $this->base;
}
public function getHeight(): float
{
return $this->height;
}
public function getArea(): float
{
return $this->base * $this->height / 2;
}
}
class Trapezoid implements ShapeInterface
{
private float $baseA;
private float $baseB;
private float $height;
public function __construct(float $baseA, float $baseB, float $height)
{
$this->baseA = $baseA;
$this->baseB = $baseB;
$this->height = $height;
}
public function getBaseA(): float
{
return $this->baseA;
}
public function getBaseB(): float
{
return $this->baseB;
}
public function getHeight(): float
{
return $this->height;
}
public function getArea(): float
{
return ($this->baseA + $this->baseB) * $this->height / 2;
}
}
public function calculateTotalArea(array $shapes): float
{
$totalArea = 0;
foreach ($shapes as $shape) {
$totalArea += $shape->getArea();
}
return $totalArea;
}
W celu poprawienia kodu wykonanych zostało kilka zmian. Odpowiedzialność obliczania pola figury geometrycznej została przekazana do każdej z klas, a metoda calculateTotalArea tylko pobiera to pole za pomocą metody getArea. Aby to było możliwe, konieczne było utworzenie interfejsu ShapeInterface, który implementuje wszystkie kształty, tak aby obliczały i zwracały swoją powierzchnię.
W momencie kiedy zostaną wprowadzone kolejne figury, metoda calculateTotalArea nie będzie wymagała żadnych zmian. Wystarczy, że nowa klasa będzie implementowała interfejs ShapeInterface. Dzięki zastosowaniu polimorfizmu została spełniona zasada open-closed principle.
Żadnych zmian?
Według zasady open-closed principle jednostka kodu powinna być zamknięta na modyfikacje, a otwarta na rozszerzanie. Czy to oznacza, że w przedstawionej w powyższym przykładzie metodzie calculateTotalArea nie wolno wprowadzać żadnych zmian? Bynajmniej. Konieczne jest tutaj doprecyzowanie, co oznacza, że kod ma być zamknięty na modyfikacje. Nie chodzi o jakiekolwiek zmiany, ale te z pewnego zakresu. W powyższym przykładzie takim obszarem jest dodawanie nowych figur. Czyli jeśli implementujemy nową figurę, to nie musimy modyfikować metody calculateTotalArea.
Wyobraźmy sobie taką sytuację, że powstaje potrzeba zmodyfikowania działania programu w taki sposób, że ma obliczać sumę pól otrzymanych figur, ale tylko tych, których powierzchnia jest większa niż 50 jednostek kwadratowych. Metoda nie jest zamknięta na taką modyfikację, więc można ją w tym wypadku zmienić, a nawet trzeba, bo inaczej nie będzie można zrealizować nowej funkcjonalności.
public function calculateTotalArea(array $shapes): float
{
$totalArea = 0;
foreach ($shapes as $shape) {
$shapeArea = $shape->getArea();
if ($shapeArea > 50) {
$totalArea += $shape->getArea();
}
}
return $totalArea;
}
Taki kod oczywiście zadziała, ale nie jest to właściwe rozwiązanie. Wymaganie związane z tym, że mają zostać zsumowane pola większe niż 50 jednostek kwadratowych może się zmienić i na przykład będą dodawane pola tylko prostokątów. Trzeba zatem utworzyć abstrakcję do filtrowania figur.
interface ShapeFilterInterface
{
public function filter(array $shapes): array;
}
class ShapeAreaGreaterThanFilter implements ShapeFilterInterface
{
private float $greaterThan;
public function __construct(float $greaterThan)
{
$this->greaterThan = $greaterThan;
}
public function filter(array $shapes): array
{
return array_filter($shapes, fn(ShapeInterface $shape) => $shape->getArea() > $this->greaterThan);
}
}
public function calculateTotalArea(array $shapes, ShapeFilterInterface $shapeFilter): float
{
$totalArea = 0;
$shapes = $shapeFilter->filter($shapes);
foreach ($shapes as $shape) {
$totalArea += $shape->getArea();
}
return $totalArea;
}
Został utworzony interfejs ShapeFilterInterface wraz z jego implementacją ShapeAreaGreaterThanFilter. Konstruktor klasy przyjmuje liczbę, od której mają być większe pola figur przekazanych do metody calculateTotalArea. Ta wartość może się zmieniać, zatem powinna zostać zapisana np. w pliku konfiguracyjnym, w którym będzie można ją łatwo zmodyfikować. Gdyby umieścić ją w kodzie filtra, to każda jej zmiana wymagałaby wyedytowania pliku z klasą.
Korzyści
Stosowanie reguły open-closed principle ma kilka zasadniczych zalet. Po pierwsze w kodzie jest mniej zależności między poszczególnymi modułami, klasami, funkcjami itd. Dzięki temu unikamy przykrej sytuacji, kiedy po zmodyfikowaniu jakiegoś fragmentu aplikacji, trzeba wprowadzić zmiany w miejscu, które jest od tego pierwszego zależne. Po wykonaniu w nim pracy, okazuje się, że jest jeszcze następne, a potem kolejne, w których coś trzeba zrobić. W takich warunkach łatwo o przeoczenie czegoś i wprowadzenie błędu do programu.
Drugą korzyścią ze stosowania tej reguły jest zredukowanie liczby problemów polegających na tym, że modyfikacja w jednym miejscu kodu psuje coś w innym fragmencie, często niezwiązanym z tym pierwszym. Ma to związek z poprzednim punktem. Jeśli nie mamy zależności, które mogą być potencjalnym źródłem błędu, to możemy swobodnie wprowadzać zmiany w kodzie.
Kolejną zaletą tej zasady jest to, że dzięki niej kod jest odpowiedni do ponownego użytku w innym module lub nawet osobnym projekcie. Można to przedstawić na przykładzie kodu z figurami geometrycznymi, który został zaprezentowany wcześniej. Można wykorzystać interfejs ShapeInterface oraz metodę calculateTotalArea, ale napisać zupełnie nowe implementacje interfejsu.
Wreszcie stosując się do zasady, pracujemy efektywniej i oszczędzamy sporo czasu, który musielibyśmy przeznaczyć na rozwiązywanie problemów wynikających z tego, że dana jednostka kodu jest otwarta na zmiany.
Podsumowanie
Po przedstawieniu definicji, przykładów oraz zalet zasady otwarte-zamknięte przyszedł czas na wypróbowanie jej w swojej codziennej pracy. Korzyści jakie to przyniesie spowodują, że pisany przez nas kod będzie dużo lepszej jakości, ponieważ zostanie przygotowany na efektywne wprowadzanie zmian, a także otrzymamy możliwość łatwego przeniesienia go do innego modułu lub projektu, co niewątpliwie zaoszczędzi wiele czasu w przyszłości.
Autorem tekstu jest Szymon Czembor