Les métaclasses en action

PythonPythonBeginner
Pratiquer maintenant

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

💡 Ce tutoriel est traduit par l'IA à partir de la version anglaise. Pour voir la version originale, vous pouvez cliquer ici

Introduction

Dans ce laboratoire (lab), vous allez apprendre les métaclasses, l'une des fonctionnalités les plus puissantes et avancées de Python. Les métaclasses vous permettent de personnaliser la création de classes, vous donnant ainsi le contrôle sur la définition et l'instanciation des classes. Vous allez explorer les métaclasses à travers des exemples pratiques.

Les objectifs de ce laboratoire sont de comprendre ce que sont les métaclasses et comment elles fonctionnent, d'implémenter une métaclasse pour résoudre des problèmes de programmation réels et d'explorer les applications pratiques des métaclasses en Python. Les fichiers modifiés dans ce laboratoire sont structure.py et validate.py.


Skills Graph

%%%%{init: {'theme':'neutral'}}%%%% flowchart RL python(("Python")) -.-> python/FunctionsGroup(["Functions"]) python(("Python")) -.-> python/ModulesandPackagesGroup(["Modules and Packages"]) python(("Python")) -.-> python/ObjectOrientedProgrammingGroup(["Object-Oriented Programming"]) python/FunctionsGroup -.-> python/function_definition("Function Definition") python/ModulesandPackagesGroup -.-> python/importing_modules("Importing Modules") python/ObjectOrientedProgrammingGroup -.-> python/classes_objects("Classes and Objects") subgraph Lab Skills python/function_definition -.-> lab-132521{{"Les métaclasses en action"}} python/importing_modules -.-> lab-132521{{"Les métaclasses en action"}} python/classes_objects -.-> lab-132521{{"Les métaclasses en action"}} end

Comprendre le problème

Avant de commencer à explorer les métaclasses, il est important de comprendre le problème que nous visons à résoudre. En programmation, nous avons souvent besoin de créer des structures avec des types spécifiques pour leurs attributs. Dans nos travaux précédents, nous avons développé un système pour les structures avec vérification de type. Ce système nous permet de définir des classes où chaque attribut a un type spécifique, et les valeurs assignées à ces attributs sont validées en fonction de ce type.

Voici un exemple de la façon dont nous avons utilisé ce système pour créer une classe Stock :

from validate import String, PositiveInteger, PositiveFloat
from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

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

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

Dans ce code, nous importons d'abord les types de validateurs (String, PositiveInteger, PositiveFloat) depuis le module validate et la classe Structure depuis le module structure. Ensuite, nous définissons la classe Stock, qui hérite de Structure. À l'intérieur de la classe Stock, nous définissons des attributs avec des types de validateurs spécifiques. Par exemple, l'attribut name doit être une chaîne de caractères, shares doit être un entier positif et price doit être un nombre à virgule flottante positif.

Cependant, il y a un problème avec cette approche. Nous devons importer tous les types de validateurs en haut de notre fichier. Au fur et à mesure que nous ajoutons de plus en plus de types de validateurs dans un scénario du monde réel, ces importations peuvent devenir très longues et difficiles à gérer. Cela pourrait nous amener à utiliser from validate import *, ce qui est généralement considéré comme une mauvaise pratique car cela peut causer des conflits de noms et rendre le code moins lisible.

Pour comprendre notre point de départ, regardons la classe Structure. Vous devez ouvrir le fichier structure.py dans l'éditeur et examiner son contenu. Cela vous aidera à voir comment la gestion de la structure de base est implémentée avant d'ajouter la fonctionnalité de métaclasse.

code structure.py

Lorsque vous ouvrez le fichier, vous verrez une implémentation de base de la classe Structure. Cette classe est responsable de la gestion de l'initialisation des attributs, mais elle n'a pas encore de fonctionnalité de métaclasse.

Ensuite, examinons les classes de validateurs. Ces classes sont définies dans le fichier validate.py. Elles ont déjà une fonctionnalité de descripteur, ce qui signifie qu'elles peuvent contrôler la façon dont les attributs sont accédés et définis. Mais nous devrons les améliorer pour résoudre le problème d'importation que nous avons discuté précédemment.

