Why UserDict/UserList
前言
有时我们想自定义一个容器类,它支持某个 Python 自带容器(比如列表或字典)的大部分方法,但覆盖了它们个别方法的实现。有几种方式来帮助我们实现这个类。本文将讨论一些可行的方案,并解释为什么直接继承 Python 的 list 或 dict 类并不一定是最好的方案。
本文中的命令行示范,都是基于以下环境:
- 操作系统:CentOS 7
- Python:3.7.9
可行方案
想实现一个自定义的容器类的需求很常见,比如我们想实现一个类似栈(Stack)
的数据结构,即支持后进先出(LIFO)
。我们当然可以直接用 Python 自带的 list,但 list 的对于中间元素的操作(insert、remove、pop 等方法)会破坏后进先出的规则。所以我们需要实现自定义的容器类,来满足栈的工作方式。
实现自定义的容器类,大致有几种可选方案:
- 创建一个新的类,手动实现所有必需的
dunder方法
(dunder 是 “Double Underscores” 的缩写,意思是以双下划线开头并以双下划线结束的方法); - 创建一个直接继承自 Python
抽象基类(abstract base class)
的类; - 创建一个直接继承自 UserList / UserDict 的类。
- 创建一个直接继承自 list / dict / set 等 Python 自带容器的类;
这些方式各有特点,其中第四种可能是大部分人无意中都会选择的方式(包括曾经的我😭)。至于为什么,我猜测可能是因为 list / dict / set 本身是最常用的类型,它们是 Python 自带的数据结构,而且是基于 C 语言实现(这里只讨论 CPython),执行效率比较高,大部分开发人员自然而然地想到继承这些类型。
然而,Python 官网 却提到,第三种方式中的 UserList / UserDict 处理起来更容易:
The class,
UserDict
acts as a wrapper around dictionary objects. The need for this class has been partially supplanted by the ability to subclass directly fromdict
; however, this class can be easier to work with because the underlying dictionary is accessible as an attribute.
class
collections.``UserDict
([initialdata])Class that simulates a dictionary. The instance’s contents are kept in a regular dictionary, which is accessible via the
data
attribute ofUserDict
instances. If initialdata is provided,data
is initialized with its contents; note that a reference to initialdata will not be kept, allowing it be used for other purposes.In addition to supporting the methods and operations of mappings,
UserDict
instances provide the following attribute:
data
: A real dictionary used to store the contents of theUserDict
class.
英翻中,简单概述:UserDict
类相当于对 dict 对象的包装,它的作用,可以通过继承 dict 所替代;然而,UserDict 这个类处理起来更容易,因为底层的字典存放在它的属性 data 中,可以直接通过属性访问。
换句话说,你如果想定义一个类,继承 dict 的大部分功能,你可以通过定义一个 dict 的子类,也可以通过定义一个UserDict
的子类来实现,区别在于,dict 本身基于 C 语言实现,它的数据结构是 C 代码,而UserDict
是 dict 的包装,它的数据结构就是一个 dict 类型的属性 data,你可以直接访问这个属性。
从它们的实现原理来看,UserDict
的执行效率并不会比 dict 差很多,因为它的底层也是一个 dict,无非就是包装了一层 Python 接口。而在使用上,继承自UserDict
要比继承自 dict 方便许多。
下面,我们先通过一个例子,看看继承自 dict 这个方式的弊端在哪里。
继承自 dict 的弊端
我们先看一个例子。假设我们现在想构建一个和普通字典类似的类型,区别在于它是一个双向(bi-directional)字典,所谓双向字典,就是当一对 (key, value) 被添加到该双向字典中,双向字典会新增两对映射关系:(key, value) 和 (value, key)。
双向字典中永远存在偶数个键值对,并且如果 d[k] == v 为 True,那么 d[v] == k 也一定为 True。
我们可以通过继承 dict 类,并覆盖 dict 类的一些方法来实现:
|
|
这里我们通过覆盖__delitem__
方法和__setitem__
方法保证了:
- 删除一对 (key, value) 的同时,会删除它对应的 (value, key);
- 当我们尝试为 key 设置新的值 value 时,如果 key 存在,那么先把原来 key 对应的已存在的值删除,同样,如果 value 存在,那么先把它对应的原来的键删除。比如,原来的双向字典是 d = {‘a’: ‘b’, ‘b’: ‘a’},当我们尝试执行 d[‘a’] = ‘c’,先要删除原来 ’a‘ 对应的键 ‘b’,即 del[‘b’];同理,当我们尝试执行 d[‘c’] = ‘b’,先要删除键 ‘b’,即 del[‘b’];
- 任何时候我们设置 (key, value),它都会同时设置 (value, key)。
这种方式实现的双向字典,似乎可以像我们期望的那样工作,但是,当我们调用update
方法来更新双向字典的时候,结果有些出乎意料:
|
|
执行结果:
|
|
用update
方法新增 (5, 6) 本应该删除原字典中的 (7, 6) 和 (6, 7),而新增 (3, 2) 本应该删除 (3, 8) 和 (8, 3),然而实际运行结果并不完全是这样。
我们可以通过自定义update
方法来覆盖 dict 的update
方法,从而解决这个问题:
|
|
但接下来我们又发现,初始化构造函数无法正常工作:
|
|
执行结果:
|
|
无奈,我们只好再尝试覆盖 dict 的构造函数:
|
|
但接下来又发现pop
方法无法工作:
|
|
同样,setdefault
方法也无法工作:
|
|
背后的原因是我们虽然重构了__delitem__
方法和__setitem__
方法,但pop
方法实际上没有调用__delitem__
方法,而setdefault
方法也没有调用__setitem__
方法。这样,pop
和setdefault
自然也就没有按照我们设计的方法工作。
如果我们想解决这个问题,我们需要继续“痛苦地”自定义pop
方法和setdefault
方法。
相信这些结果让你有些出乎意料。当我们继承 dict 想实现一个双向字典的时候,我们原本期望update
方法和__init__
方法会调用__setitem__
方法,而pop
方法和setdefault
方法会调用__delitem__
方法,但实际上它们并没有。
其实这问题并不仅限于 dict,列表和集合也存在同样的问题。
其它替代方案
在 可行方案 一节,我们介绍了自定义容器类的四种方式。第一种方式,其实就是类的全部功能完全由自己实现,本文不展开介绍;而第四种方式,即通过继承自 dict / list / set 来实现,上一节我们已经看到它存在的弊端。那么剩下的其它两个替代方案如何呢?
继承自抽象基类
Python 的collections.abc
模块包含了丰富的抽象基类,来帮助我们实现具有共性或者说共同工作方式的类,这些抽象基类就像 Java 中的接口(interfaces),声明了方法,我们要做的就是在自定义类中实现这些方法。
我们现在来尝试通过继承抽象基类来实现双向字典。我们知道,字典本身是个可变的映射类型(mutable mappings),既然我们想创建的类和字典类似,那么它本质上也是一个可变的映射。映射这个词来自于散列表,关于 Python 中散列相关的知识,有兴趣地朋友可以移步 这里。
collections.abc
模块中有Mapping
和MutableMapping
这两个和映射相关的抽象基类,MutableMapping
是可变映射,继承自不可变映射Mapping
,Mapping
继承自Container
、Iterable
等超类,它们的 UML 类图如下所示:
一般而言,自定义的类不会直接继承这两个抽象基类,这两个和映射相关的抽象基类只提供了形式接口,它们的作用就像形式化的文档。然而,在个别情况下,自定义的类可以继承这两个抽象基类(虽然并不常见),只需实现相关的抽象方法即可保证它按照我们期望的那样工作。
既然双向字典也是个可变映射类型,所以我们可以继承MutableMapping
类来实现。
|
|
这里我们只是继承,但没有实现任何方法。Python 解释器告诉我们,继承MutableMapping
需要实现__delitem__
、__getitem__
、__iter__
、__len__
和__setitem__
这些抽象方法,一旦实现了这些抽象方法,我们就可以正常使用相关的pop
、clear
、update
、setdefault
等方法了,因为它们的实现都是基于这些抽象方法。
下面是双向字典基于抽象基类的完整实现:
|
|
不同于 dict,这里的update
、setdefault
方法会调用__setitem__
方法,而pop
、clear
方法会调用__delitem__
方法。
使用抽象基类也许会让你感到似乎放弃了 Python 的鸭子类型风格(duck typing,动态类型的一种风格),而拥抱了强类型语言的面向对象编程方法。但实际上恰恰相反,抽象基类强化了鸭子类型风格,因为从抽象基类继承能让我们实现完整的”鸭子“。我们不必担心是否遗漏了可变映射类型所有必须实现的方法,因为 Python 会发出错误提示,告诉我们忘记实现了某个抽象基类必须实现的方法。
和字典类似,如果你想实现一个像列表一样的序列类型,可以继承自MutableSequence
这个抽象基类。同样,如果想实现像 set 一样的集合类型,可以继承自MutableSet
这个抽象基类。
继承自 UserList / UserDict
当使用抽象基类Mapping
、Sequence
、Set
以及它们的可变的子类型的时候,你经常会发现这些类实际上就是对一个已存在的数据结构(dict / list / set)的包装(Wrapper)
。如果你正在实现一个像字典一样的类,用这种基于 dict 包装的方式会让事情变得更简单。同理,也适用于列表和集合。
Python 实际上提供了两个更高层次的实现,来帮助你创建像列表和字典一样的类,即collections.UserList
和collections.UserDict
,这两个类包装了列表对象和字典对象。
基于UserDict
实现双向字典:
|
|
你可能会注意到一些有趣的地方。
上面的代码和我们在 继承自 dict 的弊端 一节中一开始实现的继承自 dict 的类,几乎完全一样。__setitem__
方法完全一样,__delitem__
方法有一些细微差别。
可能会让人觉得UserDict
只不过是一个比 dict 好一些的类。但这种认识并不正确。UserDict
不是 dict 简单的替换,而是对 dict 的包装。UserDict
类实现了一个 dict 类型应具有的所有接口方法,而它底层存放数据结构的载体就是一个 dict 类型的对象。如果你用 PyCharm 编辑 Python 代码,你可以尝试跳转到UserDict
的实现,在那里你会看到该类有一个成员变量 self.data,类型是 dict,它就是存放UserDict
数据结构的字典对象。
下面是另外一个基于UserDict
实现双向字典的方法:
|
|
这个方法和上一个方法的区别在于,它没有通过super
方法来调用父类UserDict
的方法,而是直接基于存放数据结构的 self.data 来实现。
UserDict
的构造函数创建了一个字典 self.data 来存放数据。这个像字典一样的类的所有方法,都是基于这个 self.data 字典来实现的,这就叫包装。
同样,UserList
也是用这个方式实现,区别在于它的 self.data 是一个列表类型 。如果想自定义字典或列表中的某个方法,我们只需要覆盖掉它即可。
你可以把UserDict
和UserList
看作包装类,当我们的类继承自这两个类,我们所有的实现都包装在 self.data 之上,我们实现的类的方法也是 self.data 方法的代理(proxy)。
从面向对象的角度来看,我们可以把UserDict
和UserList
看作是 适配器模式。
两种替代方案哪种更好
事实上,UserDict
就是继承自MutableMapping
,而MutableMapping
继承自Mapping
超类。它们都是抽象基类(ABC),但提供了好几个实用的方法。一般来说:
当你想创建一个类,它的行为和 dict 或 list 几乎一样,只存在少许差异,而你想自定义很小一部分功能,那么UserDict
和UserList
更合适。
当你想创建一个序列类型或映射类型,它们和 dict 或 list 有比较大的差异,你需要自定义许多行为,那么collections.abc
中的抽象基类更合适。
另外,如果你想创建的类型是集合,那么只能继承自collections.abc
中的MutableSet
或Set
,因为 Python 并没有提供 UserSet 类。
什么时候适合继承自 dict / list / set
上面说了这么多,那么是不是继承自 dict / list / set 就一无是处?
也不是,有两种情况是可以直接继承自 dict / list / set 的:
- 你想覆盖的功能,它只限于在一到两个方法内;
- 你不会覆盖 dict / list / set 的方法,而是在它的基础上新增一些方法。
对于第二种情况,很好理解, 你只是在原来 dict / list / set 的基础上,想封装一些自定义的方法,让你用这些容器更方便。
对于第一种情况,我们可以尝试实现一个 DefaultDict 类(和 Python 的collections.defaultdict
略有区别):
|
|
DefaultDict
类只是在构造函数中新增了 default 参数,用来设定默认值,另外使用__missing__
方法,当 key 不存在时返回默认值:
|
|
继承自 dict 并没有问题,因为我们覆盖的功能,并没有存在于其它很多地方。
总结
当你想创建一个和 dict / list / set 类似的容器类,你需要根据实际需求,思考一下哪一种方式更适合你。
如果你需要改变容器的核心功能、工作方式,那么就不适合直接继承自 dict / list / set,而是考虑下面几种情况:
如果你想创建的类是 dict 或 list 的变体,你只是想自定义很小一部分功能,那么可以考虑UserDict
和UserList
;
如果你想创建的类是 dict 或 list 的变体,它们和 dict 或 list 有较大的差异,你需要自定义较多的行为,那么你可以考虑collections.abc
中的抽象基类MutableMapping
、MutableSequence
等;
如果你想创建的类是 set 的变体,那么只能考虑collections.abc
中的MutableSet
或Set
。
除了以上情况以外,如果你有信心,你想覆盖的功能,只限于在一到两个方法内,或者你根本不会覆盖 dict / list / set 的方法,而是在它的基础上新增一些方法,那么你可以考虑直接继承自 dict / list / set 。
参考
https://www.askpython.com/python-modules/userdict-and-userlist-in-python
「 您的赞赏是激励我创作和分享的最大动力! 」
- 原文链接:https://zhuyinjun.me/2019/how_to_define_a_list-like_or_dict-like_class/
- 版权声明:本创作采用 CC BY-NC 4.0 国际许可协议,非商业性使用可以转载,但请注明出处(作者、链接),商业性使用请联系作者获得授权。