Liskov Substitution Principle

Zasada podstawienia Liskov jest to trzecia zasada projektowania obiektowego zawarta w skrócie SOLID. Mówi ona, że funkcje, które używają wskaźników lub referencji do klas bazowych musi być w stanie korzystać z obiektów klas dziedziczących po tych klasach, bez dokładnej znajomości tych obiektów. W artykule pokażę w jaki sposób zasada jest łamana i jak projektować aplikacje, żeby tej zasady nie naruszać.

Spis treści

Definicja

Funkcje, które używają wskaźników lub referencji do klas bazowych, muszą być w stanie używać również obiektów klas dziedziczących po klasach bazowych, bez dokładnej znajomości tych obiektów.

Przykład z życia

Sztandarowym przykładem wykorzystywania zasady podstawienia Liskov w życiu codziennym są Samochody. Nie ważne jakim samochodem jedziemy, starym czy nowym, drogim czy tanim, dużym czy małym producenci zawarli pewien kontrakt. Mianowicie zawsze pedał gazu jest po prawej stronie zaś hamulec na lewo od niego. Dzięki temu każdy kto jeździ samochodem jest w stanie zmienić markę pojazdu i będzie umiał taki samochód prowadzić.

Aplikacja niezgodna z LSP

Aby pokazać jak wygląda program niezgodny z zasadą podstawienia Liskov pokażę przykład z artykułu o drugiej zasadzie SOLID. Poniższy kod jest zgodny z zasadą otwarte zamknięte, ale jednak łamie zasadę LSP (a także ISP, ale o tym w osobnym artykule). Poniżej kod aplikacji.

package pl.zagora;

import pl.zagora.model.ITool;
import java.util.List;

public class Gardener {

    public void handleTheGarden( List<ITool> tools ) {
        for (ITool tool : tools) {
            tool.start();
        }
    }

}
package pl.zagora;

import pl.zagora.model.ITool;
import java.util.List;

public class ServiceTechnician {

    public void repairTools( List<ITool> tools) {
        for (ITool tool: tools) {
            tool.repair();
        }
    }
}

Powyżej mamy 2 klasy korzystające z usług interfejsu ITool. Zarówno ogrodnik jak i serwisant do wykonania swojej pracy pobierają listę narzędzi. Zobaczmy jak te narzędzia wyglądają:

package pl.zagora.model;

public interface ITool {
    void start();
    void repair();
}
package pl.zagora.model;

public class LawnMower implements ITool {

    public void start() {
        System.out.println("cut the grass");
    }

    public void repair() {
        System.out.println("sharpen the knives, adjust knives height, fix the engine");
    }
}
package pl.zagora.model;

public class Scissors implements ITool {

    public void start() {
        System.out.println("cut the bush");
    }

    public void repair() {
        System.out.println("sharpen blades and screw the screws");
    }

}

Następnie program tworzy instancje klas Gardener i ServiceTechnician, tworzy listę narzędzi i wywołuje metody handleTheGarden() i repairTools().

package pl.zagora;

import pl.zagora.model.ITool;
import pl.zagora.model.LawnMower;
import pl.zagora.model.Scissors;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Main {

    public static void main(String[] args) {

        Gardener gardener = new Gardener();
        ServiceTechnician serviceTechnician = new ServiceTechnician();

        List<ITool> tools = new ArrayList<>(Arrays.asList(new LawnMower(), new Scissors()));

        gardener.handleTheGarden(tools);
        serviceTechnician.repairTools(tools);

    }

}

Poniżej uproszczony UML pokazujący zależności między interfejsem ITool a klasami implementującymi go.

W tej chwili nie ma jeszcze żadnego problemu. Wyobraźmy sobie jednak, że do naszych narzędzi dodamy kanister z benzyną. Zobaczmy co stanie się z aplikacją.

Dodawanie nowego narzędzia – benzyny

Nasza aplikacja się rozwija i postanowiliśmy do naszej paczki narzędzi dołożyć coś co nie do końca spełnia nasze wymagania, ale najbardziej tutaj pasuje. Jest to benzyna. Zobaczmy jakie wywoła to konsekwencje. Na początek zerknijmy na schemat UML.

Klasa Gasoline, podobnie jak reszta narzędzi musi zaimplementować 2 metody: start() oraz repair(). O ile w metodzie start() będzie uzupełnianie paliwa np w kosiarce, to niestety nie da się naprawić benzyny. I tu pojawia się problem. Mamy bowiem 3 możliwości co z tym fantem zrobić. Po pierwsze możemy zostawić metodę pustą, po drugie możemy wygenerować wiadomość w konsoli, a po trzecie rzucić wyjątkiem. Wszystkie te możliwości są jednak złe, ponieważ powodują złamanie zasady podstawienia Liskov. Ja w moim przykładzie wybrałem trzeci wariant. Zobaczmy jak wygląda kod po zmianach.

package pl.zagora.model;

public class Gasoline implements ITool{
    @Override
    public void start() {
        System.out.println("Refill tool with gasoline");
    }

