Python, le serpent très dynamique

Magazine
Marque
GNU/Linux Magazine
HS n°
Numéro
49
Mois de parution
août 2010
Spécialité(s)


Résumé
Le langage Python existe depuis près de 20 ans et a acquis au fil du temps ses lettres de noblesse autant auprès des programmeurs débutants que des plus aguerris. Si les premiers aiment bien en lui le manque de cérémonial qui caractérise bon nombre d'autres langages comme le C, les derniers en apprécient les aspects dynamiques qui leur permettent de laisser cours à leur imagination. C'est sur ce dernier point que nous allons nous concentrer aujourd'hui.

Body

1. Des objets dynamiques

1.1 Des propriétés dynamiques

Dans une grande majorité de langages dits « objets », il est indispensable de définir toutes les propriétés d'une classe lors de sa définition. Prenons un exemple en C++, avec une classe basique Voiture, qui possède quelques champs qui ont leur nom et leur type bien définis.

using namespace std;

class Voiture

{

public:

  string plaque;

  string modele;

float prix;

};

int main(){

  Voiture titine;

  titine.plaque = "1234 JG 33";

  cout << "Plaque : " << titine.plaque << endl;

}

Une fois la compilation passée, tout va bien, on a notre numéro de plaque comme on s'y attend :

$ g++ test_voiture.cpp -o test_voiture

$ ./test_voiture

Plaque : 1234 JG 33

En Python, ce code va devenir :

class Voiture :

    def __init__(self):

plaque = ""

        modele = ""

        prix = 0.0

titine = Voiture()

titine.plaque = "1234 JG 33"

print "Plaque :", titine.plaque

Et là encore, le résultat sera atteint :

$ python test_voiture.py

Plaque : 1234 JG 33

Jusque-là, on ne voit pas trop l'intérêt de Python, à part des ; et des {} en moins. Là où il devient intéressant repose sur le fait que le développeur peut ajouter, vérifier ou enlever n'importe qu'elle propriété d'un objet, non pas dans la définition de la classe ou dans la fonction d'initialisation de l'instance (méthode __init__), mais n'importe où dans le code.

Notre exemple peut se transformer en une forme simplifiée :

class Voiture :

    pass

titine = Voiture()

titine.plaque = "1234 JG 33"

print "Plaque :", titine.plaque

Nous avons seulement déclaré Voiture comme une classe vide et là encore, le résultat est le même :

$ python test_voiture.py

Plaque : 1234 JG 33

Le C++ ne sera pas capable d'une telle chose. Sans déclaration préalable d'un champ, la phase de compilation ne va tout simplement pas aboutir :

g++ test_voiture.cpp -o test_voiture

test_voiture.cpp: In function ‘int main()’:

test_voiture.cpp:18: error: ‘class Voiture’ has no member named ‘plaque’

Bien entendu, l'abus de cette fonctionnalité en Python peut vite déboucher sur du code illisible et inmaintenable. Si les différents utilisateurs de la classe ne se mettent pas d'accord sur les propriétés qu'elle définit, le code n'a aucune chance de fonctionner. Ainsi, elle n'est à utiliser que dans des cas restreints, comme quand un attribut n'est utile que temporairement. Elle ne doit pas devenir la norme.

1.2 La propriété __dict__

Il est important de voir qu'un attribut ainsi ajouté n'est pas commun à toutes les instances de la classe Voiture.

class Voiture :

    pass

titine = Voiture()

tuture = Voiture()

titine.plaque = "1234 JG 33"

print "Plaque titine :", titine.plaque

print "Plaque tuture :", tuture.plaque

La sanction est immédiate :

Plaque titine : 1234 JG 33

Plaque tuture :

Traceback (most recent call last):

  File "./test_class.py", line 11, in <module>

print "Plaque tuture :", tuture.plaque

AttributeError: Voiture instance has no attribute 'plaque'

En fait, l'explication est simple : les objets en Python possèdent de base une propriété nommée __dict__, qui est un dictionnaire regroupant l'ensemble des attributs de l'objet :

titine = Voiture()

tuture = Voiture()

titine.plaque = "1234 JG 33"

print "Plaque titine :", titine.plaque

print titine.__dict__

print tuture.__dict__

donne :

Plaque titine : 1234 JG 33

Titine {'plaque': '1234 JG 33'}

Tuture {}

On obtient d'ailleurs le même résultat en remplaçant :

titine.plaque = "1234 JG 33"

par :

titine.__dict__['plaque'] = "1234 JG 33"

Ceci montre bien que __dict__ représente le point d'accès vers les propriétés des objets.

1.3 Exemple de création de propriété

Si l’ajout d'un attribut peut sembler anecdotique, il est pourtant utile dans certains cas. Ici, nous allons prendre une classe qui représente un nœud d'un graphe. Cette classe expose uniquement deux propriétés :

- un identifiant ;

- une liste des nœuds fils.

class Noeud :

    def __init__(self, ident):

self.ident = ident

 self.fils = []

n1 = Noeud(1)

n2 = Noeud(2)

n3 = Noeud(3)

