Objektově orientované programování

Co je to objektově-orientované programování?

Pro objektové (...ě-orientové) programování (OOP) je zásadní koncept objektu -- ten představuje ucelenou kombinaci dat a operací, jež s nimi jdou dělat. Objekt obvykle (ne vždy) data schovává a navenek poskytuje jen několik přístupných operací, tzv. metod. Přitom typicky toho, kdo k objektu přistupuje, nezajímá implementace oněch metod (objekt klidně jejich vykonání může delegovat na jiné objekty), ani to, jakým způsobem jsou v objektu uspořádána data.

K objektově-orientovanému programování patří i několik dalších konceptů, různé programovací jazyky si z nich berou jen některé, také je interpretují různě. Ukážeme si, které koncepty mají v Pythonu smysl a jak je využít.

Obvyklé koncepty: zapouzdření, dědičnost, polymorfismus

Objekty

V Pythonu je všechno objekt (na rozdíl od C++, Javy). Objektem jsou všechny hodnoty vestavěných typů (čísla, řetězce, ...), všechny kontejnery, i funkce, moduly, i typy objektů. Úplně vše poskytuje nějaké veřejné metody.

Typ objektu

Každý objekt má nějaký typ, přičemž typy rozdělujeme na vestavěné typy (list, tuple, int, ...) a třídy (typy definované pomocí klíčového slova class). Typ určuje, jaké metody objekt nabízí, představuje jakousi šablonu (obecné vlastnosti), od které se pak individuální objekt liší vnitřním stavem (konkrétní vlastnosti). Říkáme, že objekt je instancí daného typu. Pro zjištění typu objektu zlouží vestavěná funkce type.

In [1]:
print(type("Babička"))
print(type(46878678676848648486))              # long v Pythonu 2, int v Pythonu 3
print(type(list()))                            # instance typu list
print(type(list))                              # samotný typ list
<class 'str'>
<class 'int'>
<class 'list'>
<class 'type'>
In [2]:
print(isinstance(list(), list))                # Funkce "isinstance" kontroluje typ objektu (včetně dědičnosti)
True

Vytvoření instance

Pokud máme datový typ (třídu), jeho instanci vytvoříme podobně, jako kdybychom jej chtěli zavolat, tj. pomocí kulatých závorek. Ostatně, to už jsme dělali u vestavěných typů typu tuple, dict, list.

Fakticky se přitom volá konstruktor (viz níže).

In [3]:
objekt = list()        # Vytvoření nové instance typu "list"
objekt2 = list         # Toto není vytvoření instance! Jen nové jméno pro tentýž typ

# Podíváme se, co jsme dostali
print(f"objekt = {objekt}")
print(f"objekt2 = {objekt2}")
objekt = []
objekt2 = <class 'list'>
In [4]:
objekt2()
Out[4]:
[]

Metody - použití

Metoda objektu je funkce, která je s nějakým objektem svázaná (bez něj nemá význam) a operuje s jeho daty. Může také měnit vnitřní stav objektu, tj. hodnoty atributů.

Volání metod se v Pythonu provádí pomocí tečkového zápisu, tj. mezi objekt a volanou metodu se vloží tečka: objekt.metoda(argumenty)

In [5]:
objekt = [45, 46, 47, 48]     # Seznam je objekt, "list" je typ
objekt.append(49)             # voláme metodu "append" na seznam

objekt
Out[5]:
[45, 46, 47, 48, 49]

Metoda append nemá význam sama o sobě, pouze ve spojení s konkrétním seznamem - přidává do seznamu nový prvek.

Třídy

Třídou je jakýkoliv uživatelský typ. Podobně jako vestavěné typy nabízí metody a data (atributy), ovšem můžeme je libovolně definovat.

Nejjednodušší definice prázdné třídy (klíčové slůvko pass slouží pro prázdné třídy a metody - obchází nevýhody odsazování):

In [6]:
class Trida:        # Třída se bude jmenovat Trida (velké písmeno je zvyk, ne nutnost)
    pass            # Třída bude prázdná

Definice metody

Definice metody musí být uvnitř bloku třídy. (Pozn. Metody lze do třídy přidat i později, ale není to preferovaný způsob.)

Běžné metody (instance method) se volají na konkrétním objektu. Kromě nich existují i tzv. metody třídy a statické metody, které zde nebudeme probírat.

