4. Objektově orientované programování (OOP)


Obsah


Programovací paradigma, které zapouzdřuje vlastnosti a funkcionalitu do individuálních objektů. Fakticky jsme se s tímto paradigmatem potýkali celou dobou.

Objekt reprezentující řetězec "ahoj svete" má definovanou rovněž funkcionalitu "ahoj svete".split(), která vytvoří seznam obsahující jednotlivé řetězce, které odpovídají řětězcům vzniklým rozdělěním původního řětězce mezerami.

Objektově orientované programování (OOP) se zakládá na třech základních principech:

  1. Základní princip dědičnosti (Inheritance):

    Dědičnost je základním principem OOP, který umožňuje vytvářet hierarchie tříd, kde odvozené třídy (podtřídy) mohou zdědit atributy a metody svých předků (nadtříd). Tím se umožňuje znovupoužití kódu a organizace do hierarchií, což zlepšuje modularitu a údržbu kódu.

  2. Princip zapouzdření (Encapsulation):

    Zapouzdření se týká skrývání vnitřní implementace objektů a poskytování veřejného rozhraní pro interakci s nimi. Tímto způsobem jsou data a metody, které s nimi manipulují, přístupné pouze prostřednictvím definovaných rozhraní. To zlepšuje bezpečnost, umožňuje údržbu kódu a umožňuje jeho znovupoužití.

  3. Princip polymorfismu (Polymorphism):

    Polymorfismus umožňuje pracovat s objekty různých typů pomocí stejného rozhraní, čehož je dosaženo přetížením metod a dědičností. Polymorfismus zlepšuje flexibilitu a znovupoužití kódu tím, že umožňuje psát obecné kódy, které mohou pracovat s různými typy objektů, aniž by se musely starat o jejich konkrétní typy.

Třídy

Vytváření uživatelsky definovaných tříd (následně pak objektů) budeme v tuto chvíli chápat hlavně jako tvorbu vlastních datových struktur s navázanou funkcionalitou.

Sahat po vytváření vlastní datové struktury bychom měli pouze v případě, že vestavěné (primitivní) datové struktury nejsou dostatečné. Zkusme realizovat účet s kredity pomoci vestavěné datové struktury dict. Takové řeešení ale není dostatečně robustní pro velké projekty a nevyhnutelně by vedlo k různým problémům (opakování kódu, nepřehlednost atd).

# bankovní účet vyřešen pomocí slovníku
credit_account_1 = {"owner": "Lukas Novak", "balance": 100000}
credit_account_2 = {"owner": "Pepa Novak", "balance": 10000}

# součet kreditů na dvou účtech
credit_account_1["balance"] +  credit_account_2["balance"]

# odečet hodnoty od kreditů
credit_account_1["balance"] - 100

Nyní si ukážeme jako stejný problém vyřešit pomocí OOP. Více informací zde a zde.

Třída (příkaz class) tedy slouží k vytváření uživatelsky definovaných datových struktur. Určují jak má výsledná datová struktura vypadat a fungovat. Na základě třídy (předpis) můžeme vytvářet jednotlivé instance třídy (objekty).

Jako příklad si můžeme představit již dobře známý seznam. Jeden konkrétní seznam je instancí (objektem) třídy seznam, která popisuje jak seznamy vypadají a fungují. Třídy jsou tedy obecným předpisem, objekty pak konkrétní entity vytvořené na základě tohoto předpisu.

Třídy je dobré umisťovat do modulů stejného názvu.

V následujícím souboru credit_account.py (modul credit_account) si definujeme základní prázdnou třídu CreditAccount:

# definice prázdné třídy
class CreditAccount:
    pass

Následně nově vytvořenou třídu otestujeme:

from credit_account import CreditAccount


# dle předpisu třídy je následně možné vytvářet objekty
credit_account_1 = CreditAccount()
credit_account_2 = CreditAccount()

# ověření typu
type(credit_account_1)

# test zda je objekt instancí třídy
assert isinstance(credit_account_1, CreditAccount)
# PEP8 - název třídy používá CapWords konvenci
# správně
class CreditAccount:
    pass

# špatně
class credit_account:
    pass

Metody a atributy

