SOLID - L jak Liskov

0

Hej,
móglbymi ktoś wytłumaczyć na jakimś przykładzie z zycia na czym polega zasada Liskov z SOLIDa?
Jak to rozumiem, to chodzi o to że jeśli podmienie implementacje interfejsu to aplikacja powinna się zachowywać w ten sam sposób. Jednak jeśli rozumiem to poprawnie to w sumie nie rozumiem tego w ogóle ;). Myślałem, że różne implementacje powinny działać w różny sposób, robić coś innego a jedynie zapewnić spójny interfejs :).

Pozdr
MP

4

jeśli podmienie implementacje interfejsu to aplikacja powinna się zachowywać w ten sam sposób

To by było bez sensu przecież. Miałeś kod który zapisywał dane do MySQLa, teraz zmieniłeś implemntacje na taką która zapisuje dane na S3 w postaci ORCów. Ewidentnie aplikacja nie zachowuje się już w ten sam sposób.

Ta zasada mówi, że jeśli jakiś kod potrafi operować na obiektach bazowych to na pochodnych też powinien poprawnie pracować. Więc jeśli masz np. jakiegoś transaction managera który umie pracować z DataSource to powinien poprawnie działać zarówno dla MySQLDataSource jak i S3DataSource.

Jeśli napisałeś kod który przyjmuje obiekty DataSource a potem robi jakieś checki w stylu if dataSource instanceof MySQLDataSource albo oczekuje że ten data source ma jakieś "specjalne" metody, których w interfejsie DataSource nie ma, to łamiesz tą zasadę.

0

