Définir un objet appelable approprié

Beginner

This tutorial is from open-source community. Access the source code

Introduction

Dans ce laboratoire, vous allez apprendre à connaître les objets appelables (callable objects) en Python. Un objet appelable peut être invoqué comme une fonction en utilisant la syntaxe object(). Bien que les fonctions Python soient intrinsèquement appelables, vous pouvez créer des objets appelables personnalisés en implémentant la méthode __call__.

Vous allez également apprendre à implémenter un objet appelable en utilisant la méthode __call__ et à utiliser les annotations de fonction avec des objets appelables pour la validation des paramètres. Le fichier validate.py sera modifié au cours de ce laboratoire.

Comprendre les classes de validateurs

Dans ce laboratoire, nous allons nous appuyer sur un ensemble de classes de validateurs pour créer un objet appelable (callable object). Avant de commencer à développer, il est important de comprendre les classes de validateurs fournies dans le fichier validate.py. Ces classes nous aideront à effectuer des vérifications de type, ce qui est une partie cruciale pour garantir que notre code fonctionne comme prévu.

Commençons par ouvrir le fichier validate.py dans l'IDE Web. Ce fichier contient le code des classes de validateurs que nous allons utiliser. Pour l'ouvrir, exécutez la commande suivante dans le terminal :

code /home/labex/project/validate.py

Une fois le fichier ouvert, vous verrez qu'il contient plusieurs classes. Voici un bref aperçu de ce que fait chaque classe :

  1. Validator : C'est une classe de base. Elle a une méthode check, mais actuellement, cette méthode ne fait rien. Elle sert de point de départ pour les autres classes de validateurs.
  2. Typed : C'est une sous-classe de Validator. Son principal rôle est de vérifier si une valeur est d'un type spécifique.
  3. Integer, Float et String : Ce sont des validateurs de type spécifiques qui héritent de Typed. Ils sont conçus pour vérifier si une valeur est un entier, un nombre à virgule flottante ou une chaîne de caractères, respectivement.

Maintenant, voyons comment ces classes de validateurs fonctionnent en pratique. Nous allons créer un nouveau fichier appelé test.py pour les tester. Pour créer et ouvrir ce fichier, exécutez la commande suivante :

code /home/labex/project/test.py

Une fois le fichier test.py ouvert, ajoutez le code suivant. Ce code testera les validateurs Integer et String :

from validate import Integer, String, Float

## Test Integer validator
print("Testing Integer validator:")
try:
    Integer.check(42)
    print("✓ Integer check passed for 42")
except TypeError as e:
    print(f"✗ Error: {e}")

try:
    Integer.check("Hello")
    print("✗ Integer check incorrectly passed for 'Hello'")
except TypeError as e:
    print(f"✓ Correctly raised error: {e}")

## Test String validator
print("\nTesting String validator:")
try:
    String.check("Hello")
    print("✓ String check passed for 'Hello'")
except TypeError as e:
    print(f"✗ Error: {e}")

Dans ce code, nous importons d'abord les validateurs Integer, String et Float depuis le fichier validate.py. Ensuite, nous testons le validateur Integer en essayant de vérifier une valeur entière (42) et une valeur de chaîne de caractères ("Hello"). Si la vérification réussit pour l'entier, nous affichons un message de succès. Si elle réussit incorrectement pour la chaîne de caractères, nous affichons un message d'erreur. Si la vérification lève correctement une TypeError pour la chaîne de caractères, nous affichons un message de succès. Nous effectuons un test similaire pour le validateur String.

Après avoir ajouté le code, exécutez le fichier de test en utilisant la commande suivante :

python3 /home/labex/project/test.py

Vous devriez voir une sortie similaire à ceci :

Testing Integer validator:
✓ Integer check passed for 42
✓ Correctly raised error: Expected <class 'int'>

Testing String validator:
✓ String check passed for 'Hello'

Comme vous pouvez le voir, ces classes de validateurs nous permettent d'effectuer facilement des vérifications de type. Par exemple, lorsque vous appelez Integer.check(x), cela lèvera une TypeError si x n'est pas un entier.

Maintenant, pensons à un scénario pratique. Supposons que nous ayons une fonction qui exige que ses arguments soient de types spécifiques. Voici un exemple d'une telle fonction :

