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.

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.

0

Musimy pamiętać, że kod to tylko model prawdziwego świata. Przepisywanie własności obiektów z realu w virtual może wprowadzić w maliny.

1
Michał Sikora napisał(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.

Przecież inne działanie (w jakimś zakresie) jest celem dla którego tworzy się klasy pochodne / implementacje:

interface GreetingProvider{
  String greet();
}

class EnglishGreetings implements GreetingProvider{
  public greet(){
    return "Hello";
  }
}

class PolishGreetings implements GreetingProvider{
  public greet(){
    return "Cześć";
  }
}

Czy powyższe łamie LSP skoro poniższy test nie przechodzi?:

assertEquals(new PolishGreetings.greet(), new EnglishGreetings.greet());
1

Ciężko powiedzieć, bo się nie skompiluje bez zwracanego typu. ;)

Ale na poważnie to nie łamie, bo obie implementacje działają w ten sam sposób i spełniają kontrakt zwrócenia obiektu klasy String i nigdzie nic złego się nie stanie, jeżeli w miejscu jednego interfejsu użyję drugiego.

Ten przykład jest też trochę zbyt prosty, żeby móc pokazać złamanie LSP inne niż rzucanie wyjątkiem. Gdyby mieć np. taki zestaw klas, to już można popatrzeć szerzej na problem.

interface Container {
  boolean add(Object item);
  int itemCount();
}

class InMemoryContainer implements Container {
  private final List<Object> list;
  
  InMemoryContainer(List<Object> list) {
    this.list = new ArrayList(list);
  }

  public boolean add(Object item) {
    return list.add(item);
  }

  public int itemCount() {
    return list.size();
  }
}

class RandomContainer implements Container {
  private final Random random = new Random();

  public boolean add(Object item) {
    return random.nextBoolean();
  }

  public int itemCount() {
    return random.nextInt();
  }
}

class ConstantContainer implements Container {
  public boolean add(Object item) {
    return true;
  }

  public int itemCount() {
    return 42;
  }
}

class ThrowingContainer implements Container {
  public boolean add(Object item) {
    throw new RuntimeException();
  }

  public int itemCount() {
    throw new RuntimeException();
  }
}

Teraz, czy wszystkie poza InMemeoryContainer łamią LSP? Trochę pytanie dla filozofów. Ostatni na pewno. Pozostałe moim zdaniem też, bo kod powinien nieść semantyczną wartość w postaci nazw klas i metod, ale może to akurat moje widzimisię.

0

Nie każdy smrodek w projektowaniu hierarchii klas to naruszenie LSP. Poprawne nazywanie klas, zmiennych, metod to ważna rzecz ale nie ma nic wspólnego z LSP (a w każdym razie sposobem w jaki postrzegam tę zasadę). Jak próbowałem apisać - dla mnie LSP jest głównie jednokierunkowe i realizowane głównie w klasie zależnej. Na swój mocno uproszczony sposób sprowadzam tę zasadę do czerpania wiedzy o zależnościach jedynie z ich publicznego interface'u.
Oczywiście po stronie klas będących zależnościami trzeba pilnować się żeby w jakiś chamski sposób nie naruszyć interface'u, nie wprowadzać dodatkowych niejawnych kontraktów (np. wywołaj init() zanim wywołasz @Override doJob()) ale to jest dużo bardziej ulotne, bo to po stronie implementacji odpowiadamy na pytania w jaki sposób i dlaczego akurat tak implementujemy jakąś część interface'u.
Przykład to Collections.unmodifableList() - niby LSP złamane, a z drugiej strony robi to co trzeba.
Pozostając w javowych kolekcjach, najczęstsze naruszenia jakie widzę to właśnie takie jak przytaczałem - uzależnianie przebiegu programu od konkretnego typu jakiegoś parametru, albo, co dość częste, akcje typu:

void doSomethingWithList(List list){
  ((LinkedList)list).dequeue();
}
1

@piotrpo, @Michał Sikora: No fajna dyskusja o interfejsach, ale co to ma wspólnego z zasadą Liskov? Jeśli interfejs A to klasa bazowa, a klasa implementująca interfejs to klasa pochodna B, to trzeba przetestować równość wyników wywołań:

A a = new A();
A b = new B();
a.metoda() == b.metoda();`

czego zrobić się nie da!!!

A Wy testujecie:

A b = new B();
A c = new C();
c.metoda() == b.metoda();

gdzie B i C implementują A.

1

Dyskusja się rozwija, to dołożę swoje trzy grosze:), definicja:
"The Liskov Substitution Principle (LSP): functions that use pointers to base classes must be able to use objects of derived classes without knowing it"

Widzę to prosto, powiedzmy, że mamy interfejs, listy:

void push(T elem);
T pop();
size_t len();
bool empty();

Załóżmy implementację:
List list = new LinkedList();
Czyli wewnatrz nasza lista to Linked List.

A teraz dodamy drugą [implementację]:
List list = new ArrayList()
To z punktu widzenia usera (i interfejsu) semantyka jest taka sama, pop() zwraca pierwszy element, len() długość, itd. Natomiast, program będzie działał inaczej, bo zmienią się złożoności czasowe i pamięciowe, zresztą @Shalom napisał o tym na początku.

0

Jeżeli ludziom z doświadczeniem tak trudno idzie osiągnięcie konsensusu, to może uznajmy, że oop to bullshit i nie powinno się go wykładać?

Definicja klasy, konstruktor, minimalnie enkapsulacja i na tym etapie koniec, bo później zaczynają się tylko problemy i niepraktyczny theorycraft :D

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