Sytuacja kobiet w IT w 2024 roku
8.03.202411 min
Tomasz Sitarek

Tomasz SitarekSoftware Engineer, Architect

LSP, czyli nie zadzieraj z kobietami

Dowiedz się wszystkich najważniejszych rzeczy o Liskov Substitution Principle.

LSP, czyli nie zadzieraj z kobietami

Co jakiś czas mam przyjemność natknąć się na artykuł o zasadach SOLID. To podstawowe zasady w naszej programistycznej sztuce. Pytanie o SOLID króluje na rozmowach rekrutacyjnych, jest swoistym szach-mat na code review, bywa też przyczynkiem do żartów. Można zaryzykować stwierdzenie, że SOLID to element naszej niszowej popkultury. Jak należy pisać kod? Odpowiedź to: SOLIDnie.


Większość reguł jest dość oczywista, jednak to zazwyczaj literka L jest omawiana po macoszemu we wszelakich postach na blogach i szkoleniach. Powszechna wiedza o Liskov Substitution Principle (LSP) sprowadza się do 3 punktów:

  1. Liskov była kobietą.
  2. Obiekty dziedziczące* mogą być użyte w miejsce obiektów klasy bazowej*, a program zachowa swoją poprawność.
  3. Coś mi świta w głowie o kwadracie i prostokącie… To chyba była wikipedia.


A szkoda, bo literka L jest z nami najdłużej spośród wszystkich, bo już od 1988 roku i niesie ze sobą całe bogactwo wiedzy, która w tamtych czasach mocno poruszyła fundamenty teorii informatyki. Niech świadczy o tym fakt, że po ponad 30 latach nadal jest z nami i odgrywa znaczącą rolę. Z kronikarskiej skrupulatności odnotuję tutaj daty narodzin literek: S (2002), O (1996), L (1988), I (1996), D (1996).

Powyższe punkty 1. – 3. zazwyczaj wystarczają, ale dzisiaj postaramy się dowiedzieć więcej. Najlepiej uczyć się na błędach, zatem złamiemy LSP na wszystkie możliwe sposoby, gdyż pozwoli nam to złapać wyczucie i zrozumieć, o co w tym wszystkim chodzi. Do dzieła!

* Dlaczego ta gwiazdka? Bo nie o dziedziczenie tu chodzi! Chodzi o relację bycia podtypem. O tym przeczytasz w dalszej części artykułu.

Intro

W dalszej części artykułu zakładamy, że typ S (subtype) jest podtypem typu T (type).

Liskov Substitution Principle mówi o tym, że:

Jeżeli S jest podtypem T, to obiekty typu S mogą być użyte w miejsce obiektów typu T, a program zachowa swoją poprawność.

Czym jest powyższa zasada? Jeśli się dobrze przyjrzymy, to zauważymy, że LSP jest (częściową) definicją relacji podtypowania. Tylko tyle i aż tyle!

Relacja podtypowania opisana powyżej nazywa się Liskov Substitution Principle, Substitutability lub Behavioral Subtyping.


LSP a dziedziczenie

LSP nic nie mówi o dziedziczeniu. Chodzi tutaj o relację podtypowania, a to zupełnie coś innego. Jako przykład można podać IEnumerable<Derived> oraz IEnumerable<Base> (zakładamy, że Derived jest podtypem Base). Pierwsze jest podtypem (a dokładniej pisząc – typem mniej ogólnym) drugiego, ale nie ma między nimi relacji dziedziczenia. Takie obiekty również są objęte przez LSP. Szczegółowo ten temat jest omówiony w poście Czego nie wiesz o typach i klasach w C#.


Jeśli algorytm nie może sprawdzić LSP…

Okazuje się, że problem Behavioral Subtyping jest nierozstrzygalny. Teoria mówi, że nie można napisać algorytmu, który sprawdzi, czy LSP nie została złamana. Zatem w szczególności nie może powstać kompilator, który będzie tego pilnować za nas.


… musi to robić programista!

Barbara Liskov wraz z Jeannette Wing w pracy „A Behavioral Notion of Subtyping" wypunktowały wszystkie warunki, które muszą być spełnione, aby reguła nie była naruszona. Owe warunki przedstawiają się następująco:

Kowariancja i kontrawariancja:
     - kowariancja typów wyjściowych
     - kontrawariancja typów wejściowych

