Modest Programmer logo
3 grudnia 2019
Kolejną z zasad SOLID, dzięki której nasz kod będzie dobrej jakości jest zasada podstawień Liskov, czyli Liskov Substitution Principle (LSP) została opracowana w roku 1988, przez Amerykańską programistkę Barbarę Liskov. Po raz pierwszy zasada brzmiała tak:

"Poszukujemy następującej właściwości podstawiania: Jeżeli dla każdego obiektu o1 typu S istnieje obiekt o2 typu T taki, że dla wszystkich programów P zdefiniowanych w kategoriach T zachowanie P pozostanie niezmienione, gdy o1 zostanie podstawione za o2, to S jest podtypem T."

W sumie na tym mógłbym zakończyć ten artykuł, bo już chyba wszystko jest jasne. Prawda? No chyba nie do końca :)

W ostatecznej wersji treść zasady podstawienia Liskov brzmi tak:

"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".

Krócej mówiąc, w miejsce typu bazowego, możesz podstawić dowolny typ klasy pochodnej i nie powinieneś utracić poprawnego działania.

SOLID - Liskov Substitution Principle (LSP) - Wszystko Co Powinieneń Wiedzieć o Zasadzie Podstawienia Liskov

SOLID - Liskov Substitution Principle (LSP) - Wszystko Co Powinieneś Wiedzieć o Zasadzie Podstawienia Liskov


Zasada podstawienia Liskov jest powiązana z zasadą omawianą w poprzednim artykule, czyli zasadzie otwarte-zamknięte, ponieważ dzięki możliwości zastępowania podtypów, mamy możliwość rozbudowy klas bez konieczności ich modyfikowania. Aby dziedziczenie było dobre, klasy pochodne nie powinny nadpisywać metod klas bazowych. Natomiast można je rozszerzyć, poprzez wywołanie metody z klasy bazowej, czyli klasa pochodna powinna rozszerzać klasę bazową bez wpływania na jej działanie.

Zasada LSP dotyczy prawidłowo zaprojektowanego dziedziczenia. Jeżeli tworzymy klasę pochodną, to musimy być również w stanie użyć jej zamiast klasy bazowej. W przeciwnym przypadku oznacza to, że dziedziczenie zostało zaimplementowane nieprawidłowo.

Zacznijmy od popularnego przykładu, który bardzo dobrze pokazuje naruszenie zasady LSP, powstał już nawet słynny mem na ten temat. Myślę, że ten przykład najlepiej zilustruje Ci, czym jest zasada LSP.

LSP - Liskov Substitution Principle - Ducks

Czyli jeżeli coś wygląda jak kaczka, kwacze jak kaczka, ale potrzebuję baterii, to prawdopodobnie masz złą abstrakcję :)


public interface IDuck
{
    void Swim();
    bool IsSwimming { get; }
}

public class OrganicDuck : IDuck
{
    private bool _isSwimming = false;
    public void Swim()
    {
        Console.WriteLine("OrganicDuck swims");
        _isSwimming = true;
    }

    public bool IsSwimming { get { return _isSwimming; } }
}

public class ElectricDuck : IDuck
{
    private bool _isSwimming;

    public void Swim()
    {
        if (!IsTurnedOn)
            return;

        Console.WriteLine("ElectricDuck swims");
        _isSwimming = true;
    }

    public bool IsTurnedOn { get; set; }
    public bool IsSwimming { get { return _isSwimming; } }
}	

public class Program
{
    static void Main()
    {
        var ducks = new List();
        IDuck organicDuck = new OrganicDuck();
        IDuck electricDuck = new ElectricDuck();
        ducks.Add(organicDuck);
        ducks.Add(electricDuck);

        MakeDuckSwim(ducks); //OrganicDuck swims
    }

    private static void MakeDuckSwim(IEnumerable ducks)
    {
        foreach (var duck in ducks)
            duck.Swim();
    }
} 

Jak widzisz obie kaczki implementują interfejs IDuck. Zawierają metody Swim, lecz po wywołaniu tej metody kaczka elektryczna nie pływa, ponieważ nie została wcześniej włączona. Jeżeli chciałbyś "naprawić" ten kod, tak aby obie kaczki pływały, musiałbyś zmodyfikować metodę MakeDuckSwim i zrobić osobną logikę w tej metodzie, gdy kaczka jest typu ElectricDuck.

private static void MakeDuckSwim(IEnumerable ducks)
{
    foreach (var duck in ducks)
    {
        if (duck is ElectricDuck)        
            ((ElectricDuck)duck).TurnOn();
        
        duck.Swim(); 
    }
} 

Taka zmiana oczywiście też jest zła i narusza zasadę LSP oraz OCP (klasa nie jest zamknięta na modyfikacje). Podsumowując, zdecydowanie mamy w tym wypadku zaprojektowaną złą abstrakcję.

