Python: Metaprogrammierung in der Produktion. Teil eins

Viele Leute denken, dass Metaprogrammierung in Python den Code unnötig kompliziert, aber wenn Sie ihn richtig verwenden, können Sie komplexe Entwurfsmuster schnell und elegant implementieren. Darüber hinaus verwenden bekannte Python-Frameworks wie Django, DRF und SQLAlchemy Metaklassen, um eine einfache Erweiterbarkeit und eine einfache Wiederverwendung von Code zu ermöglichen.



In diesem Artikel werde ich Ihnen erklären, warum Sie keine Angst haben sollten, Metaprogrammierung in Ihren Projekten zu verwenden, und zeigen, für welche Aufgaben es am besten ist. Weitere Informationen zu Metaprogrammierungsoptionen finden Sie im Advanced Python- Kurs.


Erinnern wir uns zunächst an die Grundlagen der Metaprogrammierung in Python. Es ist nicht überflüssig hinzuzufügen, dass alles, was unten geschrieben wird, für Python Version 3.5 und höher gilt.


Eine kurze Tour durch das Python-Datenmodell


Wir alle wissen also, dass alles in Python ein Objekt ist, und es ist kein Geheimnis, dass es für jedes Objekt eine bestimmte Klasse gibt, von der es generiert wurde, zum Beispiel:


>>> def f(): pass >>> type(f) <class 'function'> 

Der Typ des Objekts oder der Klasse, mit der das Objekt generiert wurde, kann mithilfe der integrierten Typfunktion bestimmt werden, die eine ziemlich interessante Aufrufsignatur aufweist (wir werden etwas später darauf eingehen). Der gleiche Effekt kann erzielt werden, indem das Attribut __class__ für ein beliebiges Objekt abgeleitet wird.


Um Funktionen zu erstellen, wird eine bestimmte integrierte Klassenfunktion function . Mal sehen, was wir damit machen können. Nehmen Sie dazu das Leerzeichen aus dem Modul für integrierte Typen :


 >>> 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. 

Wie wir sehen können, ist jede Funktion in Python eine Instanz der oben beschriebenen Klasse. Versuchen wir nun, eine neue Funktion zu erstellen, ohne auf ihre Deklaration durch def . Dazu müssen wir lernen, wie Codeobjekte mit der im Interpreter integrierten Kompilierungsfunktion erstellt werden:


 #   ,    "Hello, world!" >>> code = compile('print("Hello, world!")', '<repl>', 'eval') >>> code <code object <module> at 0xdeadbeef, file "<repl>", line 1> #  ,     , #      >>> func = FunctionType(code, globals(), 'greetings') >>> func <function <module> at 0xcafefeed> >>> func.__name__ 'greetings' >>> func() Hello, world! 

Großartig! Mit Hilfe von Meta-Tools haben wir gelernt, wie man Funktionen im laufenden Betrieb erstellt, aber in der Praxis wird dieses Wissen selten verwendet. Schauen wir uns nun an, wie Klassenobjekte und Instanzobjekte dieser Klassen erstellt werden:


 >>> class User: pass >>> user = User() >>> type(user) <class '__main__.User'> >>> type(User) <class 'type'> 

Es ist offensichtlich, dass die User zum Erstellen einer user wird. Es ist viel interessanter, sich die type anzusehen, mit der die User selbst erstellt wird. Hier wenden wir uns der zweiten Option zum Aufrufen der integrierten Typfunktion zu, die in Kombination eine Metaklasse für jede Klasse in Python darstellt. Eine Metaklasse ist per Definition eine Klasse, deren Instanz eine andere Klasse ist. Mit Metaklassen können wir den Prozess zum Erstellen einer Klasse anpassen und den Prozess zum Erstellen einer Instanz einer Klasse teilweise steuern.


