Recherche de site Web

Fermetures Python : cas d'utilisation courants et exemples


En Python, une fermeture est généralement une fonction définie dans une autre fonction. Cette fonction interne récupère les objets définis dans sa portée englobante et les associe à l'objet de fonction interne lui-même. La combinaison résultante s’appelle une fermeture.

Les fermetures sont une caractéristique courante dans les langages de programmation fonctionnels. En Python, les fermetures peuvent être très utiles car elles vous permettent de créer des décorateurs basés sur des fonctions, qui sont des outils puissants.

Dans ce didacticiel, vous allez :

  • Découvrez ce que sont les fermetures et comment elles fonctionnent en Python
  • Découvrez les cas d'utilisation courants des fermetures
  • Explorer les alternatives aux fermetures

Pour tirer le meilleur parti de ce didacticiel, vous devez être familier avec plusieurs sujets Python, notamment les fonctions, les fonctions internes, les décorateurs, les classes et les instances appelables.

Apprendre à connaître les fermetures en Python

Une fermeture est une fonction qui conserve l'accès à sa portée lexicale, même lorsque la fonction est exécutée en dehors de cette portée. Lorsque la fonction englobante renvoie la fonction interne, vous obtenez un objet fonction avec une portée étendue.

En d'autres termes, les fermetures sont des fonctions qui capturent les objets définis dans leur portée englobante, vous permettant de les utiliser dans leur corps. Cette fonctionnalité vous permet d'utiliser des fermetures lorsque vous devez conserver des informations d'état entre des appels consécutifs.

Les fermetures sont courantes dans les langages de programmation axés sur la programmation fonctionnelle, et Python prend en charge les fermetures dans le cadre de sa grande variété de fonctionnalités.

En Python, une fermeture est une fonction que vous définissez dans et retournez depuis une autre fonction. Cette fonction interne peut conserver les objets définis dans la portée non locale juste avant la définition de la fonction interne.

Pour mieux comprendre les fermetures en Python, vous examinerez d'abord les fonctions internes, car les fermetures sont également des fonctions internes.

Fonctions internes

En Python, une fonction interne est une fonction que vous définissez à l'intérieur d'une autre fonction. Ce type de fonction peut accéder et mettre à jour les noms dans leur fonction englobante, qui est la portée non locale.

Voici un exemple rapide :

>>> def outer_func():
...     name = "Pythonista"
...     def inner_func():
...         print(f"Hello, {name}!")
...     inner_func()
...

>>> outer_func()
Hello, Pythonista!

>>> greeter = outer_func()
>>> print(greeter)
None

Dans cet exemple, vous définissez outer_func() au niveau du module ou à la portée globale. Dans cette fonction, vous définissez la variable locale name. Ensuite, vous définissez une autre fonction appelée inner_func(). Parce que cette deuxième fonction réside dans le corps de outer_func(), il s'agit d'une fonction interne ou imbriquée. Enfin, vous appelez la fonction interne, qui utilise la variable name définie dans la fonction englobante.

Lorsque vous appelez outer_func(), inner_func() interpole name dans la chaîne de message d'accueil et imprime le résultat sur votre écran.

Dans l'exemple ci-dessus, vous avez défini une fonction interne qui peut utiliser les noms dans la portée englobante. Cependant, lorsque vous appelez la fonction externe, vous n’obtenez pas de référence à la fonction interne. La fonction interne et les noms locaux ne seront pas disponibles en dehors de la fonction externe.

Dans la section suivante, vous apprendrez comment transformer une fonction interne en fermeture, ce qui met à votre disposition la fonction interne et les variables conservées.

Fermetures de fonction

Toutes les fermetures sont des fonctions internes, mais toutes les fonctions internes ne sont pas des fermetures. Pour transformer une fonction interne en fermeture, vous devez renvoyer l'objet fonction interne de la fonction externe. Cela peut ressembler à un virelangue, mais voici comment faire en sorte que outer_func() renvoie un objet de fermeture :

>>> def outer_func():
...     name = "Pythonista"
...     def inner_func():
...         print(f"Hello, {name}!")
...     return inner_func
...

>>> outer_func()
<function outer_func.<locals>.inner_func at 0x1066d16c0>