Jak mówi definicja funkcje, które używają referencji do wskaźników klas bazowych, powinny również mieć możliwość użycia funkcji klas pochodnych bez znajomości tego obiektu. Czyli można powiedzieć, że zasada podstawień Liskov między innymi sprowadza się do zakazu zadawania pytania o typ obiektu.


Dodatkowo, aby zasada LSP była zachowana muszą być spełnione również poniższe warunki:


#1 Kowariancja typów zwracanych w podtypie.
#2 Kontrawariancja argumentów metody w podtypie.
#3 Metody podtypu nie powinny rzucać żadnych nowych wyjątków, oprócz sytuacji, gdy nowe wyjątki są podtypami zgłaszanych metod nadtypu.
#4 W podtypie nie można wzmocnić warunków wstępnych.
#5 Warunki podrzędne nie mogą być mniej restrykcyjne w podtypie.

Omówmy każdy z tych warunków na przykładzie.


#1 Kowariancja typów zwracanych w podtypie.


Kowariancja opisuje relacje między klasami. Przyjrzyj się. proszę poniższemu przykładowi:

public class Vehicle 
{
}

public class Car : Vehicle
{
}

public class Audi : Car
{
}

public class Program
{
    private static Car GetCar()
    {
        return new Car();
    }

    static void Main()
    {
        Vehicle vehicle = GetCar();
        Car car = GetCar();
        Audi audi = GetCar(); //Błąd kompilacji
    }
} 

Przedstawiłem prostą relację dziedziczenia. Klasa Vehicle jest klasą bazową i dziedziczą po niej klasa Car oraz klasa Audi. Następnie w metodzie Main próbuje przypisać do każdego typu, obiekt typu Car, na szczęście kompilator na to nie pozwala, ponieważ do typu Audi, próbujemy przypisać typ bardziej szczegółowy. Kowariancja jest konwersją z typu bardziej szczegółowego do typu bardziej ogólnego. Czyli dla typu pochodnego możemy zawsze przypisać typ obiektu lub typ bazowy, ale nie pochodny.


#2 Kontrawariancja argumentów metody w podtypie.


Kontrawariancja jest relacją odwrotną do kowariancji, a zatem pozwala na konwersję z typu bardziej ogólnego na typ bardziej szczegółowy.

public class Program
{
    private static void TurnOn(Car car)
    {
    }

    static void Main()
    {
        TurnOn(new Vehicle()); //Błąd kompilacji
        TurnOn(new Car());
        TurnOn(new Audi());
    }
} 

Jak widzisz, metoda TurnOn oczekuje parametru typu Car, próbując przekazać jako argument typ Vehicle dostajemy błąd kompilacji. Nie można zatem przekazać typu bardziej ogólnego niż typ Car. Czyli podobnie jak w kowariancji, przed naruszeniem tej zasady programistów C# ostrzega kompilator.


#3 Metody podtypu nie powinny rzucać żadnych nowych wyjątków, oprócz sytuacji, gdy nowe wyjątki są podtypami zgłaszanych metod nadtypu.


public class Vehicle
{
    public virtual void TurnOn()
    {
        throw new IndexOutOfRangeException();
    }
}

public class Car : Vehicle
{
    public override void TurnOn()
    {
        throw new DivideByZeroException();
    }
}

public class Program
{
    public static void TurnOnVehicle(Vehicle vehicle)
    {
        try
        {
            vehicle.TurnOn();
        }
        catch (IndexOutOfRangeException)
        {
        }
    }

    static void Main()
    {
        var vehicles = new List
        {
            new Vehicle(),
            new Car()
        };

        foreach (var vehicle in vehicles)
        {
            TurnOnVehicle(vehicle); //Unhandled exception
        }
    }
} 

Powyższy kod naruszą tę zasadę, ponieważ typ Car rzuci wyjątek w metodzie Main, którego nie spodziewa się typ bazowy. Dopuszczalne w tej sytuacji byłoby rzucenie w klasie Car wyjątku, który w tym przypadku dziedziczyłby po IndexOutOfRangeException.


#4 W podtypie nie można wzmocnić warunków wstępnych.


public class Vehicle
{
    public virtual void TurnOn(int temp)
    {
        if (temp < -20)
            return;

        //logic
    }
}

public class Car : Vehicle
{
    public override void TurnOn(int temp)
    {
        if (temp < -5)
            return;

        //logic
    }
} 

Klasa pochodna jest bardziej restrykcyjna niż jej typ bazowy. Wymaga, aby argument temp była wyższy niż -5, gdzie klasa bazowa wymaga, aby argument był tylko wyższy niż -20. Typ pochodny w tym przypadku Car musi obsługiwać taki sam zakres danych lub szerszy, na pewno nie mniejszy. Jest to kolejne naruszenie zasady LSP.


