SOLID - L jak Liskov

Odpowiedz Nowy wątek
2018-12-19 15:34
Mały Pomidor
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

Pozostało 580 znaków

2018-12-19 15:50
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ę.


Masz problem? Pisz na forum, nie do mnie. Nie masz problemów? Kup komputer...
edytowany 1x, ostatnio: Shalom, 2018-12-19 15:52
Pokaż pozostałe 2 komentarze
@scibi92: choćby te wszystkie UnmodifiableCollection ;) ale akurat w kontrakcie dla add jest opisane co może się dziać więc kontrakt nie jest łamany -> https://docs.oracle.com/javas[...]a/util/Collection.html#add(E) - Shalom 2018-12-19 16:52
@Shalom: no własnie tak się spodziewałem, ale jak wiadomo moga one wyrzucić ten wyjątek więc teoretycznie gra i buczy. Chociaz lepszym rozwiązaniem byłoby zrobić w stylu Kotlina Collection i MutableCollection :) - scibi92 2018-12-19 16:55
Ale to jest akurat wyjątek, że ktoś porządnie napisał kontrakt i przewidział taką sytuację. - Shalom 2018-12-19 16:59
No nie jestem przekonany, czy po 1. komentarz można uznać za "kontrakt", a po 2. czy rzucanie UnsupportedOperationException w ogóle można uznać za dobry obiektowy kod. Imho to jest właśnie złamanie tej zasady. Kolejnym złamaniem zasady byłoby rzucanie w podklasie checked exception bardziej ogólnego niż w interfejsie lub ogólnie rzucanie checked exception tylko w podklasach. Przed tym chroni nas sama Java, a sytuacja odwrotna jest dozwolona. Z tego samego powodu nie możemy w podklasach ograniczać widoczności class members, ale możemy ją zwiększać. - tdudzik 2018-12-19 17:19
@scibi92: Dokładnie, tak jak @Shalom napisał. Na przykład wszystkie UnmodifiableCollection. Dżawadoków nawet nie traktuję jako kontrakt. W kodzie nie mam dżawadoków. W kodzie mam interfejs i metodę. Jak już to powinni dodać do sygnatury jakiś checked exception, ale już widzę te wszystkie e.printStackTrace(). - Michał Sikora 2018-12-19 21:49

Pozostało 580 znaków

2018-12-19 16:29
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


((0b10*0b11*(0b10**0b101-0b10)**0b10+0b110)**0b10+(100-1)**0b10+0x10-1).toString(0b10**0b101+0b100);
edytowany 3x, ostatnio: LukeJL, 2018-12-19 16:33
ummm.. po co dziedziczyć skoro można implementować wspólny interface? po co rectangle/square jest mutowalne? po co wystawia jawnie swoje składowe? 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? - nohtyp 2018-12-19 17:03
odpisałem ci w poście. - LukeJL 2018-12-19 18:30

Pozostało 580 znaków

2018-12-19 16:53
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


Nie pomagam przez PM. Pytania zadaje się na forum.
no tak, mamy inne efekty uboczne - nohtyp 2018-12-19 22:46

Pozostało 580 znaków

2018-12-19 18:30
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).


