5. Dunder metody a dekorátory tříd


Obsah


Dunder metody

Dunder metoda (zkráceno z "double underscore method") v Pythonu je speciální metoda, která má dvě podtržítka na začátku a na konci svého názvu, například __init__, __str__ nebo __len__. Tyto metody se také nazývají "magic methods" nebo "special methods", protože poskytují speciální funkce pro práci s objekty a třídami. Přehled dunder metod je dostupný zde.

Dunder metody se volají automaticky při použití určitých operací (např. při volání +, ==, funkce len() nebo přístupu přes index). Implementací těchto metod můžete přizpůsobit chování vlastních objektů pro specifické potřeby. Dunder metody, až na výjimky (__init__() v kombinaci se super()), nevoláme explicitně, proto místo list.__len__() napíšeme raději len(list).

Dunder metody představují elegantní řešení pro implementaci podpory vestavěných funkcí. Většinou je tedy lepší využívat implementace těchto metod pro podporu len(), než implementování vlastní metody object.length(). Protipříkladem je pomyslná třída Vector ve které chceme implementovat výpočet délky vektoru. Pozor, použití len() na instanci třídy Vector není vhodné, funkci len() používáme v kontextu kolekcí pro zjištění počtu prvků. U třídy Vector požadujeme jinou sémantiku. V takovém případě je tedy vhodnější zvolit implementaci metody Vector.length().

String reprezentace

Implementací dunder metod __repr__ a __str__ implementujeme chování funkcí repr() a str()

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
    
    def __repr__(self):
        return f"CreditAccount({self.owner}, {self.balance})"
    
    def __str__(self):
        return repr(self)
from credit_account import CreditAccount


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

credit_account_1
print(credit_account_1)
repr(credit_account_1)

Převod na jiné datové typy

__bool__ # chování funkce bool()
__complex__ # chování funkce complex()
__int__ # chování funkce int()
__float__ # chování funkce float()
__hash__ # chování funkce hash()

Následující příklad dále implementuje dunder metody __bool__ a __int__.

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
    
    def __repr__(self):
        return f"CreditAccount({self.owner}, {self.balance})"
    
    def __str__(self):
        return repr(self)
    
    def __bool__(self):
        return bool(self.balance)
    
    def __int__(self):
        return int(self.balance)
from credit_account import CreditAccount


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

assert bool(credit_account_1) == False
assert bool(credit_account_2) == True

assert int(credit_account_1) == 0

Unární číselné operátory

__abs__ # chování funkce abs()
__neg__ # chování unárního minus
__pos__ # chování unárního plus

Porovnávání

__lt__ # chování <
__le__ # chování <=
__eq__ # chování ==
__ne__ # chování !=
__gt__ # chování >
__ge__ # chování >=

Následující příklad dále implementuje dunder metodu __lt__.

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
    
    def __repr__(self):
        return f"CreditAccount({self.owner}, {self.balance})"
    
    def __str__(self):
        return repr(self)
    
    def __bool__(self):
        return bool(self.balance)
    
    def __int__(self):
        return int(self.balance)
    
    def __lt__(self, other):
        if isinstance(other, CreditAccount):
            return self.balance < other.balance

        return NotImplemented
from credit_account import CreditAccount


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

credit_account_1 < credit_account_2
credit_account_2 < credit_account_1

Aritmetické operátory

__add__ # chování +
__sub__ # chování -
__mul__ # chování *
__truediv__ # chování /
__floordiv__ # chování //
__mod__ # chování %
__divmod__ # chování divmod()
__pow__ # chování ** nebo pow()
__round__ # chování round()

Následující příklad dále implementuje dunder metodu __add__. V případě, že pro nějaký typ nejsou dunder metody implementovány, je nutné vracet konstantu NotImplemented.

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
    
    def __repr__(self):
        return f"CreditAccount({self.owner}, {self.balance})"
    
    def __str__(self):
        return repr(self)
    
    def __bool__(self):
        return bool(self.balance)
    
    def __int__(self):
        return int(self.balance)
    
    def __lt__(self, other):
        if isinstance(other, CreditAccount):
            return self.balance < other.balance

        return NotImplemented
    
    def __add__(self, other):
        if isinstance(other, CreditAccount):
            return self.balance + other.balance

        return NotImplemented
from credit_account import CreditAccount


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

credit_account_1 + credit_account_2

Aritmetické operátory je možné implementovat pro "oba směry". V situaci kdy vyhodnocujeme x + y Python hledá implementaci x.__add__ nebo y.__radd__.

__radd__
__rsub__
__rmul__
__rtruediv__
__rfloordiv__
__rmod__
__rdivmod__
__rpow__

Kombinované operátory přiřazení s aritmetickými operacemi

Je běžné, ne však nutné, aby výsledná metoda vracela self.

__iadd__ # chování +=
__isub__ # chování -=
__imul__ # chování *=
__itruediv__ # chování /=
__ifloordiv__ # chování //=
__imod__ # chování %=
__ipow__ # chování **=

