Python: métaprogrammation en production. Deuxième partie

Nous continuons à parler de métaprogrammation en Python. Lorsqu'il est utilisé correctement, il vous permet de mettre en œuvre rapidement et avec élégance des modèles de conception complexes. Dans la dernière partie de cet article, nous avons montré comment les métaclasses peuvent être utilisées pour modifier les attributs des instances et des classes.



Voyons maintenant comment modifier les appels de méthode. Vous pouvez en savoir plus sur les options de métaprogrammation dans le cours Advanced Python .


Débogage et suivi des appels


Comme vous l'avez déjà compris, en utilisant une métaclasse, n'importe quelle classe peut être transformée au-delà de la reconnaissance. Par exemple, remplacez toutes les méthodes de classe par d'autres ou appliquez un décorateur arbitraire à chaque méthode. Vous pouvez utiliser cette idée pour déboguer les performances des applications.


La métaclasse suivante mesure le temps d'exécution de chaque méthode dans la classe et ses instances, ainsi que le temps de création de l'instance elle-même:


from contextlib import contextmanager import logging import time import wrapt @contextmanager def timing_context(operation_name): """       """ start_time = time.time() try: yield finally: logging.info('Operation "%s" completed in %0.2f seconds', operation_name, time.time() - start_time) @wrapt.decorator def timing(func, instance, args, kwargs): """       .     https://wrapt.readthedocs.io/en/latest/         """ with timing_context(func.__name__): return func(*args, **kwargs) class DebugMeta(type): def __new__(mcs, name, bases, attrs): for attr, method in attrs.items(): if not attr.startswith('_'): #     attrs[attr] = timing(method) return super().__new__(mcs, name, bases, attrs) def __call__(cls, *args, **kwargs): with timing_context(f'{cls.__name__} instance creation'): #      return super().__call__(*args, **kwargs) 

Regardons le débogage en action:


 class User(metaclass=DebugMeta): def __init__(self, name): self.name = name time.sleep(.7) def login(self): time.sleep(1) def logout(self): time.sleep(2) @classmethod def create(cls): time.sleep(.5) user = User('Michael') user.login() user.logout() user.create() #   INFO:__main__:Operation "User instance creation" completed in 0.70 seconds INFO:__main__:Operation "login" completed in 1.00 seconds INFO:__main__:Operation "logout" completed in 2.00 seconds INFO:__main__:Operation "create" completed in 0.50 seconds 

Essayez d'étendre DebugMeta - DebugMeta et de DebugMeta la signature des méthodes et leur trace de pile.


Le schéma solitaire et l'interdiction de l'héritage


Et maintenant, passons à des cas exotiques d'utilisation de métaclasses dans des projets Python.


Beaucoup d'entre vous utilisent sûrement le module Python habituel pour implémenter un modèle de conception singleton (alias Singleton), car il est beaucoup plus pratique et plus rapide que d'écrire la métaclasse appropriée. Cependant, écrivons une de ses implémentations pour des raisons académiques:


 class Singleton(type): instance = None def __call__(cls, *args, **kwargs): if cls.instance is None: cls.instance = super().__call__(*args, **kwargs) return cls.instance class User(metaclass=Singleton): def __init__(self, name): self.name = name def __repr__(self): return f'<User: {self.name}>' u1 = User('Pavel') #         u2 = User('Stepan') >>> id(u1) == id(u2) True >>> u2 <User: Pavel> >>> User.instance <User: Pavel> #   , ? >>> u1.instance.instance.instance.instance <User: Pavel> 

Cette implémentation a une nuance intéressante - puisque le constructeur de classe n'est pas appelé pour la deuxième fois, vous pouvez faire une erreur et ne pas y passer le paramètre nécessaire, et rien ne se passera à l'exécution si l'instance a déjà été créée. Par exemple:


 >>> User('Roman') <User: Roman> >>> User('Alexey', 'Petrovich', 66) #     ! <User: Roman> #     User       #    TypeError! 

Voyons maintenant une option encore plus exotique: l'interdiction de l'héritage d'une classe particulière.


 class FinalMeta(type): def __new__(mcs, name, bases, attrs): for cls in bases: if isinstance(cls, FinalMeta): raise TypeError(f"Can't inherit {name} class from final {cls.__name__}") return super().__new__(mcs, name, bases, attrs) class A(metaclass=FinalMeta): """   !""" pass class B(A): pass # TypeError: Can't inherit B class from final A #    ! 

Paramétrage de la métaclasse


Dans les exemples précédents, nous avons utilisé des métaclasses pour personnaliser la création de classes, mais vous pouvez aller encore plus loin et commencer à paramétrer le comportement des métaclasses.


Par exemple, vous pouvez passer une fonction au paramètre de métaclasse lors de la déclaration d'une classe et renvoyer différentes instances de métaclasses en fonction de certaines conditions, par exemple:


 def get_meta(name, bases, attrs): if SOME_SETTING: return MetaClass1(name, bases, attrs) else: return MetaClass2(name, bases, attrs) class A(metaclass=get_meta): pass 

Mais un exemple plus intéressant est l'utilisation de paramètres extra_kwargs lors de la déclaration de classes. Supposons que vous souhaitiez utiliser la métaclasse pour modifier le comportement de certaines méthodes dans une classe, et chaque classe peut avoir des noms différents pour ces méthodes. Que faire? Et voici ce que


 #   `DebugMeta`     class DebugMetaParametrized(type): def __new__(mcs, name, bases, attrs, **extra_kwargs): debug_methods = extra_kwargs.get('debug_methods', ()) for attr, value in attrs.items(): #      ,   #    `debug_methods`: if attr in debug_methods: attrs[attr] = timing(value) return super().__new__(mcs, name, bases, attrs) class User(metaclass=DebugMetaParametrized, debug_methods=('login', 'create')): ... user = User('Oleg') user.login() #  "logout"   . user.logout() user.create() 