Vytvoření prázdné třídy neni moc praktické, pojdme definici rozšířit. Každá třída může obsahovat sadu funkcí které jsou s třídou úzce spjaty. Těmto funkcím říkáme metody. Již několikrát jsme používali metodu .split() třídy řetězce, která umí daný řetězec rozdělit na seznam řetězců.

Všimněme si, že třída a její metody používají podobnou konvenci docstringů, budeme je tedy používat (a vyžadovat) i zde.

Nejdůležitější metodou každé třídy je konstruktor .__init__(), zatím se nemusíme trápit z jakého důvodu název obsahuje podtržítka (to si vysvětlíme na dalším semináři). Hlavním úkolem konstruktoru je nastavit počáteční stav (initial state - proto název init) nově vytvořeného objektu.

Metoda .__init__() může obsahovat libovolný počet parametrů (podobně jako funkce), prvním parametrem však vždy musí být parametr self. Když je instance třídy vytvořena, je automaticky předána jako první parametr self metodě .__init__(). To je nutné pro nastavení počátečního stavu objektu (potřebujeme přístup k nově vytvořenému objektu aby jsme mohli počáteční stav nastavit).

V rámci konstruktoru definujeme takzvané atributy (sloty dle názvosloví z paradigmat programování 3), což jsou pojmenované hodnoty nově vytvořéného objektu. Do těchto atributů ukládáme hodnoty/data, které reprezentují stav daného objektu.

class CreditAccount:
    """Account with stored credits."""

    # metoda __init__ je volána při vzniku objektu třídy CreditAccount, 
    # obsahuje definici atributů objektu a kód potřebný pro vytvoření instance
    def __init__(self, owner, initial_credits=0):
        """Creates credit account with given owner and initial credits.

        Args:
            owner: owner of the account
            initial_credits (optional): credit balance. Defaults to 0.
        """

        self.owner = owner
        self.balance = initial_credits
from credit_account import CreditAccount


credit_account_1 = CreditAccount("Lukas Novak")
credit_account_2 = CreditAccount("Pepa Novak", initial_credits=200)

credit_account_1.owner
credit_account_1.balance

credit_account_1.owner = "Jaroslav Novak"
credit_account_1.balance += 300

assert credit_account_1.balance == 300

Vytvořili jsme tedy třídu CreditAccount, která při vytvoření nové instance nastaví dva atributy CreditAccount.owner a CreditAccount.balance na hodnoty owner a initial_credits. Každá instance třídy CreditAccount bude těmito atributy disponovat.

Další metodou, kterou můžeme naši třídě CreditAccount přidat je metoda CreditAccount.transfer_to(self, other, value). Narozdíl od dvou prázdných řádku mezi definicemi funkce, metody oddělujeme jedním prázdným řádkem.

class CreditAccount:
    """Account with stored credits."""

    def __init__(self, owner, initial_credits=0):
        """Creates credit account with given owner and initial credits.

        Args:
            owner: owner of the account
            initial_credits (optional): credit balance. Defaults to 0.
        """

        self.owner = owner
        self.balance = initial_credits

    # jeden prázdný řádek
    def transfer_to(self, other, value):
        """Transfer money into another account. Negative balance is allowed.

        Args:
            other: Target of money transfer.
            value: Amount of money to be transfered.
        """

        self.balance -= value
        other.balance += value
from credit_account import CreditAccount


credit_account_1 = CreditAccount("Lukas Novak")
credit_account_2 = CreditAccount("Pepa Novak", initial_credits=200)

credit_account_1.transfer_to(credit_account_2, 200)

assert credit_account_1.balance == -200
assert credit_account_2.balance == 400

Asi nás nepřekvapí, že názvy metod podléhají doporučení PEP8.

# PEP8 - názvy metod a atributů stejně jako u funkcí a proměnných
# správně
class TestClass:
    def reverse_order(self):
        pass

# špatně
class TestClass:
    def reverseOrder(self):
        pass

Atributy třídy

Zatím jsme si ukázali, že atributy jsou specifické pro jednotlivé objekty. Pokud změníme atribut u jednoho objektu, nezmění se u druhého. Atributy definované jako tzv. class attribute můžeme využívat napříč všemi instancemi třídy.

