Zasada Podstawienia Liskov

0

Mam pytanie o najbardziej chyba niejasną zasadę SOLID czyli zasadę podstawienia Liskov. Tematów w internecie na temat tej zasady jest multum, mimo wszystko mam jednak pytanie. Często się mówi, że:

jeśli będziemy tworzyć egzemplarz klasy potomnej, to niezależnie od tego, co znajdzie się we wskaźniku na zmienną, wywoływanie metody, którą pierwotnie zdefiniowano w klasie bazowej, powinno dać te same rezultaty.

To tak na prawdę wychodzi na to, że mało kiedy nadpisanie metody klasy bazowej jest możliwe żeby nie złamać tej zasady. Czy już w poniższym przykładzie jest złamana zasada Liskov, bo metoda GetName zwróci inny wynik??

    public class Car
    {
        private string _name;

        public Car(string name)
        {
            _name = name;
        }

        public virtual string GetName()
        {
            return _name;
        }
    }

    public class ElectricCar : Car
    {
        public ElectricCar(string name) : base(name)
        {
        }

        public override string GetName()
        {
            return base.GetName() + " (Electric)";
        }

        public void Charge()
        {
            Console.WriteLine("Charging ...");
        }
    }


    class Program
    {
        static void Main(string[] args)
        {
            Car car = new Car("a1");
            Console.WriteLine(car.GetName()); // a1

            car = new ElectricCar("a1");
            Console.WriteLine(car.GetName()); // a1 (Electric)
        }
    }
5

Tu nie chodzi o wywoływanie metod z tych klas, tylko o wołanie funkcji które oczekują takich obiektów jako argumentów! LSP mówi tyle, że takie podstawienie zawsze musi mieć sens. Tzn jeśli masz gdzieś funkcje która oczekuje obiektów A to wrzucenie do niej obiektów B, które dziedziczą z A, powinno nadal mieć sens i działać poprawnie.

Jeśli masz interfejs Comparable który pozwala porównywac obiekty i zrobiłeś metodę sort która oczekuje że dostanie listę obiektów Comparable, to powinno to zawsze działać. Jeśli teraz zrobisz sobie klasę X która niby jest Comparable ale rzuca jakimś NotImplementedException jak ktoś użyje compareTo to złamałes zasadę. Bo teraz nagle trzeba by w kodzie gdzieś sprawdzać jakie obiekty masz pod ręką, żeby wiedzieć czy wolno zawołać tego sort czy nie.

I bardziej generalnie: jeśli zrobiłeś sobie gdzieś taki kod, który wymaga sprawdzania jakiegoś instanceof bo w zalezności od tego, którą podklasę dostałeś ma sie dziać coś innego, to znaczy że najpewniej złamałeś LSP.

0

@Shalom: ale to już mówisz o tych dodatkowych zasadach dla reguły Liskov (Metody podtypu nie powinny rzucać żadnych nowych wyjątków itd.) - one są jasne i czytelne.
A mi chodzi o tę główną zasadę:

jeśli będziemy tworzyć egzemplarz klasy potomnej, to niezależnie od tego, co znajdzie się we wskaźniku na zmienną, wywoływanie metody, którą pierwotnie zdefiniowano w klasie bazowej, powinno dać te same rezultaty.

Jest ona np. przedstawiona na stronie Heliona (ale nie tylko):
https://blog.helion.pl/mnemonik-solid-l-liskov-substitution-principle/

– Mamy klasę A z metodą MyMethod, która zwraca wartość Z.

– Tworzymy klasę B, która dziedziczy z A.

– Niezależnie od sposobu utworzenia klasy A oraz B:

– A a = new A()

– A b1 = new B()

– B b2 = new B()

Musi zajść równość:

a.MyMethod() == b1.MyMethod() == b2.MyMethod()

W innym przypadku dochodzi do pogwałcenia zasady Liskov Substition Principle.

I takie przykłady są dla mnie niezrozumiałe, bo to eliminuje prawie każde nadpisanie metody.

4