Wyjątki:
     - jeśli typ S wprowadza nowe wyjątki, to są one podtypem wyjątków rzucanych w typie T

Kontrakty:
     - warunki wstępne (Preconditions) nie mogą być bardziej restrykcyjne w typie S niż są w typie T
     - warunki końcowe (Postconditions) nie mogą być mniej restrykcyjne w typie S niż są w typie T

Cechy (propertier):
     - zachowania niezmiennika (invariant)
     - zasada historii (history rule)


Teraz kolejno przyjrzymy się wyżej wymienionym punktom i spróbujemy naruszyć LSP z każdej z tych perspektyw. Każdy z przykładów pokazywać będzie nie tylko naruszenie, ale również będzie dokładnie obrazować niepoprawne działanie programu po podstawieniu S w miejsce T.

Kowariancja i kontrawariancja

Tutaj mam dobre wieści: język C# dba o przestrzeganie tego punktu za nas już na etapie kompilacji. Specyfikacja języka C# opisuje Variance conversion (punkt 18.2.3.3), która to właśnie opisuje postulaty B. Liskov z punktu nr 1.

Jest jednak przypadek, gdy kompilator języka C# pozwoli nam na podstawienie jednego typu w miejsce innego, a jednocześnie naruszymy zasadę związaną z kowariancją i kontrawariancją. Ten wyłom w „czystości typów” języka C# nazywa się Array covariance. Furtka ta została otwarta umyślnie, aby ułatwić pracę programistom. Jednak jak widać, należy teraz baczniej przyglądać się naszym konstrukcjom. Coś za coś. Spójrzmy na poniższy przykład:

void HandleArray(object[] arr)
{
    arr[0] = 0;
}
string[] strArr = new string[] { "a", "b", "c" };
HandleArray(strArr);//System.ArrayTypeMismatchException


Jak widać, został zaprojektowany nawet specjalny typ wyjątków na okoliczność Array covariance (System.ArrayTypeMismatchException).

Czy jednak zaprezentowany przykład łamie LSP? Nie, gdyż string[] i object[] nie są w relacji podtypowania. Powyższy przykład pokazuje jedynie, że w .NET mamy typy, dla których istnieje implicit conversion, ale nie zachowują one zasad wariancji i kontrawariancji z punktu 1.

Wyjątki

Z całą sympatią do .NET, w tym miejscu trzeba uznać wyższość Javy, gdyż w tym języku potencjalnie rzucane wyjątki są częścią sygnatury metody i łatwiej dzięki temu przestrzegać tego punktu, gdyż czyni to za nas kompilator Javy. W C# musimy robić to samodzielnie. Spójrzmy na poniższe definicje naszych typów.

class Base
{
    public virtual void Call()
    {
        throw new ArgumentException();
    }
};
 
class Derived : Base
{
    public override void Call()
    {
        throw new NullReferenceException();
    }
};


Natomiast nasz program jest dany funkcją RunProgram. W takim przypadku, użycie typu Derived skutkuje przerwaniem działania programu, gdyż zostaje rzucony nieobsłużony wyjątek.

void RunProgram(Base b)
{
    try
    {
        //do sth more
        //...
        b.Call();
    }
    catch (ArgumentException)
    { }
}
Base @base = new Base();
Derived derived = new Derived();
RunProgram(@base);//OK
RunProgram(derived);//Unhandled Exception: System.NullReferenceException


Jak naprawić sytuację? Typ Derived nie powinien rzucać nowych wyjątków. Może rzucać ArgumentException lub wyjątki będące podtypem ArgumentException, np. ArgumentNullException.

class Derived : Base
{
    public override void Call()
    {
        throw new ArgumentNullException();
    }
};


Wtedy nasz program działa poprawnie dla obu typów.

Base @base = new Base();
Derived derived = new Derived();
RunProgram(@base);//OK
RunProgram(derived);//OK

Kontrakty

Warunki wstępne

Podtyp nie może być bardziej wybredny niż typ bazowy. Musi umieć obsłużyć przynajmniej taki sam zakres danych.

class Base
{
    public virtual void Call(int x)
    {
        Assert.IsTrue(x >= 5);
        //do sth more
    }
};
 
class Derived : Base
{
    public override void Call(int x)
    {
        Assert.IsTrue(x >= 8);
        //do sth more
    }
};