>>> greeter = outer_func()

>>> greeter()
Hello, Pythonista!

Dans cette nouvelle version de outer_func(), vous renvoyez l'objet fonction inner_func au lieu de l'appeler. Lorsque vous appelez outer_func(), vous obtenez un objet fonction qui est une fermeture au lieu d'un message de bienvenue. Cet objet de fermeture se souvient et peut accéder à la valeur de name même après le retour de outer_func(). C'est pourquoi vous recevez le message de bienvenue lorsque vous appelez greeter().

Pour créer une fermeture Python, vous avez besoin des composants suivants :

  1. Une fonction externe ou englobante : il s'agit d'une fonction qui contient une autre fonction, souvent appelée fonction interne. La fonction externe peut prendre des arguments et définir des variables auxquelles la fonction interne peut accéder et mettre à jour.

  2. Variables locales à la fonction externe : ce sont des variables de sa portée englobante. Python conserve ces variables, vous permettant de les utiliser dans la fermeture, même après le retour de la fonction externe.

  3. Une fonction interne ou imbriquée : il s'agit d'une fonction définie à l'intérieur de la fonction externe. Il peut accéder et mettre à jour les variables de la fonction externe même après le retour de la fonction externe.

Dans l'exemple de cette section, vous disposez d'une fonction externe, d'une variable locale (name) et d'une fonction interne. La dernière étape pour obtenir un objet de fermeture à partir de cette combinaison consiste à renvoyer l'objet fonction interne à partir de la fonction externe.

Il est important de noter que vous pouvez également utiliser les fonctions lambda pour créer des fermetures :

>>> def outer_func():
...     name = "Pythonista"
...     return lambda: print(f"Hello, {name}!")
...

>>> greeter = outer_func()
>>> greeter()
Hello, Pythonista!

Dans cette version modifiée de outer_func(), vous utilisez une fonction lambda pour construire la fermeture, qui fonctionne comme celle d'origine.

Variables capturées

Comme vous l'avez appris, une fermeture conserve les variables de sa portée englobante. Prenons l'exemple de jouet suivant :

>>> def outer_func(outer_arg):
...     local_var = "Outer local variable"
...     def closure():
...         print(outer_arg)
...         print(local_var)
...         print(another_local_var)
...     another_local_var = "Another outer local variable"
...     return closure
...

>>> closure = outer_func("Outer argument")

>>> closure()
Outer argument
Outer local variable
Another outer local variable

Dans cet exemple, outer_arg, local_var et another_local_var sont tous attachés à la fermeture lorsque vous appelez outer_func() , même si sa portée conteneur n'est plus disponible. Cependant, closure() peut accéder à ces variables car elles font désormais partie de la fermeture elle-même. C’est pourquoi on peut dire qu’une fermeture est une fonction à portée étendue.

Les fermetures peuvent également mettre à jour la valeur de ces variables, ce qui peut donner lieu à deux scénarios : les variables peuvent pointer vers un objet immuable ou mutable.

Pour mettre à jour la valeur d'une variable qui pointe vers un objet immuable, vous devez utiliser l'instruction nonlocal. Prenons l'exemple suivant :

>>> def make_counter():
...     count = 0
...     def counter():
...         nonlocal count
...         count += 1
...         return count
...     return counter
...

>>> counter = make_counter()

>>> counter()
1
>>> counter()
2
>>> counter()
3

Dans cet exemple, count contient une référence à une valeur entière, qui est immuable. Pour mettre à jour la valeur de count, vous utilisez une instruction nonlocal qui indique à Python que vous souhaitez réutiliser la variable de la portée non locale.

Lorsque votre variable pointe vers un objet mutable, vous pouvez modifier la valeur de la variable sur place :

>>> def make_appender():
...     items = []
...     def appender(new_item):
...         items.append(new_item)
...         return items
...     return appender
...

>>> appender = make_appender()

>>> appender("First item")
['First item']
>>> appender("Second item")
['First item', 'Second item']
>>> appender("Third item")
['First item', 'Second item', 'Third item']

Dans cet exemple, la variable items pointe vers un objet list, qui est modifiable. Dans ce cas, vous n’êtes pas obligé d’utiliser le mot-clé nonlocal. Vous pouvez modifier la liste sur place.

