Énumération plus rapide

tl; dr


github.com/QratorLabs/fastenum
pip install fast-enum 

Que sont les énumérations


(Si vous pensez le savoir, faites défiler la page jusqu'à la section «Enums dans la bibliothèque standard»).

Imaginez que vous devez décrire un ensemble de tous les états possibles pour les entités de votre modèle de base de données. Vous utiliserez probablement un tas de constantes définies comme des attributs au niveau du module:
 # /path/to/package/static.py: INITIAL = 0 PROCESSING = 1 PROCESSED = 2 DECLINED = 3 RETURNED = 4 ... 

... ou en tant qu'attributs de niveau classe définis dans leur propre classe:
 class MyModelStates: INITIAL = 0 PROCESSING = 1 PROCESSED = 2 DECLINED = 3 RETURNED = 4 

Cela vous aide à vous référer à ces états par leurs noms mnémoniques, tandis qu'ils persistent dans votre stockage en tant que simples entiers. Par cela, vous vous débarrassez des nombres magiques dispersés dans votre code et le rendez plus lisible et auto-descriptif.

Mais, la constante au niveau du module et la classe avec les attributs statiques souffrent de la nature inhérente des objets python: ils sont tous mutables. Vous pouvez accidentellement attribuer une valeur à votre constante au moment de l'exécution, et c'est un gâchis pour déboguer et restaurer vos entités cassées. Ainsi, vous souhaiterez peut-être rendre votre ensemble de constantes immuable, ce qui signifie que le nombre de constantes déclarées et les valeurs auxquelles elles sont mappées ne doivent pas être modifiées au moment de l'exécution.

Pour cela, vous pouvez essayer de les organiser en tuples nommés avec namedtuple() , par exemple:
 MyModelStates = namedtuple('MyModelStates', ('INITIAL', 'PROCESSING', 'PROCESSED', 'DECLINED', 'RETURNED')) EntityStates = MyModelStates(0, 1, 2, 3, 4) 

Cependant, cela ne semble toujours pas trop compréhensible: en plus de cela, les objets namedtuple ne sont pas vraiment extensibles. Disons que vous avez une interface utilisateur qui affiche tous ces états. Vous pouvez ensuite utiliser vos constantes basées sur un module, votre classe avec les attributs ou des tuples nommés pour les rendre (les deux derniers sont plus faciles à rendre pendant que nous y sommes). Mais votre code ne fournit aucune possibilité de donner à l'utilisateur une description adéquate pour chaque état que vous avez défini. De plus, si vous prévoyez d'implémenter la prise en charge multilingue et i18n dans votre interface utilisateur, vous constaterez que remplir toutes les traductions de ces descriptions devient une tâche incroyablement fastidieuse. Les valeurs d'état correspondantes peuvent ne pas nécessairement avoir de descriptions correspondantes, ce qui signifie que vous ne pouvez pas simplement mapper tous vos états INITIAL sur la même description dans gettext . Au lieu de cela, votre constante devient ceci:
 INITIAL = (0, 'My_MODEL_INITIAL_STATE') 

Votre classe devient alors ceci:
 class MyModelStates: INITIAL = (0, 'MY_MODEL_INITIAL_STATE') 

Et enfin, votre namedtuple devient ceci:
 EntityStates = MyModelStates((0, 'MY_MODEL_INITIAL_STATE'), ...) 

Eh bien, assez bien, il s'assure maintenant que la valeur de l'état et le talon de traduction sont mappés aux langues prises en charge par votre interface utilisateur. Mais maintenant, vous remarquerez peut-être que le code qui utilise ces mappages est devenu un gâchis. Chaque fois que vous essayez d'attribuer une valeur à votre entité, vous devez également ne pas oublier d'extraire la valeur à l'index 0 du mappage que vous utilisez:

 my_entity.state = INITIAL[0] 
ou
 my_entity.state = MyModelStates.INITIAL[0] 
ou
 my_entity.state = EntityStates.INITIAL[0] 

Et ainsi de suite. Gardez à l'esprit que les deux premières approches utilisant des constantes et des attributs de classe, respectivement, souffrent toujours de mutabilité.

Et puis les énumérations arrivent sur scène


 class MyEntityStates(Enum): def __init__(self, val, description): self.val = val self.description = description INITIAL = (0, 'MY_MODEL_INITIAL_STATE') PROCESSING = (1, 'MY_MODEL_BEING_PROCESSED_STATE') PROCESSED = (2, 'MY_MODEL_PROCESSED_STATE') DECLINED = (3, 'MY_MODEL_DECLINED_STATE') RETURNED = (4, 'MY_MODEL_RETURNED_STATE') 

Voilà. Vous pouvez maintenant facilement parcourir l'énumération dans votre moteur de rendu (syntaxe Jinja2):
 {% for state in MyEntityState %} <option value=”{{ state.val }}”>{{ _(state.description) }}</option> {% endfor %} 

L'énumération est immuable pour les deux ensembles de membres (vous ne pouvez pas définir un nouveau membre au moment de l'exécution, ni supprimer un membre déjà défini) et les valeurs de membre qu'ils conservent (vous ne pouvez pas réaffecter de valeurs d'attribut ou supprimer un attribut).

