Seriál Quick-and-Dirty-Programming Část 6: Nepodceňujte LSP – Liskov Substitution Principle aneb proč čtverec není v OOP dědicem obdélníku

Na nedávném in-house školení na téma Čistý kód a Design Patterns jeden účastník vznesl následující dotaz: „Někde jsem se dočetl, že čtverec nemůže být dědicem obdélníka, protože to porušuje tzv. „Liskov Substitution Principle“ (dále jen LSP). Ale když přece podědím z Obdélníka novou třídu Čtverec a současně překryji metody pro nastavení stran tak, aby byly vždy shodné, tak vše bude fungovat. Přece platí, že čtverec je speciální případ obdélníka a proto jej zavedeme jednoduše jako speciálnější třídu, teda jako potomka obdélníka. Takže kde je problém? Mohl byste vysvětlit princip LSP?“

V tomto článku si vysvětlíme LSP princip a ukážeme si, jak dochází k jeho porušení (Poznámka: Mimochodem česky tento princip zní „Liskovové princip zastoupení“, nikoliv „Liskovův princip“, autorem je totiž žena, paní Liskovová ).

O čem je vlastně dědičnost?

Všichni známe dobře, co to znamená, když se řekne „dědičnost v OOP“. Je to zažitý a běžně používaný název interakce mezi třídami. Osobně však nepovažuji tento název za nejvhodnější, protože může vést k určitému zmatení v úvahách, k čemu má tato interakce sloužit anebo naopak k čemu tato interakce sloužit nemá. V modelovacím jazyce UML se odpovídající vztah nazývá „Generalizace“, což lépe vystihuje, o co vlastně v této interakci jde a tedy jak a kdy se má použít.

Základní a nosnou myšlenkou Generalizace (potažmo dědičnosti v OOP) je zavedení zvláštního způsobu opětovné použitelnosti pomocí zobecnění pojmů.

Představme si, že máme napsat kód, který simuluje chování Kočky, což se nám podaří. Poté začneme psát kód pro simulaci chování Psa za stejné situace a zjistíme zřetelnou „podobnost kódu“. Nasadíme tedy opětovnou použitelnost pomocí Generalizace takto: Nenapíšeme kód ani pro Kočku a ani pro Psa, ale napíšeme kód pro obecnější pojem Zvíře. Do pozice v programu u tohoto pojmu lze dosadit konkrétně prvky ze tříd Kočka a Pes (resp. prvky z dalších budoucích potomků).

(detailněji o tomto příkladu viz tento článek)

Využijeme přitom základní vlastnosti Generalizace (dědičnosti): Protože potomek umí být předkem (podědil jeho vlastnosti), tak objekt ze třídy potomka umí žít v pozici v programu, kde je deklarovaný prvek typu předek. Takto funguje základní konstrukce použití dědičnosti, což můžeme shrnout do těchto tří po sobě jdoucích bodů:

1) Jedná se o nasazení opětovné použitelnosti pomocí zobecnění pojmů a to tak, že:

2) napíšeme program v „obecnější rovině“ vůči „obecnějšímu pojmu“, tj. vůči předkovi,

3) poté do něj dosadíme prvek z „konkrétnějšího pojmu“, tj. prvek ze třídy potomka.

Co se týče LSP, tak věc je více než jednoduchá: Liskovové princip zástupnosti je vlastně o těchto třech bodech dohromady. Přitom je však třeba zdůraznit, že při správném nasazení interakce Generalizace neboli dědičnosti (a tedy splnění LSP) musí být splněny všechny tři body.

