Pokročilé techniky v Pythonu - Deskriptory

Deskriptor je jazykový konstrukt Pythonu, který programátoři většinou příliš neznají, přestože se s ním setkávají naprosto na každém kroku. Jak tedy funguje a k čemu se nám může hodit?

Descriptor je jakýkoliv objekt, který definuje jednu z metod __get__, __set__, __delete__ nebo jejich libovolnou kombinaci.

Jakmile takovýto objekt přiřadíme na nějakou třídu jako třídní proměnnou, změní se chování instancí této třídy při práci s touto proměnnou. Začněme základním příkladem:

class SomeDescriptor:
    def __get__(self, instance, owner):
        return 1

class Foo:
    bar = SomeDescriptor()

>>> baz = Foo()
>>> print(baz.bar)
1

Na třídu Foo jsme jako třídní proměnnou uložili instanci třídy SomeDescriptor. Když pak přistoupíme na instanci Foo na její členskou proměnnou bar, tak Python, místo aby vrátil přímo bar, zavolá metodu __get__ a vrátí její výsledek.

Validace vstupů pomocí deskriptoru

K čemu je to dobré? Třeba na jednoduchou validaci vstupů:

class StringType:
    def __get__(self, instance, owner):
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        if not isinstance(value, str):
            raise TypeError(f'Hodnota "{self.name}" musi byt string')

        instance.__dict__[self.name] = value

    def __set_name__(self, owner, name):
        self.name = name

class User:
    name = StringType()

>>> user = User()
>>> user.name = 'Pepa'
>>> print(user.name)
'Pepa' 
>>> user.name = 1
Traceback (most recent call last):
  File "<input>", line 1, in <module>
    user.name = 1
  File "test.py", line 7, in __set__
    raise TypeError(f'Hodnota "{self.name}" musi byt string')
TypeError: Hodnota "name" musi byt string

Asi jste si všimli, že tu používáme ještě metodu __set_name__.
Její podpora byla přidána ve verzi 3.6 a umožňuje zjištění názvu použité třídní proměnné "zevnitř" deskriptoru. Předtím musel být název předán deskriptoru přes konstruktor:

class User:
    name = StringType('name')

Funkce je deskriptor!

Použití deskriptorů je ale daleko širší, v samotném Pythonu se deskriptory používají pro implementaci property a dokonce metod! Metoda totiž v Pythonu není žádný speciální konstrukt jako v jiných jazycích. Je to prachobyčejná funkce, která je přiřazená jako třídní proměnná na třídu a díky tomu, že každá funkce v Pythonu je zároveň deskriptor (má metodu __get__), tak je možné automatické předávání instance třídy jako prvního parametru do funkce při jejím volání jako metody.

Zkusme si implementaci metod v Pythonu napsat sami, bohužel nelze změnit přímo implementaci __get__ na funkci, můžeme si ale pomoct malou oklikou:

def function_as_method(self, value):
    print(self, value)

class HelperDescriptor:
    def __get__(self, instance, owner):
        def wrapper(*args, **kwargs):
            return function_as_method(instance, *args, **kwargs)
        return wrapper

class Foo:
    baz = HelperDescriptor()

>>> bar = Foo()
>>> bar.baz(1)
<__main__.Foo object at 0x7f64f7768b70> 1

Máme tedy funkci function_as_method a chceme, aby při jejím zavolání na objektu bar byl tento objekt předán jako první parametr. Toho docílíme tak, že si uděláme pomocný deskriptor, který vrací wrapper funkci a ta volá function_as_method i s předáním objektu.

Dekorátor použitelný na funkci i metodě pomocí deskriptoru

Další zajímavou možností použití deskriptorů je pro psaní dekorátoru, který se bude dát použít jak na metodě, tak i na funkci. Pokud bychom deskriptor nepoužili, museli bychom zjišťovat, jestli nám do dekorátoru přišel jako první parametr self a podle toho měnit chování.

from contextlib import contextmanager
import time

@contextmanager
def meassure(func_name):
    start = time.time()

    yield

    end = time.time()
    duration = round(end - start, 2)
    print(f'Call of {func_name} took {duration}s')

class Decorator:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        with meassure(self.func):
            return self.func(*args, **kwargs)

    def __get__(self, instance, owner):
        def wrapper(*args, **kwargs):
            return self.__call__(instance, *args, **kwargs)
        return wrapper

class Foo:
    @Decorator
    def bar(self, x):
        pass

@Decorator
def baz(x):
    pass

>>> foo = Foo()
>>> foo.bar(1)
Call of <function Foo.bar at 0x7f49449359d8> took 0.0s
>>> baz(2)
Call of <function baz at 0x7f49449357b8> took 0.0s

Aby dekorátor i něco dělal, máme tu funkci meassure, která se chová jako context manager a bude měřit čas vykonávání dekorované funkce/metody. Pokud dekorátor použijeme na metodě, zavolá se nejprve při konstrukci třídy Foo metoda __init__ na dekorátoru a jako parametr je předána funkce bar. Pokud pak přistoupíme na instanci třídy, tedy foo.bar, je zavolána metoda __get__, která vrací wrapper. Při vykonání - foo.bar() - wrapper zavolá metodu __call__ a jako první parametr předá instanci třídy, která je pak předána dál jako self do bar.

Pokud použijeme dekorátor na funkci, proběhne konstrukce stejně, ale při volání funkce se už nevolá metoda __get__, ale přímo metoda __call__.

Závěrem

Deskriptory programátor nepíše úplně každý den, ale je dobré je znát, protože v samotném jazyce jsou doslova na každém kroku a jejich vhodným použitím se dá dosáhnout velmi zajímavých a jednoduchých řešení.