Cela fait peut-être un moment que tu programmes en Python, que tu crées des scripts et que tu résous des problèmes à droite à gauche.
Tu penses être plutôt bon, n’est-ce pas ?
Eh bien, accroche-toi, car tu pourrais même avoir un niveau de programmation avancée en Python sans même t’en rendre compte !
Des fonctions closure aux context managers, j’ai une liste de fonctionnalités avancées de Python à te montrer pour tester ton niveau Python.
Même si ces choses sont nouvelles pour toi, tu auras une excellente liste de contrôle à compléter pour passer au niveau supérieur.
1. Scope
Un aspect essentiel de la programmation avancée en Python est la connaissance approfondie du concept de portée (ou scope en anglais).
La portée définit l’ordre dans lequel l’interpréteur Python recherche les noms dans un programme. La portée de Python suit la règle LEGB (scopes Local, Enclosing, Global, et Built-in). Selon cette règle, lorsque tu accèdes à un nom (il peut s’agir de n’importe quoi, d’une variable, d’une fonction ou d’une classe), l’interpréteur le recherche dans les portées local, enclosing, global, et built-in, dans l’ordre.
Voyons des exemples pour mieux comprendre chaque niveau.
Exemple 1 – Local Scope
def func(): x = 10 print(x) func() # 10 print(x) # Raises NameError, x is only defined within the scope of func()
Ici, x n’est défini que dans la portée locale de func. C’est pourquoi il n’est accessible nulle part ailleurs dans le script.
Exemple 2 — Enclosing Scope
def outer_func(): x = 20 def inner_func(): print(x) inner_func() outer_func() # 20
La portée englobante (enclosing en anglais) est la portée intermédiaire entre les portées locales et globales. Dans l’exemple ci-dessus, x est dans la portée locale de outer_func. En revanche, x se trouve dans la portée englobante par rapport à la fonction inner_func imbriquée. La portée locale a toujours un accès en lecture seule à la portée englobante.
Exemple 3 — Global Scope
x = 30 def func(): print(x) func() # 30
Ici, x et func sont définis dans la portée globale, ce qui signifie qu’ils peuvent être lus depuis n’importe quel endroit du script en cours.
Pour les modifier à des niveaux inférieurs (local et enclosing), il convient d’y accéder à l’aide du mot-clé global :
def func2(): global x x = 40 print(x) func2() # 40 print(x) # 40
Exemple 4 — Built-in scope
La portée intégrée (built-in en anglais) comprend toutes les bibliothèques, classes, fonctions et variables déjà définies qui ne nécessitent pas d’instructions d’importation explicites. Parmi les exemples de fonctions et de variables intégrées en Python, citons print, len, range, str, int, float, etc.
2. Fonction closure
Une bonne compréhension du champ d’application ouvre les portes à un autre concept important : la fonction closure.
Par défaut, une fois que la fonction a terminé son exécution, elle retourne à l’état vierge. Cela signifie que sa mémoire est vidée de tous ses arguments passés.
def func(x): return x ** 2 func(3)
9
print(x) # NameError
Ci-dessus, nous avons attribué la valeur 3 à x, mais la fonction l’a oubliée après l’exécution. Que faire si nous ne voulons pas qu’elle oublie la valeur de x ?
C’est là que la fonction closure entre en jeu. En définissant une variable dans la portée d’une fonction interne, tu peux la stocker dans la mémoire de la fonction interne, même après le return de la fonction.
Voici un exemple simple de fonction qui compte le nombre de fois où elle a été exécutée :
def counter(): count = 0 def inner(): nonlocal count count += 1 return count return inner # Renvoi de la fonction interne counter = counter() print(counter()) # 1 print(counter()) # 2 print(counter()) # 3
1 2 3
Selon toutes les règles de Python, nous aurions dû perdre la variable counter après la première exécution. Mais comme elle se trouve dans le closure de la fonction interne, elle y restera jusqu’à ce que tu fermes la session :
counter.__closure__[0].cell_contents
3
3. Décorateurs
Les fonctions closure ont des applications plus sérieuses que les simples compteurs. L’une d’entre elles est la création de décorateurs. Un décorateur est une fonction imbriquée que tu peux ajouter à d’autres fonctions pour améliorer ou même modifier leur comportement.
Par exemple, ci-dessous, nous créons un décorateur de cache qui se souvient de l’état de chaque argument positionnel et mot-clé d’une fonction.
def stateful_function(func): cache = {} def inner(*args, **kwargs): key = str(args) + str(kwargs) if key not in cache: cache[key] = func(*args, **kwargs) return cache[key] return inner
Le décorateur stateful_function peut maintenant être ajouté aux fonctions lourdes en calcul qui peuvent être réutilisées sur les mêmes arguments. L’exemple est la fonction récursive Fibonacci suivante qui renvoie le n-ième nombre de la séquence :
%%time @stateful_function def fibonacci(n): if n <= 0: return 0 elif n == 1: return 1 else: return fibonacci(n-1) + fibonacci(n-2) fibonacci(1000)
CPU times: user 1.53 ms, sys: 88 µs, total: 1.62 ms Wall time: 1.62 ms [OUT]: 43466557686937456435688527675040625802564660517371780402481729089536555417949051890403879840079255169295922593080322634775209689623239873322471161642996440906533187938298969649928516003704476137795166849228875
Nous avons trouvé l’énorme 1000ème nombre de la séquence de Fibonacci en une fraction de seconde. Voici le temps que prendrait le même processus sans le décorateur de mise en cache :
%%time def fibonacci(n): if n <= 0: return 0 elif n == 1: return 1 else: return fibonacci(n-1) + fibonacci(n-2) fibonacci(40)
CPU times: user 21 s, sys: 0 ns, total: 21 s Wall time: 21 s [OUT]: 102334155
Il a fallu 21 secondes pour calculer le 40e nombre. Sans la mise en cache, il faudrait des jours pour calculer le 1000e.
4. Générateurs
Les générateurs sont des constructions puissantes en Python qui permettent de traiter efficacement de grandes quantités de données.
Supposons que tu disposes d’un fichier journal de 10 Go après le plantage d’un logiciel. Pour savoir ce qui s’est passé, tu dois le parcourir efficacement en Python.
La pire façon de procéder est de lire tout le fichier comme ci-dessous :
with open("logs.txt", "r") as f: contents = f.read() print(contents)
Puisque tu parcours les logs ligne par ligne, tu n’as pas besoin de lire la totalité des 10 Go, mais seulement des morceaux à la fois. C’est là que tu peux utiliser les générateurs :
def read_large_file(filename): with open(filename) as f: while True: chunk = f.read(1024) if not chunk: break yield chunk # Les générateurs sont définis avec `yield` au lieu de `return` for chunk in read_large_file("logs.txt"): process(chunk)
Ci-dessus, nous avons défini un générateur qui parcourt les lignes du fichier journal seulement 1024 à la fois. Par conséquent, la boucle for à la fin est très efficace. À chaque itération de la boucle, seules 1024 lignes du fichier sont en mémoire. Les morceaux précédents sont jetés, tandis que le reste n’est chargé qu’en cas de besoin.
Une autre caractéristique des générateurs est la possibilité de céder un élément à la fois, même en dehors des boucles, avec la fonction next. Ci-dessous, nous définissons une fonction ultra-rapide qui génère la séquence de Fibonacci.
Pour créer le générateur, tu appelles la fonction une fois et appelles next sur l’objet résultant :
def fibonacci(): a, b = 0, 1 while True: yield a a, b = b, a + b fib = fibonacci() type(fib)
generator
print(next(fib)) # 0 print(next(fib)) # 1 print(next(fib)) # 1 print(next(fib)) # 2 print(next(fib)) # 3
5. Gestionnaires de contexte
Tu dois utiliser des gestionnaires de contexte depuis longtemps. Ils permettent aux développeurs de gérer efficacement les ressources, telles que les fichiers, les bases de données et les connexions réseau. Ils ouvrent et ferment automatiquement les ressources, ce qui permet d’obtenir un code propre et sans erreur.
Mais il y a une grande différence entre l’utilisation des gestionnaires de contexte et l’écriture de tes propres gestionnaires. Lorsqu’ils sont bien conçus, ils te permettent d’abstraire un grand nombre de codes standard en plus de leur fonctionnalité d’origine.
Un exemple courant de gestionnaire de contexte personnalisé est la minuterie :
import time class TimerContextManager: """ Mesure le temps nécessaire à l'exécution d'un bloc de code. """ def __enter__(self): self.start = time.time() def __exit__(self, type, value, traceback): end = time.time() print(f"Le code a pris {end - self.start:.2f} secondes pour s'exécuter.")
Ci-dessus, nous définissons une classe TimerContextManager qui servira de futur gestionnaire de contexte. Sa méthode __enter__ définit ce qui se passe lorsque nous entrons dans le contexte avec le mot-clé with. Dans ce cas, nous démarrons la minuterie.
Dans __exit__, nous sortons du contexte, nous arrêtons la minuterie et nous indiquons le temps écoulé.
with TimerContextManager(): # Ce code est chronométré time.sleep(1)
Le code a pris 1.00 secondes pour s'exécuter.
Voici un exemple plus complexe qui permet de verrouiller les ressources afin qu’elles puissent être utilisées par un seul processus à la fois.
import threading lock = threading.Lock() class LockContextManager: def __enter__(self): lock.acquire() def __exit__(self, type, value, traceback): lock.release() with LockContextManager(): # Ce code est exécuté avec le verrou acquis # Un seul processus peut se trouver à l'intérieur de ce bloc à la fois. # Le verrou est automatiquement libéré à la fin du bloc with, même en cas d'erreur.
Voilà, c’est tout pour les 5 signes qui en disent beaucoup sur ton niveau de programmation avancée en Python !
Tu peux également consulter mon article sur l’écriture de code Python propre et sans bavure. 🙂