Uživatel Pythonu není omezen jen na užívání tříd, které nabízí standardní knihovny jazyka resp. knihovny třetích stran. Vlastní podstata objektově orientovaného programování spočívá ve vytváření vlastních tříd, které modelují nějakou množinu objektů z reálného světa či ze světů vědecko-technických abstrakcí.
Jak jste již poznali, objekty jsou navenek charakterizovány svým chováním tj. tím jaké metody můžete na daném objektu volat (počítaje v to i obecné vestavěné funkce a operace). Zopakujme si to na příkladu objektu seznamu.
seznam = [1, 5, 0] # vytvoření seznamu
seznam.sort() # volání metody
print(str(seznam)) # volání obecné vestavěné funkce 'str'
print(seznam * 5) # volání operace násobení nad objektem
Už pouhým pohledem na tento ukázkový kód zjistíme, že náš seznam je tříditelný (má
smysl nad ním volat metodu sort), je možné vypsat jeho textovou representaci
(tj. je převoditelný na řetězec) a je možné ho násobit celým číslem.
To vše samozřejmě platí i pro všechny ostatní objekty dané třídy. Navíc při použití daných metod (funkcí, operací) získáme obdobné výsledky (liší se jen různými konkrétními položkami jednotlivých seznamů).
Obecně proto platí, že třída je jednoznačně popsaná tím, že stanovíme metody, které lze volat na její instance (včetně operací a volání vestavěných funkcí), a určíme jaký efekt budou mít tyto metody pro objekt v určitém stavu, jinak (a stručněji) řečeno stanovíme rozhraní objektů.
Aby mohly objekty třídy nabízet určitou funčnost musí nějak interně representovat svůj stav. U objektů uživatelských tříd je stav representován vnitřními (pod)objekty. Jinak řečeno každý objekt uživatelské třídy je složen z alespoň jednoho (pod)objektu jiné třídy. Tyto podobjekty je nutno v rámci daného podobjektu nějak jednoznačně identifikovat, k čemuž slouží tzv. atributy.
Atribut je obdobou proměnné, která je však omezená jen na na jediný objekt. Stejně jako proměnná je to jen štítek, který (dočasně) identifikuje (pod)objekt.
Podobjekty mohou být nejen instance jednoduchých tříd jako jsou čísla nebo řetězce, ale mohou to být i například i kontejnery či dokonce objekty dalších uživatelských tříd.
Nejdříve vytvoříme triviáalní model kasičky (prasátka), do něhož můžeme vkládat libovolné peněžní prostředky (není to přiznávám příliš užitečné, ale nějak se začít musí).
Pro jednoduchost budeme předpokládat, že kasička je bezedná tj. lze do ní vložit libovolné množství peněz (obecně je velmi složité zjistit, kolik prostoru mohou zaujímat mince v prostoru kasičky).
Dále budeme předpokládat následující chování:
Z modelu je navíc zřejmé, že každý objekt kasičky (může existovat libovolné množství objektů kasiček) by měl podporovat dvě metody (modelující možné interakce s kasičkou):
1) metoda pridej_penize
Tato metoda přijímá jako parametr obnos, což je kladné celé číslo (naše kasička nebude implementovat přidání zlomků základní peněžní jednotky, pro koruny je to zbytečné). Přidáná suma není v našem modelu shora omezená (máme bezednou kasičku a koho by nepotěšilo například například přidání bilión korun).
Pokud bude vše v pořádku, metoda nemusí nic vracet (jen změní stav kasičky). Je však jasné, že může dojít k problémům. Za prvé se někdo může pokusit přidat zápornou částku (a pokusit se tak vytáhnout peníze z kasičky bez jejího rozbití). V realitě to nejde, ale v Pythonu nezabráníme použití záporné hodnoty v parametru.
Dalším typem problematického přidání je vkládání peněz do rozbité kasičky. To je podle našeho modelu nepřípustné (a ani v realitě to není snadné).
Protože při běžném použití kasičky by k těmto problematickým voláním mělo docházet jen zřídka, budeme tito situace řešit výjimkou (výjimka by měla být reakce na výjimečnou situaci). Rozhodně nemůžeme tyto situace v ignorovat a to ani v počáteční fázi, neboť uživatelé by začali kasičku využívat "netradičně", a pak by byli překvapeni, že v nové verzi jim to nefunguje (uživatelem můžete být samozřejmě i autor třídy, obecně jsou to však jiní lidé).
Ve skutečnosti jsme však nevyřešili všechny možné nedefinované či okrajové příklady. Co například přidání nulové částky? Je to trochu netradiční, ale můžeme to povolit (nemění to stav kasičky). Jak se postavíme k předání jiného než celočíselného (int) objektu. Můžeme se snažit přidat cokoliv: číslo v pohyblivé řádové čárce (to by nemusel být takový problém), komplexní číslo (kasička s imaginární jednotkou korun?), řetězce, seznamy, jiné kasičky (ty mohou obsahovat jiné kasičky, takže můžeme dostat celý řetězec kasiček, navíc do kasičky lze vložit odkaz na sebe sama, to už je hluboký filozofický problém). I když běžné kasičky nejsou tak striktní (lze do nich vkládat i jiné věci než peníze), my všechny neceločíselné objekty zakážeme (při pokusu o přidání vyvoláme výjimku).
2) metoda rozbij
Tato metoda je mnohem jednodušší. Pokud není kasička rozbitá, pak ji rozbije (= změní její stav) a vrátí celkovou naspořenou částku (může to být i nula). Pokud je již rozbitá, pak vyvoláme výjimku (nemůžeme získat peníze z již rozbité kasičky!). Žádná další možnost již není (metodě nic nepředáváme, takže její výsledek je určen pouze stavem objektu)
Nyní již máme vše připraveno k implementaci:
class Kasicka: # hlavička třídy
def __init__(self): # konstruktor
self.castka = 0 # (počáteční) nastavení atributu
self.rozbita = False # (počáteční) nastavení atributu
def pridej_penize(self, castka): # metoda
if not isinstance(castka, int):
raise Exception("Částka není celé číslo")
if castka < 0:
raise Exception("Částka je záporná")
if self.rozbita:
raise Exception("Kasička je rozbitá")
self.castka += castka # vlastní kód metody: zvýšení uložené částky o předanou částku
def rozbij(self):
if self.rozbita:
raise Exception("Kasička je rozbitá")
self.rozbita = True
return self.castka # a nezapomeneme vrátit konečný stav peněz
Implementace třídy začíná hlavičkou, která po kličovém slově class uvádí jméno třídy. V Pythonu neexistuje zcela jednotný úzus ohledně identifikátorů třídy. Klíčový dokument PEP 8 -- Style Guide for Python Code doporučuje jména začínající velkým písmenem, bez použití podtržítek na místě mezer (nová slova začínají velkým písmenem). Tuto konvenci budeme dodržovat.
Jména vestavěných tříd (např. int, str) však tuto konvenci nedodržují, neboť jejich jména jsou úzce spojena se stejnojmenými vestavěnými funkcemi. Podobný zápis se však využívá i u tříd standardní knihovny (například datetime.datetime), kde je využíván pravděpodobně z historických důvodů.
Hlavička třídy končí dvojtečkou, takže je jasné, že bude následovat odsazený blok (tzv. tělo třídy). Ten obsahuje definice metod, což jsou ve skutečnosti funkce volané nad objekty dané třídy.
První z metod má podivný název __init__ (dvě podtržítka na začátku a dvě na konci!). Metody začínající a končící dvěma podtržítky (v Pythonské slangu speciální, magické respektive dunder metody) mají v Pythonu pevně definovaný význam a téměř nikdy se nevolají přímo tj. zápisem objekt.__method__().
Metoda označená __init je tzv. konstruktor. Tato metoda se volá při vytváření/konstrukci objektu a její funkcí je vytvořit atributy objektu (definující jeho stav) a jejich inicializace tj. nastavení na počáteční hodnotu.
Prvním parametrem konstruktoru je nově vytvořený, ale prozatím ještě prázdný a neinicializovaný objekt. Tento paarmetr se v Pythonu vždy označuje jménem self (vždy odkazuje na objekt nad kterým je metoda volána, resp. k níž patří tj. jakoby na sebe sama).
Poznámka: Použití jména self není vynucováno překladačem (formálně může být použito jakékoliv jméno bez vypsání chyby). Je to však tak silná konvence, že její narušení se chápe jako závažná stylistická chyba. Je to podobné použití hovorového či dokonce vulgárního tvaru ve formálním dokumentu. Neovlivňuje to sice jeho čitelnost, může však vést k jeho odmítnutí komunitou (zde tedy ostatními programátory).
Náš konstruktor nemá žádné další parametry, neboť bude vytvářet objekty s identickým obsahem. Náš nový objekt kasičky (dočasně označený proměnou/parametrem self) bude obsahovat dva atributy (atribut je něco jako interní proměnná spojená s daným objektem). Nejdříve nastavíme atribut částka (self.castka) tak, aby označoval objekt nula (třídy int), neboť kasička je na začátku prázdná (= obsahuje O jednotek měny).
Druhý atribut self.rozbita určuje zda je kasička rozbitá (= True) nebo nikoliv (= False). Je zřejmé, že nové kasičky se nevyrábějí rozbité (tj. atribut má na počátku hodnotu False).
Tím máme objekt plně inicializovaný. Vytváření objektu můžeme hned vyzkoušet. Objekt třídy se vytvoří použitím jména třídy jako funkce (s případnými parametry v kulatých závorkách).
prasatko = Kasicka() # vznikne nový objekt odkazovaný proměnnou `prasatko`
Pro kontrolu lze vypsat hodnoty atributů nového objektu.
print(prasatko.castka)
print(prasatko.rozbita)
Další metodou je metoda přidávající peníze do prasátka (pridej_penize). I tato metoda stejně jako všechny metody nad objektem musí mít první první parametr self. Parametr označuje objekt, nad nímž se metoda volá (při volání je vlevo od tečky). V tomto případě však má i další parametr (castka), který se při volání předává běžným způsobem tj. v seznamu parametrů.
Metoda může ve svém, těle využívat libovolné atributy a metody objektu. Může samozřejmě měnit i hodnoty atributů (mohou začít odkazovat na nové objekty či se změní odkazované objekty). V našem případě zvýšíme hodnotu atributu self.castka o číselnou hodnotu, jež je označena parametrem castka.
I když má atribut stejné jméno jako parametr, jedná se o dvě různé proměnné (resp. označení). Zatímco parametr castka zanikne ihned pro dokončení volání metody (je to lokální proměnná), atribut existuje tak dlouho jako objekt, k němuž patří (tj. volání metody určitě přežije).
Podívejme se na obrázek, který zobrazuje na co odkazují proměnné a atributy na začátku těla metody pridej_penize při jejím volání na prázdný objekt (odkazovaný globální proměnou prasatko) s přidávanou částkou 10.
prasatko.pridej_penize(10)
Jak lze vidět, objekt třídy odkazován dvěma proměnnými: globální proměnnou prasatko (platí v celém Jupyter notebooku) a prvním parametrem self. Uvnitř metody však používáme jen lokální proměnné a parametry, nebo uvnitř metody nevíme jaké existují globální proměnné resp. jaké objekty právě označují). Stačí vědět, že parametr self označuje objekt, s nímž máme pracovat. Objekt samotný odkazuje pomocí svých atributů na další dva podobjekty (ty jsou primárně dostupné jen přes svůj rodičovský objekt). Všimněte si, že výraz castka (použije se parametr castka) odkazuje jiný objekt, než výraz self.castka.
Úkol: Jak se obrázek změní po přidání/přičtení částky (tj. na konci metody)?
Úkol:: Jak vypadá systém proměnných po dokončení volání funkce (po návratu do globálního kontextu)?
Vraťme se ještě k metodě pridej_penize, která ještě před vlastní akci zvýšení uložené částky kontroluje přípustnost daného volání.
Nejdříve kontroluje, zda je předaný objekt třídy int. K tomu využívá vestavěnou metodu isinstance. Ta očekává dva parametry: objekt a třídu. Vrací True je-li objekt instancí dané třídy.
isinstance(2, int)
isinstance("Eldar", str)
isinstance(prasatko, Kasicka) # je prasátko kasičkou
Pokud není objekt předaný jako parametr třídy int, pak je vyvolána výjimka pomocí příkazu raise. Příkaz raise přeruší vykonávání programu a pokud program na výjimku nezareaguje (to zatím ještě neumíme) pak je i ukončen. Argumentem je nově vytvářený objekt výjimky (zde základní třídy Exception).
Úkol: Ověření na základě příslušnosti ke třídě
intnení optimální. Proč? Jak to napsat lépe?
Testování dalších dvou podmínek použitelnosti metody je jednoduché (kladnost přidané částky a stav nerozbytí).
Stejně tak jednoduchá je i implementace metody pro rozbití kasičky (kontrola a následná změna jediného atributu). Nynínovou třídu vyzkoušímě (a pro jistotu si vytvoříme novou kasičku).
k = Kasicka() # vytvoříme
k.pridej_penize(1_000_000) # přidáme pár korun
k.pridej_penize(1) # a ještě jednou
print(k.rozbij()) # rozbijeme a vypíšeme celkovou sumu
Musíme vyzkoušet i problematická (nepřípustná volání).
k = Kasicka()
try: # zkusíme to risknout (i když očekáváme výjimku)
k.pridej_penize(-10)
except Exception as e: # pokud nastane pak ji zachytíme
print(e) # a vypíšeme
Program skončil s výjimkou, ale kasička stále existuje (v notebooku existují všechny vytvořené, dokud notebook neuzavřeme)
k.rozbij()
k.pridej_penize(100)
V popisu požadovaných rysů kasičky jsem kladl velký důraz na to, že dokud kasičku nerozbijeme, nelze zjistit kolik peněz obsahuje. To však není evidentně pravda:
k = Kasicka() # vytvoříme novou
k.pridej_penize(100)
print(k.castka)
Nyní víme, že v kasičce je 100 korun, ale přesto jsme ji nerozbili.
Situace je však ještě horší.
k.castka = 0
To je vykradení kasičky za bílého dne! Může však být ještě hůře:
k.castka = -100
Nyní máme kasičku zadluženku (tj. kasička do níž lze vkládat např. služní úpisy). Podobně můžeme z rozbité kasičky udělat nerozbitou (je to lepší než oprava, nedokážeme totiž rozlišit zda je skutečně nerozbytá nebo znovuskříšená i s původním obnosem).
Jak je to vůbec něco takového možné?
Důvod je jednoduchý. V Pythonu je dáno pouze dohodou, co smíme dělat (co je košer) a co nikoliv (co je hucpe).
Obecná dohoda je taková, že můžeme volat pouze metody (ať již přímo či nepřímo). Ty musí být napsány tak, že nevznikne žádný nedefinovaný stav. Naopak atributy by neměly být vně metod dané třídy použité.
Tato dohoda vychází ze základního principu objektově orientovaného programování. S objekty lze interagovat pouze pomocí volání metod z veřejného rozhraní. Interní (datová) representace (tj. struktura atributů) je naopak skrytá.
Tento princip se označuje jako zapouzdření (angl. encapsulation). Cílem zapouzdření není skrýt (tajná) data, ale zabránit závislosti okolního kódu na struktuře a zamezit nepřípustné modifikaci interních dat (viz dluhová kasička).
Tento princip lze vysvětlit na náramkových hodinkách. Veřejným rozhraním je ciferník, je standardizovaný a běžně se nemění. Vnitřní mechanismus je skrytý. Pokud by byl snadno viditelný pak by vnější pozorovatel mohl obejít ciferník a využívat pouze vnitřní informace (např. pozice ozubených koleček). Tato (nechtěná) závislost by vedla ke zbytečně složitému algoritmu přístupu (kolečka nejsou primárně určena pro zobrazení času), neautorizovaným změnám (není překvapivé, že po vyjmutí pár koleček nemusí hodinky fungovat a navíc asi přijdeme i o záruku) avšak především by téměř znemožnila upgrade interního mechanismus hodinek (poN přehodu na digitální technologii, již nelze kolečka pro čtení hodinek využívat, naopak ciferník může zůstat beze změny).
Některé jazyky si zapouzdřenost vynucují automaticky, jiné mají nástroje jak si zapouzdřenost vynutit explicitně pro danou třídu, Python předpokládá, že programátoři jsou dospělí a tak vědí co činí (resp. co by činit neměli).
V některých případech však základní rozdělení na veřejné metody a skryté atributy nestačí. Relativně často se používají pomocné metody, které nejsou součástí veřejného rozhraní, ale používají se výhradně v ostatních metodách (v zásadě je to dodání další skryté vrstvy schované ještě pod veřejným rozhraním).
Tyto metody lze vyznačit tím, že se na začátku jejich identifikátoru použije jedno podtržítko. V tomto případě pomůže trochu i Python, který trochu zkomplikuje jejich volání z vnějšku (zabrání se tak nechtěnému použití). Signifikantnější je však neuvedení popisu metody ve veřejné dokumentaci (co není popsáno, jako by nebylo).
Na druhou stranu některé objekty nabízejí veřejné atributy. Zde je situace jasná. Atribut je veřejný jen tehdy, když je explicitně zmíněn v dokumentaci. Dokumentace také určuje, zda je pouze pro čtení (typičtější případ) nebo i pro zápis.
Na začátek vytvoříme třídu, jejíž instance budou representovat velmi jednoduché matematické objekty: uzavřené intervaly na množině reálných čísel.
Poznámka: V rámci programátorské praxe budete jen výjimečně vytvářet třídy modelující matematické pojmy (programování není matematika). Matematické pojmy však mají pro výuku základů programování dvě hlavní výhody: jsou jednoznačně definované (= všichni mají stejnou představu o příslušných objektech). a mohou být extrémně jednoduché (což pro modely reálných objektů naplatí).
Nejdříve si určíme jaké metody budou objekty nabízet (tím určíme jejich rozhraní a nedefinujeme jejich chování).
U takto jednoduchých tříd je nejjednodušší začít návrhem malého programu, který
bude využívat objekty naší nové třídy (program je prozatím nefunkční, neboť Python
neposkytuje třídu Interval).
interval1 = Interval(0.0, 1.0) # vytvoření intervalu konstruktorem
interval2 = Interval(0.5, 3.0) # vytvoření intervalu konstruktorem
print(str(interval1)) # objekt lze převést na text -> "<0, 1>"
print(interval2.length()) # je možno zjistit délku intervalu -> 2.5
prunik = interval1.intersection(interval2) # vrátí průnik jako nový interval
print(prunik) # -> <0.5, 1>
print(2.0 in prunik) # použití operátoru `in` -> False
Podívejme se detailněji na jednotlivé požadované metody.
Při vytváření nových objektů se volá speciální metoda, která se označuje jako konstruktoru. Tato metoda přijímá parametry, pomocí nichž musí tzv. inicializovat objekt. To znamená že musí:
V našem případě má konstruktor dva parametry, které určují dolní a horní mez intervalu (v tomto pořadí).
Otázkou je, co by měl konstruktor dělat v případě, že programátor zadá meze
v opačném pořadí tj. např. Interval(2,1).
Nabízí se tři základní možnosti:
Je zřejmé, že první (pštrosí) přístup není akceptovatelný. Vždy platí, že objekt by měl být po svým vytvoření v konzistentním stavu. V opačném případě jen oddalujeme problém. Pokud budou meze nesprávně nastaveny, pak nás nesmí překvapit, že délka intervalu bude záporná a funkční nebude ani interval pro hledání průniku (oba výpočty, lze samozřejmě upravit, tak aby fungovaly i s neplatnými mezemi, ale bude to přinášet hodně práce navíc).
Druhý přístup se jeví jako perspektivnější a navíc jako vstřícný k uživatelům. Obecně však platí, že byste se měli vyhnout "magickému" chování, a to především v případě, kdy slouží k zakrývání chyb či nekonzistencí. Můžete tak totiž nechtěně bránit včasné detekci chyb. Ty se tak mohou projevit až později a bohužel často s mnohem ničivějším účinkem.
Doporučit tak lze jen poslední dva přístupy. Tolerance s upozorněním však není tak široce podporována jako mechanismus výjimek (obecně není zřejmé, jak bude upozornění vypisováno). Proto pokud máte svobodu volby, využívejte mechanismu výjimek i za cenu, předčasného ukončení programu.
Na objekt intervalu bude možno volat obecnou vestavěnou funkci str. Tato
funkce by měla vrátit textovou representaci intervalu v podobě objektu řetězce.
Tato funkce by měla vrátit representaci určenou primárně pro člověka, neboť
se využívá primárně pro generování textových výstupů a pro ladění. Obecně
nemusí být převoditelná zpět na objekt (tj. nemusí obsahovat všechny informace).
V našem případě existuje standardní notace ve tvaru <D, Y>, kde D je dolní
mez a H mez horní.
Interval bude navíc podporovat i vestavěnou funkci len, která by měla (již podle
svého názvu) vracet délku intervalu tj. H – D.
Pomocí standardního zápisu volání metody nad objektem je implementována operace, která vrací průnik dvou intervalů (nad prvním je metoda volána, druhý je předán jako parametr). Výsledkem je nový objekt representující výsledný interval. Při návrhu této metody, je nutno zohlednit fakt, že dva intervaly mohou mít prázdný průnik. Jak se v tomto případě metoda zachová?
Nemůžeme vyhodit výjimku, neboť prázdný průnik je běžným výsledkem a nejedná se tudíž o výjimečný a tím méně chybový stav.
Dalším řešením je zavést objekt, který representuje prázdný interval. To však přináší další obtíže:
H >= D, pak interval obsahuje alespoň jeden bod a není tudíž prázdný) Bohužel v tomto případě není možné najít zcela uspokojivé a přitom jednoduché řešení. Pro naše účely zvolíme následující přístup:
Interval(). Navíc umožníme i jednoparametrický konstruktor (vytvářející
jednoprvkové intervaly). To sice není nezbytné usnadňuje to však použití, neboť
není nutné dvakrát opakovat totéž číslo (resp. tutéž proměnnou). <>len na prázdný objekt je nedefinované a proto bude vyhozena
výjimka. Pro testován, zda je interval prázdný je proto nutné implementovat
dodatečnou metodu isEmpty().Příklad použití metody isEmpty:
if prunik.isEmpty():
print("prazdný průnik")
Rozhraní už máme přesně definované a nyní tedy můžeme přejít k návrhu vnitřní implementace.
Základní interní intervalů representace je zřejmá:
dva atributy, z nichž jeden bude odkazovat
reálné číslo representující dolní mez (s jménem např. low) a druhý
reálné číslo representující
horní mez (high). Otázkou je však representace prázdného atributu.
Zde existují dvě možnosti: jednou je přidání dalšího atributu empty,
který bude nést
logickou hodnotu, signalizující prázdnost intervalu. Druhou možností je využití
nějaké neplatné kombinace dolní a horní meze (např. dolní meze větší než horní).
Každé z obou řešení má své nevýhody.
Přidání dalšího atributu a podobjektu
zvětší velikost objektu naší třídy (v našem případě minimálně o 9 bytů na 64-bitovém
systému). Navíc, v případě prázdného intervalu jsou atributy dolní a horní meze
nevyužité (mohou obsahovat cokoliv). Alternativně lze v případě prázdného seznamu
definovat jen atribut empty, což však může být matoucí (především pro programátory
jazyků, v nichž nelze měnit počet atributů na základě stavu objektů).
Použití neplatného stavu (= nepřípustné kombinace hodnot atributů) šetří paměť, je však náchylnější k chybné interpretaci nebo k chybnému použití. Pokud je třída využívána dlouhodobě či je vytvářena větším týmem může dojít k dezinformacím (nepřípustný stav je chybně interpretován).
V současnosti, kdy rozsah paměti neni omezujícím faktorem je lepší preferovat explicitní representaci oproti (zne)užití neplatných hodnot. Toto pravidlo však nemá absolutní platnost.
Nyní už můžeme přistoupit k implementaci (třídu je nutné implementovat v v jediné buňce):
class Interval: # hlavička třídy
def __init__(self, low=None, high=None):
self.empty = low is None and high is None # prázdný interval
self.low = low if low is not None else high
self.high = high if high is not None else low
def __str__(self):
return f"<{self.low}, {self.high}>" if not self.empty else "<>"
def length(self):
if self.empty: # je-li prázdný
raise ValueError("Undefined operation for empty interval")
# pak vyhoď výjimku
return self.high - self.low
def intersection(self, other):
if self.empty or other.empty:
# průnikem dvou intervalů, z nichž je alespoň jeden prázdný
return Interval() # je prázdný interval
low = max(self.low, other.low)
high = min(self.high, other.high)
if low <= high:
return Interval(low, high)
else:
return Interval()
def __contains__(self, x):
if self.empty:
return False
else:
return self.low <= x and x <= self.high
Vyzkoušejme nejdříve náš návrh v podobě programu (je zbytečné popisovat třídu, která nefunguje):
interval1 = Interval(0.0, 1.0) # vytvoření intervalu konstruktorem
interval2 = Interval(0.5, 3.0) # vytvoření intervalu konstruktorem
print(str(interval1)) # objekt lze převést na text -> "<0, 1>"
print(interval2.length()) # je možno zjistit délku intervalu -> 2.5
prunik = interval1.intersection(interval2) # vrátí průnik jako nový interval
print(prunik) # -> <0.5, 1>
print(2.0 in prunik) # použití operátoru `in` -> False
Definice jednotlivých metod lze ve většině případů snadno interpretovat. Pro přetypování na řetězec je nutno definovat speciální (dunder) metodu __str__ (odpovídá příslušné vestavěné funkci str, používané ke konstrukci řetězců). Testování, zda je hodnota obsažena (= operátor in) se definuje pomocí speciální metody
__contains__ (to už není tak přímočaré, jména jednotlivých dunder metod lze nalézt např. na stránkách Data model).
Jediná metoda, která vyžaduje trochu přemýšlení je metoda intersection, neboť nalezení mezí průniku není zcela přímočaré. V každém případě je nutné si na papíře nakreslit jednotlivé případy protínajících se (a neprotínajících se) intervalů. Červeně je self interval (na něj se metoda volá) a modře other (je předán jako parametr).
Z obrázku je zřejmé, že dolní mez výsledného průniku je vždy větší z obou původních mezí tj. max(self.low, other.low) a horní mez menší z původních horních tj. min(self.high, other.high). Pokud je výsledná dolní mez větší než horní, je průnik prázdný.
Poznámka: Všimněte si, že objektové programování si vynucuje dosti asymetrický pohled na realitu. Vždy je tu hlavní objekt (adresát metody) a popřípadě další méně důležité objekty, které mohou být nezbytné, ale prostě hrají jen druhé housle. Pomocné objekty jsou předávány jako parametry. Někdy je tato asymetrie jen čistě umělá, jako je tomu zde (oprerace průniku je symetrická a proto nezáleží, jaký objekt je adresátem (
self) a jaký jen parametrem (other). Ve většině případů je však asymetrie zřejmá.
seznam.append(položka); # hlavním objekt je je seznam (jen seznam změní stav)
pokladnicka.pridej_penize(castka); # pokladnicka je opět hlavním objektem
castka.pridej_mne_do(pokladnicka); # opačné chápání je podivné (mění se jen parametr, a jméno je dlouhé)
Tato asymetrie, je obdobou asymetrie já versus ostatní svět, která je vlastní lide. Proto je někdy vhodné namísto identifikátoru
selfvyužívat zájménojáčimůj, které navíc koresponduje se zapouzdřením (ukrývám své já před světem). Stačí se jen ztotožnit s objektem. Lze tedy například říct: "dolní mez průniku je rovna maximu mé dolní meze a jeho dolní meze."
Úkol: Vyzkoušejte program i pro jiné vstupní intervaly, především otestujte situace, kdy je některý z intervalů prázdný resp. jednoprvkový.
Typickou úlohou v geograficky orientovaných informačních systémech je hledání nejbližších cest mezi dvěma místy, s využitím mapových podkladů. To je samozřejmě dosti komplexní problém a proto, si ho zjednodušíme.
Budeme proto hledat spojení jen mezi omezeným počtem míst (řekněme třeba mezi městy), jejichž (nejkratší) přímou vzdálenost zadáme ručně (pomocí volání metody).
Nejdříve zkusíme navrhnout rozhraní objektů třídy Place (tato třída může být používána v reálných aplikacích, takže volíme anglické identifikátory):
add_connection, parametrem je cílové místo a vzdálenostdistance, parametrem je cílové místo. Metoda najde nejkratší spojení (to může procházet více místy) a vrátí jeho vzdálenost. Zeměpisná poloha předávaná v konstruktoru se nepoužívá pro výpočet vzdáleností (vzdálenosti jsou v našem modelu skutečné dopravní vzdálenosti, nikoliv vzdušné vzdálenosti). Použijeme ji později pro nakreslení jednoduché mapy.
Obě metody pracující se spojením očekávají jako parametr objekt třídy Place, representující cíl. Obě metody tak pracují se dvěma objekty třídy Place. Je to jednak objekt dostupný v metodě pomocí parametru self (adresát metody = objekt, na nějž je metoda volána) representující počátek spojení, jednak objekt předaný jako běžný parametr representující cíl spojení.
Tj. předpokládáme-li, že proměnné decin a teplice označují objekty příslušných měst, pak bude lze využít následujících volání:
decin.add_connection(teplice, 21) # 35km z Děčína do Teplic
print(decin.distance(teplice)) # vypíše 35km
Navržené rozhraní má určitý nedostatek, který jste už možná zaznamenali. Pokud existuje přímé spojení z bodu A do B, pak s vysokou pravděpodobností existuje i stejně dlouhé spojení v opačném směru (většina dopravních spojení je obousměrná). Bylo by tedy užitečné, kdyby se při přidání spojení automaticky přidalo i spojení opačným směrem. Na druhou stranu výjimečně může existovat i přímé spojení jen jedním směrem, resp. obě spojení mmohou mít různou délku.
Řešením je přidání dalšího parametru bidi (relativně běžně používaná zkratka za bidirectional) k metodě add_connection. Pokud bude mít hodnotu True pak se automaticky vloží i spojení opačným směrem (při False nikoliv). Navíc zde můžeme použít implicitní hodnoty parametru. Pokud ji nastavíme na True, pak se při běžném použití nebude vůbec uvádět (a použití bude identické s výše uvedeným příkladem). Pokud bude výjimečně potřeba zadat jednodměrné spojení, bude volání o něco delší, ale stále přehledné (hlavně použijeme-li pojmenovaný parametr).
usti.add_connection(teplice, 21, bidi=False) # umělý příklad (spojení není ve skutečnosti jednosměrné)
Po návrhu rozhraní, musíme navrhnout i datovou representaci objektů míst. Jednoduché je to u jména a polohy místa, neboť každé místo bude míst jen jedno jméno, jednu zeměpisnou šířku a jednu zeměpisnou délku. Tj. stačí vytvořit tři atributy objektu, které budou označovat/odkazovat příslušné hodnoty (řetězec a dvě čísla třídy double).
Složitější je representace přímých spojení. Každé místo může mít totiž obecně neomezený počet spojení. Navíc spojení není jednoduchá hodnota, ale minimálně hodnoty dvě (identifikace cílového místa a vzdálenost, počáteční místo je dáno umístěním dané informace). Musíme proto využít nějaké kolekce.
Klíčovou kolekcí v Pythonu je seznam. Proto ověříme nejdříve jeho použitelnost.
Za prvé potřebujeme ukládat dvojice hodnot. To lze zařídit, neboť položkami seznamu mohou být (vnořené) seznamy resp. jiné kolekce. Pokud bychom takový seznam vytvářeli ručně, pak by například mohl mít tento tvar (proměnné označují příslušné objekty):
spojeniZDecina = [[usti, 15], [teplice, 21], [rumburk, 43], ...] # seznam seznamů
Namísto vnořených seznamů lze využít tzv. n-tice (angl. tuple), což je uspořádaná kolekce optimalizovaná pro malý počet prvků, která je navíc nemodifikovatelná (po vytvoření do ní nelze přidávat prvky, či prvky zaměňovat). N-tice se na rozdíl od seznamů uzavírají do kulatých závorek (ty lze v některých případech vynechat, v následujícím zápise to však možné není, jistě poznáte proč).
spojeniZDecina = [(usti, 15), (teplice, 21), (rumburk, 43), ...] # seznam dvojic
Tato implementace se jeví jako rozumná. Není přirozeně zcela dokonalá. Pokud například chcete v našem seznamu vyhledat vzdálenost do Rumburku, musíme postupně procházet jednotlivé prvky a hledat ten, jehož první položka je rovna hledanému místu. Teprve pak můžeme vrátit příslušnou vzdálenost. Můžeme si na však to připravit funkci (parametr key je hodnota kterou hledáme, v prohledávaných dvojicí musí být vždy uváděna jako první):
def get_value(listOfpairs, key):
for k, v in listOfpairs: # postupně procházíme dvojice
if k == key:
return v
return None # pokud nic nenajdeme vrátíme `None`
Novinkou kódu je uvedení dvojice proměnných na místě řídící proměnné cyklu for. Je to obdoba paralelního přiřazení. Hodnota dvojice z procházeného seznamu je přiřazena do dvojice proměnných (i kolem této dvojice je možno napsat závorky stejně jako u n-tic).
Alternativně lze dvojice ukládat pomocí slovníku (dict). Pro malý počet dvojic (řádově jednotky) to však není příliš efektivní (slovník je optimalizován pro větší množiny).
Třídu můžeme implementovat přímo v Jupyter notebooku, není to však pohodlné. Kód třídy musí být celý obsažen v jediné vstupní buňce, což je při jeho rozsahu (několik desítek řádek) nepřehledné.
Proto zkusíme třídu naprogramovat v editoru pycharm. Podle návodu v první kapitole vytvořte projekt cities (jméno projektu nehraje v Pythonu žádnou roli, slouží pouze pro organizaci skriptů v editoru). V rámci projektu vytvořte soubor cities.py. Jméno souboru musí mít příponu py. Vlastní jméno souboru (bez přípony) se používá jako jméno modulu při importování (takže je vhodné volit krátké, ale přitom dostatečně jednoznačné jméno).
Do editoru vložte nejdříve tento text (poznámky přepisovat nemusíte):
class City:
def __init__(self, name): # konstruktor
self.name = name # do objektu uložíme jméno města
self.connections = [] # seznam spojení z města je zatím prázdný
def add_connection(self, target, distance, bidi=True):
self.connections.append((target, distance))
# přidáme dvojici (město, vzdálenost) do seznamu
if bidi: # je-li spojení obousměrné
target.add_connection(self, distance, bidi=False) # vložíme i opačný směr
Úkol: Co se stane, pokud by se v implementaci metody add_connections vkládalo opačné spojení voláním metody add_connection s implicitní hodnotou parametru bidi (tj. s hodnotu True).
Před konečnou implementací nám zbývá promyslet implementaci metody distance. Implementace není zcela jednoduchá neboť vyžaduje alespoň základy algoritmického myšlení tj. abstrakce univerzálně použitelných postupů pro řešení matematicky definovaných problémů.
Jak najdeme nejkratší spojení do vzdáleného města v reálném životě. Pokud máme k dispozicí mapu pak většinou stačí jediný pohled a danou cestu nalezneme (pokud bereme v potaz jen hlavní klíčové silnice tj. v našem případě silnice druhé a vyšší třídy). Můžeme se sice splést (například v hledání cesty z Ústí do Teplic nemusí být zřejmé zda jet přes Chlumec nebo přes Řehlovice), ale chyba bude jen malá (v praxi má větší vliv kvalita silnic a aktuální dopravní situace).
Tento přístup však nelze pro náš účel použít, nemáme totiž žádné informace o vzájemné pozici měst ani globální pohled na strukturu silnic (odkud, kam a přes jaká města silnice vedou).
Zkusíme proto strategii nepříliš inteligentních, ale vytrvalých a partogeneticky se rozmnožujících mravenců. Navíc všichni tito mravenci běhají stejnou konstantní rychlostí (třeba kilometr za den).
Nejdříve položíme jediného mravence do počátečního města. Protože je jejich partogenetické rozmnožování úžasně rychlé, je pro něj záležitostí zanedbatelného okamžiku zplodit tolik nových mravenců, kolik spojení vychází z počátečního města. Nově zrození mravenci se ihned rozbíhají po těchto spojích. Mravenec rodič (tj. defacto praotec) zůstává v počátečním městě, kde (dožívá) ve štěstí a blahobytu (rozmnožování mu bohužel sebralo dost sil).
Pokud děti a případně i vzdálenější potomci (vzdálenější ve smyslu příbuzenství) dorazí do dalšího města pak jejich chování záleží na dvou okolnostech.
Pokud tam dorazí jako první a město je cílové, pak on a jeho předci vyhráli (štafetový) běh a my známe nejkratší vzdálenost mezi počátečním a cílovým městem (je dáno časem jejich vítězného doběhu, neboť rychlosti jsou konstantní a čas na rozmnožování je zanedbatelně malý). Pokud si mravenci-předci předávali i jména měst, kterými prošli, pak známe i nejkratší cestu.
Pokud mravenec dorazí jako první a město není cílové, pak se zachová jako praotec. Porodí tolik mravenců, kolik je spojení z daného města a ihned je na tato spojení vyšle. Sám zůstává a dožívá v naději, že některý z jeho potomků vyhraje (jistotu však samozřejmě nemá).
Nejsmutnější je situace, kdy mravenec do některého z měst nedorazí jako první (což se pozná snadno, neboť ve městě stále dožívá první pokořitel daného města). V tomto případě je totiž zřejmé, že on resp. jeho případné potomci již nemohou vyhrát (potomci prvního mravence již vyrazily dávno předtím). Nezbývá mu nic jiného než spáchat sebevraždu (samozřejmě bez jakéhokoliv rozmnožování).
Algoritmus běžně končí tím, že první mravenec dorazí do cílového města. Další sledování mravenců je pak samozřejmě zbytečné (nakonec všichni zbývající stejně spáší sebevraždu). Pokud je však cílové město z počátečního města nedosažitelné (což by se ve Střední Evropě stávat nemělo) končí algoritmus jinak (jedním z konkurenčních výhod dobrého programátora je schopnost vidět i tyto okrajové následky).
Úkol: Jak končí algoritmus mravenců-průzkumníků v případě nedosažitelnosti cílového města?
Tento algoritmus je relativně snadno pochopitelný, nelze jej však bohužel přímo realizovat v běžném počítači. V jednom okamžiku totiž mezi městy pobíhá větší počet mravenců, z nichž každý by pro svou programovou representaci vyžadoval nezávislý procesor, neboť jejich činnosti tj. především rozmnožování musí být zcela nezávislé na ostatních mravencích (a to i v případě, že by přesun do města či dožití by byli representovány pouze čekáním na určitý čas bez nutnosti využití procesoru). Současné počítače sice běžně obsahují více procesorů (jader). Jejich počet je však fixní a relativně malý (bylo by podivné, pokud byste byli nuceni kupovat počítač s desítkami procesorů pro nalezení nejkratší cesty v dopravním systému mezi několika městy).
Algoritmus tak musíme trochu upravit. Namísto velkého množství rozplozujících se mravenců, použijeme jen jednoho, který však musí mít schopnost teleportace a musí být gramotný.
Tento mravenec je na začátku v počátečním městě. Zjistí si všechna přímá spojení, avšak namísto aby se do některého z nich vydal si je zapíše do svého turistického pořádníku. Napíše si odkud by se měl vydat a kam by měl dorazit. Na pořádí záznamu nezáleží.
Navíc si vytvoří tabulku, v níž má u každého města poznamenanou nejmenší ověřenou vzdálenost od města startovního. Na začátku obsahuje u startovního nulu a u všech ostatních nekonečno (náš mravenec umí dokonce i ležatou osmičku).
Nyní vše běží ve velké smyčce. Mravenec se podívá do svého turistického pořadníku a teleportuje se do místa, ze kterého vychází první přímé spojení v pořadníku. To spojení projde a tím zjistí vzdálenost. Nezapomene samozřejmě projité spojení odstranit z pořadníku.
Poté se podívá se do tabulky. Sečte minimální vzdálenost města, odkud vycházelo právě projité přímé spojení, s ujitou vzdáleností. Pokud je tato vzdálenost menší než aktuální minimální vzdálenost města, v němž se právě nachází, pak najde všechna spojení vycházející z tohoto místa a zapíše si je do pořadníku (opět dvojici odkud a kam). Navíc přepíše minimální vzdálenost aktuálního místa v tabulce. V opačném případě (součet vzdáleností je větší) nemusí v tomto kroku provádět nic.
Nyní se vrátíme znovu na počátek smyčky. Pokud je pořadník prázdný pak algoritmus končí. Jinak se mravenec teleportuje do místaze kterého vychází první přímé spojení v pořadníku. To spojení projde a tím zjistí vzdálenost. Nezapomene samozřejmě projité spojení odstranit z pořadníku.
Poté se podívá se do tabulky. Sečte minimální vzdálenost města, odkud vycházelo právě projité přímé spojení, s ujitou vzdáleností. Pokud je tato vzdálenost menší než aktuální minimální vzdálenost města, v němž se právě nachází, pak najde všechna spojení vycházející z tohoto místa a zapíše si je do pořadníku (opět dvojici odkud a kam). Navíc přepíše minimální vzdálenost aktuálního místa v tabulce. V opačném případě (součet vzdáleností je větší) nemusí v tomto kroku provádět nic.
Nyní se vrátíme znovu na počátek smyčky. Pokud je pořadník prázdný pak algoritmus končí. Jinak se mravenec teleportuje do místa, ze kterého vychází první přímé spojení v pořadníku, ....
I když to na první pohled nemusí být zřejmé cyklus nakonec skončí (mravenec si od jisté chvíle již nezapíše nové spojení do svého pořadníčku a ten se tak nakonec vyprázdní). Navíc určitě prošel i minimální cestou, Opakuje totiž všechny cesty svých vzorů, kteří používali strategii "nas mnogo" (a v případě potřeby nás může být ještě víc). Minimální vzdálenost najde v tabulce v řádce cílového města. V nejhorším případě tam nalezne ležatou osmičku (pokud cílové město není ze startovního dosažitelné).
Algoritmus lze samozřejmě ještě zjednodušit. Pokud známe předem délku všech přímých spojení, pak je zbytečné aby se mravenec teleportoval. Vše snadno zjistí doma pomocí tužky a papíru (jen připisuje a ruší záznamy ve pořadníku a případně snižuje hodnoty v tabulce měst). To však může provádět i počítač a to pravděpodně značně rychleji. Proto můžeme eliminovat i onoho geniálního zástupce řádu blanokřídlých.
Navíc lze v tomto případě algoritmus i mírně optimalizovat, neboť jste si jistě všimli, že mravenci procházeli i spojení, u nichž bylo již v okamžiku startu jasné, že nebudou nejrychlejší (koncové město spojení bylo dosaženo již před startem průchodu spojením). Speciálním případem těchto zbytečných průchodů je návrat do místa, z něhož vyšel předek (resp. předci). Tyto případy lze za pomoci tabulky aktuálních minimálních vzdáleností snadno eliminovat, neboť je nemusíme zařazovat do pořadníku.
Dalším vylepšením je eliminace počátečního místa spojení v položce pořadníku, namísto toho stačí uvádět jen vypočtenou minimální vzdálenost, která může (ale nemusí) nahradit minimální vzdálenost v tabulce.
Poslední navržený algoritmus vyžaduje dvě pomocné struktury.
pořadník: seznam dvojic naplánovaných průchodů spojeními (koncové město spojení, a nabízená minimální délka spojení). Do seznamu/pořadníku přidáváme na konec a vyjímáme (zpracování+smazání) na začátku. Seznam do něhož přidáváme prvky na konci a vyjímáme je postupně na druhé straně (= počátku) se běžně označuje jako fronta).
tabulka: záznam o minimální vzdálenosti pro každé město. Lze použít seznam dvojic (jméno města, minimální vzdálenost), ale to je dost nepohodlné v případě změny minimální vzdálenosti. V tomto případě by bylo nutné původni dvojice odstraňovat. Naštěstí Python podporuje velké množství různých specializovaných kolekcí, z nichž si vybere téměř každý.
Nám se bude hodit kolekce collections.defaultdict, která efektivně ukládá dvojice (klíč, hodnota), včetně rychlého hledání hodnot podle klíče a jejich modifikace (v našem případě je klíčem město a hodnotou aktuální minimální vzdálenost). Navíc (na rozdíl od běžného slovníku) podporuje implicitní hodnotu pro klíče, které se ve slovníku nenachází. To se nám hodí, neboť náš algoritmus předpokládá, že město, které ještě nebylo dosaženo při hledání nejkratší cesty má vzdálenost nekonečnou (třída float umožňuje representovat i nekonečné hodnoty).
Slovník s implicitní hodnotou si vyzkoušíme na jednoduchém příkladě. Klíčem bude jméno studenta (řetězec), hodnotou informace o tom zda splnil podmínky zápočtu. Pokud jméno studenta ve slovníku není, pak se předpokládá, že zápočet nemá.
from collections import defaultdict
def implicit_value(): # tzv. tovární funkce produkující implicitní hodnoty
return False
zapocty = defaultdict(implicit_value) # vytvoření slovníku (s registrací tovární funkce)
zapocty["Novák"] = True # přidání dvojice (jméno, výsledek)
zapocty["Nejezchleba"] = False # přidání další dvojice
print( zapocty["Novák"]) # vypíše uloženou hodnotu pro klíč "Novák", což je True
print( zapocty["Nejezchleba"]) # pro jistotu vyzkoušíme i druhou uloženou hodnotu
print( zapocty["Snedlditetikasi"]) # vypíše hodnotu vracenou tovární metodou (= False)
Úkol: Vyzkoušejte, nahradit slovník defaultdict běžným slovníkem dict. (konstruktor běžného slovníku je vestavěný a nemusí mít žádné parametry).
Nyní se vraťme k implementaci metody distance třídy city. Implemantace výše popsaného algoritmu je relativně přímočará.
%%writefile cities.py
import math
from collections import defaultdict
def infinity_factory(): # tovární funkce pro vracení nekonečen
return math.inf
class City:
def __init__(self, name): # konstruktor
self.name = name # do objektu uložíme jméno města
self.connections = [] # seznam spojení z města je zatím prázdný
def add_connection(self, target, distance, bidi=True):
self.connections.append((target, distance))
# přidáme dvojici (město, vzdálenost) do seznamu
if bidi: # je-li spojení obousměrné
target.add_connection(self, distance, bidi=False) # vložíme i opačný směr
def distance(self, target):
# pořadník s plánem přesunu do startovního města (=self)
waiting_list = [(self, 0)]
# prázdná tabulka (nekonečné vzdálenosti)
mindist = defaultdict(infinity_factory)
while waiting_list: # dokud není pořadník prázdný
# vyjme a vrátí první dvojici (další testovaný cíl, dosžitelná vzdálenost)
goal, sum_distance = waiting_list.pop()
# je-li dosažitelná vzdálenost menší než aktuální minimální
if sum_distance < mindist[goal]:
# nová minimální vzdálenost je rovna dosažitelné
mindist[goal] = sum_distance
# projdeme spojení do okolních měst (z města `goal`)
for next_goal, next_distance in goal.connections:
# spočítáme dosažitelnou vzdálenost do okolních měst
next_sum_distance = sum_distance + next_distance
# je-li menší než aktuálná minimální
if next_sum_distance < mindist[next_goal]:
# pak přidáme spojení do pořadníku
waiting_list.append((next_goal, next_sum_distance))
# a nakonec vrátíme minimální vzdálenost cílového města
return mindist[target]
Po zapsání celého kódu do třídy City do souboru cities.py, můžeme do souboru připsat i malý testovací kód.
from cities import City
if __name__ == "__main__":
usti = City("Ústí nad Labem")
teplice = City("Teplice")
decin = City("Děčín")
most = City("Most")
lovosice = City("Lovosice")
usti.add_connection(teplice, 21)
usti.add_connection(decin, 24)
decin.add_connection(teplice, 35)
teplice.add_connection(most, 27)
lovosice.add_connection(usti, 21)
lovosice.add_connection(teplice, 25)
lovosice.add_connection(most, 36)
print(decin.distance(lovosice))
Podmínka na začátku testovacího kódu je typickým pythonským ideomem. Pokud je soubor přímo vykonáván (tj. je hlavním programem), pak standardní proměnná __name__ obsahuje řetězec __main__ (v Pythonu si opravdu užijete podtržítek). Proto se vykoná i náš testovací kód s vytvořením čtyř měst a definicí jejich přímých spojení. Nakonec program vypíše vypočtenou vzdálenost mezi Lovosicemi a Děčínem (je to 45km).
Pokud však soubor použijeme jako modul (tj. budeme ho importovat z jiného kódu), pak proměnná __name__ obsahuje jméno modulu (tj. zde city) a testovací kód se neprovede (což je správně neboť import má jen zavést nové třídy, funkce apod. nikoliv provádět nějaký podivný kód s nic neříkajícím textovým výstupem).
Jak již bylo řečeno náš skript lze importovat do jiných skriptů resp. Jupyter notebooku, pomocí příkazu import
import cities
Ne vždy se to však podaří. Důvodem je skutečnost, že Python hledá moduly jen v některých adresářích. V zásadě se jedná o tři skupiny adresářů:
Seznam prohledávaných adresářů lze získat v proměnné sys.path (nutno importovat standardní modul sys).
import sys
sys.path
Modul lze tak zpřístupnit:
První přístup je vhodný, když chceme modul využívat dlouhodobě. Druhý se hodí jen pro krátkodobé testování (seznam cest k modulům se obnovuje po každém novém spuštění Pythonu).
Úkol: Vyzkoušejte import vyýše uvedeného modulu v PyCharm (zkuste více řešení, tj. umístění ve projektovém adresáři, umístení do standardní cesty, rozšíření
sys.path)
Úkol: Vytvořte modul(skript) definující třídu representující přímku pomocí obecné rovnice ve tvaru
ax + by + c = 0. Konstruktor přímky očekává parametrya,bacobecné rovnice.Metoda
normalvrací normálový vektor přímky jako dvojici (tuple).Implementujte také metodu
p1.parallel_to(p2), která zjistí vrátíTrue, pokud jsou přímky representované objekty s proměnnýmip1ap2rovnoběžné. Podobná metodap1.perpendicular_to(p2)testuje kolmost přímek.Modul by měl mít jméno
geometry, třída pak jménoLine. Vyzkoušejte modul implementovat a v notebooku ověřte jeho funkčnost. Pokud modul vytváříte vPycharmpoužijte pro něj nový projekt (na jménu nezáleží, zvolte například takégeometry).
Modul geometry je ještě nutné otestovat:
import sys
sys.path.append("/home/fiser/PycharmProjects/geometry")
from geometry import Line
x = Line(2,5,6)
y = Line(3,2,0)
print(x.parallel_to(y))
print(y.perpendicular_to(x))
x = Line(2, 0, 8)
y = Line(5, 0, -1)
print(x.parallel_to(y))
print(y.perpendicular_to(x))
Práce s vektory pomocí n-tic nebo seznamů (v úkolu byly pro normálové vektory využity dvojice), je dost komplikovaná a nepřehledná, neboť Python nepodporuje ani základní vektorové operace (sčítání, skalární součet, apod.). V tomto případě je vhodné využít knihovnu NumPy, která se na manipulaci s vektory a vicedimenzionálními poli zaměřuje.
Datový typ je jeden ze ze základních pojmů ve světě programovacích jazyků a to již od jejich počátků.
Datový typ je množina hodnot, pro níž jsou definovány operace, které s příslušnými hodnotami operují.
Z hlediska vymezení datového typu jsou proto rozhodující tři (vzájemně) související charakteristiky:
representace: jak jsou v paměti dané hodnoty representovány (kolik bytů zaujímají, jak jsou interpretovány jednotlivé byty či jejích skupiny)
množina přípustných hodnot (stavů): všechny stavy (tj. přípustné) hodnoty dasného datového typu
specifikace (podporovaných) operací
Ve většině nízkoúrovňových jazyků jsou klíčové první dvě charakteristiky. Například ve většině implementací jazyka C je definováno, že hodnoty typu int zaujímaji čtyři nebo osm (souvislých) bytů, které jsou interpretovány jako binární representace danách čísel v tzv.
dvojkovém doplňku (https://en.wikipedia.org/wiki/Two%27s_complement). Počet bytů a především
jejich počet závisí na hardwarové či softwarové platformě, ale je pro danou platformu fixní a jednoznačně definuje množinu přípustných hodnot (od $-2^{n-1}$ do $2^{n-1}-1$).
Na druhou stranu množina přípustných není tak jasně určena. Jejím jádrem jsou přirozeně běžné číselné operace. Hodnoty typu int však lze používat i jako adresy paměti nebo jako hodnoty výčtových typů.
U vysokoúrovňových jazyků je hlavním aspektem množina operací (ta je v Pythonu určena primárně pomocí speciálních metod s dvěma podtržítky) a až poté interní representace či z ní vyplývající množina přípustných hodnot.
Lze to ilustrovat na příkladě stejnojmenné třídy (třída je jedinou representací datového typu v Pythonu). Operace s celými čísly jsou jasně definovány rozhraním třídy a všechny souvisí s její (celo)číselnou interpretací. Interní representace není všeobecně známa (a nemusí téměř žádného programátora zajímat). Rozsah (a tím množina přípustných hodnot) není určena a je dána pouze velikostí dostupné paměti.
Typ int je proto typickým zástupcem tzv. abstraktního datového typu tj. datového typu, který je primárně určen přípustnými operacemi a sekundárně i množinou přípustných hodnot (ty jsou dány možnými výsledky přípustných operací nikoliv omezeními representace).
Poznámka: Abstraktní datové typy nabízejí velmi podobný koncept jako třídy objektově orientovaného programování, nejsou však zcela zaměnitelné. Základní rozdíly:
- abstraktní datové typy jsou abstraktnější koncept, který není vázán na konkrétní realizaci (dají se vytvářet i v jazycích bez tříd a bez přímé podpory OOP)
- objektové programování zavádí i koncepty, které nejsou součástí ADT (dědičnost, apod.)
- abstraktní datové typy jsou využívány v teorii algoritmů (tj. v matematické popisu)
Abstraktní datové typy, lze v Pythonu implementovat buď pomocí modulů nebo (snadněji a přirozeněji) pomocí tříd.
Peo začátek si vytvoříme abstraktní datový typ pro modulární aritmetiku na množině $\mathbb{Z}_7$, kde. Viz https://cs.wikipedia.org/wiki/Modul%C3%A1rn%C3%AD_aritmetika.
class Z7:
def __init__(self,i):
assert 0 <= i < 7, "Number out of range"
self.i = i
def __add__(self, other): # speciální metoda volaná při aplikaci operace sčítání
return Z7((self.i + other.i)%7)
def __str__(self): # aby bylop mořno hodnoty vypisovat (převádí hodnotu na číselnou repr.)
return str(self.i)
x = Z7(3)
y = Z7(5)
print(x+y)
Úkol: Vypište sčítací tabulku pro všechny přípustné dvojice sčítanců.
Úkol: Doplňte další operace modulární aritmetiky): násobení, opačná hodnota (unární minus, vrací takové číslo, že jeho přičtením získáme 0), resp. odečítání (= přičtení opačného čísla).
Typickým příkladem abstraktních datových typů jsou kolekce například seznam a slovník.
Otázka: Jaké operace definují seznam a množinu. Jaké mají minimální požadavky na časovou složitost?
Klasickým ADT (abstraktní datový typ) je dvojice dvou kolekcí s jednoduchým, ale velmi užitečným rozhraním — zásobník a fronta (stack a queue). Python tyto ADT přímo nepodporuje, lze je však snadno realizovat pomocí vestavěných kolekcí (pro frontu modul, který však implementuje speciální verzi fronty vhodnou pro paralelní programování).
Zásobník (angl. stack) je datová struktura, která ve své klasické podobě podporuje jen dvě základní operace pro vkládání a vyjímání prvků a jednu (nepovinnou ale užitečnou) vlastnost, která testuje zda je zasobník prázdný:
push: vkládá do zásobníku libovolný objekt (ta je parametrem metody)pop: vyjímá ze zásobníku naposledy vložený objekt. Pokud je zásobník prázdný, tak je chování této operace nedefinované (metody by mělě vyhodit výjimku)isEmpty: vrací true, pokud je zásobník prázdný (což platí i bezprostředně po jeho vytvoření), jinak false.Pro zásobník je tedy typické že poslední vložená hodnota je jako první vyjímána a proto se běžně označuje zkratkou LIFO (last in first out).
Implementace zásobníku v Pythonu je snadná, neboť příslušné metody jsou již podporovány nad seznamem. Vytvořením vlastní třídy, ale zakryjeme implementaci a zpřístupníme jen ty správné opaerace (pod klasickými jmény).
class Stack:
def __init__(self):
self.data = [] # zásobník je prázdný
def push(self, value): # vložení
self.data.append(value) # přidáme na konec seznamu
def pop(self): # vyjmutí posledního vloženého
return self.data.pop() # vyjímá poslední prvek = naposledy vložený
def isEmpty(self):
return not self.data
# seznam se v kontextu, kdy je očekávána log. hodnota vyhodnotí na `true` je-li neprázdný
s = Stack()
s.push(2)
s.push(5)
print(s.isEmpty())
print(s.pop())
print(s.pop())
print(s.isEmpty())
Úkol: Ověřte, jak se chová metoda
popnad prázdným zásobník (opravte definici zásobníku, tak aby vznikla výjimka se lepší sémantikou tj. lepším popisem důvodu vzniku výjimky)
Implementace je efektivní, neboť všechny metody mají časovou složitost $O(1)$.
Kromě zákaldních metod implementace zásobníku podporují i další (výsledkem je mírně pozměněné ADT, které lze označit jako rozšířený zásobník).
top vracející horní (= naposledy vloženou) hodnotu, aniž by ji vyjmula ze zásobníkulength vracející počet aktuálně uložených prvků (někdy je implementována namísto vlastnosti isEmpty).Úkol: Implementujte tyto dodatečné operace (vlastnost
lengthpomocí speciálné metody__len__, která je volána při použití vestavěné metodylen).
Fronta (angl. queue, výslovnoat kjú) je abstraktní datový typ, jejíž rozhraní je podobné zásobníku. Operace pro vkládání se běžně nazývá enqueue, operace vyjímání dequeue (názvy nejsou tak zažité jako u zásobníku). Hlavním rozdílem je sémantika operace vyjímání, která vyjímá první (ještě nevyjmutou hodnotu), tj. realizuje strategii FIFO (first in first out).
Fronta tudíž skutečně odpovídá frontám známým z obchodů apod. I ve frontě je první obsloužen ten, kdo jako první přišel.
Implementace fronty v Pythonu není tak přímočará jako u zásobníku. Naivní implementace je obdobná zásobníku (jedinou změnou kromě změn jmen metod je jiná implementace vyjímání).
class Queue:
def __init__(self):
self.data = [] # fronta je prázdná
def enqueue(self, value): # vložení
self.data.append(value) # přidáme na konec seznamu
def dequeue(self): # vyjmutí posledního vloženého
return self.data.pop(0) # vyjímá první prvek = nejdříve vložený
def isEmpty(self):
return not self.data
# seznam se v kontextu, kdy je očekávána log. hodnota vyhodnotí na `true` je-li neprázdný
q = Queue()
q.enqueue(1)
q.enqueue(2)
print(q.isEmpty())
print(q.dequeue())
print(q.dequeue())
print(q.isEmpty())
Implementace funguje, ale časová složitost operace dequeu není optimální, neboť je rovna $O(n)$, kde $n$ je v tomto případě rovno průměrné délce fronty (= aktuálnímu počtu prvků).
Nezáleží tudíž na celkovém počtu vložených hodnot, ale jen na počtu uchovávaných (= vložených a ještě nevybraných).
V zásadě existují dvě řešení tohoto problému:
import collections
class FQueue:
def __init__(self):
self.data = collections.deque() # prázdná obousměrná fronta
def enqueue(self, value): # vložení
self.data.append(value) # přidáme na konec seznamu
def dequeue(self): # vyjmutí posledního vloženého
return self.data.popleft() # vyjímá první prvek = nejdříve vložený
def isEmpty(self):
return not self.data
# seznam se v kontextu, kdy je očekávána log. hodnota vyhodnotí na `true` je-li neprázdný
q = FQueue()
q.enqueue(1)
q.enqueue(2)
print(q.isEmpty())
print(q.dequeue())
print(q.dequeue())
print(q.isEmpty())
Úkol: Implementujte cyklickou (kruhovou) frontu. Popis jehí funkce najdete např. na https://en.wikipedia.org/wiki/Circular_buffer.
Polymorfismus je vlastnost programovacího jazyka vytvářet kódy, které jsou použitelné pro objekty několika různých typů (v OOP tříd). Podpora polymorfismu je klíčovým rysem moderních programovacích jazyků, neboť jen s pomocí polymorfismu lze psát flexibilní a přitom stručné a přehledné programy. Je klíčový především pro objektově orientované jazyka, v nichž je vytváření tříd základním prostředkem tvorby komplexnější programů (OOP jazyky musí být polymorfní od narození).
Podívejme se na dva triviální příklady polymorfismu:
def first(collection): # polymorfní funkce vracející první prvek z kolekce
return collection[0]
Tuto funkci lze zavolat na instance překvapivě velkého množství tříd.
first([2,3,5]) #seznamu
first([(2,3,5)]) # n-tice
first("Sílor") #řetězce
A mnoha dalších (zkuste nějaké ještě najít).
Tento kód však není absolutně polymorfní tj. nelze jej využít pro objekty libovolné třídy.
first(2) # číslo nemá žádné podprvky a proto nemá smysl vrátit první
first({1,2,3}) # to už je trochu překvapivější, ale ani množina nemá první prvek
Absolutně polymorfní kód je relativně těžké napsat (objekty mají jen málo věcí univerzálně sdílených). Metoda identical testuje, zda jsou dva objekty identické, Využívá funkci id, která vrací jednoznačný číselný identifikátor každého objektu (typicky je to adresa objektu v paměti).
def identical(a,b):
return id(a) == id(b)
identical(2, 2)
identical("a", "a")
identical([],[]) # ale dva prázdné seznamy nejsou identické
Poznámka: Namísto porovnání číslených identifikátorů lze samozřejmě využít polymorfní operátor
is.
Programovací jazyky poskytují různé mechanismy pro podporu polymorfirmu. Python nabízí tři z nich:
Duck typing (český překlad kachní typování se zatím neujal) vychází z následující úvahy:
If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.
Autorem je americký básník James Whitcomb Riley a ukazuje přirozený přístup logického uvažování. Na to abychom poznali kachnu, tak není zapotřebí, aby byla opatřena visačkou kachna stačí jen, pokud se chová jako kachna (přesněji jen v námi požadovaném kontextu, věta například nepožaduje, abychom zkoumali její rozmnožování, což je ve skutečnosti to, podle čeho o příslušnosti jedince k druhu rozhoduje biologie).
Poznámka: Detailnější diskusi tohoto tzv. kachního testu najdete na anglické Wikipedii (https://en.wikipedia.org/wiki/Duck_test).
Pro hlubší pochopení problematiky doporučuji hlavně dvě parafráze:
If it looks like a duck, and quacks like a duck, we have at least to consider the possibility that we have a small aquatic bird of the family Anatidae on our hands.Douglas Adams
If it looks like a terrorist, if it acts like a terrorist, if it walks like a terrorist, if it fights like a terrorist, it's a terrorist, right?Сергей Викторович Лавров
V kontextu programovacích jazyků je důsledkem aplikace "kachního" přístupu, důraz na to jak se objekt chová a nikoliv na to, jaké je konkrétní třídy.
V naší funkci first klademe na objekt jediné dva požadavky:
__index__Nikde se neuvádí jméno třídy tohoto objektu, a je to nejen zbytečné, ale i kontraproduktivní. Pokud uděláme rybníček pro kachny, pak je výhodné, pokud ho mohou používat i husy. Podobně pokud si uděláme řidičák pro auta, je rozumné ho uznávat i pro malé motocykly.
Někdy bývá dokonce složité pojmenovat skupinu tříd, pro něž je daný polymorfní kód určen. Většinou se používá:
Obecně lze říci, že dva objekty lze používat polymorfně (tj. lze napsat polymorfní kód, který pracuje s oběma objekty), pokud oba mají metodu, která:
self)Z těchto požadavků je snadno popsatelný jen první požadavek a také je nejsnadněji detekovatelný. Naopak čtvrtý požadavek, se často specifikuje je obtížně a také se jeho splnění špatně detekuje (běžně vyžaduje vytvoření pomocného kódu pro tzv. unit testing)
Podívejme se na jednoduchý příklad. Předpokládejme objekt, který representuje nějakou metriku. Metrika je objekt (v matematickém pojetí) funkce, která definuje vzdálenost mezi dvěma body.
Nejdříve si definujme detailní požadavky na objekty metrik (všimněte si, že se vyhýbám slovu třída).
self (tj. zjištění vzdálenosti dvou bodů nemá vliv na výpočet
vzdálenosti ostatních), parametry se přirozeně také nemění (což je ale u čísel v Pythonu standardem)Uveďme ukázkovou implementaci třídy, jejíž objekty splňují výše uvedené požadavky. Realizovaná metrika, je klasická euklidovská (https://mathworld.wolfram.com/EuclideanMetric.html):
import math
class EuclideanMetric:
def distance(self, p1, p2):
x1, y1 = p1
x2, y2 = p2
return math.sqrt((x1-x2)**2 + (y1-y2)**2)
# základní test funkčnosti
m = EuclideanMetric()
print(m.distance((1.0, 1.0), (2.0, 2.0)))
Pro ukázku polymorfismu musíme vytvořit ještě jednu třídu, jejíž objekty splňují naše požadavky- Zvolíme tzv. Manhattanskou distanci (
class TaxicabMetric:
def distance(self, p1, p2):
x1, y1 = p1
x2, y2 = p2
return abs(x1-x2) + abs(y1-y2)
m = TaxicabMetric()
print(m.distance((1.0, 1.0), (2.0, 2.0)))
Úkol: Prostudujte Manhattanskou metriku (metriku). Proč je v některých případech využívána namísto euklidovské?
Nyní již můžeme psát polymorfní kódy. Například následující funkce zjistí, jaký ze dvou bodů je bližší počátečnímu bodu a to v dané metrice.
def nearest(start, a, b, metric):
return a if metric.distance(start, a) < metric.distance(start, b) else b
print(nearest((0,0), (1,2), (-2,3), EuclideanMetric()))
Tento kód je polymorfní, neboť dokáže pracovat s objekty různých tříd. V případě parametru metric jsou to objekty obou tříd splňující naše požadavky.
print(nearest((0.0, 0.0), (1.2,2.0), (-2.0,3.0), TaxicabMetric()))
Navíc to může být potenciálně i objekt i jiných prozatím ještě neexistujících tříd. Tato otevřenost je typická pro většinu mechanismů polymorfismu. Dalším typickým rysem pythonského polymorfismu je dynamický charekter. O tom, jaká verze metody distance se rozhodne až při vykonávání kódu (nikoliv při jeho překladu).
Úkol: Definujte třídu pro Chebyševovu metriku (viz https://en.wikipedia.org/wiki/Chebyshev_distance) a vyzkoušejte, zda funguje s naší polymorfní funkcí.
Poznámka: Funkce
nearestje samozřejmě polymorfní i v ostatních parametrech. Body lze například zadávat jako seznnam celých čísel. Ve skutečnosti, je v Pythonu obtížné napsat kód, který není polymorfní.
Úkol: Popište, co musí splňovat objekty, který musí být parametrem následující funkce (především ten první označený
seq). Uveďte několik tříd, jejichž objekty, tyto požadavky splňují.
def in_zone(seq, minval, maxval):
for value in seq:
if not (minval <= value <= maxval):
return False
return True
Příklad využití:
in_zone([2,1,3,6], 0, 5)
Dynamický polymorfismus v Pythonu není nominální (což je typická vlastnost duck typingu). To znamená, že nezáleží na nejakém formálním označení či sdílené značce (tagu) sdílené na úrovni tříd (tj.třídy např. nemusí mít společný prefix v názvu, nemusí mít nějaký společný metatribut apod.)
Příklad ze života: Pokud vyhlásíme fotbalový turnaj, kterého se mohou zúčastnit všechny týmy, které souhlasí s pravidly (a podepíší to při jeho zahájení) a mlčky předpokládáme, že umí hrát fotbal, pak to není nominální polymorfismus. Pokud však vyžadujeme např. aby byly členy nějaké organizace (což je atribut týmu) nebo působili v nějakém městě, pak už se jedná o nominální polymorfismus (týmy musí něco nominálně splňovat).
Tento přístup je velmi flexibilné avšak na druhou stranu komplikuje zjištění, zda je nějaký objekt skutečně splňuje požadavky příslušného polymorfního kódu. Jedinou možností, jak to ověřit, je objekt použít. Musíme však být připraveni, že (v lepším případě) vznikne nějaká neočekávaná výjimka (například výjimka signalizující, že objekt případnou metodu nemá) v horším případě to vede k nedefinovanému chování (podivné hodnoty).
Python tento problém řeší pomocí tzv. abstraktních bázových tříd (abstract base class zkratka ABC). To je nepovinný prostředek jak nominálně označkovat, že daná třída splňuje určitý protokol tj. množinu metod.
Abstraktní bázové třídy tvoří hierachii, kdy každá třída může rozšiřovat jinou abstraktní třídu (označováno jako inheritance, ale má to jen velmi málo společného s dědičností uvedenou v další kapitole). V tomto případě musí objekt splňující danou abstraktní třídu splňovat i protokol třídy, kteroz rožšiřuje (a tak rekurzivně dál).
Prostředek se ve standardní knihovně omezuje v zásadě jen na tři typy objektů:
numbers a jeho dokumentace https://docs.python.org/3.8/library/numbers.html)collections.abc https://docs.python.org/3/library/collections.abc.html)Jako příklad se podívejme na klíčovou abstraktní třídu collections.abc.Sequence.
Každý objekt, který se chce prohlásit za nemodifikovatelnou sekvenci (a být plnohodnotně používán v polymorfním kódu pracujícím nad sekvencemi) musí:
__getitem__, která zajišťuje podporu indexace Reversible a CollectionTřída collections.abc.Reversible vyžaduje implementaci reverzního iterátoru (je získán voláním metody reversed a prochází sekvenci od posledního prvku k prvnímu), což je zajímavý rys typický pouze pro Python.
Třída collections.abc.Collection ve skutečnosti nevyžaduje žádné specifické metody, neboť metody uvedené v sloupci Abstract methods jen opakují metody vyžadované třemi triviálními abstraktními třídami:
__contains__: testovaní, zda je prvek obsažen v kontejneru, využíváno operátorem in (převzato ze třídy collections.abc.Container)__len__: zjištění počtu prvků (využíváno funkcí len), převzato ze třídy collections.abc.Sized (český ekvivalent mne nenapadá, snad Počitatelné)__iter__: vrací běžný (dopředný) iterátor, je tak zřejmé, že sekvence jsou tzv iterovatelné tj. lze je procházet prvek po prvku (implementují správným způsobem speciální metodu __iter_ a tak splňují abstraktní třídu collections.abc.Iterable).`Úkol: Ověřte, že n-tice splňuje rozhraní (protokol) abstraktní třídy sekvence.
Formální (nominální) ověření, že objekt splňuje všechny požadavky abstraktní třídy (překladač však přirozeně nic nekontroluje). Nejjednodušší je využití vestavěné funkce isinstance, která ověřuje, zda je objekt instancí dané třídy. Kromě třídy, ze které objekt vznikl, zohledňuje i složitější případy včetně nominální implementace dané třídy (a také dědičnost, k níž se dostaneme za chvíli).
from collections.abc import Sequence
isinstance(t, Sequence)
Mechanismus abstraktních tříd hraje v Pythonu jen pomocnou roli a v rámci duck typingu není využívána (tj. lze stačí reálná shoda protokolu, abstraktní bázové třídy nejsou vůbec brány v potaz). Mnozí u
I přes tato omezení má reálná využití:
Typové anostace se využívají dodatečný zdroj informací pro některé nástroje a dokumentaci, překladač Pythonu ji však nikdy nevyužívá! (tj, program s anotacemi, či bez nich bude fungovat stejně)
from collections.abc import MutableSequence
def multiappend(seq: MutableSequence, item, howmany : int) -> MutableSequence:
"""
Append item howmany-times to mutable sequence.
Returns:
changed sequence `seq`
"""
for _ in range(howmany):
seq.append(item)
return seq
Klíčové je především určení typu prvního parametru, Může to být libovolný objekt implementující třídu collections.abc.MutableSequence. Díky tomu např. Jupyter Lab ví, že má nabídnout metodu append po zapsaní seq. a stisku klávesy Tab. Druhý parametr typován není (může být libovolný). Třetí parametr je typován konkrétním typem (nabízí se typování typem number.Integral, ale to je trochu nadbytečné, neboť Python podporuje jen jednu třídu splňující toto rozhraní.)
Dědičnost (inheritance) je standardní mechanismus, který umožňuje vytvářet nové třídy rozšiřováním existujících, a to:
Tento mechanismus existoval už v nejstarším objektově orientovaném jazyce Simula 68 a je součástí valné většiny objektově orientovaných jazyků. Je tak součástí standardního objektově orientovaného paradigmatu a to tím nejkontraverznějším, neboť byl a stále je různě chápán resp. dokonce nechápán.
Důvodem je skutečnost, že je tento mechanismus využíván pro různé účely, které na sobě do určité míry závisejí, ale mohou být v jiné podobě nabízeny i bez využití mechanismu dědičnosti (např. polymorfismus či skládání, ale i sdílení implementací) a to často bez nepříjemných postranních efektů, které jejich souběh v dědičnosti přináší.
Následující přehled uvádí přehled klíčových účelů resp. konceptů, pro které je dědičnost využívána (a opět je nutno říci, že často i zneužívána).
Jrdnotlivé OOP jazyky podporují různé aspekty dědičnosti a kladou na ně různý důraz. Python podporuje v zásadě všechny aspekty dědičnosti (kromě univerzální podtřídy), žádný z nich však není pro Python klíčový a mnohé jsou ve skutečnosti zcela okrajové např. univerzální nadtřída, mixiny, metatřídy apod. (dědičnost obecně hraje v Pythonu relativně malou roli).
Upozornění: Role dědičnosti se i přes základní shodu, může v jednotlivých OOP jazycích podstatně lišit. Je to jedna z věcí, které znesnadňují přechod mezi jazyky a mohou být velmi matoucí (snadno si můžete přenést návyky, které jsou v kontextu jazyky vysloveně špatné). Navic se role dědičnosti v multiparadigmatických jazycích (kam do značné míry spadá i Python) může lišit i mezi jednotlivými frameworky.
Základní mechanismus dědičnosti je v Pythonu relativně jednoduchý. Ukážeme si ho na jednoduchém (umělém) příkladě.
Nejdříve vytvoříme základní (bázovou) třídu representující dvojici hodnot (Python podporuje obecné n-tice a tak je tato třída nadbytečná). Její konstruktor přrjímá dva parametry a ukládá je do atributů. Dále je definována metoda, která oba parametry prohodí (tu standardní n-tice nemá, proč?) a funkci print, která vypíše obsah (ani ta není obsažena v rozhraní n-tice, opět proč?)
class TupleBox: #
def __init__(self, p1, p2): # konstruktor se dvěma parametry
self.a1 = p1 # vytvoření a inicializace dvou atributů
self.a2 = p2
def change_attrs(self): # prohazuje oba atributy
self.a1, self.a2 = self.a2, self.a1
def print(self): # vypisuje textovou representaci do standardního výstupu
print(f"item 1: {self.a1}, item 2: {self.a2}")
Vyzkoušíme vytvořit instanci.
a = TupleBox(1,2)
a.change_attrs()
a.print()
A nyní ze třídy odvodíme novou třídu NamedTupleBox, která je
TupleBox (tj. její instance umí všechno, co instance třídy TupleBox a ještě něco navíc)TupleBox tj. její instance jsou zároveň instancemi třídy TupleBox (v této interpretaci však jsou dodatečné metody opravdu navíc). To mimo jiné znamená, že jakákoliv kód schopný
pracovat s objekty třídy TupleBox je automaticky polymorfní, neboť dokáže pracovat s objekty dvou tříd!class NamedTupleBox(TupleBox): # základní třída je v závorkách
def __init__(self, p1, p2, name):
super().__init__(p1, p2) # voláme kosntruktor předka (delegování)
self.name = name # nový atribut (instance třídy B mají jméno)
def print(self):
print(f"({self.name}) item 1: {self.a1}, item 2: {self.a2}")
def myName(self):
return self.name
Tato třída:
super, která vrací odkaz na stejný objekt jako self avšak s typem základní třídy (nikoliv třídy odvozené). Pomocí tohoto odkazu tak můžeme zavolat původní (nepředefinované) verze metod včetně původního konstruktoru. namechange_attrs, která pracuje s atributy vytvořenými konstruktorem základní třídyprint. Pokud jí zavoláme na instanci odvozené třídy, tak sice vykoná stejnou akci jako u třídy bázové (tj. vypíše svou textovou representaci na standardní výstup), ale mírně ji modifikuje (insatance má své vlastní jméno a tak ho vypíše). Metoda pracuje s původními atributy a nově přidaným atributem.maName, která vrátí jméno instance (tato metoda pracuje jen s přidaným atributem)Na rozhraní obou tříd se podíváme detailněji. Nejdříve vytvoříme objekty obou tříd.
baseObject = TupleBox(1,2)
extendedObject = NamedTupleBox(2,3, "the best of object")
Původní atributy a1 a a2 podporují oba objekty:
print(baseObject.a1)
print(baseObject.a2)
print(extendedObject.a1)
print(extendedObject.a2)
Atribut name však má jen objekt odvozené (rozšířené) třídy:
print(extendedObject.name)
print(baseObject.name)
U obou objektů lze atributy prohodit.
baseObject.change_attrs()
extendedObject.change_attrs()
A oba také mají motodu print. Její provedení u odvozené třídy se však liší.
baseObject.print()
extendedObject.print()
Rozšířená třída také navíc podporuje metodu myName.
extendedObject.myName()
Vyzkoušíme ještě chování funkce isinstance:
isinstance(baseObject, TupleBox) # objekt vždy instancí své třídy
isinstance(extendedObject, NamedTupleBox) # podobně i pro objekt rozšířené třídy
Jak však již bylo řečeno, platí, že rozšířený objekt je zároveň i instancí základní třídy.
isinstance(extendedObject, TupleBox)
Opačně to ovšem neplatí! Je to zřejmé, neboť objekt základí třídy postrádá metodu myName.
isinstance(baseObject, NamedTupleBox)
Skutečnost, že objekt rozšířené (odvozené) třídy je zároveň instancí třídy základní a může ho vždy zastoupit je jedním ze základních rysů dědičnosti a je znám pod dvěma označeními:
is-a mezi třídami. Pojmenovaná n-tice (tj. instance třídy NamedTupleBox) je (is a) n-ticí.
To se podstatně liší od relace has a, kterou se popisuje kompozice skládání. "Auto je dopravní prostředek" tj. vztah mezi objekty dopravních prostředků a objekty aut lze representovat dědičností (třída dopravních prostředků je základní třída, třída aut je třída odvozená). Naproti lze říci, že „auto má motor“ (nikoliv „auto je motor“ nebo „motor je auto“)Princip zastoupení se v praxi nepoužívá k formálnímu důkazu (je těžké formálně specifikovat všechny vlastnosti objektů dané třídy) ale k formálnímu či praktickému testování.
Musí mimo jiné platit, že
Všimněte si, že tyto podmínky odpovídají kontraktu pro metody polymorfně zaměnitelných objektů. V Pythonu je primární polymorfismus založený na kontraktech. Dva objekty mohou být zastupitelné i v případě, že mezi nimi neexistuje vztah dědičnosti. Jediný rozdíl je v tom, že:
instanceof (v případě obecného polymorfismu to lze zajistit prostřednictvím mechanismu
abstraktních bázových tříd, to je však podporováno jen u těch nejdůležitějších protokolů)Poznámka k názvosloví:
Pro základní třídu se používá i termín bázová třída (což je přímočařejší překlad z anglického base >class) resp. nadtřída (množina objektů základní třídy je nadmnožinou objektů třídy rozšířující). >Možný je i termín předek vycházející z uspořádání, které relace dědičnosti představuje (obecně však >tento termín nedoporučuji je příliš svázán s relací mezi konkrétními objekty (tvrzení typu „Adam je předkem >Jákoba“).
Pro odvozenou či rozšířenou třídu lze naopak použít termíny podtřída nebo potomek.
Úkol: Diskutujte vhodnost použití dědičnosti pro representaci následující vztahů mezi potenciálními třídami (při hodnocení záleží na kontextu použití, proto uvažujte navržené kontexty, resp. přidejte další).
- Šelma → Pes (prodejna chovatelských potřeb a krmiva, české dráhy, zoolog)
- Zaměstnanec → Sekretářka (firemní IS, pracovní úřad)
- Komplexní číslo → Racionální číslo (algebraický systém)
- Auto → Nákladní auto (policie, spediční firma)
- Auto → Červené auto (prodejce aut, pojišťovna, policie)
Relace dědičnosti může být použita i tranzitivně tj. lze vytvářet celé hierarchie tříd od nejobecnějších po nejspecifičtější. V jedné z fází vývoje objektově orientovaného paradigmatu byly v módě rozsáhlé a především hluboké hierarchie. V praxi se však ukázalo, že takto pojeté využití dědičnosti je neflexibilní a obtížně spravovatelné (dědičnost může výrazně narušit zapouzdření a tím zvyšovat závislosti mezi třídami).
V současnosti se tak preferují jen relativně mělké hierararchie o dvou až pěti úrovních. V Pythonu se kromě hierarchie dané využitím univerzální základní třídy dědičnost omezuje jen na izolované hierarchie v rámci některých standardních knihoven (ve skutečnosti jsem si nemohl na žádný konkrétní příklad kromě tříd výjimek vzpomenout).
Poněkud jiná situace je u některých knihoven zapouzdřující hierarchii v jiných jazycích a frameworcích (typicky GUI), následující obrázek ukazuje zjednodušenou hierarchii v knihovně Qt dostupné v Pythonu prostřednictvím knihovny PyQt.
object¶Všechny třídy. které namají uvedenou bázovou třídu, se v Pythonu automaticky odvozují od třídy object. Všechny třídy jsou tak odvozené ze třídy object ať už přímo, či nepřímo.
Tato skutečnost však v Pythonu nehraje téměř žádnou roli (v Pythonu 3, v Pythonu 2 byla situace jiná).
object (v Javascriptu je to běžné, neboť konstruktor univerzální třídy je základem OOP podpory)object nemůže nést žádné atributy a nemůže tak být využívána jako slovník, jehož klíči jsou identifikátory (opět na rozdíl např. od Javascriptu).Jedinou funkcí třídy objekt tak zůstává implementace několika univerzálních speciálních metod:
o = object()
print(o) # využití metody __str__
print ( o == object()) # využití metody _eq__
Standardní verze metody vrací jméno třídy následované adresou objektu, standardní porovnání pak porovnává odkazy na objekty (tj. vrací True jsou-li objekty identické).
O tom, že jsou tyto speciální metody se můžeme přesvědčit tím, že vytvořímě odvozenou třídu, která nic nepřidává (žadné atributy či metody) ani nemodifukuje.
class AnotherObject: # není nutné psát class AnotherObject(object):
pass # prázdné tělo je nutné representovat příkazem `pass`
o = AnotherObject()
print(o) # využití metody __str__
print ( o == object()) # využití metody _eq__
Úkol: Zjistěte, jaké základní implementace (speciálních) metod nabizí třída
object.
V Pythonu lze předefinovat i standardní třídy. Implementujme například roztržitý seznam, který si zapamatuje průměrně jen polovinu položek, které do něj vložíme. Tato nepříliš užitečná třída se od běžného seznamu (zdánlivě) liší jen v jediné metodě — append.
V implementaci konstruktoru je využit speciální parametr, jenž je uvozen znakem *. Tento parametr přejímá všechny nadbytečné poziční parametry (tj. parametry neuložené do formálních parametrů, jež by byly uvedeny dříve). Formálně lze tento parametr označit libovolným identifikátorem, ale v praxi se používá jen jméno args (zkratka za arguments).
Hodnotou tohoto parametru je seznam (může být i prázdný). V našem konstruktoru přejímá všechny poziční parametry a využívá je při volání konstruktoru základní třídy. I zde je použit zápis s prefixem *, který však má v případě skutečných parametrů opačnou funkci. Vezme příslušný seznam a jeho položky postupně předá jako poziční parametry (bez hvězdičky by se předal jen jeden parametr, jehož hodnotou by byl seznam). Konstruktor nadtřídy tak získá stejné parametr, jako byly předány konstruktoru odvozené třídy.
import random
class DistractedList(list):
def __init_(self, *args):
super().__init__(*args) # volání konstruktoru nadtřídy
def append(self, item):
if random.random() > 0.5:
super().append(item)
Nově odvozenou (rozšířenou) třídu vyzkoušíme tím, že do vložíme čísla od 0 do 99.
d = DistractedList()
for i in range(100):
d.append(i)
print(d)
print(len(d))
Vše se zdá v pořádku, ale není tomu tak. Na problémy narazíme v okamžiku, kdy se roztržitý seznam pokusíme naplnit pomocí iterátoru, předaného konstruktoru (což je přístup, který preferovala většina zkušenějších pythonistů).
d2 = DistractedList(range(100))
print(d2)
Je zřejmé, že se něco nepodařilo, protože seznam obsahuje všechny prvky. Je to trochu překvapivé, neboť by bylo lze předpokládat, že originální implementace konstruktoru bude hodnoty z předaného iterátoru vkládat pomocí metody append (která je v případě naší třídy předefinována).
Narážíme tak na jeden z hlavních problémů dědičnosti, neboť náš kód je závislý interní implementaci, která není součástí rozhraní a nemusí předvídat problémy spojené s použitím v odvozené třídě. Zde se autoři evidentně rozhodli nevyužít metodu append a to z důvodů efektivity (vestavěné kolekce často používají nízkoúrovňový kód v jazyce C, aby dosáhli rozumné efektivity).
Řešení v tomto případě existuje, i když není příliš elegantní. Stačí rozšířit předefinovanou verzi konstruktoru. Naštěstí tato funkce přijímá jen jediný nepovinný parametr.
class DistractedList(list):
def __init__(self, it = None):
super().__init__() # volání bezparametrického konstruktoru (vytváří prázdný seznam)
if it is not None:
for item in it:
self.append(item)
def append(self, item):
if random.random() > 0.5:
super().append(item)
d3 = DistractedList(range(100))
print(d3)
Úkol: Předefinování metody
appendnení dostatečné. Seznam podporuje i metoduinsert. Implementujte tříduDistractedLists předefinovanou metodouinsert(s obdobnou sémentikou jako máappend).
Poznámka: Náhodné vkládání na určité pozice může vést k téměř nepředvídatelnému chování (na rozdíl od
appendnení zajištěno ani zachování pořadí). Bylo by proto vhodnější tuto metodu u roztržitého seznamu nepodporovat. Při dědění však nelze žádnou z metod odstranit, neboť by to narušovalo princip zastupitelnosti – instance odvozené třídy musí být z hlediska polymorfního kódu neodlišitelné od instancí základní třídy (ony to de facto jsou instance základní třídy i když nepřímo) a tak musí podporovat i všechny jejich metody (v opačném případě je snadno odlišíte). Na druhou stranu lze přijmout představu, že odmítnutí vložení může být někdy zcela adekvátní reakce, kterou sice standardní implementace sice nevyužívá, ale není nemyslitelná u specializovaných instancí. Při tomto pohledu je důsledné vyhazování výjimkyNotImplementedErrorv odvozené metoděinsertakceptovatelné.
Úkol: Prostřednictvím dědičnosti implementujte slovník, který při použití indexace pro získání hodnoty vrací
None, pokud použitý klíč neexistuje (standardně je vyvolána výjimka). Rada: uvnitř předefinované speciální metody__getitem__můžete využít metoduget.
Python využívá dědičnost, i pro další účely:
První a čtvrtou možnost si ukažeme na příkladu tzv. cyklického seznamu, což je nemodifikovatelný seznam, jenž se navenek tváří jako n-násobné zřetězení (opakování) kratšího seznamu. Ve skutečnosti však v sobě uchovává jen tento kratší seznam. Tato kolekce je kupodivu docela užitečná (pro representaci cyklicky se opakujících struktur, například cyklicky se opakujících se nastavení).
from collections.abc import Sequence
class CyclicList(Sequence):
def __init__(self, base_iter, repetition):
self.data = list(base_iter)
self.rep = repetition
self.sublen = len(self.data)
def __len__(self): # délka seznamu
return self.rep * self.sublen
def __getitem__(self, index):
if not (0 <= index < len(self)):
raise IndexError("Index out of range")
return self.data[index % self.sublen]
Odvození z abstraktní bázové třídy Sequence není v Pythonu nezbytné. Přináší však dvě výhody.
Za prvé lze formálně testovat, že instance splňuje rozhraní (protokol) sekvencí. Aby to však byla skutečně pravda musíme implementovat dvě speciální metody (__len__ a __getitem__), které splňují určitý kontrakt:
IndexError. Překladač nekontroluje, zda jsou příslušné metody definovány, tím spíše zda splňují kontrakt.
clist = CyclicList("abc", 10)
#ověříme zda je se třída hlásí k rozhraní sekvence
print(isinstance(clist, Sequence))
# a nyní zda ji skutečně splňuje
print(len(clist))
for i in range(30): # vvypíšeme všechny prvky
print(clist[i], end="")
Otestovat je nutné i chybové stavy:
clist[30]
Druhou výhodou je že abstraktní třída funguje jako mixin tj. přidala nám i další metody, které u sekvence předokládáme:
# je iterovatelná přes všechny prvky (metoda __iter__)
for c in clist:
print(c, end="")
# lze ověřit zda obsahuje nějaký objekt (metoda __contains__)
print()
print("a" in clist)
print("z" in clist)
# metodu index (hledá první výskyt)
print(clist.index("c"))
print(clist.index("d")) # nenajde-li vyhazuje výjimku
# další přimíšenou metodou je `count`, která počítá výskyty
print(clist.count("a"))
A také reverzní iterátor:
for c in reversed(clist):
print(c, end="")
Tato funčnost ale zdarma není zadarmo. Implementace iterátorů je relativně efektivní, neboť využíví indexace (iterátor postupně vrací prvky [0], [1] až [len-1] resp. naopak.
Silně neefektivní je však implementace metod pro vyhledávání __contains__, index a count, neboť všechny tyto metody prohledávají celou n-krát opakovanou posloupnost (u prvních dvou jen v případě neúspěchu, u poslední metody vždy).
giantlist = CyclicList([0,1], 1_000_000)
# seznam zaujímá jen pár bytů, navenek však má 2 000 000 prvků
print(len(giantlist))
Vyhledání nuly je rychlé (stačí porovnat jen jeden prvek).
%%timeit
0 in giantlist
%%timeit
2 in giantlist
Je přitom jasné, že není potřeba prohledávat celé cyklicky opakované pole, stačí projít jen uložený podseznam. Proto je vhodnější předefinovat zděděné metody.
Úkol: Vytvořte novoui definici třídy
CyclicList, která je sice odvozena z abstraktní bázové třídySequence, ale předefinovává neefektivní metody (tím, že je deleguje na uložený podseznam).
Všimněte se, že nyní je vyhledávání dokonce ještě rychlejší než než původní (pozitivní) hledání (asi 8×). Důvodem je skutečnost, že volání zděděných metod vyžaduje jistou režii.
Jménem výjimka (exception) se označuje jazyková konstrukce, která pomáhá řešit výjimečné konstrukce (resp. objekt který v rámci této konstrukce vzniká a nese informací o výjimečné situaci).
Pojem výjimečná situace má i v běžném jazyce mlhavý význam. Je výjimečnou situací, když má váš ranní vlak zpoždění 10 minut, nebo nepřijede vůbec. Je výjimečnou situací, když potkáte cestou koně, slona či opilce?
Výjimečná situace se povětšinou definuje podle četnosti. Výjimečné situace jsou takové, které nastávají jen zřídka. Otázkou však je jak zřídka musí nastávat nějaká situace, aby ji bylo lze označit za výjimečnou.
Většina lidí na zemi nikdy nenavštívila Ústí nad Labem. Pokud ho náhodou navštíví (1× za život) bude to pro ně výjimečná situace?
Zajímavější je hodnocení na základě toho, zda určitou situaci zvládneme běžnými prostředky (tj. výjimečné je opak obvyklého). Pokud má vlak jen deset minut zpoždění, pak vše stíháme a nemusíme nic řešit. Pokud nepřijede vůbec a my nemáme auto, pak tuto situaci nevyřešíme vůbec nebo musíme shánět přítele s auutem (přítele s koněm bohužel nemám). I zde je všechno samozřejmě relativní. Pokud máte auto a vlak nejezdí každý druhý den, pak se samozřejmě o výjimečnou situaci nejedná.
Pomocnou charakteristikou je i schopnost řešení situace na místě vzniku. Pokud vás bolí břicho a pro řešení stačí nějaký běžný lék, pak to není výjimečná situace (tím spíše, pokud vás takový problém trápí každý měsíc a léky máte vždy po ruce). V okamžiku, kdy vás ale odvezou do nemocnice, jedná se s velkou pravděpodobností o výjimečnou situaci (pokud vás tedy neodvážejí do nemocnice každý týden, pak ale v zásadě žijete ve světě podobném kvantové mechanice, kde se s určitou pravděpodobností nacházíte zároveň jak doma tak v nemocnici).
Podobně lze výjimečné situace vymezit i v případě programování.
Výjimečná situace je stav programu, který
Uveďme několik konkrétních případů.
vyčerpání prostředků poskytovaných operačním systémem (a sdílených s jinými aplikacemi)
Příkladem budiž například místo na pevném disku nebo nedostatek rozšířené operační paměti
Tato situace není běžně pokryta základním modelem aplikace, neboť nastává nahodile a nelze jí predikovat (vyčerpání může způsobit jiný proces). Lze ji také obtížně řešit na místě vzniku, neboť může nastat skutečně kdykoliv a kdekoliv. Tj. například v nízkoúrovňovém kódu (který ani nemusí souviset s daným vyčerpáním). Pravděpodobnost stavu je navíc přirozeně malá (pokud by byla velká, pak bylo obtížné vůvec nějaké aplikace provozovat)
neexistence konfiguračního souboru
Zde lze vycházet především z nemožnosti řešení dané situace v místě jejího vzniku. Tou je knihovní funkce/metoda pro otvírání souboru (např. open). V okamžiku vzniku jakékoliv výjimečné situace, která má charakter chyby máme jen tři základní (rozumné) možnosti, jak na situaci reagovat.
První dvě možnosti nelze využít, neboť nízkoúrovňový kód ze standardní knihovny nemůže poskytovat alternativní řešení (je totiž používán v obrovském množství různých vzájemně neosouvisejících aplikacích), a nemůže dokonce ani rozumně ukončit program. Neví totiž ani jak uživatele informovat o problému. Výpis do chybového výstupu je aplikovatelný jen v konzolových aplikacích. U GUI nebo webových aplikacích se standardní chybový výstup běžně nezobrazuje.
Opakování činnosti samozřejmě také nepomůže, neboť je málo pravděpodobné, že se konfigurační program zázračně objeví (i když v multitáskových systémech ho může. alespoň teoreticky vytvořit jiný proces).
Stejnou charakteristiku mají i výjimečné stavy vzniklé chybou programátora. Pokud metodě očekávající neprázdný seznam předáme seznam bez jediného prvku nebo dokonce objekt None tak vznikne chybová situace (chybové situace jsou v mnoha případech výjimečné a naopak. takže oba pojmy často splývají).
max([])
Uvnitř metody max nastala výjimečná resp. chybová situace (kód metody nemůže rozumně ukončit program, ani tvolot alternativní řešení, neboť neexistuje cesta jak vrátit maximum z prázdného seznamu)
Výjimky nabízejí mechanismus, jak řešit výjimečné situace na příslušném místě aplikace bez výrazného komplikování programu. Celý mechanismus se provádí v několika fázích.
Tato fáze není ve skutečnosti součástí mechanismu výjimek. Detekce výjimečných (tj. typicky chybových) stavů je odpovědností programátora a patří k těm složitějším dovednostem. Detailněji byla diskutována v prním semestru.
Vyhození výjimky je snadné a už jej znáte. Jediné, co musíte rozhodnout je volba třídy výjimky. Pokud nepotřebujete speciální ošetření je nejrozumnější vyhodit výjimku třídy Exception nebo jinou standardní pokud sémanticky vyhovuje resp. je předepsána příslušným protokolem (a jeho kontraktem). V opačném případě je možné nadefinovat vlastní třídu výjimek.
Po vyhození výjimky se výrazně změní chování programu. Nevykonává se žádný kód je je postupně ukončují bloky try (viz dále) a především se postupně ukončují jednotlivé volané funkce či metody (bez toho, že by se vracela nějaká hodnota). Zanikají tak postupně lokální prostředí tj. všechny proměnné a popřípadě i objekty, které jsou u nich odkazovány (pokud
nejsou odkazovány odjinud). Obecně platí, že stejně jako v případě řek, nelze nikde vstoupit do místa stejné funkce dvakrát (při opakovaném volání se vytvoří nové lokální proměnné, které mohou obsahovat nové objekty). Při šíření výjimky se neprovádí žádný kód kromě bloku finally, V bloku finally je vykonávána i metoda __exit__ manažerů kontextu (ty se typicky postarají o úklid prosředků, apod.)
Výjimky lze zachytávat pomocí konstrukce try-except. Když výjimka opouští blok záhajený klíčovým slovem try (tzv. try-blok) je instance výjimky postupně porovnána testována s třídami uvedenými v sekcích except, které následují za try-blokem. Pokud je výjimka instancí dané třídy (přímo i nepřímo, tj. je instancí odvozené třídy), pak se výjimka naváže na proměnou uvednou za klíčovým slovem as (typicky se používá identifikátor e) a provede se obslužná rutina v daném bloku za except (a žádný jiný blok except za daným try) Pokud se neúspěšně projdou všechny bloky except (tj. objekt výjimky neni instancí žádné z uvedených tříd), pak se výjimka šíří do nadřazeného bloku try . Jestliže žádný takový nadřazený blok není, pak opustí příslušnou funkci/metodu a šíří se od místa jejího volání.
V následujícím bloku se vyvolává výjimka třídy Exception uvnitř bloku try. Po jejím vyvolání se nejdřívě ověří zda je instancí třídy KeyError (první except za blokem try). Není tomu tak (Exception je nadtřídou nikoliv podtřídou třídy LookupError). Proto se testuje druhý except-blok. Ten je splněn (výjimka je přímou instancí očekávané třídy) a tak je proveden druhý except-blok (objekt výjimky je v něm označen identifikátorem e).
try:
raise Exception("testovací výjimka")
except LookupError as e:
print(f"zachycena výjimka LookupError {e}")
except Exception as e:
print(f"zachycena obecná výjimka Exception {e}")
Úkol: Jaký
except-blok se provede (a co je vypsáno), vyhodí li se výjimka třídylookupError? A jak tomu v případě třídyValueError(ta je podtřídou třídyExceptionnikoliv všakLookuprror) neboIndexError(ta je podtřídouLookupError?
Pro snadnější orientaci v heirarchii základních výjimek viz následující obrázek:
Rozšíření úkolu: vyvolejte výjimku
IndexErrorbez použití klíčového slovaraise.
V except bloku se musí výjimka ošetřit V zásadě existují jen tří základní možnosti (finální) reakce na výjimku.
Ignorování je možné v případě, že příslušná funkčnost není kritická resp. její selhání uživatele ovlivní jen minimálně. V každém případě by měla být informace o dosažení výjimečného (v tomto případě nikoliv chybového stavu) logována (tj. uložena pro další případný rozbor). Logování je relativně komplexní záležitost (podívejte se na https://docs.python.org/3/library/logging.html)
Alternativní řešení může spočívat jen ve změně parametrů (napřiklad ve změně jména otvíraného souboru, snížení rozlišení generovaného obrázku apod.) ale může to být i zcela nezávislé řešení (například alternativní uživatelské rozhraní v případě, že primární nelze využít). I v tomto případě je vhodné použití logovat.
Rozumné ukončení programu v sobě zahrnuje:
Místo zachycení výjimky závisí na její obsluze. Typicky to bývají vrstvy zajišťující hlavní logiku programu (což jsou v rámci programu metody umístěné v řetězci volání blízko hlavního programu). Výjimkou jsou skutečně neočekávané výjimky, které se zachytávají na nejvyšší úrovni programu (tj. v například v hlavním programu) a které vedou k bezprostřednímu (ale pokud možno stále „rozumnému“) ukončení programu. Speciálním případem jsou tzv. „nezachycené“ výjimky, které opustí i hlavní program a jsou zachyceny až v kódu běhové podpory (ta připravuje prostředí pro běh pythonského programu a pak předává řízení hlavnímu programu). Tyto výjimky vždy vedou k ukončení programu (bez uložení práce a uvolnění prostředků). Často jsou také zobrazeny nějaké dodatečné informace, resp. je spuštěn debugger (to je konfigurovatelné a závisí tak na prostředí odkud byl program spuštěn). V release verzích profesionálních programů by se však „nezachycené“ výjimky neměly objevovat.
Výjimky lze zachytávat i v místech, ve kterých ještě nemůže být rozhodnuto, jaké řešení se zvolí (tj. kód je ještě příliš obecný), není však vhodné nechat danou výjimku opustit příslušnou metodu, protože je příliš závislá na interní implementaci (narušení zapouzdřenosti). V tomto případě je vhodné v rámci obsluhy
přerušení vyhodit novou výjimku, která bývá obecnější. Navíc je vhodné (primárně pro účely ladění) výjimky tzv. zřetězit. Tj. nová (abstraknější) výjimka v sobě nese odkaz na výjimku původní (konkrétnější).
Předpokládejme například zjednodušené polymorfní třídy pro čtení konfiguračného nastavení, která v jedné ze svých podob předpokládá využití jednoduchého XML formátu (další možností, které si můžete také naprogramovat je JSON, ini-soubor nebo Windows registry, vše je podporováno ve standardní knihovně Pythonu).
from xml.dom.minidom import parse, parseString
class SimpleXmlConfigReader:
def __init__(self, filename):
self.fname = filename
def get(self, settings):
with open(self.fname, "rt") as f:
element = parse(f).getElementsByTagName(settings)[0] #nalezne element specifikovaného jména
return element.childNodes[0].nodeValue # get text contents of element
Vyzkoušíme na jednoduchém XM dokumentu, v němž jednotlivá nastavení tvoří dětské elementy kořenového uzlu-
%%writefile conf.xml
<settings>
<title>Pán prstenů</title>
<author>J.R.R.Tolkein</author>
</settings>
cr = SimpleXmlConfigReader("conf.xml")
cr.get("title")
Co se stane pokud nenalezneme příslušný podelement?
cr.get("autor") # překlep
Vznikne výjimka, která není přiliš informativní (jak index out of range souvisí s čtení nastavení) a především odhaluje interní implementaci (použití tzv. DOM).
Proto je lepší už v metodě get danou výjimku zachytit a místo ní vyhodit jinou, která neodhaluje sémantické detaily, ale zaměřuje se na hlavní problém tj. neexistenci daného nastavení v konfiguračním souboru.
from xml.dom.minidom import parse, parseString
class SimpleXmlConfigReader:
def __init__(self, filename):
self.fname = filename
def get(self, settings):
with open(self.fname, "rt") as f:
try:
element = parse(f).getElementsByTagName(settings)[0]
return element.childNodes[0].nodeValue
except IndexError as e:
raise LookupError(f"Unknown settings `{settings}`") from e
V obslužném kódu výjimky IndexError vyhazujeme novou výjimku třídy LookppError (sémanticky se opravdu jedná o výjimku signalizující problém s vyhledáváním). Použití klíčového slova from výjimky zřetězí (nová výjimka LookupError tak nese informaci o tom, že vznikla jako reakce na výjimku IndexError v kódu zpracovávajícím XML).
cr = SimpleXmlConfigReader("conf.xml")
cr.get("autor")
Výpis (nezachycené) výjimky zobraze jako hlavní výjimku výjimku LookupError: Unknown settings autor (na konci), která vznikla jako důsledek výjimky IndexError.
Třída výjimek nese základní informaci o výjimečné situaci, která výjimku způsobila. Podívejme se na dva extrémy při návrhu výjimek:
všechny výjimky patří do jediné třídy
každá jednotlivá příčina výjimek má vlastní třídu, které tvoří rozsáhlou a hlubokou hierarchii dědičnosti
Skutečné využití možnosti členění výjimek je někde mezi těmito extrémy a neexistuje žádný jednotný přístup nebo metodika. Existují však neformální (a často si i protiřečící) rady:
Exception resp. několika málo top-level obecných tříd jako LookUpError, OSErrorVytváření vlastní třídy výjimek je snadné. Musí být splněny jen dva požadavky:
Exception nebo z ní odvozené třídymessageNázev třídy by měl končit slovem Exception resp. Error (nepovinné)
class MyException(Exception):
def __init__(self, msg):
self.message = "We have a problem"
raise MyException("We have a problem")