Zvláštnost (Pozn. Ano, je to opravdu divné.) definice metod (narozdíl od C++, Javy a dalších jazyků) je ta, že první argument metody je objekt, na kterém je metoda volána. Bez toho by metoda vůbec nevěděla, se kterým objektem pracuje! Dle konvence (která se snad nikdy neporušuje) se tento argument nazývá self. Při volání metody se pak vynechává a Python jej automaticky doplní.

Konstruktor

Metoda, která inicializuje objekt - zavolá se na prázdném objektu ve chvíli, kdy vytvoříme novou instanci. Můžeme jej definovat, ale nemusíme - v takovém případě se použije výchozí konstruktor, který jednoduše nedělá nic (zvláštního). Konstruktor se v Pythonu vždy jmenuje __init__ (dvě podtržítka před i po).

Data

Python příliš nerozlišuje mezi metodami a daty (tak jako obecně u proměnných -- vše je objekt). Pro něj je vše atribut daného objektu. Nastavení hodnoty se provádí podobně jako ukládání do proměnné, ale musíme přidat objekt a tečku. (Pozn. Interně jsou atributy uložené ve slovnících a při přístupu k nim se prochází slovník samotného objektu, jeho třídy, jejích nadřazených tříd, ...). Atribut daného jména nemusí přitom vůbec existovat, nemusí se nijak deklarovat.

In [18]:
class Auto:
    def __init__(self, spotreba):    # Konstruktor s argumentem
        self.spotreba = spotreba     # Argument uložíme jako atribut objektu (objekt je zde self)

    def ujed(self, vzdalenost):  # pozor - první argument metody je vždy "self"
        # Zde použiji atribut "spotreba"
        spotrebovane_litry = vzdalenost / 100 * self.spotreba
        # spotrebovane_litry je lokální proměnná, nikoliv atribut
        print(f"Jedu {vzdalenost} kilometru a spotrebuju {spotrebovane_litry} litru benzinu.")
In [10]:
auto = Auto(7.5)                    # Vytvoření instance třídy Auto se spotřebou 7.5
auto.ujed(100)                      # Při volání se *self* už nepíše
Jedu 100 kilometru a spotrebuju 7.5 litru benzinu.
In [11]:
auto.ujed(auto, 100)                # Chyba! Všimněte si, na jaký počet argumentů si Python stěžuje.
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[11], line 1
----> 1 auto.ujed(auto, 100)                # Chyba! Všimněte si, na jaký počet argumentů si Python stěžuje.

TypeError: ujed() takes 2 positional arguments but 3 were given
In [12]:
rachotina = Auto(15)
print(f"Moje rachotina má spotřebu {rachotina.spotreba} l/100 km.")
rachotina.ujed(150)
Moje rachotina má spotřebu 15 l/100 km.
Jedu 150 kilometru a spotrebuju 22.5 litru benzinu.

Seznam všech atributů získáme pomocí dir

In [13]:
# My zde filtrujeme na běžné atributy (ty s podtržítky jsou vždy něčím výjimečné)
", ".join(item for item in dir(rachotina) if not item.startswith("_"))
Out[13]:
'spotreba, ujed'

Cvičení

Rozšiřte třídu Auto o správu najetých kilometrů. Měli bychom tedy mít možnost u auto:

  1. Podívat se na celkový počet najetých kilometrů.
  2. Zjistit celkovou spotřebu paliva.

Bonus: Přidejte ještě podporu servisních intervalů.

  • Konstruktor bude mít parametr servisni_interval s výchozí hodnotou 20 000.
  • Přidejte metodu je_potreba_servis, která bude vracet True nebo False podle toho, zda je potřeba servis.
  • Přidejte metodu proved_servis, která zaznamená provedení servisu.
  • Rozšiřte metodu ujed, aby hlásila potřebu servisu 1000 km předem.

Vlastnosti (properties)

Vlastnosti jsou "chytřejší" data. Umožnují vstoupit do procesu čtení nebo nastavování atributu. Hodí se to například tehdy, pokud objekt má několik navzájem závislých parametrů a my je nechceme ukládat nezávisle; pokud chceme kontrolovat, jaká hodnota se ukládá; či pokud chceme s ukládanou nebo čtenou hodnotou ještě něco zajímavého provést (viz příklad pro kruh).

