2. Dekorátory a Objektově orientované programování (OOP)

Dekorátory

Funkce vyššího řádu

Funkce které vracejí funkce nebo přijímají funkce jako argument nazýváme funkce vyššího řádu.

# funkce vyššího řádu přijímající funkci jako argument
def apply_operation(list_, operation):
    """Applies operation on the given list"""
    return operation(list_)


# běžná funkce
def list_sum_squared(list_):
    """Squared sum of all members of given list"""
    return sum(list_) ** 2

# výsledek 36
apply_operation([1, 2, 3], list_sum_squared)
# funkce vyššího řádu vracející funkci
def parent_function():
    print("Parent function is running.")

    local_x = 10

    def local_function():
        print("Child function is running.")
        return local_x
    
    return local_function


# k vnitřní funkci nelze primo přistoupit
local_function()

# k vnitřní funkci se můžeme dostat
local_function = parent_function()

# a následně ji použít
local_function()

# připadně vše v jenom kroku
parent_function()()

Jednoduchý dekorátor

Co je dekorátor můžeme demonstrovat na jednoduchém příkladu. Představme si, že nami definovanou funkci chceme "obalit" další funkcionalitou (například vytisknout řetězec před a po volání).

# dekorátor
def my_decorator(func):
    """Decorator description."""
    def wrapper():
        print("Something before function call")
        func()
        print("Something after function call")
    
    return wrapper


# námi definovaná funkce
def my_function():
    print("Super function!")


# volání původní funkce
my_function()

# objekt funkce <function __main__.my_function()>
my_function

# obalení funkce dekorátorem
my_function_decorated = my_decorator(my_function)

# volání modifikované funkce
my_function_decorated()

# objekt funkce <function __main__.my_decorator.<locals>.wrapper()>
my_function_decorated

Všimněme si, že dekorátor splňuje definici funkce vyššího řádu. Pro jednodušší práci s dekorátory Python nabízí "syntaktický cukr" @nazev_dekoratoru.

# dekorátor
def do_twice(func):
    """Calls a function twice."""
    def wrapper():
        func()
        func()
    
    return wrapper


# dekorování funkce pomoci @
@do_twice
def my_function():
    print("Super function!")

Dekorování funkce s argumenty

V případě, že námi dekorovaná funkce přijímá argumenty narazíme na následující problém.

# dekorátor
def do_twice(func):
    """Calls a function twice."""
    def wrapper():
        func()
        func()
    
    return wrapper


# dekorování funkce pomoci @
@do_twice
def my_function(arg):
    print(f"Super function! {arg}")


# chyba - dekorovaná funkce nepočítá s argumentem
my_function("Ahoj!")

Tuto situaci opravíme následovně.

# dekorátor - podporuje předání argumentů vnitřní funkci
def do_twice(func):
    """Calls a function twice."""
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)
        func(*args, **kwargs)
    
    return wrapper


# dekorování funkce pomoci @
@do_twice
def my_function(arg):
    print(f"Super function! {arg}")


my_function("Ahoj!")

Dekorování funkce vracející hodnotu

Podobný problém nastane pokud funkce vrací hodnotu. Její dekorovaná verze bude tuto hodnotu zahazovat.

# dekorátor
def do_twice(func):
    """Calls a function twice."""
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)
        func(*args, **kwargs)
    
    return wrapper


# dekorování funkce pomoci @
@do_twice
def multiply_list(list_, by):
    """Multiply items from list by given value.

    Args:
        list_: list to be multiplied
        by: value by which items are multiplied

    Returns:
        multiplied list
    """

    result = []

    for item in list_:
        result.append(item * by)
    
    return result


# problém - výsledek nebude vrácen
multiply_list([1, 2, 3], 5)

Jednoduchou modifikací dekorátoru situaci vyřešíme.

# upravený dekorátor
def do_twice(func):
    """Calls a function twice."""
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)
        return func(*args, **kwargs)
    
    return wrapper

Problém identity funkce

Při dekorování funkce dochází ke ztrátě identity funkce, což ovlivňuje i nedosažitelnost původního docstringu. Demonstrace z předchozího příkladu:

# <function __main__.do_twice.<locals>.wrapper(*args, **kwargs)>
multiply_list
# 'wrapper'
multiply_list.__name__ 
# Help on function wrapper in module __main__:
# wrapper(*args, **kwargs)
help(multiply_list)

Situaci vyřešíme použitím dekorátoru functools.wraps, který zachová identitu dekorované funkce.


import functools


def do_twice(func):
    """Calls a function twice."""
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
        func(*args, **kwargs)
        return func(*args, **kwargs)
    
    return wrapper


