快速枚举

tl;博士


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

为什么需要枚举


(如果您了解所有信息,请转到“标准库中的枚举”部分)

想象一下,您需要在自己的数据库模型中描述一组实体的所有可能状态。 最有可能的是,您将直接在模块名称空间中定义一堆常量:
 # /path/to/package/static.py: INITIAL = 0 PROCESSING = 1 PROCESSED = 2 DECLINED = 3 RETURNED = 4 ... 

...或作为静态类属性:
 class MyModelStates: INITIAL = 0 PROCESSING = 1 PROCESSED = 2 DECLINED = 3 RETURNED = 4 

这种方法将有助于通过助记符名称引用这些状态,而在存储库中它们将是普通整数。 因此,您同时摆脱了散布在代码不同部分中的幻数,同时使其更具可读性和信息量。

但是,模块常量和具有静态属性的类都具有Python对象的固有特性:它们都是可变的(可变的)。 您可能会在运行时意外地为常量分配一个值,调试和回滚损坏的对象是另一回事。 因此,从程序执行期间声明的常量数量及其映射到的常量的数量不变的意义上讲,您可能希望使常量束不变。

为此,您可以尝试使用namedtuple()将它们组织到命名元组中,如示例所示:
 MyModelStates = namedtuple('MyModelStates', ('INITIAL', 'PROCESSING', 'PROCESSED', 'DECLINED', 'RETURNED')) EntityStates = MyModelStates(0, 1, 2, 3, 4) 

但这看起来不是很整洁和可读性,而namedtuple对象又不是很可扩展。 假设您有一个显示所有这些状态的UI。 您可以在模块,具有属性的类或命名的元组中使用常量来呈现它们(由于我们正在谈论最后两个,因此较容易呈现)。 但是,这样的代码无法为用户提供您定义的每个状态的充分描述。 此外,如果您打算在UI中实现多语言和i18n支持,您将认识到完成这些描述的所有翻译有多快成为一项繁琐的任务。 匹配状态名称不一定意味着匹配描述,这意味着您不能仅将所有INITIAL状态映射到gettext的相同描述。 而是,您的常数采用以下形式:
 INITIAL = (0, 'My_MODEL_INITIAL_STATE') 

或您的班级变成这样:
 class MyModelStates: INITIAL = (0, 'MY_MODEL_INITIAL_STATE') 

最后,命名的元组变成:
 EntityStates = MyModelStates((0, 'MY_MODEL_INITIAL_STATE'), ...) 

已经不错-现在可以保证状态值和翻译存根都以用户界面支持的语言显示。 但是您可能会注意到,使用这些映射的代码变得一团糟。 每次尝试分配实体值时,都必须从所使用的显示中提取索引为0的值:

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

依此类推。 请记住,分别使用常量和类属性的前两种方法具有可变性。

转账对我们有帮助


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

仅此而已。 现在,您可以轻松地遍历渲染器中的列表(Jinja2语法):
 {% for state in MyEntityState %} <option value=”{{ state.val }}”>{{ _(state.description) }}</option> {% endfor %} 

枚举对于一组元素都是不变的-您无法在运行时定义枚举的新成员,也无法删除已定义的成员,并且对于其存储的那些元素值-您无法[重新]分配任何属性值或删除属性。

在代码中,您只需为实体分配值,如下所示:
 my_entity.state = MyEntityStates.INITIAL.val 

一切都足够清晰,内容丰富且可扩展。 这就是我们使用枚举的目的。

我们怎样才能使其更快?


从标准库进行枚举比较慢,所以我们问自己-我们可以加快速度吗? 事实证明,我们可以执行枚举:

  • 成员枚举速度快三倍;
  • 访问成员的属性( namevalue )时,速度要快8.5;
  • 按值访问成员时快3倍(调用枚举MyEnum(value))的构造函数MyEnum(value))
  • 按名称访问成员时,速度提高了1.5倍(例如在MyEnum[name]词典中)。

Python中的类型和对象是动态的。 但是有一些工具可以限制对象的动态性质。 使用__slots__可以显着提高性能。 如果您避免在可能的情况下使用数据描述符,那么也有可能提高速度-但您必须考虑应用程序复杂性显着增加的可能性。

插槽


例如,您可以使用通过__slots__声明的类-在这种情况下,所有类的实例将仅在__slots__和所有__slots__父类中声明了一组有限的属性。

