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