Zasada otwarte-zamknięte

Elementy programu (klasy, moduły, funkcje itp.) powinny być otwarte na rozbudowę ale zamknięte na modyfikację.

Mayer Bertrand

Modyfikacja zachowań elementów programu (funkcji, klas, modułów itp.) nie powinna wymagać modyfikacji kodu. Elementy powinny być zamknięte na modyfikacje ale otwarte na rozszerzenia.

Kod programu powinien być napisany w taki sposób, żeby umożliwić jego rozbudowę, dodania nowych funkcjonalności, bez modyfikacji istniejącego kodu. Należy rozwijać program przez dodawanie nowych elementów, nie przez modyfikowanie istniejących.

Zasada otwarte-zamknięte wymaga stosowania abstrakcji.

Rozważmy przykładową sytuację. Mamy 2 klasy reprezentujące 2 rodzaje klientów:

public class RegularCustomer {
    final double discountLevel = 0.95;

    // metoda zwraca cenę jednostkową z uwzględnieniem rabatu
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PremiumCustomer {
    final double discountLevel = 0.8;

    // metoda zwraca cenę jednostkową z uwzględnieniem rabatu
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

Klasa PriceCalculator oblicza cenę całkowitą w zależności od rodzaju klienta i ilości zamówionych produktów:

public class PriceCalculator {
    // metoda zwraca koszt całkowity z rabatem w zależności od rodzaju klienta
    public double calculateFinalPrice (double basePrice, int quantity, String customerType) {
        switch (customerType) {
            case "REGULAR":
                RegularCustomer regularCustomer = new RegularCustomer();
                return quantity * regularCustomer.calculateDiscountedPrice(basePrice);
            case "PREMIUM":
                PremiumCustomer premiumCustomer = new PremiumCustomer();
                return quantity * premiumCustomer.calculateDiscountedPrice(basePrice);
            default:
                return quantity * basePrice;
        }
    }
}
public class Runner {
    public static void main (String [] args) {
        System.out.println(PriceCalculator.calculateFinalPrice(100, 100, "PREMIUM"));
}

Powyższy przykład działa poprawnie ale ma zasadniczą wadę:

  • dodanie nowego rodzaju klienta wymaga zmiany istniejącego kodu w klasie PriceCalculator.
public class VipCustomer {
    final double discountLevel = 0.5;

    // metoda zwraca cenę jednostkową z uwzględnieniem rabatu
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PriceCalculator {
    // metoda zwraca koszt całkowity z rabatem w zależności od rodzaju klienta
    public double calculateFinalPrice (double basePrice, int quantity, String customerType) {
        switch (customerType) {
            case "REGULAR":
                RegularCustomer regularCustomer = new RegularCustomer();
                return quantity * regularCustomer.calculateDiscountedPrice(basePrice);
            case "PREMIUM":
                PremiumCustomer premiumCustomer = new PremiumCustomer();
                return quantity * premiumCustomer.calculateDiscountedPrice(basePrice);
            case "VIP":
                VipCustomer vipCustomer = new VipCustomer();
                return quantity * vipCustomer.calculateDiscountedPrice(basePrice);
            default:
                return quantity * basePrice;
        }
    }
}

Poprawiona implementacja

Wprowadźmy interfejs.

public interface Customer {
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

i zaimplementujmy go w każdej klasie reprezentującej rodzaj klienta:

public class RegularCustomer implements Customer{
    final double discountLevel = 0.95;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class PremiumCustomer implements Customer{
    final double discountLevel = 0.8;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}
public class VipCustomer implements Customer{
    final double discountLevel = 0.5;

    @Override
    public double calculateDiscountedPrice (double basePrice) {
            return basePrice * discountLevel ;
    }
}

Klasa calculateFinalPrice bardzo się upraszcza:

public class PriceCalculator {
    public double calculateFinalPrice (double basePrice, int quantity, Customer customer) {
        return quantity * customer.calculateDiscountedPrice(basePrice);
    }
}

Żądamy przekazania jako argumentu dowolnego obiektu implementującego interfejs Customer. Wszystkie klasy reprezentujące rodzaj klienta implementują ten interfejs więc muszą zaimplementować metodę calculateDiscountedPrice.

Dzięki wprowadzeniu dodatkowej warstwy abstrakcji nie trzeba już sprawdzać rodzaju klienta. Program sam wywoła metodę z konkretnej implementacji interfejsu.

public class Runner {
    public static void main (String [] args) {
        System.out.println(PriceCalculator.calculateFinalPrice(100, 100, new RegularCustomer()));
}

Zalety kodu zgodnego z zasadą otwarte-zamknięte

Kod napisany zgodnie z zasadą jednej odpowiedzialności jest łatwiej rozwijać. Dzięki wprowadzeniu warstwy abstrakcji – interfejsu zawierającego zachowania wspólne dla wszystkich klas:

  • wystarczy, że nowa klasa będzie implementować interfejs żeby zmodyfikować zachowanie programu bez konieczności zmieniania istniejącego, działającego i przetestowanego kodu
  • sam kod stał się znacznie prostszy.