n1.fils.append(n2)

n2.fils.append(n3)

n3.fils.append(n1)

Nous avons une boucle dans notre graphe et définissons une fonction permettant de la détecter. Ici, elle devra identifier un problème. Elle réalise un simple parcours en profondeur et a besoin de savoir si elle est déjà passée sur un nœud ou non. On peut bien sûr utiliser un tableau global à la fonction, mais ce n'est pas très élégant, ni très pratique dans le cadre d'un parcours de graphe qui se prête plus naturellement à des algorithmes récursifs. Nous pouvons plutôt ajouter un attribut deja_vu aux nœuds permettant de savoir si nous l'avons déjà parcouru ou non.

def has_loop(root):

    if root.deja_vu :

        return True

root.deja_vu = True

    r = False

    for n in root.fils:

        r |= has_loop(n)

return r

n1.deja_vu = False

n2.deja_vu = False

n3.deja_vu = False

print "A une boucle :", has_loop(n1)

Elle détecte bien notre boucle :

$ python ./test_noeud.py

A une boucle : True

L’ajout à chaud d'une propriété a évité de la définir dans l'initialisation des nœuds et de l'alourdir inutilement.

1.4 La suppression d'une propriété

Mais nous avons dans notre exemple deux problèmes de nature bien différente :

- Il faudrait faire une boucle sur la vérification has_loop pour tous les nœuds en réinitialisant deja_vu si nous ne sommes pas sûrs de partir de la racine de l'arbre.

- Nous avons laissé traîner une propriété dans nos objets qui n'est plus utile par la suite.

Le premier problème sera laissé en exercice aux lecteurs. Le second peut se résoudre très facilement en Python : la suppression d'un attribut est aussi simple que sa création. Il suffit d'appeler le mot-clé del :

del n1.deja_vu

print n1.deja_vu

donne bien ce que l'on attend de la suppression d'une propriété :

Traceback (most recent call last):

  File "./test_noeud.py", line 29, in <module>

print n1.deja_vu

AttributeError: Noeud instance has no attribute 'deja_vu'

Les développeurs n'ont ainsi plus d'excuse pour laisser des attributs inutiles traîner dans leurs objets.

1.5 Gestion uniforme de l'accès aux propriétés

La méthode d'accès aux propriétés directement par __dict__ est pratique, mais nous verrons un peu plus tard dans la partie sur l'optimisation mémoire que cet accès direct n'est pas valable pour tous les objets. Il est préférable d'utiliser des fonctions du langage qui permettent de gérer toutes ces possibilités en un seul appel.

Elles sont au nombre de quatre :

- getattr : obtenir la valeur d'une propriété ;

- setattr : changer sa valeur ;

- hasattr : savoir si l'objet la possède ;

- delattr : la supprimer.

Ces fonctions sont très simples d'utilisation :

titine = Voiture()

print 'A la propriete plaque', hasattr(titine, 'plaque')

setattr(titine, 'plaque', '1234 JG 33')

print 'Valeur plaque', getattr(titine, 'plaque')

print 'A la propriete plaque', hasattr(titine, 'plaque')

delattr(titine, 'plaque')

On obtient :

A la propriete plaque False

Valeur plaque 1234 JG 33

A la propriete plaque True

Un avantage de cet accès est utile également lorsque le nom des propriétés n'est pas connu lors de l'écriture du code. Lorsque l'on écrit del titine.plaque, il n'est pas possible de ne définir plaque qu'à l'exécution, alors qu'avec l'écriture delattr(titine, variable), il est possible de donner la valeur que l'on souhaite à variable. Nous allons voir un exemple de cela par la suite.

2. Le data driven programming

2.1 L'art d'être feignant