LSP można rozpatrywać na dwóch poziomach: binarnym (wskaźników, dziedziczenia) i logicznym (kontraktów).

Poziom binarny mówi o tym, że jak przyjmujemy wskaźnik na klasę bazową i damy tam instancję klasy pochodnej, to program ma ciągle działać (ma się skompilować i uruchomić). Ten poziom nie mówi o nadpisywaniu metod, to tylko mówi, że ma nie być segfaulta lub innej wywrotki. To jest czysto techniczne wymaganie. Co do istoty problemu, to ten poziom nawet nie mówi o klasach, tylko o podtypach (subtyping), ale to już szersza dyskusja odnośnie algebry typów, algebry lambd itp.

Poziom logiczny mówi o kontraktach. Klasa pochodna nie może wzmocnić kontraktu wstępnego, poluźnić kontraktu wyjściowego, zepsuć niezmienników, a także niedozwolone jest złamanie zasady historii (czy jak to się tam na polski tłumaczy).

Ludzie często mieszają te dwa poziomy i myślą, że jak mam klasę pochodną, to nie mogę zmienić zachowania klasy bazowej, co jest bzdurą. Klasa pochodna musi zadziałać fizycznie (poziom binarny) i musi spełnić ten sam kontrakt (poziom logiczny). Często przytaczany przykład z kwadratem i prostokątem (omawiany przez Martina) jest zazwyczaj źle sformułowany, bo mówi, że kwadrat zmienia dwa wymiary, a prostokąt zmienia jeden. To jest poprawne, o ile tylko umieścimy to w kontrakcie, jeżeli tego w kontrakcie nie ma, to przykład jest do niczego. Bardziej realistyczny przykład na tym samym koncepcie: mamy framework desktopowy do robienia UI, mamy klasę Component, a potem robimy coś w stylu RatioPreservingComponent (takie coś można znaleźć w różnych frameworkach). Czy RatioPreservingComponent łamie LSP? Wszak to prawie to samo, co ten kwadrat z poprzedniego przykładu. I tu znowu rozbijamy się o kontrakt klasy bazowej.

0

Nie no, nadpisywanie metod przy dziedziczeniu to normalny prawilny mechanizm, więc na logikę gdzieś musi być jakiś konsensus. Jeśli mamy klasę bazową A i podklasę B, wcale nie musi zachodzić A.foo() == B.foo(). Ważne jest, żeby zawsze można było podstawić A a = new B() i operować na tym obiekcie jak gdyby był rzeczywiście typu A - na poziomie syntaktycznym oraz semantycznym, podoba mi się taki podział jak opisał @Afish. Tak rozumiem LSP.

5

Przykład pierwszy z brzegu, i IMHO najlepiej zaimplementowane OOP ever (i dodatkowo zrobione w języku, który bardzo OOP nie jest): obsługa plików w *niksach.

Masz sobie funkcję w postaci:

void print_hello(int fd) {
  write(fd, "Hello\n", 6);
}

I teraz ta funkcja ma działać dla każdego fd, które spełnia kontrakt, a takich mamy wiele:

  • stdout (na FD 1)
  • stderr (na FD 2)
  • otwarty plik, i to dodatkowo niezależnie od systemu plików
  • potok
  • gniazdo
  • plik specjalny

Więc fd spełnia LSP, bo przekazując dowolną implementację do funkcji print_hello kod będzie działać i "efekt będzie taki sam", tzn. dany odbiorca otrzyma dane wyplute przez write.

EDIT:

Zmieniłem fprintf na write, by być "bliżej" prawdziwych deskryptorów plików. Oba zapisy można używać wymiennie dzięki fdopen, ale wydaje mi się, że taki zapis będzie trochę czytelniejszy.

0
wiewiorek napisał(a):

Mam pytanie o najbardziej chyba niejasną zasadę SOLID czyli zasadę podstawienia Liskov.

Chyba najbardziej kontrowersyjną. Co więcej, mam wrażenie, że w swojej oryginalnej postaci, jest zbyt sztywna...

1 użytkowników online, w tym zalogowanych: 0, gości: 1