目录

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 from dict; 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 of UserDict 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 the UserDict 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 类的一些方法来实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class TwoWayDict(dict):
  def __delitem__(self, key):
    value = super().pop(key)
    super().pop(value, None)

  def __setitem__(self, key, value):
    if key in self:
      del self[self[key]]
    if value in self:
      del self[value]
    super().__setitem__(key, value)
    super().__setitem__(value, key)

  def __repr__(self):
    return f"{type(self).__name__}({super().__repr__()})"

这里我们通过覆盖__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方法来更新双向字典的时候,结果有些出乎意料:

1
2
3
4
5
6
7
d = TwoWayDict()
d[3] = 8
print(d)
d[7] = 6
print(d)
d.update({5: 6, 3: 2})
print(d)

执行结果:

1
2
3
TwoWayDict({3: 8, 8: 3})
TwoWayDict({3: 8, 8: 3, 7: 6, 6: 7})
TwoWayDict({3: 2, 8: 3, 7: 6, 6: 7, 5: 6})

update方法新增 (5, 6) 本应该删除原字典中的 (7, 6) 和 (6, 7),而新增 (3, 2) 本应该删除 (3, 8) 和 (8, 3),然而实际运行结果并不完全是这样。

我们可以通过自定义update方法来覆盖 dict 的update方法,从而解决这个问题:

1
2
3
4
5
def update(self, items):
  if isinstance(items, dict):
    items = items.items()
  for key, value in items:
    self[key] = value

但接下来我们又发现,初始化构造函数无法正常工作:

1
2
d = TwoWayDict({3: 5, 8: 2})
print(d)

执行结果:

1
TwoWayDict({3: 5, 8: 2})

无奈,我们只好再尝试覆盖 dict 的构造函数:

1
2
def __init__(self, items=()):
  self.update(items)

但接下来又发现pop方法无法工作:

1
2
3
4
5
6
7
8
>>> d = TwoWayDict()
>>> d[9] = 7
>>> d
TwoWayDict({9: 7, 7: 9})
>>> d.pop(9)
7
>>> d
TwoWayDict({7: 9}

同样,setdefault方法也无法工作:

1
2
3
4
5
>>> d = TwoWayDict()
>>> d.setdefault(4, 2)
2
>>> d
TwoWayDict({4: 2})

背后的原因是我们虽然重构了__delitem__方法和__setitem__方法,但pop方法实际上没有调用__delitem__方法,而setdefault方法也没有调用__setitem__方法。这样,popsetdefault自然也就没有按照我们设计的方法工作。

如果我们想解决这个问题,我们需要继续“痛苦地”自定义pop方法和setdefault方法。

相信这些结果让你有些出乎意料。当我们继承 dict 想实现一个双向字典的时候,我们原本期望update方法和__init__方法会调用__setitem__方法,而pop方法和setdefault方法会调用__delitem__方法,但实际上它们并没有。

其实这问题并不仅限于 dict,列表和集合也存在同样的问题。

 

其它替代方案

可行方案 一节,我们介绍了自定义容器类的四种方式。第一种方式,其实就是类的全部功能完全由自己实现,本文不展开介绍;而第四种方式,即通过继承自 dict / list / set 来实现,上一节我们已经看到它存在的弊端。那么剩下的其它两个替代方案如何呢?

继承自抽象基类

Python 的collections.abc模块包含了丰富的抽象基类,来帮助我们实现具有共性或者说共同工作方式的类,这些抽象基类就像 Java 中的接口(interfaces),声明了方法,我们要做的就是在自定义类中实现这些方法。

我们现在来尝试通过继承抽象基类来实现双向字典。我们知道,字典本身是个可变的映射类型(mutable mappings),既然我们想创建的类和字典类似,那么它本质上也是一个可变的映射。映射这个词来自于散列表,关于 Python 中散列相关的知识,有兴趣地朋友可以移步 这里

collections.abc模块中有MappingMutableMapping这两个和映射相关的抽象基类,MutableMapping是可变映射,继承自不可变映射MappingMapping继承自ContainerIterable等超类,它们的 UML 类图如下所示:

/2019/how_to_define_a_list-like_or_dict-like_class/uml_of_mapping_class.png
“collections.abc 中的 MutableMapping 和它的超类”

一般而言,自定义的类不会直接继承这两个抽象基类,这两个和映射相关的抽象基类只提供了形式接口,它们的作用就像形式化的文档。然而,在个别情况下,自定义的类可以继承这两个抽象基类(虽然并不常见),只需实现相关的抽象方法即可保证它按照我们期望的那样工作。

既然双向字典也是个可变映射类型,所以我们可以继承MutableMapping类来实现。

1
2
3
4
5
6
7
8
>>> from collections.abc import MutableMapping
>>> class TwoWayDict(MutableMapping):
...     pass
...
>>> d = TwoWayDict()
Traceback (most recent call last):
...
TypeError: Can't instantiate abstract class TwoWayDict with abstract methods __delitem__, __getitem__, __iter__, __len__, __setitem__

这里我们只是继承,但没有实现任何方法。Python 解释器告诉我们,继承MutableMapping需要实现__delitem____getitem____iter____len____setitem__这些抽象方法,一旦实现了这些抽象方法,我们就可以正常使用相关的popclearupdatesetdefault等方法了,因为它们的实现都是基于这些抽象方法。

下面是双向字典基于抽象基类的完整实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from collections.abc import MutableMapping

class TwoWayDict(MutableMapping):
  def __init__(self, data=()):
    self.mapping = {}
    self.update(data)

  def __getitem__(self, key):
    return self.mapping[key]

  def __delitem__(self, key):
    value = self[key]
    del self.mapping[key]
    self.pop(value, None)

  def __setitem__(self, key, value):
    if key in self:
      del self[self[key]]
    if value in self:
      del self[value]
    self.mapping[key] = value
    self.mapping[value] = key

  def __iter__(self):
    return iter(self.mapping)

  def __len__(self):
    return len(self.mapping)

  def __repr__(self):
    return f"{type(self).__name__}({self.mapping})"

不同于 dict,这里的updatesetdefault方法会调用__setitem__方法,而popclear方法会调用__delitem__方法。

使用抽象基类也许会让你感到似乎放弃了 Python 的鸭子类型风格(duck typing,动态类型的一种风格),而拥抱了强类型语言的面向对象编程方法。但实际上恰恰相反,抽象基类强化了鸭子类型风格,因为从抽象基类继承能让我们实现完整的”鸭子“。我们不必担心是否遗漏了可变映射类型所有必须实现的方法,因为 Python 会发出错误提示,告诉我们忘记实现了某个抽象基类必须实现的方法。

和字典类似,如果你想实现一个像列表一样的序列类型,可以继承自MutableSequence这个抽象基类。同样,如果想实现像 set 一样的集合类型,可以继承自MutableSet这个抽象基类。

继承自 UserList / UserDict

当使用抽象基类MappingSequenceSet以及它们的可变的子类型的时候,你经常会发现这些类实际上就是对一个已存在的数据结构(dict / list / set)的包装(Wrapper)。如果你正在实现一个像字典一样的类,用这种基于 dict 包装的方式会让事情变得更简单。同理,也适用于列表和集合。

Python 实际上提供了两个更高层次的实现,来帮助你创建像列表和字典一样的类,即collections.UserListcollections.UserDict,这两个类包装了列表对象和字典对象。

基于UserDict实现双向字典:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
from collections import UserDict

class TwoWayDict(UserDict):
  def __delitem__(self, key):
    value = self[key]
    super().__delitem__(key)
    self.pop(value, None)

  def __setitem__(self, key, value):
    if key in self:
      del self[self[key]]
    if value in self:
      del self[value]
    super().__setitem__(key, value)
    super().__setitem__(value, key)

  def __repr__(self):
    return f"{type(self).__name__}({self.data})"

你可能会注意到一些有趣的地方。

上面的代码和我们在 继承自 dict 的弊端 一节中一开始实现的继承自 dict 的类,几乎完全一样。__setitem__方法完全一样,__delitem__方法有一些细微差别。

可能会让人觉得UserDict只不过是一个比 dict 好一些的类。但这种认识并不正确。UserDict不是 dict 简单的替换,而是对 dict 的包装。UserDict类实现了一个 dict 类型应具有的所有接口方法,而它底层存放数据结构的载体就是一个 dict 类型的对象。如果你用 PyCharm 编辑 Python 代码,你可以尝试跳转到UserDict的实现,在那里你会看到该类有一个成员变量 self.data,类型是 dict,它就是存放UserDict数据结构的字典对象。

下面是另外一个基于UserDict实现双向字典的方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
from collections import UserDict

class TwoWayDict(UserDict):
  def __delitem__(self, key):
    value = self.data.pop(key)
    self.data.pop(value, None)

  def __setitem__(self, key, value):
    if key in self:
      del self[self[key]]
    if value in self:
      del self[value]
    self.data[key] = value
    self.data[value] = key

这个方法和上一个方法的区别在于,它没有通过super方法来调用父类UserDict的方法,而是直接基于存放数据结构的 self.data 来实现。

UserDict的构造函数创建了一个字典 self.data 来存放数据。这个像字典一样的类的所有方法,都是基于这个 self.data 字典来实现的,这就叫包装。

同样,UserList也是用这个方式实现,区别在于它的 self.data 是一个列表类型 。如果想自定义字典或列表中的某个方法,我们只需要覆盖掉它即可。

你可以把UserDictUserList看作包装类,当我们的类继承自这两个类,我们所有的实现都包装在 self.data 之上,我们实现的类的方法也是 self.data 方法的代理(proxy)。

从面向对象的角度来看,我们可以把UserDictUserList看作是 适配器模式

两种替代方案哪种更好

事实上,UserDict就是继承自MutableMapping,而MutableMapping继承自Mapping超类。它们都是抽象基类(ABC),但提供了好几个实用的方法。一般来说:

当你想创建一个类,它的行为和 dict 或 list 几乎一样,只存在少许差异,而你想自定义很小一部分功能,那么UserDictUserList更合适。

当你想创建一个序列类型或映射类型,它们和 dict 或 list 有比较大的差异,你需要自定义许多行为,那么collections.abc中的抽象基类更合适。

另外,如果你想创建的类型是集合,那么只能继承自collections.abc中的MutableSetSet,因为 Python 并没有提供 UserSet 类。

 

什么时候适合继承自 dict / list / set

上面说了这么多,那么是不是继承自 dict / list / set 就一无是处?

也不是,有两种情况是可以直接继承自 dict / list / set 的:

  • 你想覆盖的功能,它只限于在一到两个方法内;
  • 你不会覆盖 dict / list / set 的方法,而是在它的基础上新增一些方法。

对于第二种情况,很好理解, 你只是在原来 dict / list / set 的基础上,想封装一些自定义的方法,让你用这些容器更方便。

对于第一种情况,我们可以尝试实现一个 DefaultDict 类(和 Python 的collections.defaultdict略有区别):

1
2
3
4
5
6
7
class DefaultDict(dict):
  def __init__(self, *args, default=None, **kwargs):
    super().__init__(*args, **kwargs)
    self.default = default

  def __missing__(self, key):
    return self.default

DefaultDict类只是在构造函数中新增了 default 参数,用来设定默认值,另外使用__missing__方法,当 key 不存在时返回默认值:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
>>> d = DefaultDict({'a': 8})
>>> d['a']
8
>>> d['b']
>>> d
{'a': 8}
>>> e = DefaultDict({'a': 8}, default=4)
>>> e['a']
8
>>> e['b']
4
>>> e
{'a': 8}

继承自 dict 并没有问题,因为我们覆盖的功能,并没有存在于其它很多地方。

 

总结

当你想创建一个和 dict / list / set 类似的容器类,你需要根据实际需求,思考一下哪一种方式更适合你。

如果你需要改变容器的核心功能、工作方式,那么就不适合直接继承自 dict / list / set,而是考虑下面几种情况:

如果你想创建的类是 dict 或 list 的变体,你只是想自定义很小一部分功能,那么可以考虑UserDictUserList

如果你想创建的类是 dict 或 list 的变体,它们和 dict 或 list 有较大的差异,你需要自定义较多的行为,那么你可以考虑collections.abc中的抽象基类MutableMappingMutableSequence等;

如果你想创建的类是 set 的变体,那么只能考虑collections.abc中的MutableSetSet

除了以上情况以外,如果你有信心,你想覆盖的功能,只限于在一到两个方法内,或者你根本不会覆盖 dict / list / set 的方法,而是在它的基础上新增一些方法,那么你可以考虑直接继承自 dict / list / set 。

 

参考

https://www.askpython.com/python-modules/userdict-and-userlist-in-python

https://treyhunner.com/2019/04/why-you-shouldnt-inherit-from-list-and-dict-in-python/#What%E2%80%99s_the_alternative_to_inheriting_from_list_and_dict?


- 全文完 -

相关文章

「 您的赞赏是激励我创作和分享的最大动力! 」