Widać, że typ Base obsłuży kilka liczb, których nie obsłuży typ Derived (5, 6, 7). Zatem nasz program (funkcja RunProgram) nie zadziała prawidłowo dla typu Derived.

void RunProgram(Base b, int x)
{
    b.Call(x);
}
Base @base = new Base();
Derived derived = new Derived();
RunProgram(@base, 6);
RunProgram(derived, 6);//Unhandled Exception: NUnit.Framework.AssertionException


Podtyp Derived musi poprawnie obsługiwać cały zakres danych, które przyjmuje typ Base. Kruczek to słowo “poprawnie”, ale ono jest mocno związane z logiką biznesową i nie sposób wyczerpująco opisać rozwiązania w tym miejscu.

Typ Derived może natomiast obsługiwać szerszy zakres, np. x>0. Wtedy LSP pozostaje nienaruszona.


Warunki końcowe

Podtyp Derived musi zwrócić dane, które nie złamią warunków narzuconych na dane zwracane przez typ bazowy Base. Spójrzmy na naruszenie tego punktu.

class Base
{
    public virtual int Call()
    {
        //do sth
        int result = 2;
        Assert.IsTrue(result >= 1);
        return result;
    }
};
class Derived : Base
{
    public override int Call()
    {
        //do sth
        int result = 0;
        Assert.IsTrue(result >= 0);
        return result;
    }
};

Jak widać, typ Derived może zwrócić liczbę 0, która to nie jest dozwolona dla typu Base. Zatem warunki końcowe są mniej restrykcyjne w typie Derived niż w typie Base. Spójrzmy na problematyczną sytuację.

void RunProgram(Base b)
{
    var y = 1 / b.Call();
}
Base @base = new Base();
Derived derived = new Derived();
RunProgram(@base);
RunProgram(derived);//Unhandled Exception: System.DivideByZeroException


Rozwiązanie tego problemu ponownie mocno zależy od domeny biznesowej. Ogólna zasada jest jednak niezmienna – typ Derived musi wpasować się w ograniczenia narzucone w typie Base.

Cechy (properties)

W jakim celu tworzymy obiekty? Po co nam różne poziomy dostępności (publicprotectedprivate i te mniej znane: internal, protected internal, private protected)?

Odpowiedź jest prosta: aby chronić niezmiennik klasy (invariant) oraz przestrzegać zasady historii.


Zachowanie niezmiennika (invariant)

Każda klasa ma warunek (nawet gdy o tym nie wiemy, to jakiś jest), który jest zawsze spełniony (poza samym momentem modyfikacji klasy, wywołania metody). Przykłady to:

- użytkownik ma zawsze imię i nazwisko
- klasa obsługuje tylko prywatne adresy IP
- różnica między początkiem a końcem to jeden dzień

Coś całkowicie świętego, nienaruszalnego. Coś, co jest esencją tej klasy i naszego małego lokalnego biznesu. Nasza klasa ma reprezentować jeden dzień w kalendarzu, zatem różnica musi wynosić jeden dzień. Gdy ta różnica jest inna, to znaczy, że coś poszło nie tak. Tuż przed wywołaniem publicznych metod, ten warunek jest prawdziwy, oraz tuż po zakończeniu wywołania, nadal jest prawdziwy. Może to są już inne dane, inny adres IP lub inna data, ale warunek jest prawdziwy.

Bardziej formalna definicja to:

Niezmiennik (invariant) to funkcja ze zbioru stanów klasy w zbiór {true, false}

Niezmiennik jest zatem funkcją, która pozwala nam stwierdzić, czy bieżący stan klasy jest legalny czy nielegalny.

Taką tez definicję niezmiennika zaprezentowały B. Liskov i J. Wing w swojej pracy.


Zachowanie niezmiennika – przykład

Spójrzmy na przykład (klasa, która reprezentuje dzień, od północy do północy) oraz klasa dziedzicząca, która ten invariant narusza.

class Day
{
    public DateTime Begin { get; protected set; }
    public DateTime End => this.Begin.AddDays(1);
 
    public Day()
        => this.Begin = DateTime.MinValue;
 
    public virtual void SetToDate(DateTime begin)
        => this.Begin = begin.Date;
}
 
class DayDerived : Day
{
    public override void SetToDate(DateTime begin)
        => this.Begin = begin;//nie wyodrębniamy samego dnia!!!
}