Création de fermetures pour conserver l'état

En pratique, vous pouvez utiliser une fermeture Python dans plusieurs situations différentes. Dans cette section, vous découvrirez comment utiliser les fermetures pour créer des fonctions d'usine, maintenir l'état lors des appels de fonction et implémenter des rappels, permettant ainsi un code plus dynamique, flexible et efficace.

Création de fonctions d'usine

Vous pouvez écrire des fonctions pour créer des fermetures avec une configuration ou des paramètres initiaux. Ceci est particulièrement pratique lorsque vous devez créer plusieurs fonctions similaires avec des paramètres différents.

Par exemple, supposons que vous souhaitiez calculer des racines numériques avec différents degrés et précisions de résultat. Dans cette situation, vous pouvez coder une fonction d'usine qui renvoie des fermetures avec des degrés et des précisions prédéfinis, comme dans l'exemple ci-dessous :

>>> def make_root_calculator(root_degree, precision=2):
...     def root_calculator(number):
...         return round(pow(number, 1 / root_degree), precision)
...     return root_calculator
...

>>> square_root = make_root_calculator(2, 4)
>>> square_root(42)
6.4807

>>> cubic_root = make_root_calculator(3)
>>> cubic_root(42)
3.48

make_root_calculator() est une fonction d'usine que vous pouvez utiliser pour créer des fonctions qui calculent différentes racines numériques. Dans cette fonction, vous prenez le degré racine et la précision souhaitée comme paramètres de configuration.

Ensuite, vous définissez une fonction interne qui prend un nombre comme argument et calcule la racine spécifiée avec la précision souhaitée. Enfin, vous renvoyez la fonction interne, créant une fermeture.

Vous pouvez utiliser cette fonction pour créer des fermetures qui vous permettent de calculer des racines numériques de différents degrés, comme des racines carrées et cubiques. Notez que vous pouvez également modifier la précision du résultat.

Création de fonctions avec état

Vous pouvez utiliser des fermetures pour conserver l'état entre les appels de fonction. Ces fonctions sont appelées fonctions avec état et les fermetures sont un moyen de les créer.

Par exemple, supposons que vous souhaitiez écrire une fonction qui prend des valeurs numériques consécutives à partir d'un flux de données et calcule leur moyenne cumulée. Entre les appels, la fonction doit garder une trace des valeurs précédemment transmises. Dans cette situation, vous pouvez utiliser la fonction suivante :

>>> def cumulative_average():
...     data = []
...     def average(value):
...         data.append(value)
...         return sum(data) / len(data)
...     return average
...

>>> stream_average = cumulative_average()

>>> stream_average(12)
12.0
>>> stream_average(13)
12.5
>>> stream_average(11)
12.0
>>> stream_average(10)
11.5

Dans cumulative_average(), la variable locale data vous permet de conserver l'état entre les appels consécutifs de l'objet de fermeture que cette fonction renvoie.

Ensuite, vous créez une fermeture appelée stream_average() et vous l'appelez avec différentes valeurs numériques. Notez comment cette fermeture mémorise les valeurs précédemment transmises et calcule la moyenne en ajoutant la valeur nouvellement fournie.

Fournir des fonctions de rappel

Les fermetures sont couramment utilisées dans la programmation événementielle lorsque vous devez créer des fonctions de rappel contenant des informations de contexte ou d'état supplémentaires. La programmation d'une interface utilisateur graphique (GUI) est un bon exemple d'utilisation de ces fonctions de rappel.

Pour illustrer, supposons que vous souhaitiez créer une application "Hello, World!" avec Tkinter, la bibliothèque de programmation GUI par défaut de Python. L'application a besoin d'une étiquette pour afficher le message d'accueil et d'un bouton pour le déclencher. Voici le code de cette petite application :

import tkinter as tk

app = tk.Tk()
app.title("GUI App")
app.geometry("320x240")

label = tk.Label(
    app,
    font=("Helvetica", 16, "bold"),
)
label.pack()

def callback(text):
    def closure():
        label.config(text=text)

    return closure

button = tk.Button(
    app,
    text="Greet",
    command=callback("Hello, World!"),
)
button.pack()