Dans votre code, vous attribuez simplement des valeurs à vos entités comme ceci:
 my_entity.state = MyEntityStates.INITIAL.val 

Eh bien, assez clair. Auto-descriptif. Assez extensible. C'est pour cela que nous utilisons Enums.

Pourquoi est-ce plus rapide?


Mais l'ENUM par défaut est plutôt lent, alors nous nous sommes demandé - pourrions-nous le rendre plus rapide?
En fait, nous le pouvons. A savoir, il est possible de le faire:

  • 3 fois plus rapide sur l'accès des membres
  • ~ 8,5 fois plus rapide sur l'accès aux attributs ( name , value )
  • 3 fois plus rapide sur l'accès enum par valeur (appel sur la classe enum MyEnum(value) )
  • 1,5 fois plus rapide sur l'accès enum par nom ( MyEnum[name] type MyEnum[name] )

Les types et les objets sont dynamiques en Python. Mais Python a les outils pour limiter la nature dynamique des objets. Avec leur aide, on peut obtenir une amélioration significative des performances en utilisant __slots__ ainsi qu'en évitant d'utiliser des descripteurs de données lorsque cela est possible sans croissance de complexité significative ou si vous pouvez bénéficier de la vitesse.

Machines à sous


Par exemple, on pourrait utiliser une déclaration de classe avec __slots__ - dans ce cas, les instances de classe n'auraient qu'un ensemble restreint d'attributs: les attributs déclarés dans __slots__ et tous les __slots__ des classes parentes.

Descripteurs


Par défaut, l'interpréteur Python renvoie directement une valeur d'attribut d'un objet:
 value = my_obj.attribute # this is a direct access to the attribute value by the pointer that the object holds for that attribute 

Selon le modèle de données Python, si la valeur d'attribut d'un objet est elle-même un objet qui implémente le protocole de descripteur de données, cela signifie que lorsque vous essayez d'obtenir cette valeur, vous obtenez d'abord l'attribut en tant qu'objet, puis une méthode spéciale __get__ est appelé sur cet attribut-objet en passant l'objet gardien lui-même comme argument:
 obj_attribute = my_obj.attribute obj_attribute_value = obj_attribute.__get__(my_obj) 

Énumérations dans la bibliothèque standard


Au moins les attributs de name et de value de l'implémentation Enum standard sont déclarés en tant que types.DynamicClassAttribute . Cela signifie que lorsque vous essayez d'obtenir le name (ou la value ) d'un membre, le flux est le suivant:

 one_value = StdEnum.ONE.value # that is what you write in your code one_value_attribute = StdEnum.ONE.value one_value = one_value_attribute.__get__(StdEnum.ONE) 

 # and this is what really __get__ does (python 3.7 implementation): def __get__(self, instance, ownerclass=None): if instance is None: if self.__isabstractmethod__: return self raise AttributeError() elif self.fget is None: raise AttributeError("unreadable attribute") return self.fget(instance) 

 # since DynamicClassAttribute is a decorator on Enum methods `name` and `value` the final row of __get__() ends up with: @DynamicClassAttribute def name(self): """The name of the Enum member.""" return self._name_ @DynamicClassAttribute def value(self): """The value of the Enum member.""" return self._value_ 

Ainsi, le flux complet pourrait être représenté comme le pseudo-code suivant:
 def get_func(enum_member, attrname): # this is also a __dict__ lookup so hash + hashtable scan also occur return getattr(enum_member, f'_{attrnme}_') def get_name_value(enum_member): name_descriptor = get_descriptor(enum_member, 'name') if enum_member is None: if name_descriptor.__isabstractmethod__: return name_descriptor raise AttributeError() elif name_descriptor.fget is None: raise AttributeError("unreadable attribute") return get_func(enum_member, 'name') 

Nous avons créé un script simple qui illustre la conclusion ci-dessus:
 from enum import Enum class StdEnum(Enum): def __init__(self, value, description): self.v = value self.description = description A = 1, 'One' B = 2, 'Two' def get_name(): return StdEnum.A.name from pycallgraph import PyCallGraph from pycallgraph.output import GraphvizOutput graphviz = GraphvizOutput(output_file='stdenum.png') with PyCallGraph(output=graphviz): v = get_name() 