    @Override
    public void repair() {
        throw new Exception();
    }
}

Powyżej klasa Gasoline rzucająca wyjątek w przypadku wywołania metody repair(). Poniżej zaś zaktualizowana klasa Main.

package pl.zagora;

import pl.zagora.model.Gasoline;
import pl.zagora.model.ITool;
import pl.zagora.model.LawnMower;
import pl.zagora.model.Scissors;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Main {

    public static void main(String[] args) {

        Gardener gardener = new Gardener();
        ServiceTechnician serviceTechnician = new ServiceTechnician();

        List<ITool> tools = new ArrayList<>(Arrays.asList(new LawnMower(), new Scissors(), new Gasoline()));

        gardener.handleTheGarden(tools);
        serviceTechnician.repairTools(tools);

    }

}

oraz efekt jej działania:

Jak widać program nie kompiluje się, ponieważ obiekt typu Gasoline nie implementuje poprawnie interfejsu ITool. Została naruszona zasada LSP.

Aplikacja zgodna z LSP

Nasza aplikacja po próbie dodania nowego narzędzia – benzyny narusza zasadę podstawienia Liskov, ponieważ mamy tutaj zaprojektowaną błędną hierarchię klas. Abstrakcja w postaci interfejsu ITool nie do końca odzwierciedla to co chcemy osiągnąć. Aby rozwiązać problem należy zakwalifikować benzynę do innego interfejsu. W tym celu rozbijemy ITool na dwa interfejsy: IStartable ora IRepairable. Dzięki temu będziemy w stanie poprawić nasz program i zakwalifikować benzynę tylko jako IStartable, zaś pozostałe narzędzia zaimplementują obydwa interfejsy. Zobaczmy jak zmieni się kod aplikacji.

package pl.zagora.model;

public interface IStartable {
    void start();
}
package pl.zagora.model;

public interface IRepairable {
    void repair();
}

Powyżej interfejsy powstałe z rozbicia ITool, a poniżej rzeczywiste implementacje.

package pl.zagora.model;

public class LawnMower implements IStartable, IRepairable {

    public void start() {
        System.out.println("cut the grass");
    }

    public void repair() {
        System.out.println("sharpen the knives, adjust knives height, fix the engine");
    }
}
package pl.zagora.model;

public class Scissors implements IStartable, IRepairable {

    public void start() {
        System.out.println("cut the bush");
    }

    public void repair() {
        System.out.println("sharpen blades and screw the screws");
    }

}
package pl.zagora.model;

public class Gasoline implements IStartable{
    @Override
    public void start() {
        System.out.println("Refill tool with gasoline");
    }

}

Na uwagę zasługuje fakt, że Gasoline implementuje tylko jeden interfejs IStartable, zaś LawnMower i Scissors obydwa. Zobaczmy jak wygląda kod klas Gardener, ServiceTechnician oraz Main.

package pl.zagora;

import pl.zagora.model.IStartable;

import java.util.List;

public class Gardener {

    public void handleTheGarden( List<IStartable> startables ) {
        for (IStartable startable : startables) {
            startable.start();
        }
    }

}
package pl.zagora;

import pl.zagora.model.IRepairable;

import java.util.List;

public class ServiceTechnician {

    public void repairTools( List<IRepairable> repairables) {
        for (IRepairable repairable: repairables) {
            repairable.repair();
        }
    }
}
package pl.zagora;

import pl.zagora.model.*;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class Main {

    public static void main(String[] args) {

        LawnMower lawnMower = new LawnMower();
        Scissors scissors = new Scissors();
        Gasoline gasoline = new Gasoline();

        Gardener gardener = new Gardener();
        ServiceTechnician serviceTechnician = new ServiceTechnician();

        List<IStartable> startables = new ArrayList<>(Arrays.asList(lawnMower, scissors, gasoline));
        List<IRepairable> repairables = new ArrayList<>(Arrays.asList(lawnMower, scissors));

        gardener.handleTheGarden(startables);
        serviceTechnician.repairTools(repairables);

    }

}

Klasy Gardener i ServiceTechnician używają teraz właściwych dla siebie interfejsów, zaś w klasie głównej musieliśmy utworzyć dwie oddzielne listy. Dzięki temu jednak program działa a hierarchia klas jest zgodna z zasadą LSP. Kluczowa zmiana jest widoczna na poniższym diagramie UML. Widać, że klasa Gasoline nie implementuje interfejsu IRepairable a zatem nie jest zmuszona implementować bezsensownej z jej punktu widzenia metody repair().

Warunki wstępne

W powyższym przykładzie operowaliśmy na interfejsach jako swoistych bazach dla klas implementujących i skupiliśmy się głównie na przygotowaniu poprawnej hierarchii klas, tak aby klasy nie musiały implementować metod, które nie mają sensu z ich punktu widzenia. LSP dotyczy również dziedziczenia po klasach bazowych, które nie zawsze muszą składać się w całości z metod abstrakcyjnych. W szczególności klasa bazowa może posiadać pewne metody, które są zaimplementowane i dziedziczone przez klasy pochodne. Metody te nie powinny być nadpisywane, aczkolwiek często zachodzi też taka konieczność. Należy wtedy jednak pamiętać o pewnych zasadach. Jedna z nich dotyczy warunków wstępnych, czyli sprawdza poprawność danych wejściowych. Zmiana warunków wstępnych może prowadzić do niechcianych konsekwencji. Zobaczmy to na przykładzie.