app.mainloop()

Ce code définit une application jouet Tkinter composée d'une fenêtre avec une étiquette et un bouton. Lorsque vous cliquez sur le bouton Salut, l'étiquette affiche le message "Hello, World!".

La fonction callback() renvoie un objet de fermeture que vous pouvez utiliser pour fournir l'argument command du bouton. Cet argument accepte les objets appelables qui ne prennent aucun argument. Si vous devez transmettre des arguments comme vous l’avez fait dans l’exemple, vous pouvez utiliser une fermeture.

Décorateurs d'écriture avec fermetures

Les décorateurs sont une fonctionnalité puissante de Python. Vous pouvez utiliser des décorateurs pour modifier dynamiquement le comportement d’une fonction. En Python, vous disposez de deux types de décorateurs :

  1. Décorateurs basés sur la fonction
  2. Décorateurs de classe

Un décorateur basé sur une fonction est une fonction qui prend un objet fonction comme argument et renvoie un autre objet fonction avec des fonctionnalités étendues. Ce dernier objet fonction est également une fermeture. Ainsi, pour créer des décorateurs basés sur des fonctions, vous utilisez des fermetures.

Comme vous l'avez déjà appris, les décorateurs permettent de modifier le comportement des fonctions sans altérer leur code interne. En pratique, les décorateurs fonctionnels sont des fermetures. La caractéristique distinctive est que leur objectif principal est de modifier le comportement de la fonction que vous transmettez en argument à la fonction contenant la fermeture.

Voici un exemple de décorateur minimal qui ajoute des messages en plus des fonctionnalités de la fonction d'entrée :

>>> def decorator(function):
...     def closure():
...         print("Doing something before calling the function.")
...         function()
...         print("Doing something after calling the function.")
...     return closure
...

Dans cet exemple, la fonction externe est le décorateur. Cette fonction renvoie un objet de fermeture qui modifie le comportement d'origine de l'objet fonction d'entrée en ajoutant des fonctionnalités supplémentaires. La fermeture peut agir sur la fonction d'entrée même après le retour de la fonction decorator().

Voici comment utiliser la syntaxe du décorateur pour modifier dynamiquement le comportement d'une fonction Python standard :

>>> @decorator
... def greet():
...     print("Hi, Pythonista!")
...

>>> greet()
Doing something before calling the function.
Hi, Pythonista!
Doing something after calling the function.

Dans cet exemple, vous utilisez @decorator pour modifier le comportement de votre fonction greet(). Notez que maintenant, lorsque vous appelez greet(), vous obtenez sa fonctionnalité d'origine ainsi que la fonctionnalité ajoutée par le décorateur.

Implémentation de la mémorisation avec des fermetures

La mise en cache peut améliorer les performances d’un algorithme en évitant les recalculs inutiles. La Mémoisation est une technique de mise en cache courante qui empêche une fonction de s'exécuter plusieurs fois pour la même entrée.

La mémorisation fonctionne en stockant le résultat d'un ensemble donné d'arguments d'entrée en mémoire, puis en le référençant ultérieurement si nécessaire. Vous pouvez utiliser des fermetures pour implémenter la mémorisation.

Dans l'exemple de jouet suivant, vous profitez d'un décorateur, qui est également une fermeture, pour mettre en cache les valeurs résultant d'un calcul hypothétique coûteux :

>>> def memoize(function):
...     cache = {}
...     def closure(number):
...         if number not in cache:
...             cache[number] = function(number)
...         return cache[number]
...     return closure
...

Ici, memoize() prend un objet fonction comme argument et renvoie un autre objet de fermeture. La fonction interne exécute la fonction d'entrée uniquement pour les nombres non traités. Les nombres traités sont mis en cache dans le dictionnaire cache avec le résultat de la fonction d'entrée.

Supposons maintenant que vous disposiez de la fonction jouet suivante qui imite un calcul coûteux :

>>> from time import sleep

>>> def slow_operation(number):
...     sleep(0.5)
...

Cette fonction retarde l’exécution du code pendant seulement une demi-seconde pour imiter une opération coûteuse. Pour ce faire, vous utilisez la fonction sleep() du module time.