Ze syntaktického hlediska musíme nejdříve definovat metodu, která nese jméno vlastnosti a která tuto vlastnost "čte" (resp. vrací její hodnotu). O řádek výše musíme umístit tzv. dekorátor (tento koncept teď nebudeme podrobně vysvětlovat, jen jej pasivně použijeme) @property. Chceme-li, můžeme pak vytvořit i metodu pro zápis - ta se musí jmenovat stejně, požadovat jeden argument (ukládaná hodnota) a být uvedena dekorátorem @jmenovlastnosti.setter. Podobně bychom mohli vytvořit i metodu pro mazání (dekorátor @jmenovlastnosti.deleter), ale to se již běžně nedělá.

Jakmile máme takto vytvořené vlastnosti, přistupujeme k nim jako k běžným datovým atributům - voláme je bez závorek a přiřazujeme do nich pomocí znaménka "rovná se".

Pozn. Vlastnosti fungují podobně jako properties v C# či javabeans v Javě. Povšimněte si však, že pro přístup k vlastnostem se používá úplně stejný zápis jako pro přístup k datovým atributům. Pokud tedy budeme chtít někdy změnit chování datového atributu a udělat z něj vlastnost, klient naší třídy to nepozná a nebude muset dělat žádné změny v kódu. Není tedy vhodné přespříliš iniciativně vytvářet triviální vlastnosti, které jen obalují přístup k atributům (jako by se to jistě dělalo v Javě).

In [14]:
import math
import numbers

class Kruh:
    def __init__(self, r):
        self.polomer = r

    @property                           # Chceme definovat vlastnost pro čtení
    def obsah(self):                    # Vypadá jako obyčejná metoda
        return math.pi * self.polomer ** 2

    @obsah.setter                       # Chceme nastavit zápis do dříve definované vlastnosti
    def obsah(self, s):
        print("Měním obsah na {}".format(s))
        if not isinstance(s, numbers.Number):   # Kontrolujeme, že jde o číslo
            raise TypeError("Obsah musi byt cislo")
        self.polomer = math.sqrt(s / math.pi)

    @obsah.deleter
    def obsah(self):
        raise Exception("Mazat obsah kruhu nedava smysl.")
In [15]:
kruh = Kruh(1)
print("r = {}".format(kruh.polomer))    # Normální datový atribut
print("S = {}".format(kruh.obsah))      # Property
r = 1
S = 3.141592653589793
In [17]:
kruh.obsah = 3                          # Změníme obsah pomocí zapisovatelné vlastnosti
print("r = {}".format(kruh.polomer))    # Změnil se nám i poloměr
print("S = {}".format(kruh.obsah))      # Property
Měním obsah na 3
r = 0.9772050238058398
S = 3.0
In [19]:
# Zkusíme, co nám udělá kontrola v "setteru" vlastnosti obsah
kruh.obsah = "Asi jako největší český rybník, který se jmenuje Rožmberk."
Měním obsah na Asi jako největší český rybník, který se jmenuje Rožmberk.
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[19], line 2
      1 # Zkusíme, co nám udělá kontrola v "setteru" vlastnosti obsah
----> 2 kruh.obsah = "Asi jako největší český rybník, který se jmenuje Rožmberk."

Cell In[14], line 16, in Kruh.obsah(self, s)
     14 print("Měním obsah na {}".format(s))
     15 if not isinstance(s, numbers.Number):   # Kontrolujeme, že jde o číslo
---> 16     raise TypeError("Obsah musi byt cislo")
     17 self.polomer = math.sqrt(s / math.pi)

TypeError: Obsah musi byt cislo
In [20]:
# A další nesmyslná operace, které naše vlastnost zabrání
del kruh.obsah
---------------------------------------------------------------------------
Exception                                 Traceback (most recent call last)
Cell In[20], line 2
      1 # A další nesmyslná operace, které naše vlastnost zabrání
----> 2 del kruh.obsah

Cell In[14], line 21, in Kruh.obsah(self)
     19 @obsah.deleter
     20 def obsah(self):
---> 21     raise Exception("Mazat obsah kruhu nedava smysl.")

Exception: Mazat obsah kruhu nedava smysl.

Zapouzdření