def add(x, y):
    Integer.check(x)  ## Make sure x is an integer
    Integer.check(y)  ## Make sure y is an integer
    return x + y

Cette fonction fonctionne, mais il y a un problème. Nous devons ajouter manuellement les vérifications des validateurs chaque fois que nous voulons utiliser la vérification de type. Cela peut être chronophage et propice aux erreurs, en particulier pour les fonctions ou les projets plus importants.

Dans les prochaines étapes, nous allons résoudre ce problème en créant un objet appelable. Cet objet sera capable d'appliquer automatiquement ces vérifications de type en fonction des annotations de fonction. De cette façon, nous n'aurons pas à ajouter les vérifications manuellement à chaque fois.

Création d'un objet appelable de base

En Python, un objet appelable (callable object) est un objet qui peut être utilisé comme une fonction. Vous pouvez le considérer comme quelque chose que vous pouvez "appeler" en mettant des parenthèses après, de la même manière que vous appelez une fonction normale. Pour faire en sorte qu'une classe en Python se comporte comme un objet appelable, nous devons implémenter une méthode spéciale appelée __call__. Cette méthode est automatiquement invoquée lorsque vous utilisez l'objet avec des parenthèses, tout comme lorsque vous appelez une fonction.

Commençons par modifier le fichier validate.py. Nous allons ajouter une nouvelle classe appelée ValidatedFunction à ce fichier, et cette classe sera notre objet appelable. Pour ouvrir le fichier dans l'éditeur de code, exécutez la commande suivante dans le terminal :

code /home/labex/project/validate.py

Une fois le fichier ouvert, faites défiler jusqu'à la fin et ajoutez le code suivant :

class ValidatedFunction:
    def __init__(self, func):
        self.func = func

    def __call__(self, *args, **kwargs):
        print('Calling', self.func)
        result = self.func(*args, **kwargs)
        return result

Analysons ce que fait ce code. La classe ValidatedFunction a une méthode __init__, qui est le constructeur. Lorsque vous créez une instance de cette classe, vous lui passez une fonction. Cette fonction est ensuite stockée en tant qu'attribut de l'instance, nommé self.func.

La méthode __call__ est la partie clé qui rend cette classe appelable. Lorsque vous appelez une instance de la classe ValidatedFunction, cette méthode __call__ est exécutée. Voici ce qu'elle fait étape par étape :

  1. Elle affiche un message qui indique quelle fonction est appelée. Cela est utile pour le débogage et pour comprendre ce qui se passe.
  2. Elle appelle la fonction qui a été stockée dans self.func avec les arguments que vous avez passés lorsque vous avez appelé l'instance. Les *args et **kwargs vous permettent de passer n'importe quel nombre d'arguments positionnels et de mots-clés.
  3. Elle retourne le résultat de l'appel de la fonction.

Maintenant, testons cette classe ValidatedFunction. Nous allons créer un nouveau fichier appelé test_callable.py pour écrire notre code de test. Pour ouvrir ce nouveau fichier dans l'éditeur de code, exécutez la commande suivante :

code /home/labex/project/test_callable.py

Ajoutez le code suivant au fichier test_callable.py :

from validate import ValidatedFunction

def add(x, y):
    return x + y

## Wrap the add function with ValidatedFunction
validated_add = ValidatedFunction(add)

## Call the wrapped function
result = validated_add(2, 3)
print(f"Result: {result}")

## Try another call
result = validated_add(10, 20)
print(f"Result: {result}")

Dans ce code, nous importons d'abord la classe ValidatedFunction depuis le fichier validate.py. Ensuite, nous définissons une fonction simple appelée add qui prend deux nombres et retourne leur somme.

Nous créons une instance de la classe ValidatedFunction, en lui passant la fonction add. Cela "enveloppe" la fonction add à l'intérieur de l'instance de ValidatedFunction.

Nous appelons ensuite la fonction enveloppée deux fois, une fois avec les arguments 2 et 3, puis avec 10 et 20. Chaque fois que nous appelons la fonction enveloppée, la méthode __call__ de la classe ValidatedFunction est invoquée, qui à son tour appelle la fonction add originale.

Pour exécuter le code de test, exécutez la commande suivante dans le terminal :

python3 /home/labex/project/test_callable.py

Vous devriez voir une sortie similaire à ceci :