#5 Warunki końcowe nie mogą być mniej restrykcyjne w podtypie.


public class Vehicle
{
    public int Temp { get; set; }
    public virtual int GetTemp()
    {
        //logic
        Temp = -1;

        if (Temp < -100)
            throw new Exception("Sensor damaged.");

        return Temp;
    }
}

public class Car : Vehicle
{
    public override int GetTemp()
    {
        //logic
        Temp = -200;

        return Temp;
    }
} 

Klasa bazowa rzuci wyjątek, gdy Temp < -100 i również przynajmniej taki warunek powinien być w klasie pochodnej. Obecnie klasa Car nie sprawdza wcale właściwości Temp, przez co jest mniej restrykcyjna niż klasa bazowa Vehicle. Jest to naruszenie reguły LSP.


PODSUMOWANIE


Zasada LSP jest początkowo dość trudna do zrozumienia i programiści często mylą ją z innymi zasadami SOLID: OCP (open-closed principle - omawiana w poprzednim artykule) oraz ISP (interface segregation principle - napiszę o niej w następnym artykule). Implementując w poprawny sposób zasadę podstawienia Liskov, nie powinniśmy się posługiwać żadnym konstrukcjami warunkowymi, aby wymusić poprawne działanie, Obiekt pochodny musi z logicznego punktu widzenia być szczególnym przypadkiem obiektu bazowego. Musisz zawsze pamiętać, że możesz podstawić dowolny obiekt pochodny w miejsce obiektu bazowego i nie możesz zadawać pytania o to, jakiej klasy jest obiekt.

Poprzedni artykuł - SOLID - Open-Closed Principle (OCP) - Wszystko Co Powinieneś Wiedzieć o Zasadzie Otwarte-Zamknięte.
Autor artykułu:
Kazimierz Szpin
Kazimierz Szpin
Programista C#/.NET. Głównie pisze aplikacje w ASP.NET MVC, WPF oraz Windows Forms.
Autor bloga ModestProgrammer.pl
Komentarze (7)
Marcin
MARCIN, 4 grudnia 2019 09:27
Hej Uważam, że to błędne rozumowanie. Bez kontekstu takie przykłady nic nie wnoszą. Nadajmy kontekst.Robisz symulację/grę wyścigu kaczek organicznej na baterie nakręcanej Simulate wywoła Swimm na każdej kaczce ale jeżeli wcześniej nie włożysz baterii , a tej na korbkę nie nakręcisz to jasne, że nie popłyną. Ale to nie znaczy, że łamiesz zasadę L i masz złe abstrakcje. Czy np. kaczka-zabawka: na baterię i na korbkę, nie jest dalej kaczką-zabawką mimo że inaczej się inicjuje jej pływanie? :)
arci1910
ARCI1910, 4 grudnia 2019 11:37
Ten przykłąd z kaczką jak byś rozwiązał? W artykule brakuje mi tego, że "jest zły kod a nie ma przykładowego poprawnego"
Kazimierz Szpin
KAZIMIERZ SZPIN, 4 grudnia 2019 19:33
@MARCIN Właśnie pierwszy przykład, który podałeś łamie zasadę LSP :) Jeżeli wywołasz metodę Swim to tylko kaczka organiczna będzie pływać. Oczywiście możesz najpierw odpalać jakaś metodę TurnOn, ale jak w takim razie będzie wyglądać ta metoda w kaczce organicznej? Będzie pusta? Taka implementacja ma złą abstrakcję, przy okazji łamie kolejną zasadę ISP (Interface Segregation Principle).
Kazimierz Szpin
KAZIMIERZ SZPIN, 4 grudnia 2019 19:35
@MARCIN Drugi przykład, o którym napisałeś ("kaczka-zabawka") może zostać prawidłowo zaimplementowany i nie złamać zasady LSP, ponieważ obie kaczki potrzebują takich samych metod.
Kazimierz Szpin
KAZIMIERZ SZPIN, 4 grudnia 2019 19:39
@ARCI1910 W artykule starałem się opisać jak nie łamać zasady LSP. Aby nie złamać tej zasady musisz mieć prawidłową abstrakcję. Po prostu OrganicDuck i ElectricDuck nie mogą implementować tego samego interfejsu (abstrakcji).
Michał92
MICHAŁ92, 5 grudnia 2019 10:00
Wkoncu jakis dobry artykul o LSP
Kypy
KYPY, 5 grudnia 2019 13:14
Liskov jest mocno problematyczne.
Dodaj komentarz
© Copyright 2019 modestprogrammer.pl. Wszelkie prawa zastrzeżone. Polityka prywatności. Design by Kazimierz Szpin