PythonProgrammationDéveloppeur Python Senior

Comment la présence de `__set__` dans un **descripteur Python** modifie-t-elle la priorité des dictionnaires d'instance lors de la résolution des attributs ?

Réussissez les entretiens avec l'assistant IA Hintsage

Réponse à la question

Historique de la question

Dans les anciennes versions de Python, la résolution des attributs reposait sur une simple recherche en profondeur à travers le dictionnaire d'instance suivie de la hiérarchie des classes. Cette approche s'est révélée insuffisante pour mettre en œuvre un comportement similaire à des propriétés où les valeurs calculées devaient intercepter à la fois les lectures et les écritures sans ambiguïté. L'introduction des classes de nouvelle style dans Python 2.2 a établi le protocole des descripteurs, catégorisant les descripteurs en fonction de la présence de __set__ ou __delete__ pour résoudre les conflits de priorité.

Le problème

Sans règle de priorité stricte, l'interpréteur ne pouvait pas décider si le stockage local d'une instance devait remplacer les définitions de niveau classe ou vice versa. Si les dictionnaires d'instance prenaient toujours la priorité, les propriétés ne pouvaient pas valider les affectations car les valeurs seraient stockées directement dans __dict__. Inversement, si les attributs de classe dominaient toujours, les variables d'instance ordinaires seraient inaccessibles lorsque les noms se heurtaient à des méthodes ou à d'autres attributs de classe.

La solution

L'algorithme de recherche d'attributs de Python impose que les descripteurs de données - ceux définissant __set__ ou __delete__ - prennent la priorité sur les dictionnaires d'instance, tandis que les descripteurs non-donnés (définissant uniquement __get__) cèdent aux dictionnaires d'instance. Ce design permet à @property d'appliquer une logique de validation en interceptant les écritures, tandis que les fonctions ordinaires ou les propriétés mises en cache restent remplaçables par instance sans programmation métaprogrammatique complexe.

Situation de la vie réelle

Une équipe de développement construisait une couche de validation de données à haut débit pour une plateforme de trading financier. Ils avaient besoin de champs persistants qui validaient strictement les données du marché entrants par rapport aux contraintes réglementaires, assurant qu'aucune valeur invalide ne puisse être assignée. De plus, ils nécessitaient des indicateurs calculés qui pouvaient être mis en cache par instance pour éviter le recalcul coûteux des indices de volatilité lors des pics de trading à haute fréquence.

Solution 1 : Propriétés universelles

Une approche considérée était de mettre en œuvre tous les attributs comme des propriétés utilisant le décorateur @property. Cela offrait un contrôle de validation complet en interceptant chaque opération d'écriture via la méthode setter de la propriété. Cependant, ce design empêchait le système de contourner la validation lors du chargement de données sérialisées provenant de caches internes de confiance, créant un surcoût computationnel inutile lors des opérations de relecture en vrac.

Solution 2 : Centralisation de setattr

Une autre option consistait à remplacer __setattr__ dans la classe de base pour centraliser la logique de validation dans une seule méthode. Bien que ce contrôle centralisé offrait un point unique de modification pour les règles de validation, il introduisait une logique de branchement fragile pour distinguer entre les champs persistants nécessitant validation et les caches de calcul temporaires. De plus, cette approche interférait avec les modèles d'accès aux attributs standards attendus par les bibliothèques de sérialisation tierces, provoquant des échecs d'intégration.

Solution choisie

La solution choisie a tiré parti de la dichotomie du protocole des descripteurs directement pour satisfaire les deux exigences sans surcoût de centralisation. L'équipe a mis en œuvre ValidatedField comme un descripteur de données avec une méthode __set__ appliquant des contraintes de type et de plage, assurant qu'elle interceptera toujours les affectations indépendamment de l'état de l'instance puisque les descripteurs de données prennent la priorité sur les dictionnaires d'instance. Pour les indicateurs calculés, ils ont créé CachedMetric comme un descripteur non-donné implémentant uniquement __get__, permettant au dictionnaire d'instance d'ombrager le descripteur une fois qu'une valeur était calculée et stockée localement, contournant ainsi le recalcul lors des accès ultérieurs.

Résultat

Cette architecture a fourni une validation stricte pour les entrées externes tout en permettant un cache flexible et performant pour les valeurs dérivées. Le système a réussi à traiter des flux de marché à volume élevé sans goulets d'étranglement de validation pendant l'hydratation du cache. Le benchmarking a révélé une réduction de 40 % de la surcharge de validation pendant les scénarios de relecture historique par rapport à l'approche uniquement basée sur les propriétés, tout en maintenant une conformité réglementaire complète pour l'ingestion de données en direct.

Ce que les candidats oublient souvent

Est-ce que supprimer un attribut contourne un descripteur de données si le descripteur n'a pas de méthode __delete__ ?

Lorsqu'un descripteur de données implémente __set__ mais omet __delete__, tenter de supprimer l'attribut via del obj.attr ne revient pas au dictionnaire de l'instance. Python reconnaît toujours l'objet comme un descripteur de données en raison de la présence de __set__, et l'opération de suppression lèvera une AttributeError indiquant que l'attribut ne peut pas être supprimé. Pour autoriser la suppression, le descripteur doit explicitement définir __delete__ pour retirer la valeur de l'instance, ou la classe doit implémenter une logique de suppression personnalisée ; le mécanisme de recherche ne vérifie jamais le dictionnaire d'instance pour les attributs de descripteur de données lors des opérations de suppression.

Pourquoi super().attribute semble-t-il ignorer les descripteurs de données définis dans la classe actuelle ?

Le proxy super() implémente un mécanisme d'héritage multiple coopératif qui commence à rechercher dans l'Ordre de Résolution des Méthodes (MRO) à la classe suivant la classe actuelle dans la hiérarchie. Étant donné que le descripteur est défini sur la classe actuelle elle-même, super() l'ignore lors de la recherche. Cependant, si une classe parente définit un descripteur de données avec le même nom, super() le trouvera et appliquera les règles de priorité standard des descripteurs de données, invoquant __get__ avec l'instance et la classe propriétaire de manière appropriée. Ce comportement provient du point de départ du MRO, et non d'une exemption spéciale pour les descripteurs dans les objets proxy super.

Comment __slots__ utilisent-ils le protocole des descripteurs pour imposer des contraintes de stockage ?

Lorsqu'une classe définit __slots__, l'interpréteur Python crée automatiquement des descripteurs internes spécialisés (typiquement des objets member_descriptor au niveau C) pour chaque nom de slot et les place dans le dictionnaire de la classe. Ces descripteurs implémentent à la fois __get__ et __set__, faisant d'eux des descripteurs de données qui prennent la priorité sur toute tentative de stockage de valeurs dans un dictionnaire d'instance conventionnel. Étant donné que les instances des classes de slots manquent généralement d'un __dict__ à moins que "__dict__" ne soit explicitement inclus dans la liste des slots, le protocole des descripteurs garantit que toutes les lectures et écritures pour les attributs de slots transitent par ces descripteurs de niveau C, renforçant la sécurité de type et l'efficacité mémoire en empêchant l'attachement d'attributs arbitraires.