Calling <function add at 0x7f2d1c3a9940>
Result: 5
Calling <function add at 0x7f2d1c3a9940>
Result: 30

Cette sortie montre que notre objet appelable fonctionne comme prévu. Lorsque nous appelons validated_add(2, 3), cela appelle en fait la méthode __call__ de la classe ValidatedFunction, qui appelle ensuite la fonction add originale.

Pour l'instant, notre classe ValidatedFunction ne fait que afficher un message et transférer l'appel à la fonction originale. Dans l'étape suivante, nous allons améliorer cette classe pour effectuer une validation de type en fonction des annotations de la fonction.

Mise en œuvre de la validation de type avec les annotations de fonction

En Python, vous avez la possibilité d'ajouter des annotations de type aux paramètres de fonction. Ces annotations servent à indiquer les types de données attendus pour les paramètres et la valeur de retour d'une fonction. Elles n'imposent pas les types au moment de l'exécution par défaut, mais elles peuvent être utilisées à des fins de validation.

Jetons un coup d'œil à un exemple :

def add(x: int, y: int) -> int:
    return x + y

Dans ce code, x: int et y: int nous indiquent que les paramètres x et y devraient être des entiers. Le -> int à la fin indique que la fonction add retourne un entier. Ces annotations de type sont stockées dans l'attribut __annotations__ de la fonction, qui est un dictionnaire qui associe les noms des paramètres à leurs types annotés.

Maintenant, nous allons améliorer notre classe ValidatedFunction pour utiliser ces annotations de type pour la validation. Pour ce faire, nous devrons utiliser le module inspect de Python. Ce module fournit des fonctions utiles pour obtenir des informations sur des objets en temps réel tels que des modules, des classes, des méthodes, des fonctions, etc. Dans notre cas, nous l'utiliserons pour associer les arguments de la fonction à leurs noms de paramètres correspondants.

Tout d'abord, nous devons modifier la classe ValidatedFunction dans le fichier validate.py. Vous pouvez ouvrir ce fichier en utilisant la commande suivante :

code /home/labex/project/validate.py

Remplacez la classe ValidatedFunction existante par la version améliorée suivante :

import inspect

class ValidatedFunction:
    def __init__(self, func):
        self.func = func
        self.signature = inspect.signature(func)

    def __call__(self, *args, **kwargs):
        ## Bind the arguments to the function parameters
        bound = self.signature.bind(*args, **kwargs)

        ## Validate each argument against its annotation
        for name, val in bound.arguments.items():
            if name in self.func.__annotations__:
                ## Get the validator class from the annotation
                validator = self.func.__annotations__[name]
                ## Apply the validation
                validator.check(val)

        ## Call the function with the validated arguments
        return self.func(*args, **kwargs)

Voici ce que fait cette version améliorée :

  1. Elle utilise inspect.signature() pour obtenir des informations sur les paramètres de la fonction, telles que leurs noms, leurs valeurs par défaut et leurs types annotés.
  2. La méthode bind() de la signature est utilisée pour associer les arguments fournis aux noms de paramètres correspondants. Cela nous aide à associer chaque argument à son paramètre correct dans la fonction.
  3. Elle vérifie chaque argument par rapport à son annotation de type (si elle existe). Si une annotation est trouvée, elle récupère la classe de validateur à partir de l'annotation et applique la validation en utilisant la méthode check().
  4. Enfin, elle appelle la fonction originale avec les arguments validés.

Maintenant, testons cette classe ValidatedFunction améliorée avec quelques fonctions qui utilisent nos classes de validateurs dans leurs annotations de type. Ouvrez le fichier test_validation.py en utilisant la commande suivante :

code /home/labex/project/test_validation.py

Ajoutez le code suivant au fichier :

from validate import ValidatedFunction, Integer, String

def greet(name: String, times: Integer):
    return name * times

## Wrap the greet function with ValidatedFunction
validated_greet = ValidatedFunction(greet)

## Valid call
try:
    result = validated_greet("Hello ", 3)
    print(f"Valid call result: {result}")
except TypeError as e:
    print(f"Unexpected error: {e}")

## Invalid call - wrong type for 'name'
try:
    result = validated_greet(123, 3)
    print(f"Invalid call unexpectedly succeeded: {result}")
except TypeError as e:
    print(f"Expected error for name: {e}")

