SOLID – zasada otwarte-zamknięte

Czym jest Zasada Otwarte-Zamknięte?

Zasada ta, sformułowana przez Bertranda Meyera, mówi, że:

Encje oprogramowania (klasy, moduły, funkcje) powinny być otwarte na rozszerzenia, ale zamknięte na modyfikacje.

Co to tak naprawdę oznacza?

  • Otwarte na rozszerzenia: Powinniśmy mieć możliwość dodawania nowej funkcjonalności bez zmieniania istniejącego kodu.
  • Zamknięte na modyfikacje: Gdy dana klasa jest już napisana, przetestowana i działa, powinniśmy unikać jej modyfikacji, aby nie wprowadzić nowych błędów.

Kluczem do osiągnięcia tego celu jest abstrakcja. Zamiast uzależniać kod od konkretnych implementacji, uzależniamy go od interfejsów lub klas abstrakcyjnych. Dzięki temu możemy “podmieniać” implementacje i dodawać nowe, nie ruszając kodu, który z nich korzysta.

Kluczowe aspekty OCP

Stosowanie tej zasady prowadzi do:

  • Stabilności systemu: Rdzeń aplikacji pozostaje nietknięty, co minimalizuje ryzyko regresji (wprowadzenia błędów w działających już funkcjach).
  • Większej elastyczności: System można łatwo rozbudowywać o nowe funkcje, co jest kluczowe w dynamicznie zmieniających się projektach.
  • Lepszej skalowalności: Nowe moduły można dodawać bez ingerencji w stare, co ułatwia pracę w większych zespołach.
  • Mniejszego sprzężenia (coupling): Moduły wysokiego poziomu nie zależą od detali implementacyjnych modułów niskiego poziomu.

Przykład naruszenia zasady

Wyobraźmy sobie system, w którym mamy różne typy pracowników. Działy Finances i Hr muszą wykonywać na nich specyficzne operacje. Zobaczmy, jak wygląda kod, który nie stosuje się do OCP.

Diagram UML

Kod

W tym przykładzie klasy Finances i Hr muszą znać każdy konkretny typ pracownika.

Java
// Klasy pracowników (konkretne implementacje)
public class Ceo {
    public void calculateSalary(){ System.out.println("Pay Ceo"); }
    public void showIdCard(){ System.out.println("Greet Ceo"); }
}

public class Programmer {
    public void calculateSalary(){ System.out.println("Pay Programmer"); }
    public void showIdCard(){ System.out.println("Greet Programmer"); }
}

// Klasy operacyjne, które łamią OCP
public class Finances {
    public void calculateSalaries(Object[] employees){
        for(Object employee : employees){
            if(employee instanceof Ceo){
                ((Ceo) employee).calculateSalary();
            }
            if(employee instanceof Programmer){
                ((Programmer) employee).calculateSalary();
            }
            // Co jeśli dodamy Managera? Musimy dodać kolejny 'if'!
        }
    }
}

public class Hr {
    public void showIdCards(Object[] employees){
        for(Object employee : employees){
            if(employee instanceof Ceo){
                ((Ceo) employee).showIdCard();
            }
            if(employee instanceof Programmer){
                ((Programmer) employee).showIdCard();
            }
            // Tutaj też trzeba będzie dodać kolejny 'if'...
        }
    }
}

// Główna klasa aplikacji
public class Main {
    public static void main(String[] args) {
        Finances finances = new Finances();
        Hr hr = new Hr();
        Object[] employees = new Object[] { new Ceo(), new Programmer() };

        finances.calculateSalaries(employees);
        hr.showIdCards(employees);
    }
}

Analiza i Problem

Głównym problemem jest tutaj konieczność modyfikacji istniejących klas Finances i Hr za każdym razem, gdy dodajemy nowy typ pracownika (np. Accountant). Musielibyśmy dopisać kolejny blok if (employee instanceof Accountant). To sprawia, że kod jest:

  • Kruchy: Każda zmiana niesie ryzyko zepsucia czegoś, co już działało.
  • Sztywny: Jest odporny na zmiany i trudny w rozbudowie.
  • Trudny w utrzymaniu: Logika jest rozproszona w wielu warunkach if-else lub switch.