Vous pouvez mesurer le temps d’exécution de la fonction à l’aide du code suivant :

>>> from timeit import timeit

>>> timeit(
...     "[slow_operation(number) for number in [2, 3, 4, 2, 3, 4]]",
...     globals=globals(),
...     number=1,
... )
3.02610950000053

Dans cet extrait de code, vous utilisez la fonction timeit() du module timeit pour connaître le temps d'exécution de slow_operation() lorsque vous exécutez cette fonction avec une liste de valeurs. Pour traiter les six valeurs d'entrée, le code prend un peu plus de trois secondes. Vous pouvez utiliser la mémorisation pour rendre ce calcul plus efficace en ignorant les valeurs d'entrée répétées.

Allez-y et décorez slow_operation() en utilisant @memoize comme indiqué ci-dessous. Ensuite, exécutez le code de timing :

>>> @memoize
... def slow_operation(number):
...     sleep(0.5)
...

>>> timeit(
...     "[slow_operation(number) for number in [2, 3, 4, 2, 3, 4]]",
...     globals=globals(),
...     number=1,
... )
1.5151869590008573

Désormais, le même code prend deux fois moins de temps grâce à la technique de mémorisation. C'est parce que la fonction slow_operation() ne s'exécute pas pour les valeurs d'entrée répétées.

Réaliser l'encapsulation avec des fermetures

En programmation orientée objet (POO), les classes permettent de combiner des données et des comportements en une seule entité. Une exigence courante en POO est l'encapsulation des données, un principe qui recommande de protéger les données d'un objet du monde extérieur et d'empêcher l'accès direct.

En Python, parvenir à une encapsulation solide des données peut être une tâche difficile car il n'y a pas de distinction entre les attributs privés et publics. Au lieu de cela, Python utilise une convention de dénomination pour indiquer si un membre de classe donné est public ou non public.

Vous pouvez utiliser les fermetures Python pour obtenir une encapsulation des données plus stricte. Les fermetures vous permettent de créer une étendue privée pour les données, empêchant les utilisateurs d'accéder à ces données. Cela permet de maintenir l’intégrité des données et d’éviter des modifications involontaires.

Pour illustrer, disons que vous disposez de la classe Stack suivante :

class Stack:
    def __init__(self):
        self._items = []

    def push(self, item):
        self._items.append(item)

    def pop(self):
        return self._items.pop()

Cette classe Stack stocke ses données dans un objet list appelé ._items et implémente des opérations de pile courantes, telles que push. et pop.

Voici comment utiliser cette classe :

>>> from stack_v1 import Stack

>>> stack = Stack()
>>> stack.push(1)
>>> stack.push(2)
>>> stack.push(3)

>>> stack.pop()
3

>>> stack._items
[1, 2]

Les fonctionnalités de base de votre classe fonctionnent. Cependant, même si l'attribut ._items n'est pas public, vous pouvez accéder à ses valeurs en utilisant la notation par points comme vous le feriez avec un attribut normal. Ce comportement rend difficile l'encapsulation des données pour les protéger d'un accès direct.

Encore une fois, une fermeture constitue une astuce pour parvenir à une encapsulation des données plus stricte. Considérez le code suivant :

def Stack():
    _items = []

    def push(item):
        _items.append(item)

    def pop():
        return _items.pop()

    def closure():
        pass

    closure.push = push
    closure.pop = pop
    return closure

Dans cet exemple, vous écrivez une fonction pour créer un objet de fermeture au lieu de définir une classe. À l'intérieur de la fonction, vous définissez une variable locale appelée _items, qui fera partie de votre objet de fermeture. Vous utiliserez cette variable pour stocker les données de la pile. Ensuite, vous définissez deux fonctions internes qui implémentent les opérations de pile.

La fonction interne closure() est un espace réservé pour votre fermeture. En plus de cette fonction, vous ajoutez les fonctions push() et pop(). Enfin, vous renvoyez la fermeture résultante.

Vous pouvez utiliser la fonction Stack() principalement de la même manière que vous avez utilisé la classe Stack. Une différence significative est que vous n'avez désormais plus accès à ._items :

>>> from stack_v2 import Stack

>>> stack = Stack()
>>> stack.push(1)
>>> stack.push(2)
>>> stack.push(3)

