深入浅出地理解 Python 中可散列类型的原理和实现(三)
前言
在 Python内置的序列类型 一文中,本人简略地介绍了对象的不可变性(immutable)和可散列性(hashable),以及它们之间的关系,但没有详细展开介绍它们背后的原理。其实,散列(hash)在编程语言中是个比较重要的概念,它的原理适用于所有语言而不仅仅是 Python。本系列将以 Python 语言作为案例,用三篇文章来深入浅出地介绍 Python 中可散列类型的相关知识。
本文是第三篇,将介绍设计可散列类型的其它注意事项,以及介绍了一些替代方法,来更方便地实现可散列类型。
第一篇,在 这里;
第二篇,在 这里。
本文中的命令行示范,都是基于以下环境:
- 操作系统: CentOS 7
- Python:3.7.9
设计可散列类型的其它注意事项
不能基于对象的可变属性计算散列值
如果一个对象的属性在该对象的生命周期内是可能发生变化的,那么就不能基于这个可变属性来计算对象的散列值。
如果你想设计一个可散列类型,那么这个类型的属性全部采用不可变类型(immutable type)是最安全的。
但通常,这往往又是过于理想化的。一般来说,一个类的属性,或多或少有一些是需要在它的生命周期内被修改的。所以我们必须确定哪些属性是不会改变的,并基于它们来计算散列值。
散列值可能比相等性检查更挑剔
上一篇文章本人介绍过字典的工作原理,由于字典在查询键的时候,散列值的检查在对象相等性检查之前进行。这意味着散列值的检查是更重要的,散列值不相等的两个对象,一定是不相等的。
另一方面,这也意味散列值不一定要独一无二的,我们可以让散列值相等,从而让字典的查询完全基于对象相等性。
换句话说,就是你可以实现一个类,让散列函数返回的是一个常量,即该类所有对象的散列值相同,这样字典在查询键的时候,最终就会基于对象的相等性来判断键是否存在于字典。这样做的好处是把查询工作简化为基于对象相等性,坏处是是字典的工作模式本质上变成一个列表,字典的查询效率和列表的查询效率相同,而不再是高效的 O(1) 查询。
实现可散列类型的替代方法
通过覆盖__hash__
方法和__eq__
方法可以来实现可散列类型,但正如本系列文章中介绍的,还需遵守一些其它设计原则和需要注意的事项,所以实现一个健壮的、没有潜在 bugs 的可散列类型并不是一件简单的事。
那么,有没有其它替代方法可以让我们更聚焦于类的功能设计,而不是过多关注设计原则和注意事项这些细枝末节呢?
答案是,有的。如果我们真的想实现可散列类型,我们可以通过实现它们的不可变性来完成。
有一些内置的工具我们可以使用,比如 tuple 和 frozenset,可以使用它们把列表或集合转成可散列类型,还有 Python 3.7 引入的dataclass
,我们可以用它修饰自定义的类,使它可散列。也有一些第三方的库可以使用,比如 immutables
,我们可以用immutables
把字典转成可散列的Map
对象(immutables
可能会被纳入 python 3.9 因为它已经被 asyncio contextvar 使用)。
列表和集合
如果有一个列表,我们想让它可散列,我们可以把它转换成 tuple 来保证它不可改变:
|
|
执行结果:
|
|
如果有一个集合,我们想让它可散列,我们可以把它转换成 frozenset,来保证它不可改变,并同时维持 O(1) 复杂度的查询:
|
|
执行结果:
|
|
字典
要让字典对象变得可散列,从而实现字典嵌套,我们可以使用immutable.Map
类。immutables.Map
是一个不可改变的 Map,因此是可散列的。任何对它的改变,会创建一个新的 map,而原 map 保持不变。它的性能比deepcopy 高很多。
Python 3.7 还没有把immutables
加入标准库,所以先要安装一下:
|
|
成功安装到 Python 3.7 的site-packages
后,就可以在代码中直接使用:
|
|
执行结果:
|
|
可以看到,如果要对字典 d 直接计算散列值,会抛出异常,因为字典是不可散列的。但我们可以用 d 构造一个 Map 对象 m。这个 Map 对象除了修改操作外,支持字典其它所有操作,比如查询操作 “‘a’ in m”。如果要修改 m 的内容,会抛出 TypeError。如果调用 set 方法,它会返回一个新的 Map 对象,而原对象保持不变。由于 m 对象现在是不可变的,也是可散列的,所以我们可以把它作为 key 加入到另一个字典中,实现字典嵌套。
自定义类
Python 3.7 引入了dataclass
模块,该模块抽取了第三方库的attrs
库的一部分功能,相当于 mini 版 attrs 库。我们可以用dataclass
修饰自定义的类,把参数 frozen 设为 True,它会冻结
该类的对象,使它们无法改变,从而让自定义的类变成可散列的:
|
|
执行结果:
|
|
用dataclass
实现可散列类型非常简单,只要在类的定义前加上 “@dataclass(frozen=True)” 即可,类的属性必须用类型注解的方式来定义,因为如果在__init__
方法中初始化属性,就打破了类的属性的不可变性。__hash__
方法和__eq__
方法无需定义,dataclass
会自动定义好,默认情况下,类的所有属性都是不可变的,并且散列值基于所有属性计算出来。如果尝试修改该类对象,会抛出异常。相等性比较的是所有属性,两个对象的所有属性相等,比较结果就相等。
可以看到,用dataclass
来实现可散列类型,代码要简单许多,而且属性自动设为只读,保证了散列的安全性。所以要自定义可散列类型,首推这种方式,而不是原始的通过覆盖__hash__
方法和__eq__
方法来实现。
参考
https://docs.python.org/3.7/reference/datamodel.html#object.__hash__
https://docs.python.org/3/glossary.html
https://hynek.me/articles/hashes-and-equality/
「 您的赞赏是激励我创作和分享的最大动力! 」
- 原文链接:https://zhuyinjun.me/2019/python_hashable_type_3/
- 版权声明:本创作采用 CC BY-NC 4.0 国际许可协议,非商业性使用可以转载,但请注明出处(作者、链接),商业性使用请联系作者获得授权。