Definicja inwariantu (funkcja Invariant) oraz naszego programu (RunProgram).

bool Invariant(Day d)
{
    return d.Begin.Equals(d.Begin.Date);//invariant oznacza, że Begin jest zawsze o północy
}
 
void RunProgram(Day b)
{
    DateTime measurePoint = new DateTime(2019, 01, 18, 2, 0, 0);//z czujnika, na 100% kilka godzin po północy
    var numberOfElements = 10;//z czujnika
    var elapsedTime = measurePoint - b.Begin;
    var rate = numberOfElements / (int)elapsedTime.TotalMinutes;//musi być ok, bo Invariant dla Day spełniony (a jednak!!!)
}


Inwariant jest prosty: początek (Begin) dla dnia to północ. Klasa Day spełnia to wymaganie. Nasz program też jest nieskomplikowany: pobieramy z czujnika liczbę pomiarów oraz datę zakończenia eksperymentu na dany dzień. Zakładamy, że koniec eksperymentu następuje kilka godzin po północy. Wyliczamy wskaźnik, mówiący o tym, ile było pomiarów na minutę.

Zakładając, że inwariant jest spełniony, wszystko w powyższym kodzie będzie działać.

Day @base = new Day();
DayDerived derived = new DayDerived();
DateTime referencePoint = new DateTime(2019, 01, 18, 2, 0, 0);//2019-01-18 2:00:00
bool i;
 
i = Invariant(@base);//true
i = Invariant(derived);//true
 
@base.SetToDate(referencePoint);
derived.SetToDate(referencePoint);
 
i = Invariant(@base);//true
i = Invariant(derived);//false!!!
 
//Ponieważ derived naruszyła Invariant, zaczynają się problemy
 
RunProgram(@base);//OK
RunProgram(derived);//Unhandled Exception: System.DivideByZeroException


Jednak dla klasy DayDerived, która narusza inwariant, otrzymujemy nieobsłużony wyjątek. Problemem jest tutaj złamanie inwariantu, który był dla twórcy metody RunProgram swego rodzaju pewnikiem.

Jakie jest rozwiązanie tej patowej sytuacji? Niestety dla tak prostego przypadku nie mogę napisać nic więcej niż “przestrzegać inwariantu”. Jest jeszcze drugie rozwiązanie: może Day i Day Derived nie powinny być ze sobą w relacji podtypowania? Podtypowanie wprowadza relację typu “is-a” pomiędzy obiektami, a skoro DayDerived nie może dotrzymać inwariantu, to być może nie powinno być relacji “is-a”?


Zasada historii (history rule)

Stan klasy i zachowanie niezmiennika to nie wszystko. Trzeba jeszcze zadbać, aby przejścia pomiędzy stanami były legalne. Przykład podany przez B. Liskov i J. Wing dotyczy typu Bag. Niezmiennik to zdanie: rozmiar struktury jest zawsze mniejszy niż maksymalny rozmiar. Zasada historii to zdanie: maksymalny rozmiar struktury nie zmienia się.

Inny przykład to klasa, która odwiedza dni robocze w kalendarzu (zatem pomija weekendy i święta). Niezmiennik brzmi: różnica pomiędzy końcem a początkiem dnia wynosi 24h. Zasada historii brzmi: po poniedziałku jest wtorek, po wtorku – środa, po piątku jest poniedziałek (oczywiście trzeba tutaj uwzględnić jeszcze święta)!

Jeszcze formalna definicja:

Zasada historii to funkcja ze zbioru par stanów w zbiór {true, false}

Zasada historii jest zatem funkcją, która pozwala nam stwierdzić, czy przejście ze stanu A do stanu B jest legalne, czy nielegalne.

Taką też definicję zasady historii zaprezentowały B. Liskov i J. Wing w swojej pracy.


Zasada historii – przykład

Przykładem jest tutaj klasa, która odwiedza wszystkie dni tygodnia. Zatem zasada historii jest dana zdaniem: między dwoma dniami różnica wynosi 1 (modulo 7, bo tydzień ma siedem dni).

Klasa dziedzicząca złamie tę regułę, gdyż będzie odwiedzać tylko dni robocze, czyli pominie sobotę i niedzielę.

Spójrzmy na definicje klas.