class CreditAccount:
    """Account with stored credits."""

    max_balance = 1000  # class attribute 

    def __init__(self, owner, initial_credits=0):
        """Creates credit account with given owner and initial credits.

        Args:
            owner: owner of the account
            initial_credits (optional): credit balance. Defaults to 0.
        """

        self.owner = owner
        self.balance = initial_credits
from credit_account import CreditAccount


credit_account_1 = CreditAccount("Lukas Novak")
credit_account_2 = CreditAccount("Pepa Novak", initial_credits=200)

assert credit_account_1.max_balance == 1000
assert credit_account_2.max_balance == 1000
assert CreditAccount.max_balance == 1000

CreditAccount.max_balance = 10

assert credit_account_1.max_balance == 10
assert credit_account_2.max_balance == 10
assert CreditAccount.max_balance == 10

Atributy třídy používejte k definovaní atributů, které mají mít stejnou hodnoty pro všechny instance třídy. Atributy instancí (objektů) používejte, pokud se mají objekt od objektu lišit.

Přístup k atributům objektu

Narozdíl od jiných programovacích jazyků, jazyk Python přistupuje k atributům objektu přímo (pomocí operátoru tečky). Programátor může modifikovat a číst libovolný atribut/metodu objektu.

Není tedy třeba vytvářet přístupové metody (takzvané gettery a settery) jako v jiných jazycích. Na příštím semináři se k této problematice ještě vrátíme.

Pozor, s tímto faktem přichází velká zodpovědnost, programátor si musí sám uvědomit, jaké zásahy do objektů jsou validní a jaké mohou vést k problémům.

Existuje však způsob, kterým lze komunikovat, že uživatel přistupuje k atribut/metodě, která není zamýšlena jako veřejná (je používaná například pouze interně v rámci objektu). Toho hojně využíváme v případě, že jsou naše metody komplikované a je nutné je rozdělit na několik dílčích metod (které však nejsou zamýšleny pro samotného uživatele).

# PEP8 - metody/atributy, které nejsou zamýšlené jako veřejné 
# pojmenujeme s prefixem podtržítka
class TestClass:
    def __init__(self):
        self._private_data = []

    # většinou se jená o pomocné metody
    def _reverse_order(self):
        pass

    # použité v jiné metodě téže třídy
    def reverse(self):
        return self._reverse_order()

Dědičnost

Koncept dědičnosti je jeden z hlavních konceptů objektově orientovaného programování. Ve zkratce si nyní ukážeme, jak může jedna třída dědit funkcionalitu od jiné.

Vraťme se k předchozímu příkladu. Představme si, že mimo CreditAccount můžeme mít například i BankAccount. Asi si umíme představit, že tyto třídy mohou sdílet učitou funkcionalitu a je tedy zbytečné ji implementovat vícekrát. Z toho důvodu se nabízí realizovat obecnou třídu Account, od které bude dědit třída CreditAccount.

class Account:
    """Represents an account."""

    def __init__(self, owner, initial_balance=0):
        """Creates account with given owner and initial balance.

        Args:
            owner: owner of the account
            initial_balance (optional): initial balance. Defaults to 0.
        """

        self.owner = owner
        self.balance = initial_balance
    
    def transfer_to(self, other, value):
        """Transfer money into another account. Negative balance is allowed.

        Args:
            other: Target of money transfer.
            value: Amount of money to be transfered.
        """
        
        self.balance -= value
        other.balance += value

U definice třídy CreditAccount je nutné zdůraznit první řádek definice, do kulatých závorek uvádíme třídy (oddělené čárkou) z kterých má třída CreditAccount dědit.

V konstruktoru třídy CreditAccount si všimněme použití funkce super(). Na technické úrovni se jedná o poměrně složitou věc, nám bude stačit vysvětlení, které říká, že funkce super() umožňuje přístup k metodám z tříd od kterých dědíme.

import datetime

from account import Account


class CreditAccount(Account):
    """Represents a credit account."""

    def __init__(self, owner, initial_balance=0):
        # vyvolání konstruktoru předka
        super().__init__(owner, initial_balance=initial_balance)

        # dodané atributy
        self.expiration = datetime.datetime.now() + datetime.timedelta(days=365)
    
    # dodaná funkcionalita
    def expires_soon(self):
        """Checks if accounts validate within 30 days."""
        return datetime.datetime.now() + datetime.timedelta(days=30) >= self.expiration