Et après avoir exécuté le script, il a créé cette image pour nous:


Cela prouve que chaque fois que vous accédez au name et à la value attributs de stdlib enum, il appelle un descripteur. Ce descripteur se termine à son tour par un appel à la propriété def name(self) de stdlib enum décorée avec le descripteur.

Eh bien, vous pouvez comparer cela à notre FastEnum:
 from fast_enum import FastEnum class MyNewEnum(metaclass=FastEnum): A = 1 B = 2 def get_name(): return MyNewEnum.A.name from pycallgraph import PyCallGraph from pycallgraph.output import GraphvizOutput graphviz = GraphvizOutput(output_file='fastenum.png') with PyCallGraph(output=graphviz): v = get_name() 

Qui sort cette image:


C'est ce qui se fait réellement dans l'implémentation Enum standard chaque fois que vous accédez aux attributs de name et de value de vos membres Enum. Et c'est pourquoi notre implémentation est plus rapide.

L'implémentation de la classe Enum par la bibliothèque standard Python utilise des tonnes d'appels de protocole de descripteur. Lorsque nous avons essayé d'utiliser l'énumération standard dans nos projets, nous avons remarqué combien d'appels de protocole de descripteur pour les attributs de name et de value des membres Enum ont été invoqués. Et parce que les énumérations étaient utilisées de manière excessive dans tout le code, les performances résultantes étaient médiocres.

De plus, la classe d'énumération standard contient quelques attributs d'aide «protégés»:
  • _member_names_ - une liste qui contient tous les noms des membres enum;
  • _member_map_ - un OrderedDict qui mappe le nom d'un membre enum au membre lui-même;
  • _value2member_map_ - un dictionnaire inversé qui mappe les valeurs des membres enum aux membres enum correspondants.

Les recherches de dictionnaire sont lentes car chacune mène à un calcul de hachage et à une recherche de table de hachage, ce qui rend ces structures de base non optimales pour la classe enum. Même la récupération de membre elle-même (comme dans StdEnum.MEMBER ) est une recherche de dictionnaire.

Notre chemin


Lors du développement de notre implémentation Enum, nous avons gardé à l'esprit ces jolies énumérations en langage C et les magnifiques énumérations Java extensibles. Les principales fonctionnalités que nous souhaitions dans notre implémentation:

  • un Enum doit être aussi statique que possible; par «statique», nous voulons dire: si quelque chose pouvait être calculé une fois et au moment de la déclaration, il le devrait;
  • une Enum ne peut pas être sous-classée (doit être une classe «finale») si une sous-classe définit de nouveaux membres enum - cela est vrai pour l'implémentation de bibliothèque standard, à l'exception que la sous-classe est interdite même si aucun nouveau membre n'est défini;
  • un Enum devrait avoir de vastes possibilités d'extensions (attributs supplémentaires, méthodes, etc.).

La seule fois où nous utilisons des recherches de dictionnaire est dans une value mappage inversée vers un membre Enum. Tous les autres calculs sont effectués une seule fois pendant la déclaration de classe (où les crochets de métaclasses sont utilisés pour personnaliser la création de type).
Contrairement à l'implémentation de bibliothèque standard, nous traitons la première valeur après le signe = dans la déclaration de classe comme valeur membre:
A = 1, 'One' dans la bibliothèque standard énumère le tuple entier 1, "One" est traité comme une value
A: 'MyEnum' = 1, 'One' dans notre implémentation, seul 1 est traité comme une value

Une accélération supplémentaire est obtenue en utilisant __slots__ chaque fois que possible. Dans le modèle de données Python, les classes déclarées avec __slots__ n'ont pas d'attribut __dict__ qui contient des attributs d'instance (vous ne pouvez donc pas attribuer d'attribut non mentionné dans __slots__ ). De plus, les attributs définis dans __slots__ accessibles avec des décalages constants au pointeur d'objet de niveau C. Il s'agit d'un accès aux attributs à grande vitesse car il évite les calculs de hachage et les analyses de table de hachage.

Quels sont les avantages supplémentaires?


FastEnum n'est compatible avec aucune version de Python antérieure à 3.6, car il utilise de manière excessive le module de typing introduit dans Python 3.6; On pourrait supposer que l'installation d'un module de typing rétroporté à partir de PyPI serait utile. La réponse est non. L'implémentation utilise PEP-484 pour certaines fonctions et méthodes et les arguments de type de valeur de retour, de sorte que toute version antérieure à Python 3.5 n'est pas prise en charge en raison d'une incompatibilité de syntaxe. Mais là encore, la toute première ligne de code dans __new__ de la métaclasse utilise la syntaxe PEP-526 pour les __new__ de type variable. Donc Python 3.5 ne fera pas non plus. Il est possible de porter l'implémentation vers des versions plus anciennes, bien que nous, dans Qrator Labs, ayons tendance à utiliser l'indicateur de type chaque fois que cela est possible, car cela aide grandement à développer des projets complexes. Et bon! Vous ne voulez pas vous en tenir à n'importe quel python avant 3.6 car il n'y a pas d'incompatibilité en arrière avec votre code existant (en supposant que vous n'utilisez pas Python 2) bien que beaucoup de travail ait été fait en asyncio par rapport à 3.5.