Na kwadracie i prostokącie to było w prosty sposób wyjaśnione - poszukaj w Google ( https://www.google.com/search?q=rectangle+square+problem ), ale chodziło w skrócie o to, że jeśli masz daną klasę Square, która dziedziczy z Rectangle i jeśli ustawisz settery tak, żeby setter dla width ustawiał nie tylko szerokość ale wysokość na tę samą wartość (co ma sens logiczny, ponieważ w kwadracie szerokość musi być taka sama jak wysokość), to tym samym złamiesz zasadę Liskov i taka klasa będzie inaczej się zachowywać niż prostokąt, np. masz taki kod:

rect = Rectangle()
rect.width = 20
rect.height = 200

i jak zamieniasz na Square, rozj*bie się:

rect = Square()
rect.width = 20
rect.height = 200
# rect.width bedzie rowne 200! A nie 20 :( 

więc jeśli będziesz jej używał w miejscach, gdzie wymagany jest prostokąt, to będziesz miał inne wyniki. Złamiesz w klasie kwadrat kompatybilność wsteczną z prostokątem.

Tutaj jest circle-ellipse problem, który chyba jest czymś podobnym https://en.wikipedia.org/wiki/Circle-ellipse_problem

0

Tak jak @Shalom napisał, tutaj chodzi o tzw. kontrakty. Jako twórca interfejsu, klasy abstrakcyjnej czy zwykłej klasy po której mozna dziedziczyć mogę napisac jakie zasady powinna spełniac klasa która implementuje/dziedziczy dany typ. Np. Path ma w dokumentacji:

Implementations of this interface are immutable and safe for use by multiple concurrent threads.

Czyli jak zaimplementujesz Path mutowalny to łamie to zasadę podstawienia Liskov

0

ummm.. po co dziedziczyć skoro można implementować wspólny interface?

Czym jest implementacja wspólnego interfejsu jak nie pewnym rodzajem dziedziczenia? Czy raczej odwrotnie - dziedziczenie po klasach zakłada, że klasa potomna będzie zawierała implementację interfejsu klasy bazowej (ponieważ go odziedziczy). Jak dla mnie prawo Liskov dotyczy również/właśnie interfejsów.(gdzie przez interfejs rozumiem koncepcję programistyczną, niezależnie czy w języku osiągniemy to przez słówko "interface", klasę abstrakcyjną czy przez kacze typowanie.

po co rectangle/square jest mutowalne?

Use case mutowalnego kwadratu/prostokątu to np. program graficzny, w którym można byłoby tworzyć swoje prostokąty i za pomocą myszą zmieniać wielkość.
Oczywiście, pewnie dałoby się to zrobić funkcyjnie, niemutując niczego, tylko tworząc nowe figury za każdą zmianą - ale z drugiej strony można to zrobić właśnie w taki mutowalny sposób. Mutowalny kod nie musi być zawsze "zły" (dużo też zależy od subiektywnych poglądów. Zwolennikom programowania funkcyjnego w głowie się nie mieściłoby, żeby cokolwiek mutować).

po co wystawia jawnie swoje składowe?

To akurat nie ma nic do rzeczy. Przykład dotyczył settera jeśli dobrze pamiętam, ale przecież równie dobrze mogło dotyczyć to każdej metody, która mutuje obiekt.

Chociaż... zastanawiam się co jeślibyśmy poszli w programowanie funkcyjne i nigdy byśmy nie mutowali żadnych obiektów. Też myślę, że można by złamać zasadę Liskov. Niech by jakaś metoda zwracała co innego niż metoda bazowa (np. inny zwracany typ, gdzie np. w klasie bazowej szerokość zwracana by była jako liczba (100 pikseli = 100), a w klasie potomnej jako string (100 pikseli = "100px"). I już to by wystarczyło, żeby rozwalić kod, który oczekuje liczby.

Swoją drogą czym się różni zasada Liskov od Open Closed Principle? Można przecież argumentować, że wynika ona z OCP, i że klasy powinny być otwarte na rozszerzanie ("dziedziczenie" w tym przypadku, i np. dodawanie nowych metod) ale zamknięte na modyfikację (czyli na niekompatybilną wstecz zmianę zachowania).

wg mnie to słaby i naciągany przykład; Javascriptowco nie masz jakiś praktycznych przykładów z pracy bądź własnych projektów?

Załóżmy, że (fikcyjna sytuacja, ale podobna do tych, które faktycznie się zdarzały) wchodzę do projektu reactowego, w którym są jakieś komponenty-widżety. Np. jest jakiś kustomowy komponent Carousel, który przyjmuje właściwości typu imageList, animationDelay, onSlideChange czy showButtons albo additionalStyles).

I ten komponent ma jakieś bugi do naprawienia i mam się nimi zająć. I tak, patrząc na źródło tego komponentu dochodzę do wniosku, że lepiej napisać widżet od nowa, niż poprawiać tonu kodu spaghetti.

Problem tylko, że komponent Carousel jest używany w 50 miejscach w projekcie, na dodatek w 10 miejscach w innym projekcie pisanym przez kogoś innego.

Czyli co? w 50 miejscach będę musiał zmieniać kodzik na nową implementację oraz powiedzieć drugiemu programiście, żeby zmienił sobie kod w 10 miejscach? Niekoniecznie. Mogę zrobić tak, że napiszę komponent, który jest całkowicie kompatybilny z poprzednią, przyjmuje takie same właściwości, "to samo robi" ( z perspektywy biznesowej) jednak będzie mieć ładny kod i dobre działanie bez bugów. I zachowam prawo Liskov.

Łamiąc to prawo mógłbym tak zrobić np., że Widget byłby niekompatybilny i miałby inne właściwości. Tym sposobem kod, który z niego by korzystał, mógłby np. przestać go wyświetlać (czasami to lepsze, złamać kompatybilność wsteczną i stworzyć nowe API - ale mimo wszystko breaking changes są bolesne).

0

@LukeJL

Swoją drogą czym się różni zasada Liskov od Open Closed Principle?

OCP łatwo nauczysz wytresowaną małpę, bo całość sprowadza się do implementowania na pałę wystawionych interfejsów.

A LSP patrzy na to czego nie widać w interfejsie. Między innymi na efekty uboczne, rzucane wyjątki i zachowania jakie powinny być spełnione przez pion klas jakie implementują docelowy interfejs.

Przeważnie po LSP można poznać, czy mamy dziurę w abstrakcji. Typowy "Senior" dość często daje du,py na tym polu.

EDIT:

Przykład to np klasa, która agreguje jakieś kwoty, coś na ich podstawie oblicza i wynik wysyła mailem. Oraz jesli będziemy mieć klasę, która w tych samych okolicznościach również agreguje, oblicza wynik, ale nie wyśle maila to łamiemy liskov. To znaczy, że nie wszystkie rozszerzenia mogą być zamiennie stosowane.

0

Ja zasadę Liskov rozumiem tak, jak pokazuje ten prostacki przykład:

class Circle{
  private int radius;

  public Circle(int radius){
    this.radius = radius;
  }

  public double getPerimeter(){
    return 2*Math.PI*radius;
  }
}

class Arc extends Circle{
  private double angle;

  public Arc(int radius, double angle){
    super(radius);
    this.angle = angle;
  }

  public Arc(int radius){
    super(radius);
    this.angle = 2 * Math.PI;
  } 

 public double getPerimeter(){
     return super.getPerimeter()*normalize(angle)/(Math.PI*2);
  }

  private double normalize(double a){
    a = Math.abs(a); 
    return a > Math.PI*2 ? a - Math.round(a/(Math.PI*2)-1)*2*Math.PI : a;
  }
}

class Main {
  public static void main(String[] args) {
    Circle c = new Circle(10);
    Circle a = new Arc(10);
    Arc d = new Arc(10, Math.PI);
    System.out.println(c.getPerimeter());
    System.out.println(a.getPerimeter());
    System.out.println(d.getPerimeter());
  }
}

Przy pomocy klasy pochodnej można stworzyć substytut obiektu klasy bazowej, tutaj obiekt łuk a udaje okrąg c, w dodatku tworzone są w podobny sposób, konstruktorem jednoargumentowym z tą samą wartością.
Metoda klasy bazowej getPerimeter() daje te same wyniki dla obu obiektów.
Nie nadpisuje się metod odziedziczonych całkiem nowym kodem, ale wykorzystuje metody z klasy bazowej, w ten sposób rozszerza się działanie metod. Jeśli nie da się zachowania w klasie wyprowadzonej zdefiniować korzystając z odpowiadającej metody klasy bazowej to znaczy, że koncepcja dziedziczenia jest zła.

1

I w ten sposób dostalimy Łuk, który jest Okręgiem.
Bardzo to ciekawe.

Z puktu widzenia LSP powyższy kod, jeśli występuje w izolacji, jest zupełnie poprawny (niestety :-()

Natomiast problemem jest nazewnictwo. Ktoś kiedyś może pomyślec, ze ten okrąg (Circle) to naprawdę okrąg, i napisać np. procedurę sprawdzającą czy dane okręgi nachodzą na siebie.
A wtedy będzie co najmniej dziwnie jeśli ktoś podstawi te łuki (Arc). (Oczywiście będzie trzeba zmienić klasę i udostępnić radius, więc jest szansa, że ktoś się zaglądając w kod może połapie, że nazwa to tylko oszustwo :-) ).

Dlatego z takimi konstrukcjami warto uważąć i raczej patrzeć szerzej na LSP, w połączeniu z nazewnictwem. Tak aby nie zaskakiwać przyszłych koderów aplikacji. Don't surprise people.

0

Wydaje mi się, że trochę płyniecie z tym LSP, bo sprawa jest dość prosta (albo moje jej rozumienie). Jeżeli deklarujemy, że jakaś klasa używa klasy dajmy na to List, to musi być w stanie użyć wszystkich jej pochodnych. Nikt nie powiedział, że to ma działać:

public Optional<T> getSecondElement(List<T> list){
  if(list.size() > 1){
    return Optional.of(list.get(1);
  }
  return Optional.empty();
}

Czy powyższy kod łamie LSP? - Nie. Czy gwarantuje, że ktoś sobie w przyszłości nie zrobi własnej implementacji listy, która będzie działała błędnie? Też nie.

Złamanie LSP to po prostu coś takiego

public double calcArea(Geometry geometry){
  if(geometry instanceof Square){
    Square square = (Square)geometry;
    return square.a * square.a;
  }
  throw new RuntimeException("This method can only handle squares")
}

Podsumowując - jedyne co można założyć deklarując zależność od jakiejś klasy, to to, że dostaniemy poprawną jej implementację. Nie chodzi o to, że to zawsze zadziała (bo ktoś mógł wstrzyknąć błędną implementację), albo, że program zawsze zadziała tak samo (np. wrzucając jakąś implementację Runnable do ExecutorService wręcz zakładamy, że zostanie zrobione coś innego).

0
piotrpo napisał(a):

Jeżeli deklarujemy, że jakaś klasa używa klasy dajmy na to List, to musi być w stanie użyć wszystkich jej pochodnych. Nikt nie powiedział, że to ma działać:

Ależ oczywiście, że ma to działać (w sensie działania w ten sam sposób). Jeżeli nie działa, to właśnie ta konkretna implementacja List łamię zasadę. Wynika to wprost z definicji.

Barbara Liskov i Jeannette Wing napisały:

Subtype Requirement: Let f(x) be a property provable about objects x of type T. Then f(y) should be true for objects y of type S where S is a subtype of T.

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