Trucs amusants (et parfois dangereux) du langage Python

Trucs amusants (et parfois dangereux) du langage Python

python-logoLe Python est un langage merveilleux, toute personne objective en conviendra (les autres n’étant que des suppôts de Satan, avec deux « a », comme dans « Java »). Même en l’abordant avec quelques vagues connaissances en informatique, sa simplicité d’usage, son naturel et la richesse de ses bibliothèques et frameworks permettent à n’importe quel rookie d’en faire un usage plus que satisfaisant, que ce soit pour du scripting ou de la POO plus dense (ou un mélange des deux pour les coquins).
Le Python est d’autant plus merveilleux qu’on apprend toujours de lui, même après des années d’utilisation quotidienne. Les découvertes amènent un code plus clair, plus concis, plus efficace, mais toujours élégant, que ce soit dans la syntaxe du code ou dans son architecture.
Il est heureux d’aborder ces subtilités de cette façon, étalées dans le temps : leur assimilation devient beaucoup plus efficace, elles sont mieux comprises, mieux maîtrisées et permettent de résoudre, sinon de mitiger, des problèmes qui nous sont devenus concrets avec l’expérience. Bien souvent d’ailleurs, on les découvre en cherchant une solution à un problème; et au détour d’un thread de StackOverflow, lors d’un voyage dans un interpréteur, durant une discussion avec un gourou, TAC, la limpide vérité apparaît, nappée de son évidence immaculée… L’apothéose d’une pensée…

Après cette introduction un brin racoleuse, je me propose d’aller complétement à contre-courant en livrant d’un coup une petite sélection de mécanismes et autres possibilités du langage qui me sont chers personnellement, soit parce que je ne les connaissais pas et qu’elles m’ont grandement facilité la vie, soit parce que je pensais les utiliser (à tort) à bon escient, jusqu’à ce qu’un bug véreux me force à me mettre plus sérieusement dedans.

Mesdames, mesdemoiselles, messieurs, les trip(e)s Python du Maréchal :

List comprehension

S’il y a bien un unique avantage qu’ont les ingénieurs faisant de la modélisation scientifique, et qui sont trop jeunes pour coder en Fortran et trop vieux pour connaître le Python – ceux qui codent en MATLAB® donc -, c’est qu’ils savent combien la boucle for est un véritable poison. Cette dernière est en effet bien souvent coupable de l’inconcevable lenteur des programmes développés avec ce langage. Pour contrer cela, ils utilisent massivement la vectorisation, ce qui est très puissants dans ces milieux où tout ou presque se réduit à des matrices et des vecteurs.
Et bien le Python propose un mécanisme similaire, chez nous appelé « list comprehension ». Calmons-nous tout de suite, les gains en performances ne sont à aucun moment comparables aux facteurs 10.000 souvent atteint en MATLAB®. L’idée est d’ailleurs sensiblement différente : la vectorisation vise à s’affranchir de la boucle for en concentrant le calcul dans une expression, tandis qu’une list comprehension sert avant tout à réduire le champ des possibles de la boucle, notamment en posant les conditions dès le départ.
Imaginons ce bout de code :

a = [12, 1, 4, 6, 83, 7]
b = list()
for x in a:
    if x%2:
        b.append(x)

C’est passablement inélégant et inutilement long. On peut simplifier de cette façon:

a = [12, 1, 4, 6, 83, 7]
b = [x for x in a if x%2]

Là vous me direz que la boucle « existe » toujours et que le gain est vraisemblablement misérable.
C’est exact à cette échelle, mais un programme complet peut présenter des centaines voir des milliers d’occurrences de ce genre de code. En reprenant exactement les exemples ci-dessus, et les faisant tourner 10 millions de fois chacun, j’obtiens en moyenne 8,5 s avec le premier, 6,4 s avec le second, qui est donc 25% plus rapide ! (et encore, la magie des stats m’aurait permis de truander un 33%…)
Ajoutons à cela le fait qu’ici la charge utile de la boucle est quasiment inexistante : un test et un append. Dans le cas où le code devient plus trapu, ou qu’on parcourt plusieurs listes avec des tests récurrents, ou qu’on souhaite accéder à des éléments précis des objets de la liste, … la list comprehension devient un puissant outil de factorisation, de clarification et d’accélération du code.

Prenons la liste d suivante :

d = [{"id": 1, "dico": {"name": "prems", "load": Whatever()}},
     {"id": 3, "dico": {"name": None, "load": Whatever()}},
     {"id": 12, "dico": {"name": "deuz", "load": Whatever()}}]