描述符


默认情况下,Python解释器直接返回对象的属性值(同时,我们规定在这种情况下,该值也是Python对象,例如,就C语言而言,不是unsigned long long):
value = my_obj.attribute # , .

根据Python数据模型,如果属性值是实现描述符协议的对象,则在尝试获取该属性的值时,解释器将首先找到对该属性所引用的对象的引用,然后对其调用特殊的__get__方法,该方法将作为以下内容传递给我们的原始对象:参数:
 obj_attribute = my_obj.attribute obj_attribute_value = obj_attribute.__get__(my_obj) 

标准库中的枚举


至少将标准枚举实现的成员的namevalue属性声明为types.DynamicClassAttribute 。 这意味着当您尝试获取namevalue ,将发生以下情况:

 one_value = StdEnum.ONE.value #        #   ,      one_value_attribute = StdEnum.ONE.value one_value = one_value_attribute.__get__(StdEnum.ONE) 

 #   ,  __get__     (  python3.7): 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) 

 #   DynamicClassAttribute     `name`  `value`   __get__()  : @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_ 

因此,整个调用序列可以由以下伪代码表示:
 def get_func(enum_member, attrname): #        __dict__,        -     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') 

我们编写了一个简单的脚本来演示上述输出:
 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() 

执行后,脚本为我们提供了以下图片:


这表明每次您从标准库访问枚举成员的namevalue属性时,都会调用一个句柄。 反过来,此描述符以def name(self)方法的标准库中的Enum类的调用结尾,并用该描述符进行修饰。

与我们的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() 

下图中可以看到什么:


每当您访问其成员的namevalue属性时,所有这些操作实际上都会在标准枚举实现内发生。 这也是我们的实施速度更快的原因。

在Python标准库中实现枚举使用了对实现数据描述符协议的对象的大量调用。 当我们尝试在项目中使用标准枚举实现时,我们立即注意到为namevalue调用了多少个数据描述符。
而且由于枚举在整个代码中被广泛使用,因此产生的性能很低。

另外,标准的Enum类包含几个辅助的“受保护”属性:
  • _member_names_包含枚举成员的所有名称的列表;
  • _member_map_ - OrderedDict ,它将枚举成员的名称映射到其值;
  • _value2member_map_包含相反方向匹配的字典:枚举成员的值与枚举的相应成员。

字典搜索很慢,因为每次调用都会导致对哈希函数的计算(当然,除非将结果单独缓存,这对于非托管代码而言并不总是可能的)和在哈希表中进行搜索,这使得这些字典不是枚举的最佳基础。 甚至搜索枚举成员(如StdEnum.MEMBER )本身也是字典搜索。

我们的方法


我们创建了枚举的实现,着眼于C中的优雅枚举和Java中美丽的可扩展枚举。 我们要在家中实现的主要功能如下:

  • 枚举应尽可能静态; 此处的“静态”是指以下内容-如果某项内容只能在公告期间进行一次计算,则应在此时(且仅此刻)进行计算;
  • 如果继承类定义了枚举的新成员,则不可能从枚举中继承(它必须是“最终”类)-对于标准库中的实现而言,这是正确的,除了在那儿禁止继承,即使继承类未定义新成员也是如此;
  • 枚举应具有足够的扩展范围(其他属性,方法等)

在唯一的情况下,我们使用字典搜索-这是value到枚举成员的逆映射。 所有其他计算仅在类声明期间执行一次(其中,元类用于配置类型创建)。
与标准库不同,我们仅将类声明中=符号后的第一个值作为成员值处理:
A = 1, 'One'标准库中的1, "One" ,整个元组1, "One"视为值value
A: 'MyEnum' = 1, 'One'在我们的实现中为A: 'MyEnum' = 1, 'One' ,只有1视为值value

如果可能,通过使用__slots__获得进一步的加速。 在使用__slots__声明的Python类中, __dict____dict__创建__dict__属性,该属性包含属性名称与其值的映射(因此,您无法声明__slots__未提及的实例的任何属性)。 另外,在对象实例指针中以恒定偏移量访问__slots__中定义的属性的值。 这是对属性的高速访问,因为它避免了哈希计算和哈希表扫描。

还有哪些额外的筹码?


