Zasady SOLID #3 – Liskov Substitution Principle

matryoshka
W tym wpisie porozmawiamy o dziedziczeniu. Jeżeli dana metoda przyjmuje jako argument obiekt pewnej klasy bazowej, zachowuje się ona w pewien oczekiwany przez nas sposób. Załóżmy teraz że obiekt klasy, która dziedziczy po klasie bazowej, zostanie przesłany do tej metody. Co wtedy się stanie? Odpowiedź: działanie metody się nie zmieni. A przynajmniej nie powinno.

Wprowadzenie

Zasada numer 3 z akronimu SOLID to Zasada Podstawienia Liskov (ang. Liskov Substitution Principle). Zgodnie z tą zasadą, 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.
Autorem tej zasady jest Barbara Liskov, amerykańska informatyczka, profesor na Massachusetts Institute of Technology.

Objaśnienie zasady

Kiedy kupujemy meble do samodzielnego złożenia, dostajemy w zestawie zestaw śrub. Jeżeli zdarzy nam się zgubić część z nich, możemy dokupić je w dowolnym sklepie narzędziowym. W związku z tym, że wymiary śrub są znormalizowane, nie musimy się martwić, że dokupione przez nas śruby nie będą pasować (o ile mają tę samą długość i średnicę). Nie ma też znaczenia czy kupimy śruby z łbem sześciokątnym, gniazdem sześciokątnym (tzw. śruby imbusowe), czy też z łbem z nacięciem krzyżowym (na śrubokręt). Wszystkie te rodzaje śrub umożliwią nam złożenie naszych mebli.
Gdyby tą sytuację przyrównać do programowania, to możemy wyobrazić sobie metodę składająca meble, wykorzystującą referencję do obiektu klasy Śruba. Obiektami klasy bazowej są śruby dołączone przez producenta do zestawu. Natomiast różne rodzaje śrub, które możemy dokupić to są podklasy klasy bazowej Śruba. Możemy skorzystać z dowolnego rodzaju śrub, a otrzymamy taki sam efekt – złożone meble. Zasada Podstawienia Liskov jest tu zachowana.
Teraz wyobraźmy sobie, że podczas zakupu śrub pomyliliśmy się i zakupiliśmy śruby z gwintem lewoskrętnym. Takimi śrubami niestety nie złożymy mebli, gdyż niemożliwe jest wkręcenie śruby lewoskrętnej do otworu prawoskrętnego.
Po przełożeniu tej sytuacji na programowanie, mamy kolejną podklasę klasy Śruba, której nie możemy wykorzystać w metodzie składającej meble. Zasada LSP została złamana.

Przykład

public class Workshop {

    public void checkEngine(Vehicle vehicle) {
        vehicle.startEngine();
        System.out.println("Engine checked");
    }
}

public class Vehicle {

    public void startEngine() {
        System.out.println("Vehicle's engine running");
    }
}

public class Car extends Vehicle {

    @Override
    public void startEngine() {
        System.out.println("Car's engine running");
    }
} 
Dzisiejszy przykład będzie bardzo prosty. Mamy klasę Vehicle, która posiada jedną metodę startEngine(). Mamy też klasę Car dziedziczącą po Vehicle i nadpisującą metodę startEngine(). Mamy też klasę Workshop z metodą checkEngine(), która jako argument wykorzystuję instancję klasy Vehicle. Metoda ta dokonuje sprawdzenia, czy silnik działa poprawnie.
Póki co wszystko wygląda w porządku. Do czasu, gdy chcielibyśmy dodać do projektu klasę Bicycle.

Kod niezgodny z LSP

public class Bicycle extends Vehicle {

    @Override
    public void startEngine() {}
} 
Klasa Bicycle jest problematyczna, bo rower nie posiada silnika. Co w takim razie zrobić z metodą startEngine()? Pierwsze co przychodzi do głowy to pozostawić ciało metody puste (jak w naszym przykładzie) lub rzucić wyjątkiem. Program wciąż działa, jednak takie podejście jest niezgodne z Zasadą Podstawienia Liskov, gdyż klasy potomne powinny rozszerzać działanie klasy bazowej, a nie zmieniać je.
public class Workshop {

    public void checkEngine(Vehicle vehicle) {
        if (vehicle instanceof Bicycle) {
            System.out.println("Bicycle doesn't have an Engine!");
        } else {
            vehicle.startEngine();
            System.out.println("Engine checked");
        }
    }
} 
Drugie złamanie LSP występuje w klasie Workshop. W tej metodzie sprawdzamy jakiej klasy jest obiekt przesłany jako argument, aby wykonać poprawne działanie. A Zasada Postawienia Liskov mówi nam, że metoda wykorzystująca dane obiekty powinna działać bez znajomości tych obiektów.

Kod zgodny z LSP

