Beaucoup de gens pensent que la métaprogrammation en Python complique inutilement le code, mais si vous l'utilisez correctement, vous pouvez implémenter rapidement et avec élégance des modèles de conception complexes. De plus, les frameworks Python bien connus tels que Django, DRF et SQLAlchemy utilisent des métaclasses pour fournir une extensibilité et une réutilisation de code faciles.

Dans cet article, je vais vous expliquer pourquoi vous ne devriez pas avoir peur d'utiliser la métaprogrammation dans vos projets et montrer à quelles tâches il convient le mieux. Vous pouvez en savoir plus sur les options de métaprogrammation dans le cours Advanced Python .
Pour commencer, rappelons les bases de la métaprogrammation en Python. Il ne sera pas superflu d'ajouter que tout ce qui est écrit ci-dessous s'applique à Python version 3.5 et supérieure.
Présentation rapide du modèle de données Python
Donc, nous savons tous que tout en Python est un objet, et ce n'est un secret pour personne que pour chaque objet il existe une certaine classe par laquelle il a été généré, par exemple:
>>> def f(): pass >>> type(f) <class 'function'>
Le type de l'objet ou la classe par laquelle l'objet a été généré peut être déterminé à l'aide de la fonction de type intégrée, qui a une signature d'appel plutôt intéressante (nous en parlerons un peu plus tard). Le même effet peut être obtenu en dérivant l'attribut __class__
sur n'importe quel objet.
Ainsi, pour créer des fonctions, une certaine function
classe function
. Voyons ce que nous pouvons en faire. Pour ce faire, prenez le blanc du module de types intégré:
>>> from types import FunctionType >>> FunctionType <class 'function'> >>> help(FunctionType) class function(object) | function(code, globals[, name[, argdefs[, closure]]]) | | Create a function object from a code object and a dictionary. | The optional name string overrides the name from the code object. | The optional argdefs tuple specifies the default argument values. | The optional closure tuple supplies the bindings for free variables.
Comme nous pouvons le voir, toute fonction en Python est une instance de la classe décrite ci-dessus. Essayons maintenant de créer une nouvelle fonction sans recourir à sa déclaration via def
. Pour ce faire, nous devons apprendre à créer des objets de code à l'aide de la fonction de compilation intégrée à l'interpréteur:
Super! À l'aide de méta-outils, nous avons appris à créer des fonctions à la volée, mais dans la pratique, ces connaissances sont rarement utilisées. Voyons maintenant comment les objets de classe et les objets d'instance de ces classes sont créés:
>>> class User: pass >>> user = User() >>> type(user) <class '__main__.User'> >>> type(User) <class 'type'>
Il est assez évident que la classe User
est utilisée pour créer une instance d' user
, il est beaucoup plus intéressant de regarder la classe type
, qui est utilisée pour créer la classe User
elle-même. Ici, nous allons passer à la deuxième option d'appeler la fonction de type
intégrée, qui en combinaison est une métaclasse pour n'importe quelle classe en Python. Une métaclasse est, par définition, une classe dont l'instance est une autre classe. Les métaclasses nous permettent de personnaliser le processus de création d'une classe et de contrôler partiellement le processus de création d'une instance d'une classe.
Selon la documentation, le deuxième type(name, bases, attrs)
signature type(name, bases, attrs)
- renvoie un nouveau type de données ou, si c'est simple - une nouvelle classe, et l'attribut name
devient l'attribut __name__
de la classe retournée, bases
- la liste des classes parentes sera disponible en tant que __bases__
, Eh bien, attrs
- un objet de type dict contenant tous les attributs et méthodes de la classe, ira dans __dict__
. Le principe de la fonction peut être décrit comme un simple pseudo-code en Python:
type(name, bases, attrs) ~ class name(bases): attrs
Voyons comment vous pouvez, en utilisant uniquement l'appel de type
, construire une toute nouvelle classe:
>>> User = type('User', (), {}) >>> User <class '__main__.User'>
Comme vous pouvez le voir, nous n'avons pas besoin d'utiliser le mot class
clé class
pour créer une nouvelle classe, la fonction type
s'en passe, regardons maintenant un exemple plus compliqué:
class User: def __init__(self, name): self.name = name class SuperUser(User): """Encapsulate domain logic to work with super users""" group_name = 'admin' @property def login(self): return f'{self.group_name}/{self.name}'.lower()
Comme vous pouvez le voir dans les exemples ci-dessus, la description des classes et des fonctions à l'aide des mots-clés class
et def
n'est que du sucre syntaxique et tous les types d'objets peuvent être créés par des appels ordinaires aux fonctions intégrées. Et maintenant, enfin, parlons de la façon dont vous pouvez utiliser la création dynamique de classes dans des projets réels.
Parfois, nous devons valider les informations de l'utilisateur ou d'autres sources externes selon un schéma de données précédemment connu. Par exemple, nous voulons changer le formulaire de connexion utilisateur depuis le panneau d'administration - supprimer et ajouter des champs, changer la stratégie de leur validation, etc.
Pour illustrer cela, essayons de créer dynamiquement un formulaire Django , dont la description du schéma est stockée au format json
suivant:
{ "fist_name": { "type": "str", "max_length": 25 }, "last_name": { "type": "str", "max_length": 30 }, "age": { "type": "int", "min_value": 18, "max_value": 99 } }
Maintenant, sur la base de la description ci-dessus, créez un ensemble de champs et un nouveau formulaire en utilisant la fonction type
nous connaissons déjà:
import json from django import forms fields_type_map = { 'str': forms.CharField, 'int': forms.IntegerField, }
Super! Vous pouvez maintenant transférer le formulaire créé vers le modèle et le rendre pour l'utilisateur. La même approche peut être utilisée avec d'autres cadres pour la validation et la présentation des données ( sérialiseurs DRF , guimauve et autres).
Ci-dessus, nous avons examiné la métaclasse de type
déjà «terminée», mais le plus souvent dans le code, vous créerez vos propres métaclasses et les utiliserez pour configurer la création de nouvelles classes et leurs instances. Dans le cas général, le "blanc" d'une métaclasse ressemble à ceci:
class MetaClass(type): """ : mcs – , <__main__.MetaClass> name – , , , "User" bases – -, (SomeMixin, AbstractUser) attrs – dict-like , cls – , <__main__.User> extra_kwargs – keyword- args kwargs – """ def __new__(mcs, name, bases, attrs, **extra_kwargs): return super().__new__(mcs, name, bases, attrs) def __init__(cls, name, bases, attrs, **extra_kwargs): super().__init__(cls) @classmethod def __prepare__(mcs, cls, bases, **extra_kwargs): return super().__prepare__(mcs, cls, bases, **kwargs) def __call__(cls, *args, **kwargs): return super().__call__(*args, **kwargs)
Pour utiliser cette métaclasse pour configurer la classe User
, la syntaxe suivante est utilisée:
class User(metaclass=MetaClass): def __new__(cls, name): return super().__new__(cls) def __init__(self, name): self.name = name
La chose la plus intéressante est l'ordre dans lequel l'interpréteur Python appelle la métaméthode métaclasse au moment où la classe elle-même est créée:
- L'interpréteur détermine et recherche les classes parentes de la classe actuelle (le cas échéant).
- L'interpréteur définit une métaclasse (
MetaClass
dans notre cas). - La méthode
MetaClass.__prepare__
est MetaClass.__prepare__
- elle doit renvoyer un objet de type dict dans lequel les attributs et les méthodes de la classe seront écrits. Après cela, l'objet sera transmis à la MetaClass.__new__
via l'argument attrs
. Nous parlerons de l'utilisation pratique de cette méthode un peu plus loin dans les exemples. - L'interpréteur lit le corps de la classe
User
et génère des paramètres pour les transmettre à la métaclasse MetaClass
. - La méthode
MetaClass.__new__
est MetaClass.__new__
- la méthode MetaClass.__new__
, renvoie l'objet de classe créé. Nous avons déjà rencontré les arguments name
, bases
et attrs
lorsque nous les avons passés à la fonction type
, et nous parlerons un peu plus tard du paramètre **extra_kwargs
. Si le type de l'argument attrs
été modifié à l'aide de __prepare__
, il doit être converti en dict
avant de le passer à l'appel de méthode super()
. - La méthode
MetaClass.__init__
est MetaClass.__init__
- la méthode d'initialisation avec laquelle vous pouvez ajouter des attributs et des méthodes supplémentaires à l'objet classe dans la classe. En pratique, il est utilisé dans les cas où les métaclasses sont héritées d'autres métaclasses, sinon tout ce qui peut être fait dans __init__
est mieux fait dans __new__
. Par exemple, le paramètre __slots__
ne peut être défini que dans la méthode __new__
en l'écrivant dans l'objet attrs
. - À cette étape, la classe est considérée comme créée.
Créez maintenant une instance de notre classe User
et regardez la chaîne d'appel:
user = User(name='Alyosha')
- Au moment d'appeler
User(...)
interpréteur appelle la MetaClass.__call__(name='Alyosha')
, où il passe l'objet classe et les arguments passés. MetaClass.__call__
appelle User.__new__(name='Alyosha')
- une méthode constructeur qui crée et renvoie une instance de la classe User
- Ensuite,
MetaClass.__call__
appelle User.__init__(name='Alyosha')
- une méthode d'initialisation qui ajoute de nouveaux attributs à l'instance créée. MetaClass.__call__
renvoie l'instance créée et initialisée de la classe User
.- À ce stade, une instance de la classe est considérée comme créée.
Cette description, bien sûr, ne couvre pas toutes les nuances de l'utilisation des métaclasses, mais il suffit de commencer à utiliser la métaprogrammation pour implémenter certains modèles architecturaux. Passons aux exemples!
Classes abstraites
Et le tout premier exemple peut être trouvé dans la bibliothèque standard: ABCMeta - une métaclasse vous permet de déclarer n'importe lequel de nos résumés de classe et de forcer tous ses descendants à implémenter des méthodes, propriétés et attributs prédéfinis, alors regardez:
from abc import ABCMeta, abstractmethod class BasePlugin(metaclass=ABCMeta): """ supported_formats run """ @property @abstractmethod def supported_formats(self) -> list: pass @abstractmethod def run(self, input_data: dict): pass
Si toutes les méthodes abstraites et les attributs ne sont pas implémentés dans l'héritier, alors lorsque nous essayons de créer une instance de la classe héritière, nous obtenons une TypeError
:
class VideoPlugin(BasePlugin): def run(self): print('Processing video...') plugin = VideoPlugin()
L'utilisation de classes abstraites permet de corriger immédiatement l'interface de classe de base et d'éviter de futures erreurs d'héritage, par exemple des fautes de frappe dans le nom d'une méthode redéfinie.
Système de plugin d'enregistrement automatique
Très souvent, la métaprogrammation est utilisée pour implémenter divers modèles de conception. Presque tous les frameworks connus utilisent des métaclasses pour créer des objets de registre . Ces objets stockent des liens vers d'autres objets et leur permettent d'être reçus rapidement n'importe où dans le programme. Prenons un exemple simple d'enregistrement automatique de plugins pour lire des fichiers multimédias de différents formats.
Implémentation de la métaclasse:
class RegistryMeta(ABCMeta): """ , . " " -> " " """ _registry_formats = {} def __new__(mcs, name, bases, attrs): cls: 'BasePlugin' = super().__new__(mcs, name, bases, attrs)
Et voici les plugins eux-mêmes, nous prendrons l'implémentation BasePlugin
de l'exemple précédent:
class BasePlugin(metaclass=RegistryMeta): ... class VideoPlugin(BasePlugin): supported_formats = ['mpg', 'mov'] def run(self): ... class AudioPlugin(BasePlugin): supported_formats = ['mp3', 'flac'] def run(self): ...
Après avoir exécuté ce code, l'interpréteur enregistrera 4 formats et 2 plugins dans notre registre qui peuvent traiter ces formats:
>>> RegistryMeta.show_registry() {'flac': <class '__main__.AudioPlugin'>, 'mov': <class '__main__.VideoPlugin'>, 'mp3': <class '__main__.AudioPlugin'>, 'mpg': <class '__main__.VideoPlugin'>} >>> plugin_class = RegistryMeta.get_plugin('mov') >>> plugin_class <class '__main__.VideoPlugin'> >>> plugin_class().run() Processing video...
Il convient de noter une nuance plus intéressante de travailler avec des métaclasses, grâce à l'ordre de résolution de méthode non évident, nous pouvons appeler la méthode show_registry
non seulement sur la classe RegistyMeta
, mais sur toute autre classe dont il s'agit d'une métaclasse:
>>> AudioPlugin.get_plugin('avi')
À l'aide de métaclasses, vous pouvez utiliser des noms d'attribut de classe comme métadonnées pour d'autres objets. Rien n'est clair? Mais je suis sûr que vous avez déjà vu cette approche à plusieurs reprises, par exemple la déclaration déclarative des champs de modèle dans Django:
class Book(models.Model): title = models.Charfield(max_length=250)
Dans l'exemple ci-dessus, title
est le nom de l'identifiant Python, il est également utilisé pour nommer la colonne dans la table du book
, bien que nous ne l'ayons indiqué explicitement nulle part. Oui, une telle «magie» peut être réalisée à l'aide de la métaprogrammation. Imaginons par exemple un système de transmission des erreurs d'application au front-end, afin que chaque message ait un code lisible qui peut être utilisé pour traduire le message dans une autre langue. Donc, nous avons un objet message qui peut être converti en json
:
class Message: def __init__(self, text, code=None): self.text = text self.code = code def to_json(self): return json.dumps({'text': self.text, 'code': self.code})
Tous nos messages d'erreur seront stockés dans un "espace de noms" distinct:
class Messages: not_found = Message('Resource not found') bad_request = Message('Request body is invalid') ... >>> Messages.not_found.to_json() {"text": "Resource not found", "code": null}
Maintenant, nous voulons que le code
devienne non null
, mais not_found
, pour cela, nous écrivons la métaclasse suivante:
class MetaMessage(type): def __new__(mcs, name, bases, attrs): for attr, value in attrs.items():
Voyons à quoi ressemblent maintenant nos publications:
>>> Messages.not_found.to_json() {"text": "Resource not found", "code": "not_found"} >>> Messages.bad_request.to_json() {"text": "Request body is invalid", "code": "bad_request"}
Ce dont vous avez besoin! Vous savez maintenant quoi faire pour que le format de données vous permette de trouver facilement le code qui les traite.
Un autre cas courant est la mise en cache de toutes les données statiques au stade de la création de classe, afin de ne pas perdre de temps à les calculer pendant l'exécution de l'application. De plus, certaines données peuvent être mises à jour lors de la création de nouvelles instances de classes, par exemple, un compteur du nombre d'objets créés.
Comment cela peut-il être utilisé? Supposons que vous développez un cadre pour la création de rapports et de tableaux et que vous disposez d'un tel objet:
class Row(metaclass=MetaRow): name: str age: int ... def __init__(self, **kwargs): self.counter = None for attr, value in kwargs.items(): setattr(self, attr, value) def __str__(self): out = [self.counter]
Nous voulons enregistrer et augmenter le compteur lors de la création d'une nouvelle ligne et également générer à l'avance l'en-tête de la table résultante. La métaclasse à la rescousse!
class MetaRow(type):
Deux choses doivent être clarifiées ici:
- La classe
Row
n'a pas d'attributs de classe avec le name
et l' age
noms - ce sont des annotations de type , donc ils ne sont pas dans les attrs
dictionnaire attrs
, et pour obtenir une liste de champs, nous utilisons l' __annotations__
classe __annotations__
. - L'opération
cls.row_count += 1
était censée vous induire en erreur: comment cela? Après tout, cls
est une classe Row
; elle n'a pas l'attribut row_count
. Tout est vrai, mais comme je l'ai expliqué ci-dessus - si la classe créée n'a pas d'attribut ou de méthode qu'ils essaient d'appeler, alors l'interpréteur va plus loin dans la chaîne des classes de base - s'il n'y en a pas, une recherche est effectuée dans la métaclasse. Dans de tels cas, afin de ne dérouter personne, il est préférable d'utiliser un autre enregistrement: MetaRow.row_count += 1
.
Voyez avec quelle élégance vous pouvez maintenant afficher la table entière:
rows = [ Row(name='Valentin', age=25), Row(name='Sergey', age=33), Row(name='Gosha'), ] print(' | '.join(Row.__header__)) for row in rows: print(row)
№ | age | name 1 | 25 | Valentin 2 | 33 | Sergey 3 | N/A | Gosha
Soit dit en passant, l'affichage et l'utilisation d'une table peuvent être encapsulés dans une classe Sheet
distincte.
À suivre ...
Dans la partie suivante de cet article, je décrirai comment utiliser des métaclasses pour déboguer votre code d'application, comment paramétrer la création d'une métaclasse et montrer des exemples de base d'utilisation de la méthode __prepare__
. Restez à l'écoute!
Plus en détail sur les métaclasses et les descripteurs en Python, je le dirai dans le cadre d' Advanced Python .