code validate.py

En regardant ces classes de validateurs, vous comprendrez mieux comment le processus de validation fonctionne et quelles modifications nous devons apporter pour améliorer notre code.

✨ Vérifier la solution et pratiquer

Collecter les types de validateurs

En Python, les validateurs sont des classes qui nous aident à nous assurer que les données répondent à certains critères. Notre première tâche dans cette expérience est de modifier la classe de base Validator afin qu'elle puisse collecter toutes ses sous - classes. Pourquoi devons - nous faire cela ? Eh bien, en collectant toutes les sous - classes de validateurs, nous pouvons créer un espace de noms qui contient tous les types de validateurs. Plus tard, nous injecterons cet espace de noms dans la classe Structure, ce qui nous facilitera la gestion et l'utilisation de différents validateurs.

Maintenant, commençons à travailler sur le code. Ouvrez le fichier validate.py. Vous pouvez utiliser la commande suivante dans le terminal pour l'ouvrir :

code validate.py

Une fois le fichier ouvert, nous devons ajouter un dictionnaire au niveau de la classe et une méthode __init_subclass__() à la classe Validator. Le dictionnaire au niveau de la classe sera utilisé pour stocker toutes les sous - classes de validateurs, et la méthode __init_subclass__() est une méthode spéciale en Python qui est appelée chaque fois qu'une sous - classe de la classe actuelle est définie.

Ajoutez le code suivant à la classe Validator, juste après la définition de la classe :

## Add this to the Validator class in validate.py
validators = {}  ## Dictionary to collect all validator subclasses

@classmethod
def __init_subclass__(cls):
    """Register each validator subclass in the validators dictionary"""
    Validator.validators[cls.__name__] = cls

Après avoir ajouté le code, votre classe Validator modifiée devrait maintenant ressembler à ceci :

class Validator:
    validators = {}  ## Dictionary to collect all validator subclasses

    @classmethod
    def __init_subclass__(cls):
        """Register each validator subclass in the validators dictionary"""
        Validator.validators[cls.__name__] = cls

    def __set_name__(self, owner, name):
        self.name = name

    def __get__(self, instance, owner):
        if instance is None:
            return self
        return instance.__dict__[self.name]

    def __set__(self, instance, value):
        self.validate(value)
        instance.__dict__[self.name] = value

    def validate(self, value):
        pass

Maintenant, chaque fois qu'un nouveau type de validateur est défini, comme String ou PositiveInteger, Python appellera automatiquement la méthode __init_subclass__(). Cette méthode ajoutera ensuite la nouvelle sous - classe de validateur au dictionnaire validators, en utilisant le nom de la classe comme clé.

Vérifions si notre code fonctionne. Nous allons créer un simple script Python pour vérifier le contenu du dictionnaire validators. Vous pouvez exécuter la commande suivante dans le terminal :

python3 -c "from validate import Validator; print(Validator.validators)"

Si tout fonctionne correctement, vous devriez voir une sortie similaire à celle - ci, montrant tous les types de validateurs et les classes correspondantes :

{'Typed': <class 'validate.Typed'>, 'Positive': <class 'validate.Positive'>, 'NonEmpty': <class 'validate.NonEmpty'>, 'String': <class 'validate.String'>, 'Integer': <class 'validate.Integer'>, 'Float': <class 'validate.Float'>, 'PositiveInteger': <class 'validate.PositiveInteger'>, 'PositiveFloat': <class 'validate.PositiveFloat'>, 'NonEmptyString': <class 'validate.NonEmptyString'>}

Maintenant que nous avons un dictionnaire contenant tous nos types de validateurs, nous pouvons l'utiliser à l'étape suivante pour créer notre métaclasse.

Création de la métaclasse StructureMeta

Maintenant, parlons de ce que nous allons faire ensuite. Nous avons trouvé un moyen de collecter tous les types de validateurs. Notre prochaine étape consiste à créer une métaclasse. Mais qu'est - ce exactement qu'une métaclasse ? En Python, une métaclasse est un type spécial de classe. Ses instances sont des classes elles - mêmes. Cela signifie qu'une métaclasse peut contrôler la façon dont une classe est créée. Elle peut gérer l'espace de noms où les attributs de classe sont définis.

