Python - jsou deskriptory pomalé?

Konečně jsem se po dlouhé době dostal ke shlédnutí výborné přednášky Python3 metaprogramming. Příklady, co David Beazley ukazoval, mě inspirovali k napsání pár testů rychlosti deskriptorů a dalších pokročilejších technik v Pythonu.

Co je deskriptor

Deskriptor je libovolný objekt, který implementuje metody __get__, __set__ a __delete__. Pokud pak v nějaké třídě přiřadíme nějakému třídnímu atributu tento deskriptor jako hodnotu, změníme způsob, jakým se bude k tomuto atributu přistupovat. Místo přímého přístupu se pak volají právě metody deskriptoru.

Např.:

class NejakyDeskriptor(object):
    def __set__(self, obj, value):
        print('set')

    def __get__(self, obj, type=None):
        print('get')


class Foo(object):
    boo = NejakyDeskriptor()

foo = Foo()
foo.boo = 1
foo.boo

Různé implementace

Pro benchmark naimplementuji třídu Stock (podobnou jako David v přednášce) pěti způsoby:

Klasické settery a gettery

class Stock(object):

    def get_shares(self):
        return self._shares

    def set_shares(self, shares):
        if not isinstance(shares, int):
            raise TypeError('Expected int')
        if shares < 0:
            raise ValueError('Expected value >= 0')
        self._shares = shares

Máme tedy třídu StockClassic, která má setter set_shares. Ten provádí kontrolu na typ a hodnotu a poté nastaví "privátní" atribut. Podobně nainmplementujeme i ostatní metody.

Tento způsob je velmi jednoduchý a samozřejmě také rychlý. Znamená ale opakované psaní spousty kódu, zkusme to lépe.

Property

class Stock(object):

    @property
    def shares(self):
        return self._shares

    @shares.setter
    def shares(self, shares):
        if not isinstance(shares, int):
            raise TypeError('Expected int')
        if shares < 0:
            raise ValueError('Expected value >= 0')
        self._shares = shares

V tomto případě použijeme dekorátor property, který vytvoří deskriptor volající námi definované metody.

S takovýmto kódem se trochu lépe pracuje (nemusíme volat metody, můžeme přistupovat "přímo" k atributům), ale psaní tříd je stále hodně zdlouhavé a opakuje se nám stejný kód pro validace.

Jednoduché deskriptory

Property můžeme snadno přepsat na jednoduchý znovupoužitelný deskriptor.

class PositiveFloat(object):
    def __init__(self, name):
        self.name = name

    def __set__(self, obj, value):
        if not isinstance(value, float):
            raise TypeError('Expected float')
        if value < 0:
            raise ValueError('Expected value >= 0')
        obj.__dict__[self.name] = value

    def __get__(self, obj, type=None):
        obj.__dict__.get(self.name, None)

class Stock(object):
    name = String('name')
    shares = PositiveInteger('shares')
    price = PositiveFloat('price')

Pokaždé když přistupujeme k atributům name, shares a price, volají se metody deskriptorů. V metodě __set__ deskriptor provede validace a poté přistoupí přímo ke slovníku objektu a zapíše na něj novou hodnotu atributu. Stejným způsobem můžeme snadno přepsat i PositiveFloat a String.

Tady už nějaký kód při psaní tříd ušetříme, protože můžeme deskriptory používat stále dokola. Samotné deskriptory ale obsahují duplicitní kód validací, zkusme to vyřešit dědičností.

Hierarchie deskriptorů

class Descriptor(object):
    def __init__(self, name=None):
        self.name = name

    def __set__(self, obj, value):
        obj.__dict__[self.name] = value

    def __get__(self, obj, type=None):
        return obj.__dict__.get(self.name, None)

class Typed(Descriptor):
    def __set__(self, obj, value):
        if not isinstance(value, self.type):
            raise TypeError('Expected %s' % self.type)
        super(Typed, self).__set__(obj, value)

class String(Typed):
    type = str

class Integer(Typed):
    type = int

class Float(Typed):
    type = float