((0b10*0b11*(0b10**0b101-0b10)**0b10+0b110)**0b10+(100-1)**0b10+0x10-1).toString(0b10**0b101+0b100);
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. - jeszcze chwila i dojdziesz w końcu do tego dlaczego wymyślono typy. :D - tdudzik 2018-12-19 19:47
To nie ma nic do rzeczy. Czepiasz się tylko po to, żeby się czepiać. No to równie dobrze można powiedzieć, że w klasie przodku width było w pikselach, a w klasie potomku width zwracał przeliczone np. na centymetry albo procenty ekranu. I w jednym przypadku będzie 100 a w drugim 23 bo tak wyjdzie z wyliczeń. I co? Typy są takie same, ale i tak jest złamany kontrakt (więc prędzej: dlatego wymyślono testy). - LukeJL 2018-12-19 20:35
@LukeJL: przecież to było pół żartem pół serio, bo wiem że nie przepadasz za typami :) W każdym razie w tym przypadku nadal rozwiązaniem mogą być typy (i właśnie dlatego mówi się, że dzięki typom niektóre rzeczy może sprawdzać kompilator a nie programista pisząc testy). Można np. zwracać obiekt Size czy coś takiego, który ma metody toPixels(), toCm() itp. Wtedy kontrakt jest jasny i trudniejszy do złamania. - tdudzik 2018-12-19 20:43
@LukeJL ten przykład nie ma nic wspólnego nawet z SOLID xD Twój zespół wprost zahardcodował użycie tego komponentu. Ty wyrąbałeś poprzednia wersja i napisałeś swoja. - nohtyp 2018-12-19 22:39
@nohtyp Nie zahardkodował, tylko przekazał mu właściwości. To powszechna praktyka w React. Czy dobra to już inna sprawa. Mając styczność z wieloma tego typu kodami dochodzę do wniosku, że lepszym podejściem jest albo wstrzykiwanie danych do komponentów w inny, bardziej kontrolowany sposób (czyli takie DI), albo po prostu minimalizowanie liczby właściwości i standaryzowanie ich, przynajmniej w zakresie jednego projektu, ale najlepiej w zakresie całego ekosystemu React - LukeJL 2018-12-20 14:13
(dla przykładu teraz wyszukałem 3 widżety carousela do Reacta na npm i wszystkie 3 mają kompletnie innne API, co pokazuje jak bardzo ten system jest niedojrzały, skoro nawet ludzie się nie mogą zdecydować czy właściwość nazwać dots czy showIndicators a może dodawać kropki jako dodatkowy subkomponent <Dot/> - LukeJL 2018-12-20 14:13

Pozostało 580 znaków

2018-12-19 22:26
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.

edytowany 1x, ostatnio: nohtyp, 2018-12-19 22:35
Pokaż pozostałe 2 komentarze
2. @tdudzik rly? no właśnie całe LSP łamiesz, gdy twoja implementacja odjeżdża od spodziewanego zachowania - nohtyp 2018-12-20 10:28
@nohtyp: tylko najpierw trzeba zdefiniować to "spodziewane zachowanie". Wymyśl jakiś bardziej realny przykład nie łamiący SRP to przedyskutujemy :) a ad. 1. to takie pociąganie za sznurki to coś w stylu application service z DDD. Raczej dziwnie byłoby tworzyć dla niego interfejs i implementacje, więc znów przykład mało realny. - tdudzik 2018-12-20 10:35
Przykład łamie SRP, ale pokazuje o co chodzi z zachowaniem. W wersji EN na wiki jest to ładnie opisane w terminach zachowań podtypu w stosunku do typu bazowego. W wersji PL jest to jakoś bełkotliwie opisane. https://en.wikipedia.org/wiki/Liskov_substitution_principle - yarel 2018-12-20 10:40
@tdudzik gdy mamy 2 klasy, obie implementują ten sam interfejs ale jedna klasa przepuszcza wyjątki dotyczące konsoli, a druga wyjątki dotyczące sieci - nohtyp 2018-12-20 11:03

Pozostało 580 znaków

2018-12-20 12:54
cs
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.

edytowany 8x, ostatnio: cs, 2018-12-20 23:10
Hmm, jak utworzysz łuk o kącie 0, to jakie będziesz miał zachowanie getPerimiter()? :) - yarel 2018-12-20 13:05
@yarel: teraz pasi ?;) - cs 2018-12-20 13:11
Wcześniej mógł polecieć wyjątek i zachowanie klasy potomnej było inne niż klasy bazowej, więc LSP było złamane. Kod który korzystał z Car i getPerimiter() mógł nie wiedzieć, że ma szansę oberwać wyjątkiem. - yarel 2018-12-20 13:19
Nie będzie pasiło dopóki nie podzielisz odwrotnie ;-) - Skok Napięcia 2018-12-20 13:20
racja, ja się robi 10 rzeczy na raz to z zasady nici z zasad ;-) - cs 2018-12-20 13:48