Gemäß der Dokumentation gibt der zweite Signaturtyp type(name, bases, attrs) - einen neuen Datentyp oder, wenn auf einfache Weise - eine neue Klasse zurück, und das __name__ wird zum __name__ -Attribut der zurückgegebenen Klasse, bases - die Liste der übergeordneten Klassen wird als __bases__ verfügbar __bases__ . Nun, attrs - ein attrs Objekt, das alle Attribute und Methoden der Klasse enthält, wird in __dict__ . Das Prinzip der Funktion kann in Python als einfacher Pseudocode beschrieben werden:


 type(name, bases, attrs) ~ class name(bases): attrs 

Mal sehen, wie Sie mit nur dem Typaufruf eine völlig neue Klasse erstellen können:


 >>> User = type('User', (), {}) >>> User <class '__main__.User'> 

Wie Sie sehen, müssen wir das Schlüsselwort class nicht verwenden, um eine neue Klasse zu erstellen. Die Typfunktion verzichtet darauf. Schauen wir uns nun ein komplizierteres Beispiel an:


 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() #     SuperUser "" CustomSuperUser = type( #   'SuperUser', #  ,      (User, ), #         { '__doc__': 'Encapsulate domain logic to work with super users', 'group_name': 'admin', 'login': property(lambda self: f'{self.group_name}/{self.name}'.lower()), } ) assert SuperUser.__doc__ == CustomSuperUser.__doc__ assert SuperUser('Vladimir').login == CustomSuperUser('Vladimir').login 

Wie Sie den obigen Beispielen entnehmen können, ist die Beschreibung von Klassen und Funktionen mit den Schlüsselwörtern class und def nur syntaktischer Zucker, und alle Arten von Objekten können durch gewöhnliche Aufrufe integrierter Funktionen erstellt werden. Lassen Sie uns abschließend darüber sprechen, wie Sie die dynamische Erstellung von Klassen in realen Projekten verwenden können.


Erstellen Sie dynamisch Formulare und Validatoren


Manchmal müssen wir Informationen vom Benutzer oder von anderen externen Quellen nach einem zuvor bekannten Datenschema validieren. Zum Beispiel möchten wir das Benutzeranmeldeformular im Admin-Bereich ändern - Felder löschen und hinzufügen, die Strategie ihrer Validierung ändern usw.


Versuchen wir zur Veranschaulichung, dynamisch ein Django- Formular zu erstellen, dessen Beschreibung im folgenden json Format gespeichert ist:


 { "fist_name": { "type": "str", "max_length": 25 }, "last_name": { "type": "str", "max_length": 30 }, "age": { "type": "int", "min_value": 18, "max_value": 99 } } 

Erstellen Sie nun basierend auf der obigen Beschreibung eine Reihe von Feldern und ein neues Formular mit der bereits bekannten Typfunktion:


 import json from django import forms fields_type_map = { 'str': forms.CharField, 'int': forms.IntegerField, } # form_description –  json    deserialized_form_description: dict = json.loads(form_description) form_attrs = {} #            for field_name, field_description in deserialized_form_description.items(): field_class = fields_type_map[field_description.pop('type')] form_attrs[field_name] = field_class(**field_description) user_form_class = type('DynamicForm', (forms.Form, ), form_attrs) >>> form = user_form_class({'age': 101}) >>> form <DynamicForm bound=True, valid=Unknown, fields=(fist_name;last_name;age)> >>> form.is_valid() False >>> form.errors {'fist_name': ['This field is required.'], 'last_name': ['This field is required.'], 'age': ['Ensure this value is less than or equal to 99.']} 

Großartig! Jetzt können Sie das erstellte Formular in die Vorlage übertragen und für den Benutzer rendern. Der gleiche Ansatz kann mit anderen Frameworks für die Datenvalidierung und -präsentation ( DRF-Serializer , Marshmallow und andere) verwendet werden.


Konfigurieren der Erstellung einer neuen Klasse über die Metaklasse


Oben haben wir uns die bereits abgeschlossene Metaklasse angesehen, aber meistens erstellen Sie im Code Ihre eigenen Metaklassen und verwenden sie, um die Erstellung neuer Klassen und ihrer Instanzen zu konfigurieren. Im allgemeinen Fall sieht das "Leerzeichen" einer Metaklasse folgendermaßen aus:


 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) 

