Tout ce qui fait que Python 3 est meilleur que Python 2 21 Recently updated !
jeudi 26 janvier 2017 à 15:17Avec de nombreuses distros Linux qui viennent avec Python 3 par défaut ainsi que Django et Pyramid qui annoncent bientôt ne plus supporter Python 2, il est temps de faire un point.
Python 3 est aujourd’hui majoritairement utilisé pour tout nouveau projet ou formation que j’ai pu rencontrer. Les plus importantes dépendances ont été portées ou possèdent une alternative. Six
et Python-future
permettent d’écrire facilement un code compatible avec les deux versions dans le pire des cas.
Nous sommes donc bien arrivés à destination. Il reste quelques bases de code encore coincées, la vie est injuste, mais globalement on est enfin au bout de la migration. Mais ça en a mis du temps !
Il y a de nombreuses raisons qui ont conduit à la lenteur de la migration de la communauté :
- Python 2 est un très bon langage. On ne répare pas ce qui marche.
- Il y a beaucoup de code legacy en Python 2 et ça coûte cher de migrer.
- La PSF a été trop gentille avec la communauté et l’a chouchoutée. En JS et Ruby ils ont dit “migrez ou allez vous faire foutre” et tout le monde a migré très vite.
Mais je pense que la raison principale c’est le manque de motivation pour le faire. Il n’y a pas un gros sticker jaune fluo d’un truc genre “gagnez 30% de perfs en plus” que les devs adorent même si ça n’influence pas tant leur vie que ça. Mais les codeurs ne sont pas rationnels contrairement à ce qu’ils disent. Ils aiment les one-liners, c’est pour dire.
Pourtant, il y a des tas de choses excellentes en Python 3. Simplement:
- Elles ne sont pas sexy.
- Elles ne se voient pas instantanément.
- Personne n’en parle.
Après 2 ans de Python 3 quasi-fulltime, je peux vous le dire, je ne veux plus coder en Python 2. Et on va voir pourquoi.
Unicode, mon bel unicode
On a crié que c’était la raison principale. A mon avis c’est une erreur. Peu de gens peuvent vraiment voir ce que ça implique.
Mais en tant que formateur, voilà ce que je n’ai plus a expliquer:
- Pourquoi un accent dans un commentaire fait planter le programme. Et
# coding:
- Pourquoi il faut faire
from codecs import open
et non pas justeopen
. - Pourquoi
request.get().read() + 'é'
fait planter le programme. Etencode()
etdecode()
. - Pourquoi
os.listdir()[0] + 'é'
fait afficher des trucs chelous. - Pourquoi
print(sql_row[0])
fait planter le programme ou affiche des trucs chelous. Ou les deux.
Et je peux supprimer de chacun de mes fichiers:
- Tous les
u
ou lesfrom __future__/codecs
- Les en-tête d’encoding.
- La moitié des
encode
/decode
.
Et toutes les APIS ont un paramètre encoding
, qui a pour valeur par défaut ‘UTF8′.
Debuggage for the win
Des centaines d’ajustements ont été faits pour faciliter la gestion des erreurs et le debuggage. Meilleures exceptions, plus de vérifications, meilleurs messages d’erreur, etc.
Quelques exemples…
En Python 2:
>>> [1, 2, 3] < "abc" True |
En Python 3 TypeError: unorderable types: list() < str()
of course.
En Python 2, IOError
pour tout:
>>> open('/etc/postgresql/9.5/main/pg_hba.conf') Traceback (most recent call last): File "<stdin>", line 1, in <module> IOError: [Errno 13] Permission denied: '/etc/postgresql/9.5/main/pg_hba.conf' >>> open('/etc/postgresql/9.5/main/') Traceback (most recent call last): File "<stdin>", line 1, in <module> IOError: [Errno 21] Is a directory: '/etc/postgresql/9.5/main/' |
En Python 3, c'est bien plus facile à gérer dans un try
/except
ou à debugger:
>>> open('/etc/postgresql/9.5/main/pg_hba.conf') Traceback (most recent call last): File "<stdin>", line 1, in <module> PermissionError: [Errno 13] Permission denied: '/etc/postgresql/9.5/main/pg_hba.conf' >>> open('/etc/postgresql/9.5/main/') Traceback (most recent call last): File "<stdin>", line 1, in <module> IsADirectoryError: [Errno 21] Is a directory: '/etc/postgresql/9.5/main/' |
Les cascades d'exceptions en Python 3 sont très claires:
>>> try: ... open('/') # Erreur, c'est un dossier: ... except IOError: ... 1 / 0 # oui c'est stupide, c'est pour l'exemple ... Traceback (most recent call last): File "<stdin>", line 2, in <module> IsADirectoryError: [Errno 21] Is a directory: '/' During handling of the above exception, another exception occurred: File "<stdin>", line 4, in <module> ZeroDivisionError: division by zero |
La même chose en Python 2:
Traceback (most recent call last): File "<stdin>", line 4, in <module> ZeroDivisionError: integer division or modulo by zero |
Bonne chance pour trouver l'erreur originale.
Plein de duplications ont été retirées.
En Python 2, un dev doit savoir la différence entre:
range()
etxrange()
map/filter
etitertools.imap/itertools.ifilter
dict.items/keys/values
,dict.iteritems/keys/values
etdict.viewitems/keys/values
open
etcodecs.open
- Et
md5
vshashlib.md5
getopt
,optparse
,argparse
- Cette manipulation de fichier se fait avec
sys
,os
oushutil
? - On hérite de
UserDict
oudict
?UserList
oulist
? - ...
Sous peine de bugs ou de tuer ses perfs.
Certains bugs sont inévitables, et les modules csv
et re
sont par exemple pour toujours buggés en Python 2.
Good bye boiler plate
Faire un programme correct en Python 2 requière plus de code. Prenons l'exemple d'une suite de fichier qui contient des valeurs à récupérer sur chaque ligne, ou des commentaires (avec potentiellement des accents). Les fichiers font quelques centaines de méga, et je veux itérer sur leurs contenus.
Python 2:
# coding: utf8 from __future__ import unicode_literals, division import os import sys from math import log from codecs import open from glob import glob from itertools import imap # pour ne pas charger 300 Mo en mémoire d'un coup FS_ENCODING = sys.getfilesystemencoding() SIZE_SUFFIXES = ['bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] def file_size(size): order = int(log(size, 2) / 10) if size else 0 # potential bug with log2 size = size / (1 << (order * 10)) return '{:.4g} {}'.format(size, suffixes[order]) def get_data(dir, *patterns, **kwargs): """ Charge les données des fichiers """ # keyword only args convert = kwargs.get('convert', int) encoding = kwargs.get('encoding', 'utf8') for p in patterns: for path in glob(os.path.join(dir, p)): if os.path.isfile(path): upath = path.decode(FS_ENCODING, error="replace") print 'Trouvé: ', upath, file_size(os.stat(path).st_size) with open(path, encoding=encoding, error="ignore") as f: # retirer les commentaires lines = (l for l in f if "#" not in l) for value in imap(convert, f): yield value |
C'est déjà pas mal. On gère les caractères non ASCII dans les fichiers et le nom des fichiers, on affiche tout proprement sur le terminal, on itère en lazy pour ne pas saturer la RAM... Un code assez chouette, et pour obtenir ce résultat dans d'autres langages vous auriez plus dégueu (ou plus buggé).
Python 3:
# wow, so much space, much less import from math import log2 # non buggy log2 from pathlib import Path # plus de os.path bullshit SIZE_SUFFIXES = ['bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB', 'EiB', 'ZiB', 'YiB'] def file_size(size): order = int(log2(size) / 10) if size else 0 size = size / (1 << (order * 10)) return f'{size:.4g} {suffixes[order]}' # fstring def get_data(dir, *patterns, convert=int, encoding="utf8"): # keyword only """ Charge les données des fichiers """ for p in patterns: for path in Path(dir).glob(p): if path.is_file(): print('Trouvé: ', path, file_size(path.stat().st_size)) with open(path, encoding=encoding, error="ignore") as f: # retirer les commentaires lines = (l for l in f if "#" not in l) yield from map(convert, lines) # déroulage automatique |
Oui, c'est tout.
Et ce n'est pas juste une question de taille du code. Notez tout ce que vous n'avez pas à savoir à l'avance pour que ça marche. Le code est plus lisible. La signature de la fonction mieux documentée. Et il marchera mieux sur une console Windows car le support a été amélioré en Python 3.
Et encore j'ai été sympa, je n'ai pas fait la gestion des erreurs de lecture des fichiers, sinon on en avait encore pour 3 ans.
Il y a des tas d'autres trucs.
L'unpacking généralisé:
>>> a, *rest, c = range(10) # récupérer la première et dernière valeur >>> foo(*bar1, *bar2) # tout passer en arg >>> {**dico1, **dico2} # fusionner deux dico :) |
Ou la POO simplifiée:
class FooFooFoo(object): ... class BarBarBar(FooFooFoo): def wololo(self): return super(BarBarBar, self).wololo() |
Devient:
class FooFooFoo: ... class BarBarBar(FooFooFoo): def wololo(self): return super().wololo() |
Python 3 est tout simplement plus simple, et plus expressif.
Bonus
Evidement il y a plein de trucs qui n'intéresseront qu'une certaine catégorie de devs:
ipaddress.ip_address
pour parser les adresses IP.asyncio
pour faire de l'IO non blocante sans threads.enum
des enum de toutes sortes.functools.lru_cache
pour cacher le résultat de ses fonctions.- type hints pour vérifier la validité de son code.
- l'opérateur
@
pour multiplier des matrices avecnumpy
. concurrent.futures
pour faire des pools non blocantes propres.statistics
stats performantes et correctes.tracemalloc
trouver les fuites de mémoire.faulthandler
gérer les crash du code C proprement.
Si vous n'êtes pas concernés, ça n'est pas motivant. Mais si ça vous touche personnellement, c'est super cool.
Au passage, depuis la 3.6, Python 3 est enfin plus rapide que Python 2 pour la plupart des opérations :)
Pour finir...
Toutes ces choses là s'accumulent. Un code plus court, plus lisible, plus facile à débugger, plus juste, plus performant. Par un tas de petits détails.
Alors oui, la migration ne va pas restaurer instantanément votre érection et vous faire perdre du poids. Mais sur le long terme, tout ça compte énormément.