Následující příklad dále implementuje dunder metodu __iadd__. V případě, že pro nějaký typ nejsou dunder metody implementovány, je nutné vracet konstantu NotImplemented.

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
    
    def __repr__(self):
        return f"CreditAccount({self.owner}, {self.balance})"
    
    def __str__(self):
        return repr(self)
    
    def __bool__(self):
        return bool(self.balance)
    
    def __int__(self):
        return int(self.balance)
    
    def __lt__(self, other):
        if isinstance(other, CreditAccount):
            return self.balance < other.balance

        return NotImplemented
    
    def __add__(self, other):
        if isinstance(other, CreditAccount):
            return self.balance + other.balance

        return NotImplemented
    
    def __iadd__(self, value):
        if isinstance(value, int):
            self.balance += value
            return self
        
        return NotImplemented
from credit_account import CreditAccount


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

credit_account_1 += 200

credit_account_1 += credit_account_2

Bitové operátory

__invert__ # chování ~
__lshift__ # chování <<
__rshift__ # chování >>
__and__ # chování &
__or__ # chování |
__xor__ # chování ^

Emulace kolekcí

Důležité, dostaneme se k nim později.

__index__ # chování převodu na integer například při slicingu
__len__ # chování len()
__getitem__ # chování x[20]
__setitem__ # chování x[20] = 2
__delitem__ # chování del x[20]
__contains__ # chování in

Použití implementovaných dunder metod

