PythonProgrammationDéveloppeur Python Senior

Par quels moyens une instance **Python** peut-elle spécifier ses classes de base logiques pour participer au calcul de l'ordre de résolution des méthodes ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Historique de la question

Le protocole __mro_entries__ a été introduit dans Python 3.7 via PEP 560 ("Support de base pour le module typing et les types génériques"). Avant cette amélioration, des alias génériques tels que typing.List[int] ne pouvaient pas être utilisés comme classes de base dans les définitions de classes car type.__new__ exigeait strictement que toutes les bases soient des instances de type. Cette limitation obligeait le module typing à s'appuyer sur des astuces de métaclasses fragiles, difficiles à maintenir et posant des problèmes de performance. Le protocole a été conçu pour découpler l'expression syntaxique d'une base de sa contribution sémantique au graphe d'héritage, permettant un meilleur support pour les génériques et les motifs de fabrique.

Le problème

Lorsque CPython traite une définition de classe, il doit calculer l'ordre de résolution des méthodes (MRO) en utilisant l'algorithme de linéarisation C3 afin d'assurer une hiérarchie de recherche de méthodes cohérente et prévisible. Si un objet de base n'est pas une classe (par exemple, un générique paramétré ou un objet de configuration), l'interpréteur n'a pas les informations de type nécessaires pour placer la nouvelle classe correctement dans l'arbre d'héritage. Ignorer de tels objets casserait les vérifications isinstance et les chaînes super(), tandis que les rejeter complètement empêcherait des modèles de méta-programmation puissants. Le défi principal était de permettre à ces objets non-classe de déclarer quelles classes concrètes elles représentent logiquement lors de la phase de construction de la classe.

La solution

Python inspecte maintenant chaque item dans le tuple de bases pour une méthode __mro_entries__(self, bases) lors de la création de classe. Si cette méthode existe, elle est invoquée avec le tuple de bases d'origine et doit retourner un tuple de classes réelles à substituer pour l'objet dans le calcul de MRO. Les classes retournées sont alors traitées comme si elles avaient été explicitement listées comme bases. Ce mécanisme permet à une instance d'agir comme un espace réservé transparent qui se résout en classes concrètes au moment de la définition.

class ConfigurableMixin: def __init__(self, feature): self.feature = feature def __mro_entries__(self, bases): # Injecter dynamiquement les classes de base en fonction de la configuration if self.feature == "logging": return (LoggingSupport,) return (BaseFeature,) class LoggingSupport: def log(self, msg): print(msg) class BaseFeature: pass # L'instance est remplacée par LoggingSupport dans le MRO class Service(ConfigurableMixin("logging")): pass print(LoggingSupport in Service.__mro__) # True

Situation de la vie réelle

Dans un grand framework web asynchrone, les développeurs avaient besoin de créer une fabrique DatabaseMixin qui, lorsqu'elle était instanciée avec une URL de base de données spécifique (par exemple, DatabaseMixin("postgresql://")), injecterait automatiquement à la fois ConnectionPool et AsyncSession en tant que classes de base dans la classe de service de l'utilisateur. La difficulté était que DatabaseMixin(...) retournait une instance d'objet simple, pas une classe, mais devait participer au MRO comme si le développeur avait explicitement écrit class UserService(ConnectionPool, AsyncSession).

Solution 1 : Métaclasse personnalisée Une approche consistait à créer une métaclasse qui scannait le tuple bases dans __new__, identifiait les instances DatabaseMixin, et les remplaçait par les classes cibles avant d'appeler super().__new__. Cela permettait un contrôle précis mais introduisait le problème de "conflit de métaclasse" : tout service utilisant cette métaclasse ne pouvait pas hériter d'autres classes qui définissaient leurs propres métaclasses, telles que certaines classes de base ORM. De plus, le débogage devenait difficile car la syntaxe de définition de classe cachait des transformations complexes, et les traces de pile visaient les détails internes de la métaclasse plutôt que le code utilisateur.

Solution 2 : Décoration de classe après création Une autre option était d'utiliser un décorateur de classe appliqué après la création de la classe. Le décorateur copierait manuellement les méthodes de ConnectionPool et AsyncSession dans la nouvelle classe ou utiliserait type.__setattr__ pour les injecter. Bien que cela évitât la viralité des métaclasses, cela rompait fondamentalement le modèle d'héritage de Python : isinstance(UserService(), ConnectionPool) retournerait False, et les appels super() dans les méthodes copiées se résoudraient incorrectement car le MRO ne contenait pas réellement les classes parentes. Cela conduisait à des bogues subtils où les utilitaires du framework ne réussissaient pas à reconnaître les services comme capables de base de données.