## Invalid call - wrong type for 'times'
try:
    result = validated_greet("Hello ", "3")
    print(f"Invalid call unexpectedly succeeded: {result}")
except TypeError as e:
    print(f"Expected error for times: {e}")

Dans ce code, nous définissons une fonction greet avec les annotations de type name: String et times: Integer. Cela signifie que le paramètre name devrait être validé à l'aide de la classe String, et le paramètre times devrait être validé à l'aide de la classe Integer. Nous enveloppons ensuite la fonction greet avec notre classe ValidatedFunction pour activer la validation de type.

Nous effectuons trois cas de test : un appel valide, un appel invalide avec le mauvais type pour name, et un appel invalide avec le mauvais type pour times. Chaque appel est enveloppé dans un bloc try-except pour capturer toute exception TypeError qui pourrait être levée lors de la validation.

Pour exécuter le fichier de test, utilisez la commande suivante :

python3 /home/labex/project/test_validation.py

Vous devriez voir une sortie similaire à la suivante :

Valid call result: Hello Hello Hello
Expected error for name: Expected <class 'str'>
Expected error for times: Expected <class 'int'>

Cette sortie démontre que notre objet appelable ValidatedFunction impose maintenant la validation de type en fonction des annotations de fonction. Lorsque nous passons des arguments du mauvais type, les classes de validateurs détectent l'erreur et lèvent une TypeError. De cette façon, nous pouvons nous assurer que les fonctions sont appelées avec les bons types de données, ce qui aide à prévenir les bugs et rend notre code plus robuste.

Défi : Utilisation d'un objet appelable comme méthode

En Python, lorsque vous utilisez un objet appelable (callable object) comme méthode au sein d'une classe, vous devez relever un défi unique. Un objet appelable est quelque chose que vous pouvez "appeler" comme une fonction, comme une fonction elle-même ou un objet avec une méthode __call__. Lorsqu'il est utilisé comme méthode de classe, cela ne fonctionne pas toujours comme prévu en raison de la façon dont Python passe l'instance (self) comme premier argument.

Explorons ce problème en créant une classe Stock. Cette classe représentera une action avec des attributs tels que le nom, le nombre de parts et le prix. Nous utiliserons également un validateur pour nous assurer que les données avec lesquelles nous travaillons sont correctes.

Tout d'abord, ouvrez le fichier stock.py pour commencer à écrire notre classe Stock. Vous pouvez utiliser la commande suivante pour ouvrir le fichier dans un éditeur :

code /home/labex/project/stock.py

Maintenant, ajoutez le code suivant au fichier stock.py. Ce code définit la classe Stock avec une méthode __init__ pour initialiser les attributs de l'action, une propriété cost pour calculer le coût total et une méthode sell pour réduire le nombre de parts. Nous essaierons également d'utiliser la classe ValidatedFunction pour valider l'entrée de la méthode sell.

from validate import ValidatedFunction, Integer

class Stock:
    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    @property
    def cost(self):
        return self.shares * self.price

    def sell(self, nshares: Integer):
        self.shares -= nshares

    ## Try to use ValidatedFunction
    sell = ValidatedFunction(sell)

Après avoir défini la classe Stock, nous devons la tester pour voir si elle fonctionne comme prévu. Créez un fichier de test nommé test_stock.py et ouvrez-le en utilisant la commande suivante :

code /home/labex/project/test_stock.py

Ajoutez le code suivant au fichier test_stock.py. Ce code crée une instance de la classe Stock, affiche le nombre initial de parts et le coût, essaie de vendre quelques parts, puis affiche le nombre de parts mis à jour et le coût.

from stock import Stock

try:
    ## Create a stock
    s = Stock('GOOG', 100, 490.1)

    ## Get the initial cost
    print(f"Initial shares: {s.shares}")
    print(f"Initial cost: ${s.cost}")

    ## Try to sell some shares
    s.sell(10)

    ## Check the updated cost
    print(f"After selling, shares: {s.shares}")
    print(f"After selling, cost: ${s.cost}")

except Exception as e:
    print(f"Error: {e}")

Maintenant, exécutez le fichier de test en utilisant la commande suivante :

python3 /home/labex/project/test_stock.py

Vous rencontrerez probablement une erreur similaire à :

Error: missing a required argument: 'nshares'