Implementovanou funkcionalitu můžeme použít rovněž v rámci třídy.

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
    
    def __repr__(self):
        return f"CreditAccount({self.owner}, {self.balance})"
    
    def __str__(self):
        return repr(self)
    
    def __bool__(self):
        return bool(self.balance)
    
    def __int__(self):
        return int(self.balance)
    
    def __lt__(self, other):
        if isinstance(other, CreditAccount):
            return self.balance < other.balance

        return NotImplemented
    
    def __add__(self, other):
        if isinstance(other, CreditAccount):
            return self.balance + other.balance

        return NotImplemented
    
    def __iadd__(self, value):
        if isinstance(value, int):
            self.balance += value
            return self
        
        return NotImplemented
    
    def __isub__(self, value):
        if isinstance(value, int):
            self.balance -= value
            return self
        
        return NotImplemented
    
    def transfer_to(self, other, value):
        """Transfer credit into another credit account. Negative balance is allowed.

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

        self -= value
        other += 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)

credit_account_1
credit_account_2

Příklad: dekorátor pomocí třídy

S využitím dunder metody __call__, která umožní objektu být zavolán jako funkce (takový objekt je tzv. callable), můžeme implementovat dekorátory funkcí jako třídy.

import functools


class CountCalls:
    """
    A decorator class that counts and logs the number of times a function is called,
    while preserving the original function's documentation and signature.
    
    Args:
        start (int): The initial value for the call counter.
    """
    
    def __init__(self, start=0):
        self.start = start 
    
    def __call__(self, func):
        """
        Decorate the given function to count calls.
        
        Args:
            func (callable): The function to decorate.
        
        Returns:
            callable: The wrapped function with call counting.
        """
        self.num_calls = self.start
        self.func = func

        def wrapper(*args, **kwargs):
            self.num_calls += 1
            print(f"Call {self.num_calls} of {self.func.__name__}()")
            return self.func(*args, **kwargs)
        
        # zachováme identitu původní funkce.
        functools.update_wrapper(wrapper, func)
        return wrapper
@CountCalls()
def hello():
    """Says 'Hello!'."""
    print("Hello!")
     

hello()
# Call 1 of hello()
# Hello!
hello()
# Call 2 of hello()
# Hello!

print(hello.__name__)    
# hello
print(hello.__doc__)     
# "Says 'Hello!'"

Dekorátory ve třídách

@property

V jazyce Python nepoužíváme klasické (například Javovské) gettery, settery (tedy metody s názvy get_temperature a set_temperature). To plyne z vlastnosti veřejné dostupnosti všech hodnot objektu (narozdíl od jazyků jako je třeba Java). Vraťme se k jednoduchému příkladu třídy CreditAccount. Atribut CreditAccount.balance je dostupný pomocí tečkového operátoru.
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
from credit_account import CreditAccount


credit_account = CreditAccount("Lukas Novak")

credit_account.balance

Co můžeme dělat pokud chceme například ověřovat, že balance nemůže být nastavena na zápornou hodnotu?

# špatné, ale bohužel časté řešení

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 = 0
        self.set_balance(initial_credits)
    
    def set_balance(self, new_balance):
        if new_balance < 0:
            raise ValueError("Balance cannot be negative number!")

        self.balance = new_balance
from credit_account import CreditAccount


credit_account = CreditAccount("Lukas Novak", 0)

credit_account.set_balance(0)

# negativní hodnotu balance můžeme stále nastavit
credit_account.balance = -100

Pro případy, kdy chceme modifikovat chování přístupu k atributu třídy, je nutné použít dekorátor @property, čímž z něj uděláme vlastnost. Místo atributu balance definujeme pro uchování hodnoty atribut _balance, který začíná podtržítkem, a tedy dle konvence jde o interní atribut, který by uživatel objektu neměl sám měnit.

Pokud definujeme @property bez setteru, vytvoříme tím vlastnost, jejíž hodnotu uživatel nemůže změnit, čímž lze simulovat privátní atribut. Při pokusu o změnu takové vlastnosti totiž nastane AttributeError.

# správně

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 = 0
        self.balance = initial_credits
    
    @property
    def balance(self):
        return self._balance
    
    @balance.setter
    def balance(self, new_balance):
        """Sets new value of balance, new value cannot be negative."""
        if new_balance < 0:
            raise ValueError("Balance cannot be negative number!")

        self._balance = new_balance

    @balance.deleter
    def balance(self):
        self._balance = 0

    @property
    def owner(self):
        return self._owner
from credit_account import CreditAccount

credit_account = CreditAccount("Lukas Novak", 0)

credit_account.balance = 10

# volani deleteru
del credit_account.balance

credit_account.balance

# volani setteru
credit_account.balance = -100
# ValueError: Balance cannot be negative number!

# pokus o zmenu vlastnosti bez setteru
credit_account.owner = "Pavel Novák"
# AttributeError: property 'owner' of 'CreditAccount' object has no setter

# bohužel, pořád bude fungovat
credit_account._balance = -100
credit_account._owner = "Pavel Novák"

@classmethod vs @staticmethod

Nejprve se podívejme na dekorátor @classmethod. Tento dekorátor lze použít v situaci kdy je nutné metodám předat odkaz na celou třídu. Demonstrovat jej můžeme na příkladu metod from_*, tedy metod které umí vytvořit instanci třídy různými způsoby.

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
    
    @classmethod
    def from_csv(cls, input_string, separator=","):
        """Creates CreditAccount class from csv string"""
        owner, initial_credits = input_string.split(separator)

        return cls(owner, int(initial_credits))
from credit_account import CreditAccount


credit_account = CreditAccount.from_csv("Lukas Novak,200")

credit_account.owner
credit_account.balance

Speciální dekorátor @staticmethod naopak nevyžaduje (a nemá) přístup ke své třídě.

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
    
    @staticmethod
    def credit_to_money(credit, exchange_rate):
        """Calculates money value of credits.

        Args:
            credit: amount of credits
            exchange_rate: how many money per one credit

        Returns: money value
        """
        
        return credit * exchange_rate
from credit_account import CreditAccount


# dostupné z třídy
CreditAccount.credit_to_money(100, 20)
credit_account = CreditAccount("Lukas Novak", 200)

# dostupné rovněž z objektu
credit_account.credit_to_money(100, 20)

Příklad: dekorátor třídy

Dekorovat můžeme i samotné třídy - tento dekorátor například vytvoří třídu, ke které lze vytvořit jen jednu instanci.

# příklad převzat z: https://realpython.com/
import functools


def singleton(cls):
    """Make a class a Singleton class (only one instance)"""
    singleton_instance = None

    @functools.wraps(cls)
    def wrapper_singleton(*args, **kwargs):
        nonlocal singleton_instance
        if singleton_instance is None:
            singleton_instance = cls(*args, **kwargs)
        return singleton_instance
    
    return wrapper_singleton


@singleton
class TheOne:
    pass


first_one = TheOne()
another_one = TheOne()

id(first_one)
# 140094218762310

id(another_one)
# 140094218762310

first_one is another_one
# True

@dataclass

Pro rychlé vytvoření třídy lze použít dekorátor tříd @dataclass.

from dataclasses import dataclass

@dataclass
class CreditAccount:
    """Account with stored credits."""
    owner: str
    balance: int = 0

Dekorátor @dataclass přidá automaticky konstruktor:

def __init__(self, owner: str, balance: int = 0):
    self.owner = owner
    self.balance = balance

Dekorátor @dataclass je mnohem komplexnější, celkový popis je možné nalézt zde. Pravděpodobně nás zaskočilo, že uvádíme datový typ u jednotlivých atributů. Jedná se o nápovědu typování, o této možnosti v jazyku Python si řekneme v budoucnu.

Pořadí definic metod

Neexistuje žádný jeden správný způsob v jakém pořadí metody třídy definovat, důležitá je spíše konzistence (chceme udržet metody stejného typu u sebe, nebo slučovat metody). Populární možnost je následující:

class MyClass:
    # Initialization method __init__ (and __new__, __del__ if needed)

    # Representation methods __repr__ and __string__

    # Dunder methods
    
    # @staticmethod a @classmethod

    # @property

    # _private_method(self)

    # public_method(self)