W celu zachowania zgodności programu z Zasadą Podstawienia Liskov, zmienimy nieco naszą strukturę klas dziedziczących po klasie bazowej Vehicle.
UML diagram
public class Workshop {

    public void checkEngine(VehicleWithEngine vehicle) {
        vehicle.startEngine();
        System.out.println("Engine checked");
    }
}

public class Vehicle {
}

public class VehicleWithEngine extends Vehicle {

    public void startEngine() {
        System.out.println("Vehicle's engine running");
    }
}

public class Car extends VehicleWithEngine {

    @Override
    public void startEngine() {
        System.out.println("Car's engine running");
    }
}

public class VehicleWithoutEngine extends Vehicle {
}

public class Bicycle extends VehicleWithoutEngine {
} 
Teraz kasa Car dziedziczy po VehicleWithEngine. Natomiast Bicycle dziedziczy po VehicleWithoutEngine, która to nie posiada metody startEngine(). Klasa Workshop od teraz przyjmuje jako argument tylko obiekty klasy VehicleWithEngine. Dzięki temu nie musimy w ciele metody checkEngine() sprawdzać z jakim obiektem mamy do czynienia.

Dodatkowe reguły

W celu zachowania poprawnego działania obiektów klasy bazowej i obiektów klas pochodnych, powinniśmy zachować dwie reguły dotyczące warunków wstępnych i warunków końcowych metod tych obiektów.

Reguła warunków wstępnych

Warunki wstępne są to warunki, które muszą zostać spełnione przed wykonaniem danego kodu. Zasada Podstawienia Liskow mówi nam, że warunki wstępne w klasach pochodnych nie mogą zostać wzmocnione. Spójrzmy na poniższy kod:
public class Vehicle {

    protected double fuelPercentage;

    public void startEngine() throws Exception {
        if (fuelPercentage <= 0.03) {
            throw new Exception();
        }
        System.out.println("Vehicle's engine running");
    }
}

public class Car extends Vehicle {

    @Override
    public void startEngine() throws Exception {
        if (fuelPercentage <= 0.05) {
            throw new Exception();
        }
        System.out.println("Car's engine running");
    }
} 
W tym przypadku klasa Vehicle ma w metodzie startEngine() dodatkowe zabezpieczenie uniemożliwiające uruchomienie silnika, gdy w zbiorniku paliwa jest mniej niż 3% paliwa. Natomiast w klasie Car dziedziczącej po Vehicle warunek jest bardziej restrykcyjny i uniemożliwi uruchomienie silnika, gdy paliwa jest mniej niż 5%.
Zasada LSP została złamana. Klasa pochodna może mieć warunki wstępne osłabione lub takie same, ale nie może wzmacniać warunków.

Reguła warunków końcowych

Warunki końcowe to warunki, które muszą zostać spełnione po wykonaniu danego kodu. Zasada Podstawienia Liskov mówi, iż warunki końcowe w klasach pochodnych nie mogą zostać osłabione. Poniżej przykład złamania tej reguły:
public class Vehicle {

    protected double oilPressure;

    public void startEngine() throws Exception {
        System.out.println("Vehicle's engine running");
        if (oilPressure <= 1.0) {
            stopEngine();
            throw new Exception();
        }
    }

    public void stopEngine() {
        System.out.println("Vehicle's engine stopped");
    }
}

public class Car extends Vehicle {

    @Override
    public void startEngine() throws Exception {
        System.out.println("Car's engine running");
        if (oilPressure <= 0.8) {
            stopEngine();
            throw new Exception();
        }
    }

    @Override
    public void stopEngine() {
        System.out.println("Car's engine stopped");
    }
} 
W metodzie startEngine(), po uruchomieniu silnika sprawdzamy ciśnienie oleju. W klasie bazowej, gdy ciśnienie jest mniejsze od 1.0 bar, silnik zostaje wyłączony. W klasie pochodnej warunek ten został osłabiony, gdyż silnik zostaje wyłączony, gdy ciśnienie jest mniejsze niż 0.8 bar.
Tutaj mamy sytuację odwrotną w stosunku do reguły warunków wstępnych. Gdyby warunki końcowe zostały wzmocnione lub przynajmniej takie same, wszystko byłoby w porządku. Jednak skoro warunki zostały osłabione, zasada LSP nie jest zachowana.

Podsumowanie

Dziedziczenie jest jednym z fundamentalnych mechanizmów programowania obiektowego. Daje nam bardzo wiele możliwości rozbudowy pisanych przez nas programów. Jednak nie możemy z niego korzystać dowolnie, gdyż może to doprowadzić do wielu nieoczekiwanych problemów. Zasada Podstawienia Liskov jest nieocenioną pomocą, dzięki której programy wykorzystujące mechanizm dziedziczenia będą się zachowywały w sposób przewidywalny, tak jak tego oczekujemy.

Znajdź mnie również na:

arrow