Dans notre cas, nous voulons créer une métaclasse qui rendra les types de validateurs disponibles lorsque nous définissons une sous - classe de Structure. Nous ne voulons pas avoir à importer explicitement ces types de validateurs à chaque fois.

Commençons par rouvrir le fichier structure.py. Vous pouvez utiliser la commande suivante pour l'ouvrir :

code structure.py

Une fois le fichier ouvert, nous devons ajouter du code en haut, avant la définition de la classe Structure. Ce code définira notre métaclasse.

from validate import Validator
from collections import ChainMap

class StructureMeta(type):
    @classmethod
    def __prepare__(meta, clsname, bases):
        """Prepare the namespace for the class being defined"""
        return ChainMap({}, Validator.validators)

    @staticmethod
    def __new__(meta, name, bases, methods):
        """Create the new class using only the local namespace"""
        methods = methods.maps[0]  ## Extract the local namespace
        return super().__new__(meta, name, bases, methods)

Maintenant que nous avons défini la métaclasse, nous devons modifier la classe Structure pour l'utiliser. De cette façon, toute classe qui hérite de Structure bénéficiera de la fonctionnalité de la métaclasse.

class Structure(metaclass=StructureMeta):
    _fields = []

    def __init__(self, *args, **kwargs):
        if len(args) > len(self._fields):
            raise TypeError(f'Expected {len(self._fields)} arguments')

        ## Set all of the positional arguments
        for name, val in zip(self._fields, args):
            setattr(self, name, val)

        ## Set the remaining keyword arguments
        for name, val in kwargs.items():
            if name not in self._fields:
                raise TypeError(f'Invalid argument: {name}')
            setattr(self, name, val)

    def __repr__(self):
        values = [getattr(self, name) for name in self._fields]
        args_str = ','.join(repr(val) for val in values)
        return f'{type(self).__name__}({args_str})'

Analysons ce que ce code fait :

  1. La méthode __prepare__() est une méthode spéciale en Python. Elle est appelée avant la création de la classe. Son rôle est de préparer l'espace de noms où les attributs de classe seront définis. Nous utilisons ChainMap ici. ChainMap est un outil utile qui crée un dictionnaire en couches. Dans notre cas, il inclut nos types de validateurs, les rendant accessibles dans l'espace de noms de la classe.

  2. La méthode __new__() est responsable de la création de la nouvelle classe. Nous extrayons uniquement l'espace de noms local, qui est le premier dictionnaire dans le ChainMap. Nous rejetons le dictionnaire des validateurs car nous avons déjà rendu les types de validateurs disponibles dans l'espace de noms.

Avec cette configuration, toute classe qui hérite de Structure aura accès à tous les types de validateurs sans avoir besoin de les importer explicitement.

Maintenant, testons notre implémentation. Nous allons créer une classe Stock en utilisant notre classe de base Structure améliorée.

cat > stock.py << EOF
from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

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

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

Si notre métaclasse fonctionne correctement, nous devrions être en mesure de définir la classe Stock sans importer les types de validateurs. C'est parce que la métaclasse les a déjà rendus disponibles dans l'espace de noms.

Tester notre implémentation

Maintenant que nous avons implémenté notre métaclasse et modifié la classe Structure, il est temps de tester notre implémentation. Le test est crucial car il nous aide à nous assurer que tout fonctionne correctement. En exécutant des tests, nous pouvons détecter tout problème potentiel dès le départ et nous assurer que notre code se comporte comme prévu.

Tout d'abord, exécutons les tests unitaires pour voir si notre classe Stock fonctionne comme prévu. Les tests unitaires sont de petits tests isolés qui vérifient les parties individuelles de notre code. Dans ce cas, nous voulons nous assurer que la classe Stock fonctionne correctement. Pour exécuter les tests unitaires, nous utiliserons la commande suivante dans le terminal :

python3 teststock.py

Si tout fonctionne correctement, tous les tests devraient passer sans erreur. Lorsque les tests sont exécutés avec succès, la sortie devrait ressembler à ceci :