package preconditions;

public class Parent {

    int calculateTotalPrice(int price, int discount) throws Exception {
        if (price == 0) {
            throw new Exception();
        }
        return price - discount;
    }

}

Powyższa klasa Parent posiada metodę, która rzuca wyjątkiem jeżeli cena jest równa 0. Oznacza to, że klienci tej klasy są przygotowani na taki obrót sprawy i zabezpieczą się przed przekazaniem wartości 0 do tej metody.

package preconditions;

public class Child extends Parent{

    @Override
    int calculateTotalPrice(int price, int discount) throws Exception {
        if (price == 0 || price > 15) {
            throw new Exception();
        }
        return price - discount;
    }
}

Klasa Child dziedziczy po klasie Parent jednak nadpisuje metodę calculateTotalPrice i zaostrza warunek początkowy. Będzie rzucała wyjątek również gdy cena będzie większa od 15. Jeżeli zamiast klasy Parent dokonamy podstawienia klasy Child to klienci nie będą przygotowani na taką ewentualność. Zgodnie z zasadą Liskov Substitution nie mają oni bowiem obowiązku znać szczegółów obiektów dziedziczących po klasie bazowej. Niewyłapany wyjątek spowoduje wysypanie się programu.
Sytuacja ta powtarza się na tyle często, że powstała reguła, która zapobiega naruszaniu LSP poprzez błędne zmienianie warunków początkowych. Brzmi ona następująco: Warunki początkowe nie mogę być ostrzejsze w klasie pochodnej niż w klasie bazowej. Mogą być osłabiane, ponieważ wtedy nie zostanie rzucony wyjątek tam, gdzie klienci się tego nie spodziewają. Jednak zaostrzanie jest zabronione.

Warunki końcowe

Podobna sytuacja, aczkolwiek odwrotna zachodzi przy warunkach końcowych. Założmy, że na koniec metody chcemy zweryfikować wynik zanim zostanie on zwrócony. Zobaczmy przykład.

package postconditions;

public class Parent {

    int calculateTotalPrice(int price, int discount) throws Exception {
        int totalPrice = price - discount;
        if (totalPrice <= 0) throw new Exception();
        return totalPrice;
    }

}

Po uwzględnieniu obniżki sprawdzamy czy cena nie spadła poniżej 0. Wówczas rzucany jest wyjątek, a zatem klienci są przygotowani na taką sytuację i mogą taki wyjątek obsłużyć.

package postconditions;

public class Child extends Parent {

    @Override
    int calculateTotalPrice(int price, int discount) {
        return price - discount;
    }
}

Klasa pochodna Child zlikwidowała warunek końcowy. Zatem nie rzuci wyjątkiem w sytuacji gdy cena spadnie poniżej 0. To narusza logikę biznesową, ponieważ cena nie może być niedodatnia. Zatem również została złamana zasada LSP. Reguła dotycząca warunków końcowych mówi: Nie wolno osłabiać warunków końcowych w klasach pochodnych. Zaostrzenie warunków końcowych jest dopuszczalne, ponieważ wówczas klient i tak jest przygotowany, że coś może pójść nie tak. Wypatruje wyjątku i jeżeli dostanie go tam gdzie go wcześniej nie było to nie jest to żadna przeszkoda.

Korzyści płynące ze stosowania LSP

Stosowanie w projektowaniu oprogramowania zasady podstawienia Liskov pozwala na zamienne stosowanie podtypów klas bazowych bez znajomości szczegółów obiektów tych klas. Ułatwia to utrzymanie aplikacji w przypadku wprowadzania zmian, dodawania nowych klas itp. Poprawna hierarchia zapobiega też trudnym do rozwiązania i dość nieoczekiwanym błędom kompilacji, a także błędom w logice działania przy bardzo złożonych oprogramowaniach.

Podsumowanie

Zasada podstawienia Liskov traktuje o dziedziczeniu. Mówi, że należy tak projektować aplikację, żeby klasy wysokich poziomów uzależniać od klas bazowych, a dynamicznie przypisywać do nich podtypy tych klas. Aby osiągnąć ten cel trzeba spełnić kilka fundamentalnych warunków. Podstawą jest tutaj poprawne wymyślenie abstrakcji tj. hierarchii dziedziczenia, a także w przypadku nadpisywania metod pamiętanie o odpowiednim dopasowaniu się do warunków wstępnych oraz końcowych. Jest to dość trudna reguła, która często jest mylona zarówno z reguła otwarte zamknięte jak i segregacji interfejsów. Kluczowa w tym momencie jest przyczyna, dla której ją stosujemy a zatem możliwość zamiennego korzystania z klas.