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,
UserDictacts 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
dataattribute ofUserDictinstances. If initialdata is provided,datais 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,
UserDictinstances provide the following attribute:
data: A real dictionary used to store the contents of theUserDictclass.
英翻中,简单概述: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 国际许可协议,非商业性使用可以转载,但请注明出处(作者、链接),商业性使用请联系作者获得授权。