>>> stack.pop()
3

>>> stack._items
Traceback (most recent call last):
    ...
AttributeError: 'function' object has no attribute '_items'

La fonction Stack() vous permet de créer des fermetures qui fonctionnent comme s'il s'agissait d'instances de la classe Stack. Cependant, vous n'avez pas d'accès direct à ._items, ce qui améliore l'encapsulation de vos données.

Si vous êtes vraiment pointilleux, vous pouvez utiliser une astuce avancée pour accéder au contenu de ._items :

>>> stack.push.__closure__[0].cell_contents
[1, 2]

L'attribut .__closure__ renvoie un tuple de cellules contenant des liaisons pour les variables de la fermeture. Un objet cellule possède un attribut appelé cell_contents, que vous pouvez utiliser pour obtenir la valeur de la cellule.

Même si cette astuce vous permet d’accéder aux variables saisies, elle n’est généralement pas utilisée dans le code Python. En fin de compte, si vous essayez de réaliser l’encapsulation, pourquoi la briseriez-vous ?

Explorer des alternatives aux fermetures

Jusqu’à présent, vous avez appris que les fermetures Python peuvent aider à résoudre plusieurs problèmes. Cependant, il peut être difficile de comprendre comment ils fonctionnent sous le capot, donc l'utilisation d'un outil alternatif peut faciliter le raisonnement de votre code.

Vous pouvez remplacer une fermeture par une classe qui produit des instances appelables en implémentant la méthode spéciale .__call__(). Les instances appelables sont des objets que vous pouvez appeler comme vous appelleriez une fonction.

Pour illustrer, revenons à la fonction d'usine make_root_calculator() :

>>> def make_root_calculator(root_degree, precision=2):
...     def root_calculator(number):
...         return round(pow(number, 1 / root_degree), precision)
...     return root_calculator
...

>>> square_root = make_root_calculator(2, 4)
>>> square_root(42)
6.4807

>>> cubic_root = make_root_calculator(3)
>>> cubic_root(42)
3.48

La fonction renvoie des fermetures qui conservent les arguments root_degree et precision dans sa portée étendue. Vous pouvez remplacer cette fonction d'usine par la classe suivante :

class RootCalculator:
    def __init__(self, root_degree, precision=2):
        self.root_degree = root_degree
        self.precision = precision

    def __call__(self, number):
        return round(pow(number, 1 / self.root_degree), self.precision)

Cette classe prend les deux mêmes arguments que make_root_calculator() et les transforme en attributs d'instance.

En fournissant la méthode .__call__(), vous transformez vos instances de classe en objets appelables que vous pouvez appeler en tant que fonctions régulières. Voici comment utiliser cette classe pour créer des objets de type fonction de calculatrice racine :

>>> from roots import RootCalculator

>>> square_root = RootCalculator(2, 4)
>>> square_root(42)
6.4807

>>> cubic_root = RootCalculator(3)
>>> cubic_root(42)
3.48

>>> cubic_root.root_degree
3

Comme vous pouvez en conclure, la fonction RootCalculator fonctionne à peu près de la même manière que la fonction make_root_calculator(). En plus, vous avez désormais accès à des arguments de configuration comme root_degree.

Conclusion

Vous savez maintenant qu'une fermeture est un objet fonction généralement défini dans une autre fonction en Python. Les fermetures récupèrent les objets définis dans leur portée englobante et les combinent avec l'objet fonction interne pour créer un objet appelable avec une portée étendue.

Vous pouvez utiliser des fermetures dans plusieurs scénarios, en particulier lorsque vous devez conserver l'état entre des appels de fonction consécutifs ou écrire un décorateur. Ainsi, savoir comment utiliser les fermetures peut être une excellente compétence pour un développeur Python.

Dans ce didacticiel, vous avez appris :

  • Que sont les fermetures et comment elles fonctionnent en Python
  • Quand les fermetures peuvent être utilisées dans la pratique
  • Comment les instances appelables peuvent remplacer les fermetures

Avec ces connaissances, vous pouvez commencer à créer et à utiliser des fermetures Python dans votre code, surtout si vous êtes intéressé par les outils de programmation fonctionnelle.