… où Whatever() instancie une classe possédant une méthode whatever_func qu’on veut appeler avec en argument la valeur du champ "name" associé.
La manipulation suivante :

for x in d:
    if x["id"]%2:
        if x["dico"]["name"]:
            x["dico"]["load"].whatever_func(x["dico"]["name"])
>> prems

…permet de la faire, mais est excessivement sale.
Contrairement à :

for x, y in [z["dico"].values() for z in d if z["id"]%2 and z["dico"]["name"]]:
    x.whatever_func(y)
>> prems

… qui, vous en conviendrez, a tout de même plus de gueule [1].

Alors oui, j’entends l’argument que j’ai bien de la chance que x tombe bien sur l’instance de Whatever(), ce qui m’évite un douloureux AttributeError pas du tout géré dans le cas contraire… Et oui. Mais je vais pas tout faire à votre place non plus !

Pour conclure sur les list comprehension, sans aller jusqu’à promouvoir les codes qui ramènent tous leurs tests dans des listes, générant ainsi des lignes d’une longueur gargantuesque, j’encourage très vivement leur utilisation dès qu’elles permettent de condenser du code de façon lisible.

Decorators (et surtout property)

Les décorateurs, c’est la vie. Vous les utilisez forcément, même sans le savoir. Les routines fournies par des frameworks comme Django ou Flask (pour lier une méthode à une URL, vérifier si l’utilisateur est loggué, …), les classmethod/staticmethod, tout ça c’est des décorateurs.

Je n’ai pas envie de faire le topo sur comment faire des décorateurs, considérez cet article bien complet pour cela.

Ce qui m’intéresse ici est surtout le décorateur property. On le cantonne souvent au rôle d’un simple getter, mais il s’agit de bien plus que ça.
L’usage basique de property est le suivant:

class Test(object):
    def __init__(self):
        self._field = "string"

    @property
    def field(self):
        return self._field

a = Test()
a.field
>> 'string"

Ce qu’on oublie souvent à ce niveau, c’est que la méthode field n’est, justement, plus une méthode field à ce point. Ou, plus précisément, Test.field ne renvoie plus sur la méthode field:

Test.field
>> <property object at 0x01D26900>

En fait, le décorateur property a créé dans Test une variable de classe field pointant sur une instance du type property. Ici, nous n’avons renseigné que le champs getter de property, mais il reste les champs setter et deleter:

class Test(object):
    def __init__(self):
        self._field = "string"

    @property
    def field(self):
        return self._field

    @field.setter
    def field(self, value):
        self._field = value

    @field.deleter
    def field(self):
        del self._field

Bien comprendre ici que les field.setter et field.deleter permettent de définir les champs setter et deleter de field, instance du type property.
Sans utiliser la notation @, l’équivalent de tout cela est :

class Test(object):
    def __init__(self):
        self._field = "string"

    def getter(self):
        return self._field

    def setter(self, value):
        self._field = value

    def deleter(self):
        del self._field

    field = property(fget = getter, fset = setter, fdel = deleter)

L’utilisation du @property permet de définir implicitement une variable de le classe Test de même nom que la méthode décorée, et d’utiliser ce nom pour gérer les champs getter et deleter. Cette « souplesse » apporte en revanche l’obligation d’appeler les méthodes décorées par les setter et deleter du même nom que la la méthode décorée par property, et évidemment de déclarer cette dernière en premier.

C’est très clair, et c’est génial.

Usage du « with »

with, c’est le mot-clef utilisé massivement pour tripatouiller dans des fichiers. Prisonnier de ce rôle ingrat, ce pauvre with mériterait plus de considération car son utilisation peut être beaucoup plus large.
Partons de sa définition. On a l’équivalence (ref : PEP 343) entre ces deux morceaux de code :

with EXPR as VAR: # "as VAR" optionnal
    BLOCKmgr = (EXPR)
exit = type(mgr).__exit__
value = type(mgr).__enter__(mgr)
exc = True
try:
    try:
        VAR = value # only if "as VAR"
        BLOCK
    except:
        exc = False
        if not exit(mgr, *sys.exc_info()):
            raise
finally:
    if exc:
        exit(mgr, None, None, None)