Um diese Metaklasse zum Konfigurieren der User , wird die folgende Syntax verwendet:


 class User(metaclass=MetaClass): def __new__(cls, name): return super().__new__(cls) def __init__(self, name): self.name = name 

Das Interessanteste ist die Reihenfolge, in der der Python-Interpreter die Metaklassen-Metamethode zum Zeitpunkt der Erstellung der Klasse selbst aufruft:


  1. Der Interpreter ermittelt und findet die übergeordneten Klassen für die aktuelle Klasse (falls vorhanden).
  2. Der Interpreter definiert eine Metaklasse (in unserem Fall MetaClass ).
  3. Die Methode MetaClass.__prepare__ wird MetaClass.__prepare__ Sie sollte ein diktartiges Objekt zurückgeben, in das die Attribute und Methoden der Klasse geschrieben werden. Danach wird das Objekt über das Argument MetaClass.__new__ attrs . Wir werden etwas später in den Beispielen über die praktische Anwendung dieser Methode sprechen.
  4. Der Interpreter liest den Hauptteil der User Klasse und generiert Parameter, um sie an die MetaClass Metaklasse zu übergeben.
  5. Die MetaClass.__new__ Methode MetaClass.__new__ - die MetaClass.__new__ Methode gibt das erstellte Klassenobjekt zurück. Wir haben die Argumente name , attrs und attrs bereits getroffen, als wir sie an die type Funktion übergeben haben, und wir werden etwas später über den Parameter **extra_kwargs sprechen. Wenn der Typ des attrs Arguments mit __prepare__ geändert wurde, muss es in ein __prepare__ konvertiert werden, bevor es an den Methodenaufruf super() wird.
  6. Die MetaClass.__init__ Methode MetaClass.__init__ - die Initialisierungsmethode, mit der Sie dem Klassenobjekt in der Klasse zusätzliche Attribute und Methoden hinzufügen können. In der Praxis wird es in Fällen verwendet, in denen Metaklassen von anderen Metaklassen geerbt werden, andernfalls ist alles, was in __init__ getan werden kann, besser in __new__ . Beispielsweise kann der Parameter __slots__ nur in der Methode __new__ werden, indem er in das attrs Objekt geschrieben wird.
  7. In diesem Schritt wird die Klasse als erstellt betrachtet.

Erstellen Sie nun eine Instanz unserer User und sehen Sie sich die Aufrufkette an:


 user = User(name='Alyosha') 

  1. Zum Zeitpunkt des Aufrufs von User(...) ruft User(...) Interpreter die MetaClass.__call__(name='Alyosha') -Methode auf, bei der das Klassenobjekt und die übergebenen Argumente übergeben werden.
  2. MetaClass.__call__ ruft User.__new__(name='Alyosha') - eine Konstruktormethode, die eine Instanz der User Klasse erstellt und zurückgibt
  3. Als MetaClass.__call__ ruft MetaClass.__call__ User.__init__(name='Alyosha') - eine Initialisierungsmethode, die der erstellten Instanz neue Attribute hinzufügt.
  4. MetaClass.__call__ gibt die erstellte und initialisierte Instanz der User Klasse zurück.
  5. Zu diesem Zeitpunkt wird eine Instanz der Klasse als erstellt betrachtet.

Diese Beschreibung deckt natürlich nicht alle Nuancen der Verwendung von Metaklassen ab, aber es reicht aus, mit der Metaprogrammierung zu beginnen, um einige Architekturmuster zu implementieren. Vorwärts zu den Beispielen!


Abstrakte Klassen


Das allererste Beispiel finden Sie in der Standardbibliothek: ABCMeta - Mit einer Metaklasse können Sie jede unserer Klassen als abstrakt deklarieren und alle Nachkommen dazu zwingen, vordefinierte Methoden, Eigenschaften und Attribute zu implementieren.


 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 