FastEnum与3.6之前的任何版本的Python不兼容,因为它普遍使用在Python 3.6中实现的类型注释。 可以假设从PyPi安装typing模块会有所帮助。 简短的答案是没有。 该实现使用PEP-484作为某些函数,方法和指向返回类型的指针的参数,因此由于语法不兼容,不支持Python 3.5之前的任何版本。 但是,再次, __new__元类中的第一行代码使用PEP-526语法指示变量的类型。 因此,Python 3.5也不起作用。 您可以将实现移植到旧版本,尽管我们在Qrator Labs倾向于尽可能使用类型注释,因为这对开发复杂项目很有帮助。 好吧,最后! 您不希望陷入3.6版之前的Python中,因为在较新的版本中,您的现有代码不会向后不兼容(前提是您未使用Python 2),并且与3.5 asyncio相比,在asyncio的实现方面已经做了很多工作我们认为,值得立即更新。

反过来,与标准库不同,这使得不需要专门导入auto 。 您仅表示该枚举的成员将是该枚举的一个实例,而根本不提供任何值-该值将自动为您生成。 尽管Python 3.6足以使用FastEnum,但请记住,仅在Python 3.7中引入了保留字典中键的顺序(对于情况3.6,我们没有单独使用OrderedDict )。 我们不知道自动生成值的顺序很重要的任何示例,因为我们假设如果开发人员为环境提供了为枚举成员生成和分配值的任务,那么值本身对其并不那么重要。 但是,如果您仍未切换到Python 3.7,我们会警告您。

那些需要枚举从0(零)开始而不是默认值(1)的用户可以在声明_ZERO_VALUED枚举时使用特殊属性来执行此操作,该枚举不会存储在结果类中。

但是,存在一些限制:枚举成员的所有名称都必须用大写字母书写,否则,元类将不会处理它们。

最后,您可以为枚举声明一个基类(请注意,该基类可以使用元类本身,因此您无需为所有子类提供元类)-只需在此类中定义常规逻辑(属性和方法),而不定义枚举的成员(因此该类不会被“确定化”)。 在您可以声明所需的此类继承类之后,继承人本身将具有相同的逻辑。

别名及其如何提供帮助


假设您使用以下代码:
 package_a.some_lib_enum.MyEnum 

并且MyEnum类的声明如下:
 class MyEnum(metaclass=FastEnum): ONE: 'MyEnum' TWO: 'MyEnum' 

现在,您已经决定要进行一些重构,并将清单转移到另一个包中。 您创建如下所示的内容:
 package_b.some_lib_enum.MyMovedEnum 

MyMovedEnum的声明如下:
 class MyMovedEnum(MyEnum): pass 

现在,您已经准备好将位于旧地址的传输视为过时的阶段。 您重写此枚举的导入和调用,以便现在使用该枚举的新名称(其别名)-您可以确保该别名枚举的所有成员实际上都是在类中使用旧名称声明的。 在您的项目文档中,您声明MyEnum过时,并将在以后从代码中删除。 例如,在下一个版本中。 假设您的代码使用pickle将对象存储为包含枚举成员的属性。 此时,您在代码中使用MyMovedEnum ,但是在内部,所有枚举成员仍然是MyEnum实例。 下一步是交换MyEnumMyMovedEnum的声明,以便MyMovedEnum不是MyMovedEnum的子类,并MyEnum其所有成员本身。 另一方面, MyEnum现在不声明任何成员,而仅成为MyMovedEnum的别名(子类)。

仅此而已。 在unpickle阶段重新启动应用程序时,该枚举的所有成员将重新声明为MyMovedEnum实例,并与此新类关联。 当您确定所有存储在(例如,数据库中的)对象都已重新反序列化(并可能再次序列化并存储在存储库中)时,您可以发布一个新版本,该版本先前已被标记为过时的类MyEnum可能被声明为不必要,并已从代码库中删除。

自己尝试一下: github.com/QratorLabs/fastenum,pypi.org/project/fast-enum
业力方面的专家请给作者FastEnum- santjagocorkez

UPD:在1.3.0版中,可以从现有的类继承,例如intfloatstr 。 此类枚举的成员将相等性测试成功传递给具有相同值( IntEnum.MEMBER == int(value_given_to_member) )的干净对象,并且当然,它们是这些继承类的实例。 反过来,这允许从int继承的枚举成员成为sys.exit()的直接参数,作为python解释器返回代码。

Source: https://habr.com/ru/post/zh-CN480614/


All Articles