Partie à trois : Python, __slots__ et metaclass
( 15 Feb 2010 )NOTE 2021: je savais trouvé mes titres quand même ^^
Les langages dynamiques sont pratiques pour se frotter facilement à de nouveaux paradigmes de programmations. Aucune technique n'étant parfaite, l'aspect dynamique se paye.
Le prix à payer pour les langages dynamiques
Bien souvent on pense au coût CPU pour ces langages, mais cette ressource n'est pas la seule à prendre cher. Là où un accès à une structure est en 0(1) en C ou C++, il peut être plus élevé dans des langages où les propriétés des objets ne sont pas identiques entre les instances.
Il en est de même pour la RAM : les objets pouvant avoir de nouvelles propriétés à chaud, leur accès se fait en vérifiant le dictionnaire dict des objets.
Le dictionnaire est fort simple:
class Test:
def __init__(self, x, y):
self.x = x
self.y = y
point = Test(1, 2)
print 'Initialement', point.__dict__
point.z = 3
print 'Apres', point.__dict__
Donne :
Initialement {'y': 2, 'x': 1}
Apres {'y': 2, 'x': 1, 'z': 3}
C'est sympa, c'est dynamique. Mais ceci a un coût en Mémoire :
- ici nous avons un seul objet, mais si nous avons 1000 points, chacun aura son propre dict indépendant
- et surtout les chaînes 'x', 'y' et 'z' seront dupliquées dans chaque instance.
Imaginons que nous avons 1000000 de points à conserver, la consommation de RAM va être de 176mo sur notre exemple (Python 2.6.4).
Si nous prenons des classes avec des noms de propriétés plus grandes que 'x', on peut atteindre des sommets en terme de consommation de RAM pour finalement pas grand chose.
Le module guppy (disponible sur pypi de mémoire) peut être très pratique pour observer qui consomme de la RAM dans notre application.
Son utilisation est fort simple :
from guppy import hpy
hp=hpy()
print hp.heap()
Sa sortie est (relativement) éloquente :
Partition of a set of 2024657 objects. Total size = 173885852 bytes.
Index Count % Size % Cumulative % Kind (class / dict of class)
0 999999 49 135999864 78 135999864 78 dict of __main__.Test
1 999999 49 31999968 18 167999832 97 __main__.Test
2 127 0 4073248 2 172073080 99 list
3 10686 1 744928 0 172818008 99 str
4 5540 0 203368 0 173021376 100 tuple
5 347 0 115160 0 173136536 100 dict (no owner)
6 1539 0 104652 0 173241188 100 types.CodeType
7 64 0 100480 0 173341668 100 dict of module
8 175 0 94840 0 173436508 100 dict of type
9 194 0 86040 0 173522548 100 type
78% de la consommation mémoire est due aux dict de nos points, les valeurs de ces instances consommant quant à elles 18%.
slots : c'est les soldes pour Python
Lorsque l'on sait à l'avance quelles vont être les possibilités des noms de propriétés de nos instances, il peut être pratique de recourir à l'utilisation des slots. C'est un tuple dans la classe où les noms des propriétés vont être mises en commun pour toutes les instances de la classe. Attention, son utilisation est fort simple, mais elle limite certaines possibilités de Python par la suite, comme certains problèmes avec tout ce qui touche la sérialisation d'objet par exemple.
Si vous souhaitez l'utiliser, c'est fort simple, il suffit de rajouter le tuple à la classe si elle hérite d'object :
class Test(object):
__slots__ = ('x', 'y', 'z')
def __init__(self, x, y):
self.x = x
self.y = y
Si simple? Non en fait. le slots va remplacer dict qui va tout simplement disparaitre!
Notre code va lamentablement échouer avec:
Initialement
Traceback (most recent call last):
File "test_slot.py", line 10, in <module>
print 'Initialement', point.__dict__
AttributeError: 'Test' object has no attribute '__dict__'
Pour contourner cela, il suffit de rajouter dict au slots:
class Test(object):
__slots__ = ('__dict__', 'x', 'y', 'z')
def __init__(self, x, y):
self.x = x
self.y = y
On relance, la consommation passe à 47Mo. (Les gains sont encore plus importants avec des chaînes de plus d'un caractère :) ). Pour Shinken par exemple, avec 100000 services, j'étais à plus de 2Go de RAM consommée, avec les slots, je suis tombé à moins 50Mo environs...
Metaclass : une classe pour en modifier d'autres
En Python, on a déjà vu que les classes sont des objets comme les autres. Qui dit objet dit instanciation. Lors de cette instanciation, il peut être pratique de changer des choses à la volée. C'est justement le rôle des metaclass. C'est une classe qui va contrôler la création d'une autre. Elles peuvent être utilisées pour par exemple tracer automatiquement tous les appels de méthode d'une classe. Pour un tel exemple, voir sur http://www.afpy.org/Members/kerflyn/metaclass qui présente très bien cela.
On mixe le tout
Vous allez me dire: bon c'est bien les metaclass, mais c'est quoi le rapport avec les slots? Et bien c'est pratique lorsque l'on a beaucoup de propriétés dans une classe, comme par exemple Service ou Host de Shinken. Jusqu'à maintenant, lorsque je rajoutait une nouvelle propriété à ces classes, je rajoutais une ligne dans le tableau properties ou running_properties, mais je devais penser à rajouter ce même paramètre dans le tuple slots de la classe. Autant dire qu'une fois sur deux, j'oubliais. De plus, ça fait un gros pâté en début de classe, et je n'aime pas ça.
Je suis tombé sur http://code.activestate.com/recipes/435880/ qui présente comment générer automatiquement le tuple slots pour ses classes en regardant tout simplement les variables fournies à init (il semble créer d'ailleurs une liste qui doit être changée en tuple par l'interpréteur). Bon pour les Host et Service, il n'y a qu'un seul paramètre, un tableau de construction. Mais ça m'a donné l'idée d'adapter ce code pour qu'il utilise les tableaux properties et running_properties de mes classes qui contiennent toutes les propriétés de mes objets.
Edit : Merci à Bertrand Mathieu pour la simplification du code par set.
Ceci donne au final la classe AutoSlots suivante :
class AutoSlots(type):
def __new__(cls, name, bases, dct):
slots = dct.get('__slots__', set())
#Now get properties from properties and running_properties
if 'properties' in dct:
slots.update((p for p in dct['properties']))
if 'running_properties' in dct:
lots.update((p for p in dct['running_properties']))
dct['__slots__'] = tuple(slots)
return type.__new__(cls, name, bases, dct)
Qui est appelée avec :
class Service(SchedulingItem):
#AutoSlots create the __slots__ with properties and
#running_properties names
__metaclass__ = AutoSlots
[..]
Maintenant les slots sont construits à la volée, et il n'y a plus de risque d'oublier des paramètres et mes classes Host/Service se re-concentrent un peu sur ce qu'elles doivent faire, et non sur une astuce pour contourner une consommation excessive de RAM par Python.
NOTE 2021: c'est quand même con de devoir en arriver là pour gagner de la ram non?