class DayVisitor : ICloneable//odwiedza wszystkie dni
{
    public DayOfWeek CurrentDay { get; protected set; } = DayOfWeek.Friday;
 
    public virtual DayOfWeek MoveToNextDay()
    {
        CurrentDay = (DayOfWeek)(((int)CurrentDay + 1) % 7);
        return CurrentDay;
    }
 
    public virtual object Clone()
    {
        return new DayVisitor { CurrentDay = this.CurrentDay };
    }
}
class WorkingDayVisitor : DayVisitor//odwiedza dni robocze
{
    public override DayOfWeek MoveToNextDay()
    {
        if (CurrentDay == DayOfWeek.Friday)
        {
            CurrentDay = (DayOfWeek)(((int)CurrentDay + 3) % 7);
        }
        else
        {
            CurrentDay = (DayOfWeek)(((int)CurrentDay + 1) % 7);
        }
        return CurrentDay;
    }
 
    public override object Clone()
    {
        return new WorkingDayVisitor { CurrentDay = this.CurrentDay };
    }
}


Interfejs I Cloneable jest zaimplementowany tylko na potrzeby sprawdzenia i udowodnienia, że history rule została złamana. Spójrzmy na nasz program i history rule.

bool HistoryRule(DayVisitor d1, DayVisitor d2)
{
    return (((int)d1.CurrentDay + 1) % 7) == (int)d2.CurrentDay;
}
 
void RunProgram(DayVisitor dv)
{
    Dictionary<DayOfWeek, int> carsByDay = new Dictionary<DayOfWeek, int>();//ile samochodów sprzedano danego dnia tygodnia
    for (int i = 0; i < 7; i++)
    {
        var nextDay = dv.MoveToNextDay();
        carsByDay.Add(nextDay, GetCarNumberForDay(nextDay));
    }
}
 
int GetCarNumberForDay(DayOfWeek d) => 4;//Każdego dnia sprzedaliśmy 4 samochody


Nasz program odwiedza wszystkie 7 dni tygodnia i oblicza, ile samochodów sprzedano danego dnia (funkcja pomocnicza GetCarNumberForDay, dla prostoty zawsze zwraca 4).

Teraz pora na dowód, że złamanie history rule niesie ze sobą problemy.

DayVisitor @base = new DayVisitor();
WorkingDayVisitor derived = new WorkingDayVisitor();
bool historyRule;
 
var baseState1 = (DayVisitor)@base.Clone();
@base.MoveToNextDay();
var baseState2 = (DayVisitor)@base.Clone();
 
var derivedState1 = (WorkingDayVisitor)derived.Clone();
derived.MoveToNextDay();
var derivedState2 = (WorkingDayVisitor)derived.Clone();
 
historyRule = HistoryRule(baseState1, baseState2);//true
historyRule = HistoryRule(derivedState1, derivedState2);//false
 
//Ponieważ derived naruszyła history rule, zaczynają się problemy
 
RunProgram(@base);//OK
RunProgram(derived);//Unhandled Exception: System.ArgumentException (bo dodaliśmy do słownika drugi raz ten sam klucz)


Dla klasy bazowej program działa, dla podklasy – otrzymujemy nieobsłużony wyjątek. Wynika to z naruszenia zasady historii.

Rozwiązanie problemu jest podobne jak dla przypadku z inwariantem: albo przestrzegamy zasady historii, albo rezygnujemy z podtypowania. Wybór zależy od domeny biznesowej, w kontekście której projektujemy nasze rozwiązanie.

Podsumowanie

Mam nadzieję, że udało mi się pokazać, o co chodziło B. Liskov i J. Wing w ich słynnej pracy. Okazuje się, że LSP można naruszyć na wiele sposobów, czasami bardzo trudnych do wyśledzenia, a sztampowe omówienia dostępne powszechnie pomijają najważniejsze aspekty literki L z SOLID’a. Warto wspomnieć, że LSP pomaga nam również lepiej modelować biznesowy kontekst aplikacji, gdyż zmusza nas do odpowiedzi na pytania o relacje pomiędzy obiektami (mam tutaj na myśli głównie dwa ostatnie punkty: inwariant i zasadę historii).

Polecam również opracowanie tematu LSP autorstwa Patryka Kubieli, jak również całą jego serię “Nie SOLID-nie“.

<p>Loading...</p>