Cette erreur se produit parce que lorsque Python appelle une méthode comme s.sell(10), il appelle en réalité Stock.sell(s, 10) en arrière-plan. Le paramètre self représente l'instance de la classe, et il est automatiquement passé comme premier argument. Cependant, notre classe ValidatedFunction ne gère pas correctement ce paramètre self car elle ne sait pas qu'elle est utilisée comme méthode.

Comprendre le problème

Lorsque vous définissez une méthode à l'intérieur d'une classe puis la remplacez par une instance de ValidatedFunction, vous enveloppez essentiellement la méthode originale. Le problème est que la méthode enveloppée ne gère pas correctement le paramètre self automatiquement. Elle attend les arguments d'une manière qui ne prend pas en compte le fait que l'instance est passée comme premier argument.

Résoudre le problème

Pour résoudre ce problème, nous devons modifier la façon dont nous gérons les méthodes. Nous allons créer une nouvelle classe appelée ValidatedMethod qui peut gérer correctement les appels de méthode. Ajoutez le code suivant à la fin du fichier validate.py :

import inspect

class ValidatedMethod:
    def __init__(self, func):
        self.func = func
        self.signature = inspect.signature(func)

    def __get__(self, instance, owner):
        ## This method is called when the attribute is accessed as a method
        if instance is None:
            return self

        ## Return a callable that binds 'self' to the instance
        def method_wrapper(*args, **kwargs):
            return self(instance, *args, **kwargs)

        return method_wrapper

    def __call__(self, instance, *args, **kwargs):
        ## Bind the arguments to the function parameters
        bound = self.signature.bind(instance, *args, **kwargs)

        ## Validate each argument against its annotation
        for name, val in bound.arguments.items():
            if name in self.func.__annotations__:
                ## Get the validator class from the annotation
                validator = self.func.__annotations__[name]
                ## Apply the validation
                validator.check(val)

        ## Call the function with the validated arguments
        return self.func(instance, *args, **kwargs)

Maintenant, nous devons modifier la classe Stock pour utiliser ValidatedMethod au lieu de ValidatedFunction. Ouvrez à nouveau le fichier stock.py :

code /home/labex/project/stock.py

Mettez à jour la classe Stock comme suit :

from validate import ValidatedMethod, Integer

class Stock:
    def __init__(self, name, shares, price):
        self.name = name
        self.shares = shares
        self.price = price

    @property
    def cost(self):
        return self.shares * self.price

    @ValidatedMethod
    def sell(self, nshares: Integer):
        self.shares -= nshares

La classe ValidatedMethod est un descripteur (descriptor), qui est un type spécial d'objet en Python qui peut modifier la façon dont les attributs sont accédés. La méthode __get__ est appelée lorsque l'attribut est accédé comme une méthode. Elle retourne un objet appelable qui passe correctement l'instance comme premier argument.

Exécutez à nouveau le fichier de test en utilisant la commande suivante :

python3 /home/labex/project/test_stock.py

Maintenant, vous devriez voir une sortie similaire à :

Initial shares: 100
Initial cost: $49010.0
After selling, shares: 90
After selling, cost: $44109.0

Ce défi vous a montré un aspect important des objets appelables. Lorsque vous les utilisez comme méthodes dans une classe, ils nécessitent un traitement spécial. En implémentant le protocole de descripteur avec la méthode __get__, nous pouvons créer des objets appelables qui fonctionnent correctement à la fois comme fonctions autonomes et comme méthodes.

Résumé

Dans ce laboratoire (lab), vous avez appris à créer des objets appelables (callable objects) appropriés en Python. Tout d'abord, vous avez exploré des classes de validateurs de base pour la vérification de type et créé un objet appelable en utilisant la méthode __call__. Ensuite, vous avez amélioré cet objet pour effectuer des validations basées sur les annotations de fonction et relevé le défi d'utiliser des objets appelables comme méthodes de classe.

Les concepts clés abordés incluent les objets appelables et la méthode __call__, les annotations de fonction pour les indications de type (type hinting), l'utilisation du module inspect pour examiner les signatures de fonction, et le protocole de descripteur (descriptor protocol) avec la méthode __get__ pour les méthodes de classe. Ces techniques vous permettent de créer de puissants enrobages de fonction (function wrappers) pour le traitement avant et après l'appel, ce qui est un modèle fondamental pour les décorateurs et d'autres fonctionnalités avancées de Python.