À mon avis, cela s'est avéré très élégant! Vous pouvez trouver beaucoup de modèles pour utiliser ce paramétrage, mais rappelez-vous la règle principale - tout est bon avec modération.


__prepare__ méthodes


Enfin, je parlerai de l’utilisation possible de la méthode __prepare__ . Comme mentionné ci-dessus, cette méthode doit renvoyer un objet dictionnaire, que l'interpréteur remplit au moment d'analyser le corps de la classe, par exemple, si __prepare__ renvoie l'objet d = dict() , puis lors de la lecture de la classe suivante:


 class A: x = 12 y = 'abc' z = {1: 2} 

L'interprète effectuera les opérations suivantes:


 d['x'] = 12 d['y'] = 'abc' d['z'] = {1: 2} 

Il existe plusieurs utilisations possibles de cette fonctionnalité. Ils ont tous différents degrés d'utilité, donc:


  1. Dans les versions de Python = <3.5, si nous devions conserver l'ordre de déclaration des méthodes dans une classe, nous pourrions renvoyer des collections.OrderedDict de la méthode __prepare__ , dans les versions plus anciennes, les dictionnaires intégrés préservent déjà l'ordre d'ajout de clés, donc OrderedDict n'est plus nécessaire.
  2. Le module de bibliothèque standard enum utilise un objet de type dict personnalisé pour déterminer quand un attribut de classe est dupliqué lors de la déclaration. Le code peut être trouvé ici .
  3. Pas du tout du code prêt pour la production, mais un très bon exemple est la prise en charge du polymorphisme paramétrique .

Par exemple, considérons la classe suivante avec trois implémentations d'une seule méthode polymorphe:


 class Terminator: def terminate(self, x: int): print(f'Terminating INTEGER {x}') def terminate(self, x: str): print(f'Terminating STRING {x}') def terminate(self, x: dict): print(f'Terminating DICTIONARY {x}') t1000 = Terminator() t1000.terminate(10) t1000.terminate('Hello, world!') t1000.terminate({'hello': 'world'}) #  Terminating DICTIONARY 10 Terminating DICTIONARY Hello, world! Terminating DICTIONARY {'hello': 'world'} 

De toute évidence, la dernière méthode de terminate déclarée écrase les implémentations des deux premières, et nous avons besoin que la méthode soit sélectionnée en fonction du type de l'argument passé. Pour ce faire, nous programmons quelques objets wrapper supplémentaires:


 class PolyDict(dict): """ ,               PolyMethod. """ def __setitem__(self, key: str, func): if not key.startswith('_'): if key not in self: super().__setitem__(key, PolyMethod()) self[key].add_implementation(func) return None return super().__setitem__(key, func) class PolyMethod: """    ,            .       ,       : instance method, staticmethod, classmethod. """ def __init__(self): self.implementations = {} self.instance = None self.cls = None def __get__(self, instance, cls): self.instance = instance self.cls = cls return self def _get_callable_func(self, impl): # ""  classmethod/staticmethod return getattr(impl, '__func__', impl) def __call__(self, arg): impl = self.implementations[type(arg)] callable_func = self._get_callable_func(impl) if isinstance(impl, staticmethod): return callable_func(arg) elif self.cls and isinstance(impl, classmethod): return callable_func(self.cls, arg) else: return callable_func(self.instance, arg) def add_implementation(self, func): callable_func = self._get_callable_func(func) #   ,     1  arg_name, arg_type = list(callable_func.__annotations__.items())[0] self.implementations[arg_type] = func 

La chose la plus intéressante dans le code ci-dessus est l'objet PolyMethod , qui stocke un registre avec des implémentations de la même méthode, selon le type d'argument passé à cette méthode. Nous retournerons l'objet PolyDict de la méthode __prepare__ et enregistrerons ainsi différentes implémentations des méthodes avec le même nom terminate . Un point important - lors de la lecture du corps de la classe et lors de la création de l'objet attrs , l'interpréteur y place les fonctions dites unbound , ces fonctions ne savent pas encore sur quelle classe ou instance elles seront appelées. Nous avons dû implémenter un protocole de descripteur afin de définir le contexte lors de l'appel de fonction et passer soit self soit cls comme premier paramètre, soit ne rien passer si la staticmethod appelée.


En conséquence, nous verrons la magie suivante:


 class PolyMeta(type): @classmethod def __prepare__(mcs, name, bases): return PolyDict() class Terminator(metaclass=PolyMeta): ... t1000 = Terminator() t1000.terminate(10) t1000.terminate('Hello, world!') t1000.terminate({'hello': 'world'}) #  Terminating INTEGER 10 Terminating STRING Hello, world! Terminating DICTIONARY {'hello': 'world'} >>> t1000.terminate <__main__.PolyMethod object at 0xdeadcafe> 

Si vous connaissez d'autres utilisations intéressantes de la méthode __prepare__ , veuillez écrire dans les commentaires.


Conclusion


La métaprogrammation est l'un des nombreux sujets dont j'ai parlé dans Advanced Python . Dans le cadre du cours, je vous expliquerai également comment utiliser efficacement les principes de SOLID et GRASP dans le développement de grands projets Python, concevoir l'architecture d'application et écrire du code hautes performances et de haute qualité. Je serai heureux de vous voir dans les murs du quartier binaire!

Source: https://habr.com/ru/post/fr422415/


All Articles