Solution 3 : Protocole __mro_entries__ L'équipe a choisi d'implémenter __mro_entries__ sur l'objet retourné par DatabaseMixin. La méthode retournait (ConnectionPool, AsyncSession) en fonction de l'URL analysée. Cette solution s'intégrait parfaitement dans le mécanisme de création de classe natif de CPython. Le MRO était calculé correctement, les vérifications isinstance fonctionnaient naturellement, et il n'y avait pas de conflits de métaclasses. L'instance de fabrique agissait comme un espace réservé déclaratif qui se dissolvait dans la structure d'héritage appropriée lors de la construction de la classe, préservant la sémantique de super() et la compatibilité avec l'héritage multiple.

Le résultat était une API propre et intuitive où les développeurs pouvaient écrire class OrderService(DatabaseMixin(postgres_url)): et recevoir automatiquement des capacités de pooling de connexion et de gestion de session avec une résolution de méthode correcte, un support IDE complet, et aucun surcoût d'exécution ou conflit d'héritage.

Ce que les candidats manquent souvent

Comment la linéarisation C3 gère-t-elle les doublons potentiels lorsque __mro_entries__ étend une base en des classes déjà présentes ailleurs dans la liste d'héritage ?

Lorsque __mro_entries__ retourne une classe qui apparaît également ailleurs dans les bases (par exemple, si une fabrique s'étend en (BaseA,) et qu'une autre base explicite est Derived(BaseA)), l'algorithme C3 de Python traite le tuple étendu comme la liste de base effective. L'algorithme fusionne ensuite ces listes tout en préservant l'ordre de priorité local et en garantissant la monotonie. Parce que C3 est conçu pour gérer les ancêtres communs, BaseA n'apparaît qu'une seule fois dans le MRO final, positionné après toutes les classes qui en dépendent mais avant object. Les candidats croient souvent à tort que cela crée un conflit ou une entrée dupliquée, mais le processus de linéarisation déduplique naturellement tout en maintenant la contrainte "enfants avant parents", garantissant une résolution de méthode cohérente.

Pourquoi __mro_entries__ ne peut-il pas accéder à la classe en cours de création, et quelle erreur spécifique se produit-elle s'il tente de le faire ?

Lors de la création de la classe, type.__new__ appelle __mro_entries__ sur les objets de base avant que l'objet de classe lui-même ne soit instancié. Le dictionnaire de l'espace de noms existe, mais l'objet de classe n'a pas encore d'identité. Si l'implémentation tente d'accéder aux attributs de la classe prospective (par exemple, en se référant au nom de la classe depuis une portée externe ou en essayant d'inspecter bases comme s'ils étaient déjà liés à la nouvelle classe), elle lèvera une NameError ou une AttributeError parce que la liaison n'existe pas encore. Les candidats supposent souvent qu'ils peuvent inspecter l'état final de la classe ou le __dict__ pour prendre des décisions dynamiques, mais la méthode ne reçoit que le tuple des bases d'origine comme argument et doit compter sur son propre état interne pour déterminer la valeur de retour.

L'enregistrement d'un objet avec __mro_entries__ en tant que sous-classe virtuelle d'un ABC via abc.ABCMeta.register() fait-il apparaître l'ABC dans le MRO ?

Non. L'enregistrement de sous-classe virtuelle est un mécanisme d'exécution qui remplit un cache interne au sein de l'ABC pour les vérifications isinstance() et issubclass(). Cela ne modifie pas l'attribut __mro__ de la sous-classe. Lorsque MyClass(MyObject()) est définie et que MyObject() retourne (ConcreteBase,) via __mro_entries__, seule ConcreteBase apparaît dans MyClass.__mro__. Si ConcreteBase est enregistré en tant que sous-classe virtuelle de MyABC, alors isinstance(MyClass(), MyABC) retourne True, mais MyABC ne sera pas présent dans MyClass.__mro__. Les candidats confondent souvent la sous-classe virtuelle avec la véritable héritage, ce qui entraîne de la confusion sur la raison pour laquelle les appels super() ou l'inspection du MRO ne reflètent pas la relation ABC, ou pourquoi les méthodes définies sur l'ABC ne sont pas disponibles via l'héritage.