credit_account_1 = CreditAccount("Lukas Novak")
credit_account_2 = CreditAccount("Pepa Novak", initial_balance=200)
# voláme zděděnou metodu
credit_account_2.transfer_to(credit_account_1, 100)
# výsledek 100
credit_account_1.balance

# výsledek True
isinstance(credit_account_1, CreditAccount)
# výsledek také True! 
isinstance(credit_account_1, Account)

Příklad: uživatelsky definovaná výjimka

class InvalidTransferError(Exception):
    """Raised when illegal transfer is issued."""

    def __init__(self, account, transfer, message="Not enough gold."):
        self.account = account
        self.transfer = transfer
        super().__init__(message)
        

class Account:
    """Represents an account."""

    # ...

    def transfer_to(self, other, value):
        """Transfer money into another account. Negative balance is allowed.

        Args:
            other: Target of money transfer.
            value: Amount of money to be transfered.
        """
        
        if self.balance - value < 0:
            raise InvalidTransferError(self, value)  

        self.balance -= value
        other.balance += value


credit_account_1 = Account("Lukas Novak")
credit_account_2 = Account("Pepa Novak", initial_balance=200)

try:
    credit_account_2.transfer_to(credit_account_1, 500)
except InvalidTransferError as err:
    print(f"{err.account.balance} is not enough to transfer {err.transfer}!")
    # handle exception

Mixins

Používání takzvaných mixinů je populární způsob využití vícenásobné dědičnosti. Již jsme zmínili, že do závorek na prvním řádku definice třídy je možné uvádět několik tříd z kterých bude třída dědit funkcionalitu.

Mějme tedy situaci, kdy definujeme třídu Account.

class Account:
    """Represents an account."""

    def __init__(self, owner, initial_balance=0, creation_datetime=datetime.datetime.now()):
        """Creates account with given owner and initial balance.

        Args:
            owner: owner of the account
            initial_balance (optional): initial balance. Defaults to 0.
            creation_datetime (optional): creation datetime. Defaults to datetime.now().
        """

        self.owner = owner
        self.balance = initial_balance
        self.creation_datetime = creation_datetime
    
    def transfer_to(self, other, value):
        """Transfer money into another account. Negative balance is allowed.

        Args:
            other: Target of money transfer.
            value: Amount of money to be transfered.
        """

        self.balance -= value
        other.balance += value

Dále definujeme ExpirableMixin, který dodává funkcionalitu "vypršení platnosti".

class ExpirableMixin:

    def expires_soon(self, length=datetime.timedelta(days=365)):
        """Check if it expires soon or not.

        Args:
            length (timedelta, optional): How long is not expired. Defaults to timedelta(days=365).

        Returns:
            bool: True if expires within 30 days.
        """

        return datetime.datetime.now() + datetime.timedelta(days=30) >= self.creation_datetime + length

Jednoduše pak můžeme vytvořit třídu CreditAccount se stejnou funkcionalitou.

from account import Account, ExpirableMixin


class CreditAccount(Account, ExpirableMixin):
    """Represents a credit account."""

    pass

Hlavní síla mixinů je v jejich modulárnosti a přehlednosti. V kontextu vícenásobné dědičnosti je tzv. diamantový problém v jazyce Python vyřešen tak, že se priority v dědičnosti určují pořadím tříd uvedených v závorkách. Pokud by tedy nastal problém, že obě třídy od kterých naše třída dědí implementují metodu se stejným názvem, bude použita ta metoda jejíž třída je uvedena jako první.

NamedTuple

Ne vždy je potřeba vytvářet celou třídu, takovým případem je reprezentace jednoduchých strukturovaných dat. Jestliže potřebujeme funkcionalitu tuple s pojmenovaním jednotlivých uložených hodnot, můžeme použít collections.NamedTuple.

from collections import namedtuple


Person = namedtuple("Person", ["name", "phone", "email"])

owner = Person("Lukas Novak", "723812052", "novak@gmail.com")

owner.name
owner.phone
owner.email

# funguje jako klasický tuple
assert owner.name == owner[0]

# nelze, je to tuple
owner.name = "Pepa Novak"