@do_twice
def multiply_list(list_, by):
    """Multiply items from list by given value.

    Args:
        list: list to be multiplied
        by: value by which items are multiplied

    Returns:
        multiplied list
    """
    
    result = []

    for item in list_:
        result.append(item * by)
    
    return result


# <function __main__.multiply_list(list_, by)>
multiply_list
# 'multiply_list'
multiply_list.__name__
# Help on function multiply_list in module __main__:
#
# multiply_list(list_, by)
#    Multiply items from list by given value.
#    
#    Args:
#        list: list to be multiplied
#        by: value by which items are multiplied
#    
#    Returns:
#        multiplied list
help(multiply_list)

Příklad z praxe

Základní šablona dekorátoru je následující:

import functools


def decorator(func):
    """Description."""
    @functools.wraps(func)
    def wrapper_decorator(*args, **kwargs):
        # kód vykonaný před voláním funkce
        value = func(*args, **kwargs)
        # kód vykonaný po volání funkce
        return value
    return wrapper_decorator

Jedním z příkladu je takzvaný timing funkce (měření doby běhu).

import functools
import time


def timer(func):
    """Print the runtime of the decorated function"""
    @functools.wraps(func)
    def wrapper_timer(*args, **kwargs):
        start_time = time.perf_counter()

        value = func(*args, **kwargs)

        end_time = time.perf_counter()
        run_time = end_time - start_time

        print(f"Finished {func.__name__} in {run_time:.4f} secs")

        return value

    return wrapper_timer


@timer
def waste_some_time(num_times):
    for _ in range(num_times):
        1 + 1


# Finished waste_some_time in 0.0020 secs
waste_some_time(100000) 

Zanořování dekorátorů

Dekorátory je možné zanořovat, pozor, záleží na pořadí!

@do_twice
@timer
def waste_some_time(num_times):
    for _ in range(num_times):
        1 + 1


# Finished waste_some_time in 0.0020 secs
# Finished waste_some_time in 0.0018 secs
waste_some_time(100000) 


@timer
@do_twice
def waste_some_time(num_times):
    for _ in range(num_times):
        1 + 1


# Finished waste_some_time in 0.0035 secs
waste_some_time(100000) 

Dekorátory s argumenty

Dekorátoru je možné předávat argumenty.

def repeat(num_times):
    """Repeats function n times."""
    def decorator_repeat(func):
        @functools.wraps(func)
        def wrapper_repeat(*args, **kwargs):
            for _ in range(num_times):
                value = func(*args, **kwargs)
            return value
        return wrapper_repeat
    return decorator_repeat


@repeat(num_times=4)
@timer
def waste_some_time(num_times):
    for _ in range(num_times):
        1 + 1


# Finished waste_some_time in 0.0013 secs
# Finished waste_some_time in 0.0015 secs
# Finished waste_some_time in 0.0012 secs
# Finished waste_some_time in 0.0012 secs        
waste_some_time(100) 

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

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" obsahuje 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 vlastnosti a metody svých předků (nadtříd). Tím se umožňuje znovupoužití kódu a organizace funkcí a vlastností 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 objektům jednoho typu pracovat s objekty různých typů a chovat se v souladu s jejich specifickými vlastnostmi a funkcemi. To je dosaženo přetížením metod, dědičností a rozhraní. 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.

Více informací zde a zde.

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

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 vlastnosti

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).

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

    # metoda __init__ je volána při vzniku objektu třídy CreditAccount, 
    # obsahuje definici vlastností 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í dvě vlastnosti CreditAccount.owner a CreditAccount.balance na hodnoty owner a initial_credits. Každá instance třídy CreditAccount bude těmito vlastnosti 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 vlastností stejně jako u funkcí a proměnných
# správně
class TestClass:
    def reverse_order(self):
        pass

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

Vlastnosti třídy

Zatím jsme si ukázali, že vlastnosti jsou specifické pro jednotlivé objekty. Pokud zmeníme vlastnost u jednoho objektu, nezmení se u druhého. Vlastnosti definované jako vlastnosti třídy, můžeme využívat napříč všemi instancemi třídy.

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

    max_balance = 1000

    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

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

Přístup k vlastnostem objektu

Narozdíl od jiných programovacích jazyků, jazyk Python přistupuje k vlastnostem objektu přímo (pomoci operátoru tečky). Programátor může modifikovat a číst libovolnou vlastnost/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 vlastnosti/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/vlastnosti, 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é vlastnosti
        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

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"

Zápočtový úkol

Nevíte si rady? Přečtěte si "Jak pracovat s Github Classroom?".

Dobrovolné úkoly