Pozostało 580 znaków

2018-12-21 19:08
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.


Bardzo lubie Singletony, dlatego robię po kilka instancji każdego.
edytowany 3x, ostatnio: jarekr000000, 2018-12-21 19:21

Pozostało 580 znaków

2018-12-21 19:43
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).

Pozostało 580 znaków

2018-12-21 20:47
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.

edytowany 3x, ostatnio: Michał Sikora, 2018-12-21 20:49

Pozostało 580 znaków

2018-12-21 21:01
cs
0

Bingo, @jarekr000000, jesteś pierwszym, który na to zwrócił uwagę. Ostatnio właśnie zastanawiałem się, dlatego twórcy Javy wybrali słowo extends, a nie inherits, skoro ma to służyć dziedziczeniu. A może nie chodzi tu o odziedziczenie, a o rozszerzanie, które może realizować dziedziczenie. Przecież w tym kultowym przykładzie z prostokątem i kwadratem nie wychodzi dziedziczenie, choć każdemu świta, że każdy kwadrat to prostokąt. Nie wychodzi, bo zamiast rozszerzać to kombinować trzeba co zrobić ze zbędnym polem. Jeśli zacznie się myśleć, że tylko rozszerzamy klasę i nie mącimy głowę dziedziczeniem, to Arc jest specjalizacją Circle, bo jest kawałkiem Circle, który wzbogacamy o dodatkowe pole. Gdyby trzymać się koncepcji, że okrąg to łuk o kącie 2Pi to znowu utkniemy w znanym problemie. Wg mnie to zły pomysł budowania kolejnej klasy, ze względu na szczególny przypadek wartości pól. Natomiast odwrotnie to ma jakieś uzasadnienie, gdy w programie musimy operować na milionie okręgów, to chyba lepiej zapisywać je przy pomocy klasy z jednym polem niż z dwoma.

Natomiast co do tego co może pomyśleć ktoś o klasie i jej rozbudowie, jest to tylko przykład, ilustracja, a nie kompletny typ. Nie udostępniałem promienia, bo nie był tutaj potrzebny, ale jasne, że powinien być getter, współrzędne środka itd. Metoda testująca nachodzenie okręgów, może być wykorzystana w testowaniu nachodzenia łuków. Co więcej musi działać tak samo w obu klasach, jeśli łuk ma kąt pełny, więc łuk o taki kącie musi wykorzystać metodę z Circle.

Mam wrażenie jakbyś ten post pisał dla samego siebie. Reasumując uważasz, że dałeś ciała czy nie? - nohtyp 2018-12-21 22:03
@nohtyp: Sam oceń, mnie się to rozwiązanie podoba. - cs 2018-12-21 22:35
Prostszy byłby wariant z interfejsem ala Shape. Wtedy Circle i Arc osobno sobie implementują swoje metody. I taki wariant jest prostszy w utrzymaniu, ponieważ jakiekolwiek decyzje w obrębie Circle nie będą później rzutować na klasę Arc. - nohtyp 2018-12-21 23:19
Może, ale nie chodziło o tworzenie biblioteki figur, ale demonstracje zasady Liskov, więc konieczne było dziedziczenie. Interfejs z shape, to powielanie kodu przy implementacji i definicji kolejnych klas, ale jak mówisz, elastyczne w odniesieniu do klas, przy których ciągle się grzebie. W przypadku figur właściwie wszystko wiadomo np. nie zmieni się wzór na liczenie obwodu, więc obie klasy są ze sobą silnie związane. - cs 2018-12-21 23:57

Pozostało 580 znaków

Odpowiedz
Liczba odpowiedzi na stronę

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