Poznámka: Jak je ukázáno v předešlém článku, tak postup uvedený v předešlých třech bodech úzce souvisí s principem Open / Closed takto: Zatímco kód pro předka je uzavřen (dokonce v prostředí JAVA anebo C# může být zkompilován), je možné flexibilně přidávat nové potomky (tj. zde je patrná otevřenost kódu). Současně tento postup úzce souvisí s principem Dependency Injection, který v podstatě říká, že dosazení prvku z potomka do pozice předka musí být navrženo tak, aby nebyla narušena „obecná rovina“ kódu pro předka a nedošlo tak k chybě „předčasné konkretizace“ obecného kódu pro předka.

O čem není dědičnost

V předešlých verzích UML se interakce Generalizace nazývala jinak a to jako dvojice názvů “Generalizace – Specializace“. Nejenom, že se jednalo o pěkný jazykolam, tak krom toho toto dlouhé pojmenování nebylo úplně přesné, resp. mohlo vést ke zmatení, jak tato interakce funguje. Název později změnili na pouhou „Generalizaci“ a slůvko specializace vypustili, což se mi jeví jako lepší.

Interakce Generalizace totiž nefunguje ve směru „odshora dolů“ (tj. jako „specializace“ od předka k potomkovi), ale funguje naopak ve směru „zespodu nahoru“ (tj. jako „generalizace od potomka k předkovi“).

Když se podíváme na předchozí obrázek znázorňující Generalizaci Zvíře, Kočka, Pes, tak bychom jej měli číst odspodu a to takto: třída Kočka (totéž samozřejmě pro Psa) používá v Generalizaci třídu Zvíře (tj. „dědí z ní“) a proto každá Kočka (resp. Pes) umí být Zvířetem, neboli pojmově je současně také i Zvířetem. Díky této interakci má tedy každý prvek ze třídy Kočka (resp. Pes) dva názvy: Je to Kočka (resp. Pes) a současně je to i (obecněji pojato) Zvíře. V Generalizaci je tedy schována určitá „pojmová ekvivalence“, kdy jeden prvek má a tedy podporuje najednou oba dva pojmy (programátorsky řečeno oba dva interfejsy), z nichž jeden pojem je obecnější. Co je však důležité: toto platí bez nějakého „ale“ anebo „až na to že“.

(poznámka: Mnoho programátorů rádo používá větu „je to totéž, až na to že“. To je samozřejmě protimluv).

Generalizace tedy není o „rozvinutí pojmu dolů do speciálnějšího případu“, ale obráceně, jedná se o „zobecnění nahoru“, přičemž toto zobecnění je určeno pro nasazení opětovné užitelnosti, kdy do kódu pro předka se dosazuje prvek ze třídy potomka.

Rada: Nedívejte se tedy na Generalizaci (potažmo na dědičnost v OOP) shora dolů, tj. od předka k potomkovi, ale naopak, od potomka k předkovi. Předek je pouze nově zavedený obecnější název pro prvky z potomka a slouží pro nasazení re-use zobecněním.

Protože platí pro opětovnou použitelnost princip anonymity klienta (tj. opětovně používaný kód nesmí být závislý na tom, kdo jej používá, jinak to není opětovná použitelnost pro kohokoliv), tak je zřejmé, že kód napsaný pro předka (Zvíře) je nezávislý na potomcích (Kočka, Pes) a každé dosazení potomka proto musí odpovídat konstrukci „já jsem současně i předek“ (a to bez výjimek nebo bez nějakých „až na to že“).

Proč tedy čtverec není dědicem obdélníka?

Tyto poznatky aplikujme na konkrétní dotaz účastníka se čtvercem a obdélníkem

Můžeme říci větu: „Kočka je vlastně současně i Zvíře“ (je to druhý obecnější pojem pro prvek ze třídy Kočka), jenže pro Čtverec toto už neplatí. Obdélník totiž není zobecnění pro Čtverec ve stejném smyslu zobecnění, jako Kočka je Zvířetem. Když totiž do Obdélníka dosadíme Čtverec, tak stále přitom na horní úrovni mluvíme o Obdélníku (o Zvířeti), ale přestane se chovat jako obecnější pojem Obdélník.

Jakékoliv dodatky „je to obdélník, až na to že strany jsou stejné“ jsou na horní úrovni nepřípustné, protože se tím ztratila obecnost programu pro vrchní obecnější úroveň programu, tj. pro Obdélník. Na horní úrovni programu pro Obdélník pracujeme s Obdélníkem a Obdélník by se měl chovat jako Obdélník, protože to od něj očekáváme bez dodatku ohledně použitých potomků, protože horní obecnější úroveň programu musí být na potomcích nezávislá.

Příklad nekorektního chování Obdélníku s dosazeným Čtvercem

Představme si tento pseudokód nějaké funkcionality (např. metoda nějakého objektu):

f(x,y)
{
 if (x == y) then exit
 myObd.Seta(x)
 myObd.Setb(y)
 g = 1/(myObd.a – myObd.b)
 etc…
}

Tento kód (ať už je smysl příkladu jakkoliv ujetý) nebude pro obecný obdélník při výpočtu hodnoty g padat (protože jsou ošetřeny hodnoty x a y), ale pro Čtverec dosazený do myObd padat bude.

Platí totiž jednoduchá věta:

Obdélník není obecně zástupným pojmem pro Čtverec.

A to je právě to, o čem je ona „zástupnost“ (tj. substition) v principu LSP a zde je pěkně vidět, k čemu vede, když je porušena.

Námitka: ale čtverec je přece speciálním případem obdélníka, tak proč to nefunguje?

Takto zní častá námitka programátorů. To je sice pravda, ale v této úvaze jsou skryty dvě zásadní chyby:

1. V uvedené větě se vyskytuje častá chyba zvaná jako „chyba v úrovni meta“. Každý program se skládá ze dvou disjunktních prostorů: Z prostoru tříd (statická část, část kódu) a z prostoru instancí (objekty, dynamická část). Důležité je vědět, zda dané tvrzení platí pro třídy nebo pro instance. A právě tvrzení „čtverec je zvláštní případ obdélníka“ nepatří do prostoru tříd, ale do prostoru instancí (díky shodě hodnot). Je to stejný nesmysl, jako by někdo tvrdil, že Kružnice je zobecněným pojmem pro Bod, protože Bod je speciálním případem Kružnice s poloměrem nula.

2. Namísto pohledu zespodu nahoru tj. nasazení re-use pomocí zobecnění, se zde dědičnost chybně považuje za přechod odshora dolů jako specializace. Dědičnost není o tom podědit a něco přidat (něco jako „extenze“). Je to naopak pohled zespodu o zobecnění.

Závěr

LSP princip není o ničem jiném, než o zástupnosti pojmů v Generalizaci (dědičnosti):

Předek je obecně zástupným pojmem pro potomka.

Pokud to neplatí, tak někde v návrhu se skrývá chyba, což se jeví jako porušení tohoto principu.


Nepřehlédněte aktuální nabídku

Prosperující SW firma hledá analytiky a JAVA programátory v okolí Hradce Králové

V případě zájmu pište na e-mail objects@object.cz


Categories

About the Author:

správce a majitel Serveru objektových technologíí http://www.objects.cz

4 Comments
  1. Jiří Matějček

    Moc pěkné, děkuji za tento vhled. Představuji si to ve vektorových editorech (Corel Draw, Illustrator, AutoCad), že jsou dva přístupy. Buď zavedu obecnější Obdélník a je mi jedno, že zrovna nastala situace a==b, jen ve výpočtech to musí být ošetřeno, jinak můžu obrazec modifikovat jak je libo. Nebo zavedu Čtverec i Obdélník a pak ze Čtverce můžu udělat Obdélník jen nějakou operací “převeď na obdélník” a naopak. Čtverci nedovolím, aby se poměrově deformoval, stále bude držet poměr stran 1:1. Ovšem nic mi nebrání nastavit obdélník tak, aby měl obě strany stejné. Takže ošetření výpočtů při rovnosti stran musí mít také implementováno, přeci při shodnosti stran nebudu měnit typ, a řešit zda při a = 1 a b = 1,00001 mám obdélník už považovat za čtverec? Tedy z toho mi plyne, že čtverec bude mít i jiné chování než obdélník, a není to proto, že má stejné strany, ale že si přejeme, aby se vždy “choval” jako čtverec. Z toho cítím, že jde o různé typy, které od sebe nedědí a předek je abstraktní např. “Obrazec” nebo “Geometrický útvar”.

    Ale i tak je hranice rozpoznání velmi tenká, a nejsem si jistý, že v jiných (byznysových) případech to rozpletu správně :) Zkusím najít obdobné konkrétní případy, kde si dát na to pozor. Nebo někdo takové má a podělí se s námi?

  2. V OOP musí potomek umět plně zastoupit předka (aspoň to tvrdí paní Liskovová).

    A teď si představuji hlasatelku ČST Saskii Burešovou: “A v roli hubnoucího obdélníka dnes vystoupí pan Čtverec.”

    Už vidím jak následně řeší paní Indra Vostrá v pořadu Nad dopisy diváků stížnosti, že Čtvercův výkon byl ve hře naprosto nevěrohodný.

  3. Martin Veverka

    Vybraný příklad obdélník – čtverec je trochu matoucí, protože když se řekne obdélník, většinou si každý představí nestejně dlouhé strany. (Obdobně, když řeknu, že vidím hvězdu, málo bude předpokládat, že koukám na slunce.) Pak je snadné uvěřit, že obdélník není generalizace čtverce.

    Ale zkusme použít pojmy Pravoúhelník a dva potomky Čtverec a Obdélník. Zde už o správnosti generalizace Čtverce na Pravoúhelník a Obdélník na Pravoúhelník nemůže být pochyb. Totiž Čtverec je i Pravoúhelník a Obdélník je i Pravoúhelník.

    Pokud nějaký kód dostává Pravoúhelník, tak ví, že úhly budou pravé a že protilehlé strany budou stejné, může tedy zjistit jejich velikost voláním GetA a GetB, kdy v případě obdélníku dostane různé hodnoty a v případě čtverce stejné. Mohli bychom postupovat dále, generalizací Pravoúelníku je Čtyřúhelník, který bude mít zjevně metody GetA, GetB, GetC a GetD. S generalizací můžeme pokračovat na mnohoúhelník s metodou GetDekyStran vracející seznam čísel atd.

    Co nám způsobuje problém je změna velikost stran útvaru. Nemůžeme generalizovat nastavení velikosti stran, protože nejsme schopni definovat co to znamená. Například SetA by mělo změnit jen délku strany A a nic jiného. Ale tak by se v případě, že dostáváme Pravoúhelník, stal ze Čtverce Obdélník a v případě, že dostáváme Čtyřúhelník, tak by se ze všeho stal nepravidelný čtyřúhelník, ale nikoli jednoznačně, ještě bychom museli zadat nějaký úhel. Tedy metoda SetA do předka nepatří, modifikovat po jednotlivých stanách musíme konkrétní potomky.

    V předkovi mohou být pouze metody poskytující informace o objektu a metody, které objekt modifikují, ale nemění tvar, například scale(číslo), která vynásobí všechny hrany stejným číslem.

    Závěrem k původnímu problému: Obdélník je generalizací čtverce (matematicky i programátorsky), ale metody pro nastavování délky hran do této generalizace nepatří.

  4. Pavel Polívka

    Výborný článek – jako detektivka :)

    Moc za něj děkuji, protože právě argument, že čtverec je speciálním případem obdélníka bylo to první, co mě při dočtení prvního odstavce vrtalo hlavou a až na jeho konci jsem našel vysvětlení :-)

0 Pings & Trackbacks

Leave a Reply