Quelques enseignements de ce code:

  • Il est facile de créer des objets manipulables avec with, il suffit simplement que EXPR renvoie l’instance d’une classe possédant des méthodes __enter__ et __exit__.
  • Le code est blindé, on va pas s’amuser à rentrer dans le with et faire des pâtés de sable sans être sûr de l’existence des méthodes __enter__ et __exit__.
    Inversement, on ne peut sortir du with sans appeler __exit__, même si BLOCK contient un break, un return, ou un raise (merci la double imbrication de try).
  • L’utilisation de with renvoie l’idée de la création d’un contexte temporaire, et peut donc s’appliquer à une grande quantité d’opérations (fichiers, connexions, contrôle de threads, etc.). Assez naturellement, Python propose un module contextlib pour faciliter l’écriture de code pouvant être exploité par with.

Le multithreading en Python

On touche ici une notion intéressante du Python. Ses détracteurs insistent souvent sur une tare présumée du langage : il serait incapable de gérer efficacement le multithreading.

Précisons d’abord les choses, en nous restreignant à l’interpréteur CPython, qui est l’interpréteur Python le plus utilisé (à tel point qu’on parle généralement de lui lorsqu’on évoque l’interpréteur de Python). Concentrons-nous de plus sur la version 2.7 du langage, la version 3 apportant des modifications significatives à ce niveau que je ne maîtrise pas. Dans ces conditions, toute réflexion sur les aspects de multithreading va inexorablement tomber sur le Global Interpreter Lock (GIL de son petit nom).
Je vous invite à lire ce très intéressant article pour saisir le problème et prendre un peu d’avance sur ce qui va être dit plus bas.

La restriction qu’apporte le GIL est que CPython ne peut exécuter qu’une instruction à la fois, et de fait, il ne peut gérer qu’un seul thread à la fois. Lorsqu’il doit distribuer [2] plusieurs threads, c’est à dire plusieurs flux d’exécutions indépendants, sur plusieurs processeurs physiques, l’interpréteur devient le goulot d’étranglement du programme, et les gains en performances par rapport à un programme linéaire sont généralement faibles, voir parfois négatif, puisqu’en plus la distribution sur plusieurs processeurs physiques demandes une intervention beaucoup plus conséquente du noyau, ne serait-ce que pour la cohérence des caches.

La capture d’écran ci-dessous illustre bien ce propos : le processeur à 100% fait tourner un programme très simple faisant à l’infini un incrément sur une variable. Ce programme exploite totalement le processeur, et uniquement dans le user space.

2 programmes Python, un mono-thread, le second avec 10 threads.
2 programmes Python, un single thread, le second possédant 10 threads.

Les autres processeurs sont partiellement occupés par un second programme, qui lance 10 threads contenant chacun exactement la même charge que le premier programme (incrément infini d’une variable). On voit que l’interpréteur CPython plafonne à une utilisation cumulée aux alentours de 180% de processeur, et les barres rouges soulignent la part importante des opérations du kernel space.

Le gain véritable est donc limité, et encore les threads ne sont ici pas inter-dépendants : ils ne travaillent pas sur les mêmes variables, ils ne communiquent pas entre eux… Si c’était le cas, on pourrait attendre une nouvelle dégradation des performances.

La solution ici est donc de travailler sur différents process, donc utiliser os.fork ou encore la bibliothèque multiprocessing (très agréable à utiliser). Chaque process étant dévolue à un interpréteur dédié, l’exploitation des processeurs est bien meilleure comme on peut le voir ci-dessous (toujours la même charge que précédemment, mais dans 5 process différents).

5 process
5 process

Cela signifie-t-il qu’il faille simplement bannir tout usage du multithreading dans Python ?

Évidemment que non !

Les exemples ci-dessus montrent qu’il ne faut pas utiliser les threads Python pour du calcul distribué. Dans ce cas, effectivement, CPython n’est pas pertinent et inefficace. Mais il s’agit d’un cas de figure très spécifique. Dans la vraie vie, que font la plupart des programmes ? Ils guettent une action utilisateur, ils lisent un fichier, ils requêtent une base de données… bref ils attendent ! Et dans ce cas l’interpréteur a largement le temps de gérer sa petite affaire sans ralentir qui que ce soit.

Donc si votre programme à besoin dans un coin d’un morceau de code indépendant du reste, comme par exemple surveiller de temps en l’espace restant sur un disque, ou lancer une commande shell et attendre qu’elle se termine sans bloquer le fil d’exécution principale du programme, les threads ont toute leur place ! Encore plus de part la richesse de la bibliothèque threading, qui permet une gestion très souple (et – grosse foire aux modules – les communications deviennent un jeu d’enfant si on ajoute la bibliothèque Queue).

