Wstęp
Na pewno spotkaliście się z sytuacją, że klasy implementowały jakiś wielki interfejs i na części z nich był //no-op
czyli no operation lub kod, który robił coś innego niż metoda by wskazywała. Czasami autor pokusił się o jakieś wytłumaczenie w komentarzu typu //workaround for:
ale zazwyczaj kończyło się na długim i żmudnym debugowaniu kodu, który miał dziwne skutki uboczne. Co gorsza pod koniec debugowania dochodziliśmy do wniosku, że bez porządnego przeorania kodu to tam nic innego się nie da zrobić.
Mogłoby się wydawać, że jeśli mamy możliwość dziedziczenia oraz stosowanie interfejsów to możemy dostarczać dowolne implementacje danej abstrakcji i to jest prawda do momentu, w którym jest to zgodne z abstrakcja. Np. jeśli jakaś klasa implementuje interfejs, który posiada funkcję addItem(Item item)
powinna za pomocą tej funkcji dodawać obiekt Item
a nie usuwać lub edytować obiekt albo zastępować inny. Tworzenie niekompletnej implementacji lub niezgodnej ze specyfikacją jest jedną z cech bardzo słabego kodu, powodem wielu problemów oraz źródłem trudno wykrywalnych błędów.
Barbara Liskov, autorka zasady podstawienia (Liskov Substitution Principle dalej LSP) napisała, że:
Jeśli dla każdego obiektu O1 typu S istnieje obiekt O2 typu T taki, że dla wszystkich programów P zdefiniowanych w kategoriach T zachowanie P pozostanie niezmienione, gdy O1 zostanie podstawione za O2, to S jest podtypem T.
Bardziej przystępnym językiem oznacza to, że jeśli mamy funkcję która przyjmuje jako argument obiekt pewnej klasy implementującej interfejs A to każda implementacja interfejsu A przekazana do tej funkcji nie powinna zmieniać jej działania. Jeśli któraś z implementacji interfejsu A psuje zachowanie się tej funkcji to implementacja ta nie spełnia zasady LSP.
Lub jeszcze prościej. Mamy 2 klasy B i C, które implementują interfejs A.
Jeśli program działa poprawnie korzystając z obiektu klasy B i jeśli po zamianie tego obiektu na obiekt klasy C będzie nadal działał poprawnie to program spełnia zasadę LSP. Jeśli zaś jego zachowanie nie będzie poprawne, klasa nie spełnia zasady LSP.
Przykład 1:
Niektóre interfejsy nie zawierają obsługi wyjątków ale zwracają błędy jako wynik funkcji. Weźmy na przykład interfejs Storage<T>
, który posiada metodę addItem
i zwraca true
jeśli uda się dodać element lub false
jeśli się nie uda.
public interface Storage<T>{ /** * Add item into storage * @param item - any item to add * @return true if add with success or false when item is not add */ public boolean addItem(T item); }
interface Storage<T> { /** * Add item into storage * @param item - any item to add * @return true if add with success or false when item is not add */ fun addItem(item: T): Boolean }
Taki sposób zwracania błędu może nie przypaść do gustu programiście piszącemu LimitedStorage
, który ma ograniczone miejsce na obiekty. Postanowił więc napisać własny wyjątek LimitExceededException
class LimitExceededException extends RuntimeException {}
class LimitExceededException : Exception()
i zamiast zwracać wartość false
w przypadku osiągnięcia limitu rzucić wyjątek:
public class LimitedStorage<T> implements Storage<T> { private int limit; private ArrayList<T> list = new ArrayList<>(); public LimitedStorage(int itemLimit){ limit = itemLimit; } @Override public boolean addItem(T item) { if(list.size() >= limit) throw new LimitExceededException(); else list.add(item); return true; } }
class LimitedStorage<T>(private val limit: Int) : Storage<T> { private val list = ArrayList<T>() override fun addItem(item: T): Boolean { if (list.size >= limit) throw LimitExceededException() else list.add(item) return true } }
Zauważ, że w przypadku implementacji Java wyjątek LimitExceededException
dziedziczy po RuntimeException
, ponieważ w Javie trzeba zadeklarować w funkcji bazowej jakie wyjątki są zwracane – nie dotyczy to właśnie RuntimeException
inaczej dostajemy (na szczęście) błąd:

W przypadku Kotlina niestety mamy dowolność i bez żadnych deklaracji można rzucać wyjątki na prawo i lewo.
Powyższy program łamie zasadę LSP. Kod który używa interfejsu Storage
zgodnie z jego przeznaczeniem i z obsługą wyniku działania funkcji true
i false
przestanie działać gdy będzie operował na obiekcie LimitedStorage
.
Rozwiązanie
Aby zachować obsługę wyjątków i być nadal zgodnym ze starym interfejsem należy po prostu dodać do nowej klasy nową funkcję a przeciążoną zaimplementować zgodnie z interfejsem:
public class LimitedStorage<T> implements Storage<T> { private int limit; private ArrayList<T> list = new ArrayList<>(); public LimitedStorage(int itemLimit) { limit = itemLimit; } @Override public boolean addItem(T item) { if (list.size() >= limit) return false; else list.add(item); return true; } public void addNewItem(T item) throws LimitExceededException { if (!addItem(item)) throw new LimitExceededException(); } }
class LimitedStorage<T>(private val limit: Int) : Storage<T> { private val list = ArrayList<T>() override fun addItem(item: T): Boolean { if (list.size >= limit) return false else list.add(item) return true } fun addNewItem(item: T) { if (!addItem(item)) throw LimitExceededException() } }
Duplikacja kodu? Zaledwie kilka linijek i nic nie psuje. Podstawienie LimitedStorage
nie zepsuje działania żadnej funkcji, która korzysta w sposób poprawny z interfejsu Storage
.
Pamiętaj, że to Twój kod musi się dostosować do interfejsu i działać wg. jego zasad. Nie możesz wprowadzać własnych zasad i zmian. Jeśli chcesz, to wprowadź własny ale nie psuj tego co już istnieje.
Przykład 2: Nowe klimatyzatory
Częstym powodem naruszenia LSP jest chęć dopasowania kodu, który bezpośrednio nie jest kompatybilny z interfejsem. Weźmy jako przykład klasę AirConditioner
, która jest odpowiedzialna za sterowanie bardzo prostą klimatyzacją bez termostatu (sterujemy poprzez ustawienie poziomów z zakresu -10 do 10 )
public class AirConditioner { private static final int MAX_LEVEL = 10; private static final int MIN_LEVEL = -10; private int level; private boolean working = false; void turnOn() { working = true; } void turnOff() { working = false; } public void setLevel(int level) { if (level < MIN_LEVEL) { this.level = MIN_LEVEL; } else if (level > MAX_LEVEL) { this.level = MAX_LEVEL; } else { this.level = level; } } }
Pewnego razu do systemu wprowadzono nowe klimatyzacje, które steruje się poprzez zadanie oczekiwanej temperatury a nie poziomu mocy. Mogłoby się wydawać, że dobrym pomysłem jest przeciążenie funkcji setLevel
tak aby nie wykonywała żadnej czynności i napisanie nowej metody do ustawiania temperatury:
public class DigitalAirConditioner extends AirConditioner { private float temperature; @Override public void setLevel(int level) { //no-op } public void setTemperature(float temperature) { this.temperature = temperature; } }
Rozwiązanie ma pewną wadę. W systemie może istnieć klasa sterująca wszystkimi klimatyzacjami AirConditionersDriver
z funkcją setLevelForAirConditioners(int level)
, która ustawia jednakowy poziom dla wszystkich klimatyzatorów.
public class AirConditionersDriver { private List<AirConditioner> airConditioners; public AirConditionersDriver(List<AirConditioner> airConditioners) { this.airConditioners = airConditioners; } public void setLevelForAll(int level) { airConditioners.forEach(ac -> ac.setLevel(level)); } }
W momencie gdy w kolekcji airConditioners
znajdą się obiekty typu DigitalAirConditioner
to nie zostanie na nich wykonane żadne działanie. Zatem obiekt DigitalAirConditioner
narusza zasadę LSP, ponieważ funkcja setLevelForAll
nie działa prawidłowo gdy operuje na tych obiektach.
Ten defekt można zauważyć także w testach klasy AirConditioner
, które powinny przejść pozytywnie także dla klasy podrzędnej.
@RunWith(JUnit4.class) public class AirConditionerTest { AirConditioner airConditioner; @Before public void setUp() throws Exception { airConditioner = new AirConditioner(); } @Test public void isWorkingShouldReturnTrueWhenAirConditionerWorking() throws Exception { airConditioner.turnOn(); assertTrue(airConditioner.isWorking()); } @Test public void isWorkingShouldReturnFalseWhenAirConditionerIsTurnedOff() throws Exception { airConditioner.turnOff(); assertFalse(airConditioner.isWorking()); } @Test public void levelCannotBeHigherThanMaxValue() throws Exception { airConditioner.setLevel(AirConditioner.MAX_LEVEL + 10); assertEquals(airConditioner.getCurrentLevel(), AirConditioner.MAX_LEVEL); } @Test public void levelCannotBeLowerThanMaxValue() throws Exception { airConditioner.setLevel(AirConditioner.MIN_LEVEL - 10); assertEquals(airConditioner.getCurrentLevel(), AirConditioner.MIN_LEVEL); } @Test public void setLevelShouldSetTheLevelOfACBetweenMinLevelAndMaxLevel() throws Exception { for (int i = AirConditioner.MIN_LEVEL; i < AirConditioner.MAX_LEVEL; i++) { airConditioner.setLevel(i); assertEquals(i, airConditioner.getCurrentLevel()); } } }
na 5 testów aż 3 nie przechodzą pozytywnie co jest bardzo dużym defektem, który może mieć negatywny wpływ na wiele komponentów w aplikacji:
Aby DigitalAirConditioner
spełniał zasadę LSP i jednocześnie miał możliwość korzystania z funkcji setTemperature
możemy zastosować 2 rozwiązania:
Rozwiązanie 1: implementacja funkcji setValue
Jednym z prostszych rozwiązań jest po prostu zaimplementowanie funkcji setValue
w taki sposób aby przeliczała skalę od -10 do 10 na skalę dostępnych temperatur:
public class DigitalAirConditioner extends AirConditioner { public static final float MAX_TEMP = 26; public static final float MIN_TEMP = 15; private float temperature; @Override public void setLevel(int level) { super.setLevel(level); temperature = calculateTemperatureFromLevel(); } public void setTemperature(float temperature) { if (temperature > MAX_TEMP) { this.temperature = MAX_TEMP; } else if (temperature < MIN_TEMP) { this.temperature = MIN_TEMP; } else { this.temperature = temperature; } setLevel(calculateLevelFromTemperature()); } private float calculateTemperatureFromLevel() { float percent = levelToPercent(); float tempRange = MAX_TEMP - MIN_TEMP; return tempRange * percent; } private float levelToPercent() { int relativeLevel = getCurrentLevel() - MIN_LEVEL; int range = MAX_LEVEL - MIN_LEVEL; return relativeLevel * 1.0f / range; } private int calculateLevelFromTemperature() { float percent = temperatureToPercent(); int levelRange = MAX_LEVEL - MIN_LEVEL; return Math.round(levelRange * percent); } private float temperatureToPercent() { float relativeTemp = temperature - MIN_TEMP; float range = MAX_TEMP - MIN_TEMP; return relativeTemp / range; } }
rozwiązanie może nie jest idealne i ma swoje wady ale pozwala na używanie DigitalAirConditioner
w podobny sposób jak AirConditioner
. Powyższa prowizorka ma jednak wadę, powyższa klasa nie może być użyta w innym systemie, ponieważ silnie zależy od przestarzałej klasy AirConditioner
. W momencie gdy z systemu zostaną usunięte wszystkie stare klimatyzatory, AirConditioner
przestanie być przydatny i będzie mógł być usunięty ale niestety będzie to niosło za sobą zmianę DigitalAirConditioner
. Dlatego powyższe rozwiązanie nie jest rekomendowane.
Rozwiązanie 2: rezygnacja z dziedziczenia
Drugim rozwiązaniem jest uznanie klasy DigitalAirConditioner
jako niepochodną od AirConditioner
i stworzenie nowej klasy DigitalAirConditionerAdapter
która byłaby pochodną klasy AirConditioner
i symulowała działanie klasycznej klimatyzacji mimo oddziaływania na nowoczesną. (Wykorzystanie wzorca Adapter)
public class DigitalAirConditioner { public static final float MAX_TEMP = 26; public static final float MIN_TEMP = 15; private float temperature; private boolean working = false; void turnOn() { working = true; } void turnOff() { working = false; } boolean isWorking() { return working; } public void setTemperature(float temperature) { if (temperature > MAX_TEMP) { this.temperature = MAX_TEMP; } else if (temperature < MIN_TEMP) { this.temperature = MIN_TEMP; } else { this.temperature = temperature; } } public float getCurrentTemperature() { return this.temperature; } }
public class DigitalAirConditionerAdapter extends AirConditioner{ private DigitalAirConditioner airConditioner; public DigitalAirConditionerAdapter(DigitalAirConditioner airConditioner) { this.airConditioner = airConditioner; } @Override public void setLevel(int level) { super.setLevel(level); airConditioner.setTemperature(calculateTemperatureFromLevel()); } @Override public int getCurrentLevel() { return calculateLevelFromTemperature(); } private float calculateTemperatureFromLevel() { float percent = levelToPercent(); float tempRange = MAX_TEMP - MIN_TEMP; return tempRange * percent; } private float levelToPercent() { int relativeLevel = getCurrentLevel() - MIN_LEVEL; int range = MAX_LEVEL - MIN_LEVEL; return relativeLevel * 1.0f / range; } private int calculateLevelFromTemperature() { float percent = temperatureToPercent(); int levelRange = MAX_LEVEL - MIN_LEVEL; return Math.round(levelRange * percent); } private float temperatureToPercent() { float relativeTemp = airConditioner.getCurrentTemperature() - MIN_TEMP; float range = MAX_TEMP - MIN_TEMP; return relativeTemp / range; } }
Nowa klasa nie wydaje się tak elegancka jak poprzednia implementacja ale zastosowanie DigitalAirConditionerAdapter
ma dodatkową zaletę. Niedoświadczeni programiści mogliby ulec chęci sprawdzenia typu i rzutowania go na DigitalAirConditioner
gdzieś głęboko w kodzie, naruszając zasadę OCP:
if(airConditioner instanceof DigitalAirConditioner) { ((DigitalAirConditioner) airConditioner).setTemperature(23); }
Zastosowanie DigitalAirConditionerAdapter
uniemożliwia dostęp do klasy typu DigitalAirConditioner
i do możliwości ustawiania temperatury co zmniejsza prawdopodobieństwo pojawienia się takich konstrukcji w kodzie.
Podsumowanie
LSP powinna być stosowana bez względu na to czy stosujemy zasadę SRP, OCP czy DIP – jest to całkowicie niezależna zasada od warunków panujących w projekcie. Tworzenie klas pochodnych, które mają inne zachowanie niż wynikałoby to ze specyfikacji interfejsu lub klasy nadrzędnej to proszenie się o problemy. Pracowałem przy projekcie w którym było masa klas, które w rażący sposób naruszały zasadę LSP – funkcje nic nie robiące, robiące co innego lub jakieś przełączniki włączające lub wyłączające funkcje w wielostopniowym dziedziczeniu. Każde ich użycie generowało liczne problemy, musiały być specyficznie obsłużone, dokładnie przeanalizowane, powstawały nowe błędy i traciło się czas.
Tworząc klasy pochodne lub nowe implementacje pewnych typów zwróćcie uwagę czy przypadkiem nie naruszacie zasady LSP. Jeśli takie naruszenie wystąpi należy podjąć kroki, które doprowadzą do zgodności z LSP. Nie ma uniwersalnej metody na rozwiązanie wszystkich przypadków naruszenia LSP ale najgorsze rozwiązanie, które doprowadza do zgodności z LSP jest lepsze niż zignorowanie problemu i zrobienie czegoś miernie. Lepiej zaniechać dziedziczenia, napisać trochę więcej kodu niż potem żałować, że się nabałaganiło.
Bibliografia
[1] https://www.youtube.com/watch?v=Mmy1EUKC_iE
[2] https://www.youtube.com/watch?v=gwIS9cZlrhk&t=611s
[3] http://blog.cleancoder.com/
[4] Zwinne wytwarzanie oprogramowania. Najlepsze zasady, wzorce i praktyki, Robert C. Martin, Helion 2015
[5] Czysty kod. Podręcznik dobrego programisty, Robert C. Martin, Helion 2014
[6] https://www.youtube.com/watch?v=t86v3N4OshQ