Wenn nicht alle abstrakten Methoden und Attribute im Erben implementiert sind, erhalten wir beim Versuch, eine Instanz der TypeError zu erstellen, einen TypeError :


 class VideoPlugin(BasePlugin): def run(self): print('Processing video...') plugin = VideoPlugin() # TypeError: Can't instantiate abstract class VideoPlugin # with abstract methods supported_formats 

Die Verwendung abstrakter Klassen hilft dabei, die Basisklassenschnittstelle sofort zu reparieren und zukünftige Vererbungsfehler zu vermeiden, z. B. Tippfehler im Namen einer überschriebenen Methode.


Automatisches Registrierungs-Plugin-System


Sehr oft wird die Metaprogrammierung verwendet, um verschiedene Entwurfsmuster zu implementieren. Fast jedes bekannte Framework verwendet Metaklassen, um Registrierungsobjekte zu erstellen. Solche Objekte speichern Links zu anderen Objekten und ermöglichen den schnellen Empfang an einer beliebigen Stelle im Programm. Betrachten Sie ein einfaches Beispiel für die automatische Registrierung von Plugins zum Abspielen von Mediendateien in verschiedenen Formaten.


Metaklassenimplementierung:


 class RegistryMeta(ABCMeta): """ ,      .     " " -> " " """ _registry_formats = {} def __new__(mcs, name, bases, attrs): cls: 'BasePlugin' = super().__new__(mcs, name, bases, attrs) #     (BasePlugin) if inspect.isabstract(cls): return cls for media_format in cls.supported_formats: if media_format in mcs._registry_formats: raise ValueError(f'Format {media_format} is already registered') #       mcs._registry_formats[media_format] = cls return cls @classmethod def get_plugin(mcs, media_format: str): try: return mcs._registry_formats[media_format] except KeyError: raise RuntimeError(f'Plugin is not defined for {media_format}') @classmethod def show_registry(mcs): from pprint import pprint pprint(mcs._registry_formats) 

Und hier sind die Plugins selbst. Wir übernehmen die BasePlugin Implementierung aus dem vorherigen Beispiel:


 class BasePlugin(metaclass=RegistryMeta): ... class VideoPlugin(BasePlugin): supported_formats = ['mpg', 'mov'] def run(self): ... class AudioPlugin(BasePlugin): supported_formats = ['mp3', 'flac'] def run(self): ... 

Nach dem Ausführen dieses Codes registriert der Interpreter 4 Formate und 2 Plugins in unserer Registrierung, die diese Formate verarbeiten können:


 >>> 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... 

Es ist erwähnenswert, dass eine weitere interessante Nuance der Arbeit mit Metaklassen vorhanden ist. Dank der nicht offensichtlichen Reihenfolge der Methodenauflösung können wir die show_registry Methode nicht nur für die RegistyMeta Klasse RegistyMeta , sondern für jede andere Klasse, von der es sich um eine Metaklasse handelt:


 >>> AudioPlugin.get_plugin('avi') # RuntimeError: Plugin is not found for avi 

Verwenden von Attributnamen als Metadaten


Mithilfe von Metaklassen können Sie Klassenattributnamen als Metadaten für andere Objekte verwenden. Nichts ist klar? Aber ich bin sicher, dass Sie diesen Ansatz schon oft gesehen haben, zum Beispiel die deklarative Deklaration von Modellfeldern in Django:


 class Book(models.Model): title = models.Charfield(max_length=250) 

Im obigen Beispiel ist title der Name des Python-Bezeichners. Er wird auch verwendet, um die Spalte in der book zu benennen, obwohl wir dies nirgendwo explizit angegeben haben. Ja, solche „Magie“ kann mit Hilfe der Metaprogrammierung realisiert werden. Implementieren wir beispielsweise ein System zum Übertragen von Anwendungsfehlern an das Front-End, sodass jede Nachricht einen lesbaren Code enthält, mit dem die Nachricht in eine andere Sprache übersetzt werden kann. Wir haben also ein Nachrichtenobjekt, das in json konvertiert werden kann:


 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}) 