Cela, à son tour, rend inutiles les importations spéciales comme auto , contrairement à la bibliothèque standard. Vous tapez-hint tous vos membres Enum avec votre nom de classe Enum, ne fournissant aucune valeur du tout - et la valeur serait générée automatiquement pour vous. Bien que python 3.6 soit suffisant pour fonctionner avec FastEnum, soyez averti que l'ordre standard de garantie de déclaration du dictionnaire n'a été introduit que dans python 3.7. Nous ne connaissons aucun appareil utile où l'ordre de valeur généré automatiquement est important (car nous supposons que la valeur générée elle-même n'est pas la valeur dont un programmeur se soucie). Néanmoins, considérez-vous averti si vous restez avec python 3.6;

Ceux qui ont besoin que leur énumération commence à 0 (zéro) au lieu de 1 par défaut peuvent le faire avec un attribut de déclaration d'énumération spécial _ZERO_VALUED , cet attribut est "effacé" de la classe Enum résultante;

Il y a cependant quelques limitations: tous les noms de membres enum doivent être MAJUSCULÉS sinon ils ne seront pas récupérés par la métaclasse et ne seront pas traités comme des membres enum;

Cependant, vous pouvez déclarer une classe de base pour vos énumérations (gardez à l'esprit que la classe de base peut utiliser la métaclasse enum elle-même, vous n'avez donc pas besoin de fournir de métaclasse à toutes les sous-classes): vous pouvez définir une logique commune (attributs et méthodes) dans ce mais ne peut pas définir de membres enum (de sorte que la classe ne sera pas "finalisée"). Vous pouvez ensuite sous-classer cette classe dans autant de déclarations d'énumération que vous le souhaitez et cela vous fournira toute la logique commune;

Alias Nous les expliquerons dans un sujet séparé (implémenté en 1.2.5)

Alias ​​et comment ils pourraient aider


Supposons que vous ayez du code qui utilise:
 package_a.some_lib_enum.MyEnum 

Et que MyEnum est déclaré comme ceci:
 class MyEnum(metaclass=FastEnum): ONE: 'MyEnum' TWO: 'MyEnum' 

Maintenant, vous avez décidé de refactoriser et de déplacer votre énumération dans un autre package. Vous créez quelque chose comme ceci:
 package_b.some_lib_enum.MyMovedEnum 

Où MyMovedEnum est déclaré comme ceci:
 class MyMovedEnum(MyEnum): pass 

Maintenant. Vous êtes prêt à commencer l'étape de "dépréciation" pour tout le code qui utilise vos énumérations. Vous détournez les utilisations directes de MyEnum pour utiliser MyMovedEnum (ce dernier a tous ses membres MyEnum dans MyEnum ). Vous MyEnum dans vos documents de projet que MyEnum est obsolète et sera supprimé du code à un moment donné dans le futur. Par exemple, dans la prochaine version. Considérez que votre code enregistre vos objets avec des attributs enum en utilisant pickle. À ce stade, vous utilisez MyMovedEnum dans votre code, mais en interne, tous vos membres enum sont toujours les instances MyEnum . Votre prochaine étape serait d'échanger les déclarations de MyEnum et MyMovedEnum afin que MyMovedEnum ne soit plus une sous-classe de MyEnum et de déclarer tous ses membres lui-même; MyEnum , d'autre part, ne déclarerait aucun membre mais deviendrait juste un alias (sous-classe) de MyMovedEnum .

Et cela le conclut. Au redémarrage de vos runtimes sur une étape de décapage, toutes vos valeurs d'énumération seront redirigées vers MyMovedEnum et seront reliées à cette nouvelle classe. Au moment où vous êtes sûr que tous vos objets marinés ont été non (re) marinés avec cette structure d'organisation de classe, vous êtes libre de faire une nouvelle version, où précédemment marqué comme obsolète, votre MyEnum peut être déclaré obsolète et effacé de votre base de code.

Nous vous encourageons à l'essayer! github.com/QratorLabs/fastenum , pypi.org/project/fast-enum . Tous les crédits vont à l'auteur de FastEnum santjagocorkez .

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


All Articles