Avant cela, j'aimerai introduire la technique suivante par une toute petite incartade personnelle : je me souviendrai probablement toute ma vie d'une phrase prononcée par un de mes professeurs : « un bon informaticien est un informaticien feignant ». Plus j'y pense, et plus je trouve qu'il a parfaitement raison. Outre le fait qu'il est agréable dans ces conditions de chercher à « être bon », on peut voir dans « bon informaticien » une personne à la recherche de l'efficacité (à opposer à une personne à la recherche de la perfection, qui est contraire à l'efficacité) et dans « feignant », une automatisation des opérations répétitives, ce qui est au final l'essence même de l'informatique. Cette automatisation va permettre d'enlever des opérations manuelles qui sont sources d'erreurs, et va améliorer l'efficacité globale du système.

La technique que nous allons voir ici est l'illustration même de ce principe : on va enlever des opérations manuelles pour le développeur afin de diminuer sa charge et le risque d'erreur par la même occasion. Le fait qu'il ait moins de travail va lui permettre de se concentrer sur les actions à fortes valeurs ajoutées, comme travailler sur l'architecture ou optimiser des algorithmes trop consommateurs.

2.2 Moins de code, (normalement) moins d'erreurs

Cette technique se nomme data driven programming. Elle consiste à ne plus coder « en dur » les opérations que l'on fait sur les propriétés d'un objet, mais de lire ces opérations d'un tableau. Typiquement, ce dernier sera un dictionnaire dont les clés sont les noms des attributs sur lesquels nous allons opérer, et les valeurs sont les opérations à effectuer, typiquement une fonction.

Voici un exemple sur le même code avec et sans la technique. Imaginons ici que nous créons les objets Voitures avec des valeurs lues d'un fichier plat, donc de simples chaînes de caractères. Nous souhaitons changer les champs qui en ont besoin en vraies valeurs :

taquau = Voiture('1234 JG 33', prix='100.0', annee='1999', puissance='50')

tuture_madame = Voiture('9507 JG 60', prix='1500.0', annee='2000', puissance='110')

monstre_monsieur = Voiture('8746 JG 02', prix='17999.9', annee='2009', puissance='140')

voitures = [taquau, tuture_madame, monstre_monsieur]

#Version classique

for voiture in voitures :

 voiture.prix = float(voiture.prix)

 voiture.annee = int(voiture.annee)

 voiture.puissance = int(voiture.puissance)

code

On la change maintenant par une nouvelle version. Le tableau operation liste nos actions à effectuer sur les attributs des voitures :

#Version en data driven

operations = {'prix': float, 'annee' : int, 'puissance' : int}

for voiture in voitures :

 for prop in operations :

val = operations[prop](getattr(voiture, prop))

  setattr(voiture, prop, val)

Et on demande l'affichage :

for voiture in voitures :

 print voiture.__dict__

On obtient :

{'plaque' : '1234 JG 33', 'annee': 1999, 'prix': 100.0, 'puissance': 50}

{'plaque' : '9507 JG 60', 'annee': 2000, 'prix': 1500.0, 'puissance': 110}

{'plaque' : '8746 JG 02', 'annee': 2009, 'prix': 17999.9, 'puissance': 140}

Comme toute technique, elle a ses avantages et ses inconvénients. Dans sa forme actuelle, il n'est pas évident de voir son avantage, car l’ajout d'un argument n'est pas plus simple dans le tableau qu'explicitement dans une ligne de code et obscurcit légèrement le code. Nous allons voir par la suite qu'elle peut se révéler très efficace.

2.3 Complétons le tableau des opérations

Si le gain précédent n'est pas suffisant avec une seule opération, qu'à cela ne tienne, ajoutons-en. Il est rare de n'avoir qu'une opération à faire sur ses objets. Plaçons-nous dans un cas simple : nous souhaitons toujours lire la configuration d'objets depuis un fichier texte. Ces objets possèdent de nombreuses propriétés, certaines étant obligatoires, d'autres non. Étant lues comme des chaînes de caractères, il est nécessaire de les transformer en « objets Python » comme des entiers ou des flottants, comme précédemment. Pour finir, certaines de nos propriétés étant optionnelles, il est important de définir pour celles-ci des valeurs par défaut. De cette manière, une fois lus et gérés, nos objets seront complets, prêts à servir l'application.

Vu que nous avons plusieurs opérations, il serait possible d'utiliser plusieurs dictionnaires. Il est cependant bien plus efficace (entendre « feignant » ici) de n'en utiliser qu'un seul pour toutes.

Un exemple de tableau d'opérations sera le suivant :

#required : indispensable à la définition

#default : valeur par défaut si non définie

#pythonize : transformation du string en «objet»

operations = {'plaque' : {'required' : True},

   'prix' : {'required' : False, 'default' : '0.0', 'pythonize' : float},

   'annee' : {'required' : False, 'default' : '2010', 'pythonize' : int},

   'puissance' : {'required' : False, 'default' : '0', 'pythonize' : int}

}

taquau = Voiture('1234 JG 33', annee='1999', puissance='50')

tuture_madame = Voiture('9507 JG 60', prix='1500.0')

monstre_monsieur = Voiture('8746 JG 02', prix='17999.9', annee='2009', puissance='140')

voitures = [taquau, tuture_madame, monstre_monsieur]

for voiture in voitures :

 for prop in operations :

#vérification de propriété obligatoire

  if operations[prop]['required'] and not hasattr(voiture, prop):

print 'Voiture', voiture, ' manque la propriété', prop

   continue

  if not hasattr(voiture, prop):

   val = operations[prop]['default']

   setattr(voiture, prop, val)

  if 'pythonize' in operations[prop]:

   f = operations[prop]['pythonize']

val = f(getattr(voiture, prop))

  setattr(voiture, prop, val)

On obtient alors les objets suivants :

{'plaque' : '1234 JG 33', 'annee': 1999, 'prix': 0.0, 'puissance': 50}

{'plaque' : '9507 JG 60', 'annee': 2010, 'prix': 1500.0, 'puissance': 0}

{'plaque' : '8746 JG 02', 'annee': 2009, 'prix': 17999.9, 'puissance': 140}

L'avantage de l'utilisation du data driven sur ces traitements réside dans le nombre des attributs et d'opérations gérés de cette manière. Là où avec la méthode manuelle, le développeur avait besoin de l'ordre de O(P*Op) lignes (P étant le nombre de propriétés, Op le nombre d'opérations), ici, c'est de l'ordre de O(P + Op). Si pour notre petit exemple le gain est moyen, pour des objets un peu conséquents, le gain se compte rapidement en centaines de lignes.

Un autre avantage concerne également la localisation des informations sur les attributs. Si un développeur souhaitait en changer un avec la méthode « normale », comme un simple changement de nom, il lui faudrait parcourir le code et changer toutes ses occurrences. Avec cette nouvelle méthode, ils sont tous regroupés en un seul endroit, le tableau des opérations. Le code écrit est, lui, indépendant du nom des propriétés. Le développeur n'a besoin que de changer une occurrence pour que tout son code soit changé.

Cette méthode revient en fait à définir des propriétés à nos attributs : celle-ci est obligatoire et est entière, celle-ci est optionnelle et vaut par défaut 1, etc.

2.4 L'ADN des objets

Nous voyons que finalement, toute l'intelligence de cette méthode réside dans ce tableau d'opérations. Si l'objet était une cellule, ce tableau serait l'ADN, car il définit comment se créent et se comportent nos objets. L'ADN se trouve dans le noyau de nos cellules, mais où placer notre tableau ?

Ce tableau n'est pas spécifique à une instance d'objet, mais bien à toute sa classe. C'est donc dans celle-ci qu'il faut le placer. Cet emplacement est tout bénéfique :

- Il évite des duplications du tableau pour chaque instance.

- Il sera utile en cas d'héritage de classe, chacune ayant son propre ADN.

Mais un problème se pose cependant : comment accéder depuis une instance à cette information stockée dans sa classe ?

Python propose une solution très simple à ce problème : l'instance peut accéder à sa classe grâce à la propriété __class__. La classe sera vue comme un simple objet, comme n'importe quel autre en Python, et ceci concerne également toute la partie dynamique d’ajout de propriétés, comme nous le verrons par la suite.

Pour l'instant, revenons à notre classe Voiture. Sa nouvelle définition devient :

class Voiture :

adn = {'plaque' : {'required' : True},

 'prix' : {'required' : False, 'default' : '0.0', 'pythonize' : float},

 'annee' : {'required' : False, 'default' : '2010', 'pythonize' : int},

 'puissance' : {'required' : False, 'default' : '0', 'pythonize' : int}

 }

 def default_and_complete(self):

operations = self.__class__.adn

  for prop in operations :

#vérification de propriété obligatoire

  if operations[prop]['required'] and not hasattr(self, prop):

#etc

   #etc...

En cas d’ajout de propriétés à Voiture, il suffit de modifier le tableau adn et elle sera gérée comme il faut par le reste du code. La fainéantise (comprendre « efficacité ») à l'état brut.

2.5 ADN et héritage

Cette méthode devient encore plus efficace lorsque le développeur l'utilise pour plusieurs classes. Imaginons de nouvelles classes nommées Conducteur, Entrepôt, et Assurance. Il peut être intéressant de définir une classe mère nommée Item, qui propose la méthode default_and_complete et dont les autres héritent. De cette manière, pour intégrer, vérifier et transformer chaque classe, il suffira de lui définir son tableau adn. Lorsque l'instance va appeler la fonction, elle va rechercher le tableau self.__class__ qui sera bien celui de sa propre classe. Multiplier les classes va permettre de gagner encore en complexité de code, passant de O(C*P*Op) lignes (C étant le nombre de classes) à O(C*P + Op).

Une telle définition peut être :

class Conducteur(Item):

    adn = {'nom' : {'required' : True},

  'prenom' : {'required' : True},

  'age' : {'required' : False, 'default' : '30', 'pythonize' : int}

        }

class Entrepôt(Item):

    adn = {'adresse' : {'required' : True},

  'superficie' : {'required' : True, 'pythonize' : int}

        }

class Assurance(Item):

    adn = {'prix' : {'required' : True, 'pythonize' : float},

  'valididé' : {'required' : False, 'default' : '2010', 'pythonize' : int},

}

A titre d'exemple, cette technique a été utilisée dans le projet Shinken, réimplémentation en Python de Nagios (daemon en C). Dans Nagios, il est possible de définir des objets de manière partielle et le nombre de propriétés des objets est couramment supérieur à 50. Là où la partie de code de Nagios de la création des objets fait environ 15000 lignes, celle de Shinken en fait moins de 900 grâce à l'utilisation massive de cette technique.

3. L'impact de cette dynamicité et sa gestion

3.1 Une consommation mémoire accrue

Nous avons vu une partie de la puissance de la dynamicité de Python. Les miracles et l'informatique n'étant pas faits pour s'entendre, ces techniques ont un coût non négligeable sur les ressources consommées par les applications. Le CPU est impacté, car là où l'accès à une structure est en 0(1) en C ou C++, elle est bien plus consommatrice dans les langages dynamiques. Cependant, la ressource la plus touchée est indéniablement la mémoire. S'il est bien un point soulevé par les administrateurs systèmes lorsqu'ils doivent gérer une application faite dans un langage interprété, c'est le fait qu'ils vont devoir commander beaucoup de barrettes pour leurs serveurs.

Voyons un peu ce qu'il en est dans les faits avec Python. Comme nous l'avons vu, il stocke pour chaque instance un dictionnaire __dict__ avec des chaînes de caractères pour le nom des propriétés et leur valeur. Imaginons une classe Personne qui possède quelques propriétés :

class Personne:

    def __init__(self):

        self.nom = ''

        self.prenom = ''

self.age = 0

        self.adresse = ''

        self.etat_marital = None

        self.passes_temps = []

Si on en crée 100000, nous arrivons à une consommation globale de 60 Mo. Si cette valeur est intéressante, elle n'aide pas à déterminer ce que l'on doit optimiser. C'est pour cela que nous allons utiliser le module guppy, disponible sur le site pypi.org et s'installant simplement :

sudo easy_install guppy

L'utilisation de guppy est fort simple :

from guppy import hpy

hp=hpy()

print hp.heap()

Nous obtenons le détail de la consommation mémoire :

Partition of a set of 325223 objects. Total size = 60656360 bytes.

Index Count   %     Size   % Cumulative % Kind (class / dict of class)

0 99999 31 51999480 86 51999480 86 dict of __main__.Personne

1 100126 31 3619648   6 55619128 92 list

     2 99999 31 3199968   5 58819096 97 __main__.Personne

     3 10852   3   758840   1 59577936 98 str

     4 5606 2 207132 0 59785068 99 tuple

     5    347   0   116696   0 59901764 99 dict (no owner)

     6   1541   0   104788   0 60006552 99 types.CodeType

     7     66   0   101520   0 60108072 99 dict of module

     8    175   0    94840   0 60202912 99 dict of type

     9    194   0    86040   0 60288952 99 type

<88 more rows. Type e.g. '_.more' to view.>

On remarque que sur les 60 Mo consommés par l'application, 51 Mo le sont par les dictionnaires des personnes. Il est vrai que stocker 100000 fois les chaînes nom, prenom, age, etc., est assez consommateur.

3.2 Les __slots__ au secours

Les langages compilés classiques n'ont pas ce problème, car l'accès à une propriété se fait toujours avec le même décalage d'adresse par rapport au début de l'objet. Ils n'ont pas besoin de garder le nom des attributs. Une fonctionnalité de Python permet d'imiter ce fonctionnement, tout en conservant les propriétés dynamiques des objets. Son utilisation est fort simple : il suffit de déclarer les champs que nous souhaitons utiliser dans un tuple nommé __slot__ situé dans la classe. Une restriction cependant : cette dernière doit être une « new style class », qui se dit d'une classe qui hérite de la classe object et qui ne change pas grand chose dans la grande majorité des cas par rapport à une classe normale. L'aspect « nouveau » de ces classes ne doit pas effrayer : elles étaient nouvelles pour la version 2.2 de Python, qui date tout de même de 2003...

__slots__ va en fait prendre la place de __dict__ et ne conserver qu'une seule fois les chaînes de caractères des noms des propriétés pour toutes les instances de la classe.

Voici une utilisation de __slots__ avec notre classe Personne :

class Personne(object):

__slots__ = ('nom', 'prenom', 'age', 'adresse', 'etat_marital', 'passes_temps')

    def __init__(self):

        self.nom = ''

        self.prenom = ''

self.age = 0

        self.adresse = ''

        self.etat_marital = None

        self.passes_temps = []

Et le résultat de la consommation fournie par guppy :

Partition of a set of 225244 objects. Total size = 9858472 bytes.

Index Count   %     Size   % Cumulative % Kind (class / dict of class)

0 99999 44 4399956 45   4399956 45 __main__.Personne

     1 100126 44 3619680 37   8019636 81 list

     2 10860   5   759048   8   8778684 89 str

     3 5610 2 207316 2 8986000 91 tuple

     4    347   0   116696   1   9102696 92 dict (no owner)

     5   1541   1   104788   1   9207484 93 types.CodeType

     6     66   0   101520   1   9309004 94 dict of module

     7    176   0    95360   1   9404364 95 dict of type

     8    195   0    86608   1   9490972 96 type

     9   1465   1    82040   1   9573012 97 function

<87 more rows. Type e.g. '_.more' to view.>

La consommation globale est passée de 60 Mo à un peu moins de 10. Ceci s'explique simplement : le principal consommateur, qui était dict of __main__.Personne, n'est plus présent. Le gain est substantiel, mais n'avons-nous pas sacrifié des possibilités du langage pour quelques Mo de mémoire gagnés ?

Tentons d’ajouter un attribut qui n'a pas été déclaré dans __slots__ :

p = Personne()

p.voiture = 'tuture'

Le verdict est sans merci :

Traceback (most recent call last):

  File "tmp/test_memory.py", line 19, in <module>

p.voiture = 'tuture'

AttributeError: 'Personne' object has no attribute 'voiture'

Horreur et damnation, les __slots__ dans leur forme actuelle empêchent l’ajout à chaud d'attributs. Comme nous l'avons vu, __slots__ remplace __dict__, et c'est lui qui offrait l'aspect dynamique du langage. Heureusement, la solution à ce problème est toute simple : il suffit d’ajouter __dict__ dans le tuple __slots__ pour retrouver nos fonctionnalités manquantes :

class Personne(object):

__slots__ = ('nom', 'prenom', 'age', 'adresse', \

                 'etat_marital', 'passes_temps', '__dict__')

    def __init__(self):

        self.nom = ''

        self.prenom = ''

self.age = 0

        self.adresse = ''

        self.etat_marital = None

        self.passes_temps = []

p = Personne()

p.voiture = 'tuture'

print 'A une voiture?', hasattr(p, 'voiture')

print 'Voiture :', p.voiture

donne bien :

A une voiture? True

Voiture : tuture

On voit bien que l’ajout de propriété est accepté, de même que l'appel hasattr. Cet appel, avec ses confrères getattr, setattr et delattr, s'occupe de rechercher l'information au bon endroit, sans avoir à se soucier si l'attribut est déclaré ou non dans __slots__.

3.3 Les __slots__ et la sérialisation

Un autre souci se pose avec la sérialisation d'objets qui utilisent __slots__. La sérialisation permet de transformer les objets en format plus ou moins binaire pour les échanger entre machines, ou tout simplement les sauvegarder dans des fichiers plats. Le module dédié à cela se nomme cPickle. Son utilisation est fort simple :

import cPickle

a = [1, 2, 3]

#On enregistre a dans toto.dump

f = open('toto.dump', 'w')

cPickle.dump(a, f)

del a

f.close()

#On relit toto.dump

f = open('toto.dump', 'r')

a = cPickle.load(f)

print a

[1, 2, 3]

Mais que se passe-t-il si l'on souhaite sérialiser un objet utilisant __slots__ ? Dans une situation normale, la sérialisation s'occupe du __dict__ pour sauvegarder/relire l'objet. Mais avec __slots__, la description de l'objet se trouve dans sa classe, et ceci n'est pas « sauvegardable » dans un fichier. L'appel dump sur l'objet Personne va échouer :

Traceback (most recent call last):

  File "tmp/test_memory.py", line 26, in <module>

    cPickle.dump(p, f)

  File "/usr/lib/python2.6/copy_reg.py", line 77, in _reduce_ex

    raise TypeError("a class that defines __slots__ without "

TypeError: a class that defines __slots__ without defining __getstate__ cannot be pickled

Python demande qu'une méthode __getstate__ soit définie. Ce sera à elle de renvoyer un dictionnaire, ou plus généralement un objet, comprenant les propriétés que souhaite exporter le développeur. De même, une méthode __setstate__ sera nécessaire pour regénérer l'objet à partir du dictionnaire sauvegardé.

Nous pouvons ajouter les méthodes suivantes à la classe Personne :

   def __getstate__(self):

      return {'nom' : self.nom, 'prenom' : self.prenom, 'age' : self.age, \ 

'adresse' : self.adresse, 'etat_marital' : self.etat_marital, \

        'passe_temps' : self.passes_temps}

                                                                                                                                                                                              

    #setstate mode 'efficace'

def __setstate__(self, state):

        for prop in state :

 setattr(self, prop, state[prop])

Les objets Personne peuvent ensuite être sérialisés sans problème :

f = open('personne.dump', 'w')

cPickle.dump(Personne(), f)

f.close()

f = open('toto.dump', 'r')

p = cPickle.load(f)

print p

On obtient :

<__main__.Personne object at 0xb76cf62c>

Dans notre exemple, nous avons codé getstate à la main. Il peut être utile ici d'appliquer « l'art du bon informaticien » et utiliser un tableau adn pour sauvegarder ou non certaines propriétés.

4. Les metaclass : une classe en crée une autre

4.1 Intérêt et création d'une metaclass

En Python, nous avons vu que les classes sont des objets comme les autres. Qui dit objet dit instanciation. Lors de celle-ci, il est courant de modifier les valeurs des objets. Mais que ceci implique-t-il pour les classes ? Comment pouvons-nous changer ses caractéristiques lors de leur création ? C’est justement le rôle des metaclass. C’est une classe qui va contrôler la création d’une autre.

Sa définition est relativement simple : elle hérite de la classe type et implémente la méthode __new__, qui contrôle la création des classes. Cette méthode prend en paramètre :

- cls : pointeur vers la metaclass qui implémente __new__ ;

- name : nom de la classe créée ;

- bases : liste des classes dont hérite la classe ;

- attrs : liste des attributs déjà connus de la classe.

C'est principalement sur ce dernier argument que nous allons pouvoir influer. Du point de vue de la classe générée, il suffit de faire appel à la metaclass par l’ajout de la propriété __metaclass__ dans sa définition.

4.2 Exemple d'utilisation d'une metaclass

Notre premier exemple de metaclass est très simple : il va se limiter à lister les attributs de la classe générée.

class MetaclassDescription(type):                                                                                                                                                                                          

   def __new__(cls, name, bases, attrs):

print "Cls :", cls                                                                                                                      

      print "Classe :", name

      print "Bases :", bases     

      print "Attributs :"

      for name in attrs:

         print name, ':', attrs[name]                                                                                                                                                                            

      return type.__new__(cls, name, bases, attrs)

class Personne(object):                                                                                                                                                                                              

__metaclass__ = MetaclassDescription                                                                                                                                                                             

    super_attribut = "Description complete"                                                                                                                                                                                                                                                                                                                                                                                 

    def __init__(self):                                                                                                                                                                                              

        self.nom = ''                                                                                                                                                                                                

        self.prenom = ''

        […]

Ceci donne au lancement de l'application :

Cls : <class '__main__.MetaclassDescription'>

Classe : Personne

Bases : (<type 'object'>,)

Attributs :

__module__ : __main__

__metaclass__ : <class '__main__.MetaclassDescription'>

__setstate__ : <function __setstate__ at 0xb7833294>

__slots__ : ('nom', 'prenom', 'age', 'adresse', 'etat_marital', 'passes_temps', '__dict__')

super_attribut : Description complete

__getstate__ : <function __getstate__ at 0xb783364c>

__init__ : <function __init__ at 0xb782de9c>

4.3 Générer __slots__ automatiquement

Si notre précédent exemple permet de visualiser le fonctionnement d'une metaclass, il n'est pas des plus utiles dans la vie d'un développeur. Nous allons chercher encore une fois à être efficace grâce à cette technique. Nous avons vu précédemment l'intérêt de __slots__. Un de ses défauts était qu'il fallait le remplir à la main avec toutes les propriétés qui devaient composer notre objet. C'est fastidieux, et il y a fort à parier qu'au bout d'un moment, le développeur oubliera lors d'un ajout de nouveaux attributs de les y ajouter. L'impact sera uniquement sur la mémoire, il y a donc de fortes chances qu'il passe totalement inaperçu.

Nous allons utiliser une metaclass pour générer automatiquement __slots__. Elle va avoir besoin de la liste des propriétés de la classe, et ceci tombe bien, c'est justement le rôle de notre cher tableau adn.

class AutoSlots(type):

   def __new__(cls, name, bases, attrs):

      slots = attrs.get(’__slots__’, set())

      slots.add('__dict__')

      

      for p in attrs['adn']:

slots.add(p)

attrs['__slots__'] = tuple(slots)

      return type.__new__(cls, name, bases, attrs)

class Personne(object):                                                                                                                                                                                              

    __metaclass__ = AutoSlots

      adn = {'nom' : {'required' : True},                                                                                                                                                                              

           'prenom' : {'required' : True},                                                                                                                                                                           

           'age' : {'required' : True, 'pythonize' : int},

[...]

p = Personne()                                                                                                                                                                                                       

print 'Personne slots :', p.__class__.__slots__

On obtient bien :

Personne slots : ('__dict__', 'nom', 'age', 'prenom', 'adresse', 'etat_marital', 'passes_temps')

5. Les decorators

5.1 Quand le développeur se change en décorateur

Nous avons vu que les classes et leurs instances étaient des objets comme les autres. Une autre famille d'objets au point de vue conceptuel l'est également réellement en Python : les fonctions. Tout comme pour les classes avec les metaclass, nous allons pouvoir contrôler la création et l'exécution des fonctions. Les fonctions possèdent des propriétés comme __doc__, __init__ ou __call__. Ce sont ces deux dernières qui vont nous intéresser ici : __init__ est appelée à la définition de la fonction, __call__ lorsqu'elle est exécutée. Nous pouvons envelopper, ou décorer en terme pythonique, ces appels avec d'autres fonctions.

C'est le rôle des decorators. Ce sont des classes qui vont pouvoir « s'appliquer » sur les fonctions, comme une classe qui hérite d'une autre. La classe décoratrice n'a besoin que de définir elle-même les méthodes __init__ ou __call__, suivant si elle veut modifier la création de la fonction ou son exécution. Il suffit d'appeler le decorator avec le signe @ juste avant la déclaration d'une fonction pour qu'il soit appliqué. Il est possible d'avoir plusieurs decorators sur une fonction, un par ligne. Ils seront tous appelés les uns à la suite des autres.

5.2 Exemple de decorator : memoized

Prenons un exemple classique pour les decorators : memoized. C'est un decorator qui va ajouter à la fonction une propriété cache où vont être stockés tous les résultats des appels de la fonction. Lors de ces appels justement, si les arguments ont déjà été appelés, plutôt que de rééxécuter la fonction, le decorator va renvoyer la valeur mise précédemment en cache.

Cette méthode de cache est sûrement l'une des plus efficaces pour qui veut optimiser la consommation CPU au prix d'une augmentation de sa consommation mémoire.

class memoized(object):

def __init__(self, func):

      self.func = func

      self.cache = {}

def __call__(self, *args):

        if args in self.cache :

            return self.cache[args]

        else:                                                                                                                                                                                             

            self.cache[args] = value = self.func(*args)                                                                                                                                                              

            return value

calls = 0

@memoized

def fibonacci(n):

   global calls

   calls += 1

   if n in (0, 1):                                                                                                                                                                                                   

      return n                                                                                                                                                                                                       

   return fibonacci(n-1) + fibonacci(n-2)                                                                                                                                                                            

                                                                                                                                                                                                                    

fibonacci(5)

print 'calls apres fib5', calls

fibonacci(6)

print 'calls apres fib6', calls

print 'Cache :', fibonacci.cache

On obtient :

calls apres fib5: 6

calls apres fib6: 7

{(0,): 0, (1,): 1, (2,): 1, (3,): 2, (4,): 3, (5,): 5, (6,): 8}

Sans @memoized, on obtiendrait :

Calls apres fib5: 15

Calls apres fib6: 40

Plus une fonction est appelée souvent et avec des arguments similaires, plus cette méthode est efficace. Nous voyons ici que sa mise en place est très facile, et qu'une ligne suffit désormais au développeur pour ajouter un système de cache à une fonction qui n'en avait pas. De plus, celle-ci peut se concentrer sur son rôle (ici, calculer la suite de Fibonacci) et non gérer elle-même un cache pour un problème de performance. Les decorators sont la base du développement orienté aspect, qui consiste à découpler les différents aspects d'un programme (aspect métier, aspect log, etc.).

6. La puissance derrière les écailles du serpent

Nous n'avons fait que soulever quelques facilités apportées par la dynamicité de Python, de nombreuses autres restent encore à explorer, comme les décorateurs de classes ou les properties. Ce langage permet aux développeurs de laisser libre cours à leur imagination. Bien utilisées, ces techniques avancées peuvent faciliter la vie des programmeurs, leur permettant de se concentrer sur des tâches plus importantes que le simple remplissage de fichiers de codes sources, comme l'écriture des tests de non-régressions, ou les réunions d'architectures devant la machine à café.




Article rédigé par

Par le(s) même(s) auteur(s)

Coder des modules pour Shinken et sa WebUI

Magazine
Marque
GNU/Linux Magazine
HS n°
Numéro
62
Mois de parution
septembre 2012
Spécialité(s)
Résumé
Nous avons vu que de nombreuses fonctionnalités optionnelles telles que la sauvegarde des données de rétention en base MongoDB, ou l’authentification au sein de l’interface Web de Shinken étaient fournies à travers des modules. Voyons maintenant comment en créer de nouvelles.

Les derniers articles Premiums

Les derniers articles Premium

Cryptographie : débuter par la pratique grâce à picoCTF

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

L’apprentissage de la cryptographie n’est pas toujours évident lorsqu’on souhaite le faire par la pratique. Lorsque l’on débute, il existe cependant des challenges accessibles qui permettent de découvrir ce monde passionnant sans avoir de connaissances mathématiques approfondies en la matière. C’est le cas de picoCTF, qui propose une série d’épreuves en cryptographie avec une difficulté progressive et à destination des débutants !

Game & Watch : utilisons judicieusement la mémoire

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Au terme de l'article précédent [1] concernant la transformation de la console Nintendo Game & Watch en plateforme de développement, nous nous sommes heurtés à un problème : les 128 Ko de flash intégrés au microcontrôleur STM32 sont une ressource précieuse, car en quantité réduite. Mais heureusement pour nous, le STM32H7B0 dispose d'une mémoire vive de taille conséquente (~ 1,2 Mo) et se trouve être connecté à une flash externe QSPI offrant autant d'espace. Pour pouvoir développer des codes plus étoffés, nous devons apprendre à utiliser ces deux ressources.

Raspberry Pi Pico : PIO, DMA et mémoire flash

Magazine
Marque
Contenu Premium
Spécialité(s)
Résumé

Le microcontrôleur RP2040 équipant la Pico est une petite merveille et malgré l'absence de connectivité wifi ou Bluetooth, l'étendue des fonctionnalités intégrées reste très impressionnante. Nous avons abordé le sujet du sous-système PIO dans un précédent article [1], mais celui-ci n'était qu'une découverte de la fonctionnalité. Il est temps à présent de pousser plus loin nos expérimentations en mêlant plusieurs ressources à notre disposition : PIO, DMA et accès à la flash QSPI.

Les listes de lecture

9 article(s) - ajoutée le 01/07/2020
Vous désirez apprendre le langage Python, mais ne savez pas trop par où commencer ? Cette liste de lecture vous permettra de faire vos premiers pas en découvrant l'écosystème de Python et en écrivant de petits scripts.
11 article(s) - ajoutée le 01/07/2020
La base de tout programme effectuant une tâche un tant soit peu complexe est un algorithme, une méthode permettant de manipuler des données pour obtenir un résultat attendu. Dans cette liste, vous pourrez découvrir quelques spécimens d'algorithmes.
10 article(s) - ajoutée le 01/07/2020
À quoi bon se targuer de posséder des pétaoctets de données si l'on est incapable d'analyser ces dernières ? Cette liste vous aidera à "faire parler" vos données.
Voir les 53 listes de lecture

Abonnez-vous maintenant

et profitez de tous les contenus en illimité

Je découvre les offres

Déjà abonné ? Connectez-vous