Alle unsere Fehlermeldungen werden in einem separaten "Namespace" gespeichert:


 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} 

Jetzt möchten wir, dass der code nicht null , sondern nicht not_found wird. Dazu schreiben wir die folgende Metaklasse:


 class MetaMessage(type): def __new__(mcs, name, bases, attrs): for attr, value in attrs.items(): #          Message #    code    # ( code   ) if isinstance(value, Message) and value.code is None: value.code = attr return super().__new__(mcs, name, bases, attrs) class Messages(metaclass=MetaMessage): ... 

Mal sehen, wie unsere Beiträge jetzt aussehen:


 >>> 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"} 

Was du brauchst! Jetzt wissen Sie, was zu tun ist, damit Sie anhand des Datenformats leicht den Code finden können, der sie verarbeitet.


Zwischenspeichern von Metadaten über eine Klasse und ihre Nachkommen


Ein weiterer häufiger Fall ist das Zwischenspeichern statischer Daten in der Phase der Klassenerstellung, um keine Zeit mit der Berechnung zu verschwenden, während die Anwendung ausgeführt wird. Darüber hinaus können einige Daten aktualisiert werden, wenn neue Instanzen von Klassen erstellt werden, z. B. ein Zähler für die Anzahl der erstellten Objekte.


Wie kann das genutzt werden? Angenommen, Sie entwickeln ein Framework zum Erstellen von Berichten und Tabellen und haben ein solches Objekt:


 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] #  __header__      for name in self.__header__[1:]: out.append(getattr(self, name, 'N/A')) return ' | '.join(map(str, out)) 

Wir möchten den Zähler beim Erstellen einer neuen Zeile speichern und erhöhen und den Header der resultierenden Tabelle im Voraus generieren. Metaklasse zur Rettung!


 class MetaRow(type): #      row_count = 0 def __new__(mcs, name, bases, attrs): cls = super().__new__(mcs, name, bases, attrs) #          cls.__header__ = ['№'] + sorted(attrs['__annotations__'].keys()) return cls def __call__(cls, *args, **kwargs): #      row: 'Row' = super().__call__(*args, **kwargs) #    cls.row_count += 1 #     row.counter = cls.row_count return row 

Zwei Dinge müssen hier geklärt werden:


  • Die Row Klasse hat keine Klassenattribute mit den Namen name und age Dies sind attrs , daher befinden sie sich nicht in den attrs Wörterbuchschlüsseln. Um eine Liste der Felder zu erhalten, verwenden wir das __annotations__ .
  • Die Operation cls.row_count += 1 sollte Sie irreführen: wie? Schließlich ist cls eine Row Klasse und hat nicht das Attribut row_count . Alles ist wahr, aber wie ich oben erklärt habe - wenn die erstellte Klasse kein Attribut oder keine Methode hat, die sie aufrufen möchten, geht der Interpreter weiter entlang der Kette von Basisklassen - wenn keine darin enthalten sind, wird eine Suche in der Metaklasse durchgeführt. In solchen Fällen ist es besser, einen anderen Datensatz zu verwenden, um niemanden zu verwirren: MetaRow.row_count += 1 .

Sehen Sie, wie elegant Sie jetzt die gesamte Tabelle anzeigen können:


 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 

Das Anzeigen und Arbeiten mit einer Tabelle kann übrigens in einer separaten Sheet Klasse zusammengefasst werden.


Fortsetzung folgt...


Im nächsten Teil dieses Artikels werde ich beschreiben, wie Sie Metaklassen zum Debuggen Ihres Anwendungscodes verwenden, wie Sie die Erstellung einer Metaklasse parametrisieren und grundlegende Beispiele für die Verwendung der __prepare__ -Methode zeigen. Bleib dran!


Ausführlicher über Metaklassen und Deskriptoren in Python werde ich im Rahmen von Advanced Python berichten .

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


All Articles