Jest to jawne złamanie zasady OCP – klasy nie są zamknięte na modyfikacje.


Poprawne zastosowanie zasady

Naprawmy nasz projekt, wprowadzając wspólną abstrakcję – interfejs Employee.

Diagram UML

Kod po refaktoryzacji

1. Tworzymy interfejs (naszą abstrakcję):

Java
public interface Employee {
    void calculateSalary();
    void showIdCard();
}

2. Klasy pracowników implementują interfejs:

Java
public class Ceo implements Employee {
    @Override
    public void calculateSalary() { System.out.println("Pay Ceo"); }
    @Override
    public void showIdCard() { System.out.println("Greet Ceo"); }
}

public class Programmer implements Employee {
    @Override
    public void calculateSalary(){ System.out.println("Pay Programmer"); }
    @Override
    public void showIdCard(){ System.out.println("Greet Programmer"); }
}

// Możemy teraz łatwo dodać nową klasę!
public class Accountant implements Employee {
    @Override
    public void calculateSalary(){ System.out.println("Pay Accountant"); }
    @Override
    public void showIdCard(){ System.out.println("Greet Accountant"); }
}

3. Modyfikujemy klasy operacyjne, by zależały od abstrakcji:

Java
import java.util.List;

public class Finances {
    public void calculateSalaries(List<Employee> employees){
        employees.forEach(employee -> employee.calculateSalary());
    }
}

public class Hr {
    public void showIdCards(List<Employee> employees){
        employees.forEach(Employee::showIdCard); // Inny zapis tego samego
    }
}

4. Aktualizujemy główną klasę aplikacji:

Java
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        Finances finances = new Finances();
        Hr hr = new Hr();

        List<Employee> employees = new ArrayList<>();
        employees.add(new Ceo());
        employees.add(new Programmer());
        employees.add(new Accountant()); // Dodanie nowego pracownika jest banalnie proste!

        finances.calculateSalaries(employees);
        hr.showIdCards(employees);
    }
}

Analiza

Teraz klasy Finances i Hrzamknięte na modyfikacje. Nie obchodzi ich, jakie konkretne typy pracowników przetwarzają – wiedzą tylko, że każdy obiekt na liście jest typu Employee i na pewno ma metody calculateSalary() i showIdCard().

Jednocześnie system jest otwarty na rozszerzenia. Możemy dodać dziesięć nowych stanowisk (np. Tester, Manager, Designer) – wystarczy, że stworzymy nowe klasy implementujące interfejs Employee. Nie musimy dotykać ani jednej linijki kodu w klasach Finances i Hr.


Porównanie przykładów

CechaPrzed refaktoryzacjąPo refaktoryzacji
RozszerzalnośćWymaga modyfikacji istniejących klasNowe funkcje dodawane przez nowe klasy
ZależnościOd konkretnych implementacji (np. Ceo, Programmer)Od abstrakcji (Employee)
StabilnośćNiska – istniejący kod jest często zmienianyWysoka – przetestowany kod pozostaje nietknięty
Struktura koduZłożone warunki if-instanceof-thenProste, polimorficzne wywołania metod

Podsumowanie

Zasada Otwarte-Zamknięte jest sercem projektowania obiektowego. Promuje użycie abstrakcji i polimorfizmu, co prowadzi do tworzenia systemów, które są jednocześnie stabilne i elastyczne. Pamiętaj – projektuj swój kod tak, aby można było go rozszerzać, a nie przepisywać od nowa.


Bibliografia

  • Martin, R. C. (2003). Agile Software Development, Principles, Patterns, and Practices. Pearson.
  • Meyer, B. (1988). Object-Oriented Software Construction. Prentice Hall.