V Pythonu není tento (pro OOP klíčový) koncept příliš dodržen. Zásady OOP tvrdí, že data by neměla být zvenčí přístupná, jiné jazyky obvykle nabízejí způsob, jímž lze schovat i některé metody (jako klíčová slova private, protected v C++, Javě). Python toto neřeší, ve výchozím nastavení je vše přístupno. Obvyklá konvence:

  • Na data objektů (pokud třída není vyloženě jednoduchá) se zvenčí nesahá.
  • Metody, jejichž jméno začíná podtržítkem, se zvenku nevolají (protože nejsou součástí "veřejného" rozhraní objektu).
  • Pokud chceme data chránit, můžeme pro ně vytvořit vlastnost (property).
  • Případné odlišnosti a obecně způsob, jakým se s metodami a daty nakládá, by měly být uvedeny v dokumentaci třídy.
  • Existují způsoby, jak zapouzdření lze vynutit (předefinování způsobu přístupu k atributům, ...), které se však příliš nepoužívají.

Python ovšem na oplátku nabízí velmi vysokou úroveň introspekce, neboli schopnosti dozvědět se informace o objektech (jejich typ, atributy apod.) za běhu.

Podtržítkové konvence

V Pythonu obecně jsou konvence velice silně zakořeněné. Na objektech to je vidět snad nejvíce.

  1. "Soukromé" atributy (atributem se v Pythonu často rozumí jak data tak metody -- vše je objekt) se pojmenovávají s podtržítkem na začátku, tj. např. _soukroma_metoda.
  2. Dvě podtžítka na začátku názvu atributu jej navíc přejmenují, takže je opravdu těžké se na něj odkazovat mimo kontext dané třídy.
  3. Atributy se dvěma podtžítky na začátku i na konci mají speciální význam (viz dokumentace). Už jsme viděli __init__, podíváme se na několik dalších.
    • __repr__ a __str__ převádějí objekt na řetězec.
    • __getattr__ a __setattr__ slouží pro čtení a ukládání nenalezených atributů.
    • __call__ se zavolá pokud použijeme objekt jako funkci.
    • __doc__ obsahuje dokumentaci (docstring).
    • __dict__ obsahuje slovník se jmenným prostorem obejktu.
    • ... dále existují speciální funkce pro logické operátory, pro emulaci funkcionality kontejnerů (iterace, položky, řezy), pro aritmetické operace atd.
In [21]:
# co všechno obsahuje instace typu object
dir(object())
Out[21]:
['__class__',
 '__delattr__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__getattribute__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__le__',
 '__lt__',
 '__ne__',
 '__new__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']
In [22]:
# a co obsahuje jednoduchá funkce
def foo(x):
    """Toto je funkce foo"""
    return x
dir(foo)
Out[22]:
['__annotations__',
 '__call__',
 '__class__',
 '__closure__',
 '__code__',
 '__defaults__',
 '__delattr__',
 '__dict__',
 '__dir__',
 '__doc__',
 '__eq__',
 '__format__',
 '__ge__',
 '__get__',
 '__getattribute__',
 '__globals__',
 '__gt__',
 '__hash__',
 '__init__',
 '__init_subclass__',
 '__kwdefaults__',
 '__le__',
 '__lt__',
 '__module__',
 '__name__',
 '__ne__',
 '__new__',
 '__qualname__',
 '__reduce__',
 '__reduce_ex__',
 '__repr__',
 '__setattr__',
 '__sizeof__',
 '__str__',
 '__subclasshook__']

Dědičnost

Třída může svoje chování (i data) odvozovat od nějaké jiné třídy, čímž si ušetříme spoustu práce při opakování společných rysů. V takovém případě řekneme, že naše nová třída (dceřinná) od té původní (rodičovské) dědí.

  • V dceřinné třídě můžeme změnit definici některé metody z rodičovské třídy.
  • Konstruktory se standardně dědí (Na rozdíl od C++ či Javy, kde se musí explicitně volat, v Pythonu se volají jen pokud definujeme nový konstruktor a chceme zavolat i nadřazený.)
  • Instance dceřinné třídy se mohou použít kdekoliv, kde počítá s objektem rodičovské třídy. Toto platí v Pythonu ještě obecněji - obvykle se nekontrolují konkrétní typy, projde jakýkoliv objekt, který nabízí používané atributy/metody.

Syntax: Jméno rodičovské třídy se dává do závorky za jméno (místo object, od kterého třídy obvykle dědí).

In [23]:
class Clovek:
    def __init__(self, jmeno):         # Konstruktor, který nastaví atribut "jmeno"
        self.jmeno = jmeno

    def rekni(self, veta):             # Výchozí definice metody "rekni"
        print(type(self).__name__ + ": " + veta)

    def predstav_se(self):
        self.rekni("Jmenuji se %s." % self.jmeno)

    def pozdrav(self):
        self.rekni("Dobrý den.")

    def rozluc_se(self):
        self.rekni("Nashledanou.")