........
----------------------------------------------------------------------
Ran 6 tests in 0.001s

OK

Les points représentent chaque test qui a réussi, et le OK final indique que tous les tests ont été un succès.

Maintenant, testons notre classe Stock avec des données réelles et la fonctionnalité de formatage de table. Cela nous donnera un scénario plus réaliste pour voir comment notre classe Stock interagit avec les données et comment le formatage de table fonctionne. Nous utiliserons la commande suivante dans le terminal :

python3 -c "
from stock import Stock
from reader import read_csv_as_instances
from tableformat import create_formatter, print_table

## Read portfolio data into Stock instances
portfolio = read_csv_as_instances('portfolio.csv', Stock)
print('Portfolio:')
print(portfolio)

## Format and print the portfolio data
print('\nFormatted table:')
formatter = create_formatter('text')
print_table(portfolio, ['name', 'shares', 'price'], formatter)
"

Dans ce code, nous importons d'abord les classes et les fonctions nécessaires. Ensuite, nous lisons les données d'un fichier CSV dans des instances de Stock. Après cela, nous affichons les données du portefeuille, puis nous les formatons en tableau et affichons le tableau formaté.

Vous devriez voir une sortie similaire à celle - ci :

Portfolio:
[Stock('AA',100,32.2), Stock('IBM',50,91.1), Stock('CAT',150,83.44), Stock('MSFT',200,51.23), Stock('GE',95,40.37), Stock('MSFT',50,65.1), Stock('IBM',100,70.44)]

Formatted table:
      name     shares      price
---------- ---------- ----------
        AA        100       32.2
       IBM         50       91.1
       CAT        150      83.44
      MSFT        200      51.23
        GE         95      40.37
      MSFT         50       65.1
       IBM        100      70.44

Prenez un moment pour apprécier ce que nous avons accompli :

  1. Nous avons créé un mécanisme pour collecter automatiquement tous les types de validateurs. Cela signifie que nous n'avons pas à suivre manuellement tous les validateurs, ce qui nous fait gagner du temps et réduit le risque d'erreurs.
  2. Nous avons implémenté une métaclasse qui injecte ces types dans l'espace de noms des sous - classes de Structure. Cela permet aux sous - classes d'utiliser ces validateurs sans avoir à les importer explicitement.
  3. Nous avons éliminé le besoin d'importations explicites des types de validateurs. Cela rend notre code plus propre et plus facile à lire.
  4. Tout cela se passe en coulisse, ce qui rend le code pour définir de nouvelles structures propre et simple.

Le fichier final stock.py est remarquablement propre par rapport à ce qu'il aurait été sans notre métaclasse :

from structure import Structure

class Stock(Structure):
    name = String()
    shares = PositiveInteger()
    price = PositiveFloat()

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

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

Sans avoir besoin d'importer directement les types de validateurs, le code est plus concis et plus facile à maintenir. Ceci est un excellent exemple de la façon dont les métaclasses peuvent améliorer la qualité de notre code.

Résumé

Dans ce laboratoire (lab), vous avez appris à exploiter le potentiel des métaclasses en Python. Tout d'abord, vous avez compris le défi lié à la gestion des importations pour les types de validateurs. Ensuite, vous avez modifié la classe Validator pour collecter automatiquement ses sous - classes et créé une métaclasse StructureMeta pour injecter les types de validateurs dans les espaces de noms des classes. Enfin, vous avez testé l'implémentation avec une classe Stock, éliminant ainsi le besoin d'importations explicites.

Les métaclasses, une fonctionnalité avancée de Python, permettent de personnaliser le processus de création de classes. Bien qu'elles devraient être utilisées avec modération, elles offrent des solutions élégantes à des problèmes spécifiques, comme le montre ce laboratoire. En utilisant une métaclasse, vous avez simplifié le code pour définir des structures avec des attributs validés, éliminé le besoin d'importer explicitement les types de validateurs et créé une API plus maintenable et élégante. Ce modèle d'injection d'espace de noms basé sur les métaclasses peut être appliqué à d'autres scénarios pour simplifier l'API utilisateur.