Pour conclure sur ce point: oui, il y a bien une anguille sous CPython pour ce qui concerne les thread… Mais il est très réducteur de condamner directement leur usage ! Avec un peu de compréhension, on peut leur trouver une utilité très légitime.

…et encore d’autres

La liste est infinie tant le Python est riche. Je me permets cependant d’ajouter quelques item qui aurait peut-être mérité plus de précision (mais j’ai la flemme) :

  • L’opérateur is, trivial par définition mais curieux et finalement assez dangereux si on l’utilise pour des string ou des entiers/réels. Par exemples :
"aa" is "a" + "a"
>> True
"aa" is "a" + 1*"a"
>> False

ou

120*2 is 240
>> True
130*2 is 260
>> False

Il vaut mieux en rester à sa définition et ne l’utiliser que pour vérifier si 2 instances sont effectivement les mêmes.

  • Les metaclass, puissant mécanisme de configuration de classes à leurs instanciations.
    Beaucoup d’experts déconseillent vivement leurs utilisations; pour reprendre une citation de Tim Peters tirée du thread de stack overflow proposé plus haut : « Metaclasses are deeper magic than 99% of users should ever worry about. If you wonder whether you need them, you don’t. The people who actually need them know with certainty that they need them, and don’t need an explanation about why. »
    Non seulement elles rendent le code illisible, mais elles sont souvent révélatrices d’une mauvaise compréhension d’un problème (et d’une résolution inutilement complexe et dangereuse).
  • Les conditions inline qui permet de densifier du code et sont pratiques pour des petites vérifications (pour les plus grosses avec du traitement, ça devient vite illisible) :
mesg = "ok" if test_is_ok() else "error"
  • Les fonctions lambda, ces vils bouts de codes chers aux paresseux. Ça c’est mal. N’en faites pas. Jamais.
  • Les fonctions built-in filter et reduce. Notez que filter permet dans certains cas de simplifier l’écriture des lists comprehension, par exemple :
b = [x for x in a if x%2] # exemple pris plus haut

devient

b = filter(lambda x: x%2, a) # lambda = beurk
  • Les arguments par défauts dans la déclaration d’une fonction. L’argument par défaut étant créé en même temps que la fonction, attention aux effets de bord…
def test(l=[]):
    l.append(1)
test() # l était [] et devient [1]
>> [1]
test([]) # ici on travaille sur la liste passée en argument, mais on
         # a toujours l qui traîne dans un coin avec la valeur [1]...
>> [1]
test() # l était [1] et devient [1, 1]
>> [1, 1]
test() # etc.
>> [1, 1, 1]

Conclusion:

Faites du Python ! C’est beau, pur, ça dégage l’esprit, et contrairement au Tai Chi, on n’est pas obligé de supporter cet odeur d’encens et ces sons de cloches horribles.
Y paraitrait même que certains milieux s’arracheraient les développeurs Python… À bon entendeur !

 

1. Ici vous remarquerez que je ne la ramène pas pour les perfs, vu que sur cet exemple je n’ai pas vu de gain convaincant (plutôt une légère perte…)

2. L’interpréteur ne « distribue » évidemment pas les instructions tout seul sur les processeurs, plusieurs acteurs interviennent dans l’affaire, à commencer par le noyau, voire même des routines hardware, par exemples si les processeurs intègrent du multithreading.

2 réflexions sur « Trucs amusants (et parfois dangereux) du langage Python »

  1. Salut !
    j’ai atterri par hasard sur cette page que j’ai trouvé intéressante.
    Je suis débutant en python (pour le loisir) mais j’arrive bien à progresser tranquillement.

    Voici 2 bibliothèques que j’aimerai beaucoup apprendre à maitriser :
    http://www.brython.info/
    http://www.skulpt.org/
    Penses tu un jour faire une espèce de tuto ou juste des exemples ?
    J’ai quelques notions avec Cherrypy, mais j’ai du mal à faire fonctionner tout cela ensemble et c’est bien dommage !

    Dans l’ espoir d’avoir une réponse, bonne continuation pour ton site.

    1. Bonsoir,

      Merci pour ton message !

      Désolé, mais je ne pense pas pouvoir beaucoup t’aider pour ces bibliothèques, car je suis vraiment gauche lorsqu’on parle de rendu utilisateur, mon travail et mes intérêts me conduisant à trifouiller quasi-exclusivement côté serveur.
      Si je trouvais le temps d’écrire actuellement, ce serait plutôt sur MongoDB ou RabbitMQ.

      Je te souhaite bonne route dans ta quête de connaissance !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.