class Elektrikar(Clovek):
    def oprav_televizi(self):         # Nová metoda v rodičovské třídě - jiný Clovek ji neumí
        self.rekni("Bude to v cuku letu.")
        print("---Elektrikar něco šudlá.---")
        self.rekni("A je to.")

    def predstav_se(self):            # Předefinovaná metoda "predstav_se" využívá atribut rodičovské třídy
        self.rekni("Já sem ňákej %s." % self.jmeno)

class Nemocny(Clovek):
    def rekni(self, veta):            # Předefinovaná metoda, která staví na rodičovské metodě.
        """Nemocný má plný nos a co chvíli kýchne."""
        trantab = "".maketrans("nmNM", "dbDB")

        Clovek.rekni(self, veta.translate(trantab))   # Volání rodičovské metody
        self.kychni()

    def kychni(self):                 # Nová metoda v rodičovské třídě - jiný Clovek ji neumí
        print("---Heeeepččččíííík---")
In [26]:
elektikar = Elektrikar("Franta Vopička")
nemocny = Nemocny("Tomáš Marný")

# Rozhovor ze života
nemocny.pozdrav()          # Všimněte si, že "pozdrav" je rodičovská metoda, ale volá se "rekni" z dceřinné třídy.
elektikar.pozdrav()
nemocny.predstav_se()
elektikar.predstav_se()
nemocny.rekni("Opravte mi, prosím, televizi.")
elektikar.oprav_televizi()
nemocny.rekni("Máte mé neskonalé díky.")
elektikar.rozluc_se()
nemocny.rozluc_se()
Nemocny: Dobrý ded.
---Heeeepččččíííík---
Elektrikar: Dobrý den.
Nemocny: Jbeduji se Tobáš Bardý.
---Heeeepččččíííík---
Elektrikar: Já sem ňákej Franta Vopička.
Nemocny: Opravte bi, prosíb, televizi.
---Heeeepččččíííík---
Elektrikar: Bude to v cuku letu.
---Elektrikar něco šudlá.---
Elektrikar: A je to.
Nemocny: Báte bé deskodalé díky.
---Heeeepččččíííík---
Elektrikar: Nashledanou.
Nemocny: Dashledadou.
---Heeeepččččíííík---
In [27]:
nemocny.oprav_televizi()           # Nemocný neumí opravit televizi
# A elektrikář neumí kýchnout
---------------------------------------------------------------------------
AttributeError                            Traceback (most recent call last)
Cell In[27], line 1
----> 1 nemocny.oprav_televizi()           # Nemocný neumí opravit televizi
      2 # A elektrikář neumí kýchnout

AttributeError: 'Nemocny' object has no attribute 'oprav_televizi'

Pokud bychom chtěli nemocného elektrikáře, tak bychom museli sáhnout po vícenásobné dědičnosti a museli bychom ošetřit, že se správně volají rodičovské metody. Ještě lépe bychom využili tzv. mixiny a do objektů jejich stav přidávali dynamicky, ale to už je opravdu pokročilé téma, zabývat se jím nebudeme.

Dědění od vestavěných typů

Lze dědit i od vestavěných typů (a je to mnohdy i užitečné, ačkoliv to náš příklad nedokazuje).

In [28]:
# Ukázka seznamu, který nevrátí svoji položku, pokud jej nepoprosíme.
class NerudnySeznam(list):
    def __getitem__(self, index):                     # Předefinujeme speciální metodu, která slouží k přístupu k položkám
        if isinstance(index, tuple) and index[1].lower()[:6] in ("prosim"):
            return list.__getitem__(self, index[0])   # Voláme původní metodu z typu "list"
        else:
            print("A že bys poprosil!?")
            return None

s = NerudnySeznam((1, 2, 3, 4))
print(s[1])
A že bys poprosil!?
None
In [29]:
print(s[1, "prosim"])
2

Pokročilá témata

Všechna následující témata jsou hrozně zajímavá a příšerně užitečná, nicméně nemáme na OOP tolik času, abychom se jim mohli věnovat. Přesto doporučujeme, abyste si o nich něco přečetli, budete-li mít chvilku času.

  • Vícenásobná dědičnost
  • Metody třídy
  • Statické metody
  • Abstraktní třídy
  • Polymorfismus
  • Metatřídy
  • Návrhové vzory

Komentáře

Comments powered by Disqus