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ými settery a gettery metodami (ala Java)
- pomocí property dekorátorů
- vlastními jednoduchými deskriptory
- hierarchií deskriptorů
- hierarchií deskriptorů s metatřídou
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.