class Positive(Descriptor):
    def __set__(self, obj, value):
        if value < 0:
            raise ValueError('Expected value >= 0')
        super(Positive, self).__set__(obj, value)

class PositiveInteger(Integer, Positive):
    pass

class PositiveFloat(Float, Positive):
    pass

Nyní již se nám žádný kód validací neopakuje.

V deskriptorech používáme vícenásobnou dědičnost, která může na první pohled vypadat dost složitě. Co se tedy ve skutečnosti děje když se zavolá metoda __set__ na instanci PositiveInteger? Na pořadí předků záleží, zavolají se tedy metody __set__ na Typed, Positive a Descriptor. Jak přesně vypadá pořadí tříd, kde se budou hledat metody, můžeme zjistit zavoláním PositiveInteger.mro().

Stále jsme se ale nezbavili jedné duplicity a to opakování názvu atributu v konstruktoru deskriptorů, což můžeme vyřešit pomocí metatřídy.

Hierarchie deskriptorů s metatřídou

Třída je továrna na objekty. Podobně metatřída je továrna na třídy. Metatřídy nám umožňují provádět různé šikovné úpravy na třídách při jejich vytvoření.

class MyMeta(type):
    def __init__(cls, classname, bases, clsdict):
        for name, item in clsdict.iteritems():
            if isinstance(item, Descriptor):
                item.name = name

class Stock(object):
    __metaclass__ = MyMeta

    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

V Pythonu2 určíme metatřídu pomocí atributu __metaclass__. Metatřída je třída jejímž rodičem je type místo object. V naší metatřídě v metodě __init__ proiterujeme celý slovník třídy a pokud je nějaký prvek náš deskriptor, tak mu nastavíme jméno.

Máme hotovo. V Pythonu 3 bychom mohli ještě pomocí __signature__ vytvořit generickou __init__ metodu, ale do toho se již pouštět nebudu, mrkněte případně na Davidovu přednášku.

Benchmark

Nad každou implementací jsem milionkrát provedl zapsání a načtení hodnoty. Zde jsou výsledky:

Python 2.7.9

metoda čas poměr
Classic get 0.668046 1
Classic set 1.484425 1
Property get 0.921953 1.38
Property set 1.936587 1.3
Descriptor get 1.635823 2.45
Descriptor set 2.303607 1.55
DescriptorHierarchy get 1.595446 2.39
DescriptorHierarchy set 4.933396 3.32
DescriptorHierarchyWithMeta get 1.678463 2.51
DescriptorHierarchyWithMeta set 4.915713 3.31

Python 3.4.2

metoda čas poměr
Classic get 1.028834 1
Classic set 1.848903 1
Property get 1.073665 1.04
Property set 1.860438 1.01
Descriptor get 1.9231 1.87
Descriptor set 2.660129 1.44
DescriptorHierarchy get 1.889621 1.84
DescriptorHierarchy set 6.273938 3.39
DescriptorHierarchyWithMeta get 1.880042 1.83
DescriptorHierarchyWithMeta set 6.252514 3.38

PyPy 2.4.0

metoda čas poměr
Classic get 0.015187 1
Classic set 0.013867 1
Property get 0.009597 0.63
Property set 0.015619 1.13
Descriptor get 0.01784 1.17
Descriptor set 0.018436 1.33
DescriptorHierarchy get 0.082476 5.43
DescriptorHierarchy set 0.098304 7.09
DescriptorHierarchyWithMeta get 0.088534 5.83
DescriptorHierarchyWithMeta set 0.101391 7.31

Závěr

Použítí složitých hierarchií deskriptorů a metatříd rozhodně není zadarmo, zároveň nám ale mohou ušetřit psaní spousty kódu. Nad jejich použitím bychom tedy měli přemýšlet a vyvarovat se nadužívání těchto technik.

Jako velmi zajímavý se v testu ukázal výkon JIT překladače PyPy, který rychlostí mnohonásobně překonal výchozí CPython.

K dispozici je i celý zdrojový soubor benchmarku.