共计 8925 个字符,预计需要花费 23 分钟才能阅读完成。
在创建和解决许多涉及自定义集合的练习时,我意识到从 list
, dict
和 set
继承通常会带来一些微妙的困扰。我写这篇文章是为了解释为什么我通常不建议在Python中从这些内置类继承。
我的示例将重点放在 dict
和 list
上,因为这些很可能更常见地被子类化。
创建一个自定义词典
我们想要创建一个双向字典。当添加一个键值对时,键映射到值,同时值也映射到键。
这个字典中的元素总是偶数个。如果 d[k] == v
是 True
,那么 d[v] == k
也总是 True
。
我们可以尝试通过自定义删除和设置键值对来实现这一点。
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__()})"
在这里,我们确保:
- 删除键将同时删除它们对应的值
- 每当我们为
k
设置一个新值时,任何现有的值都将被正确地移除 - 每当我们设置一个键值对时,相应的值键对也会被设置
设置和删除双向字典中的项目似乎按照我们的预期工作:
>>> d = TwoWayDict()
>>> d[3] = 8
>>> d
TwoWayDict({3: 8, 8: 3})
>>> d[7] = 6
>>> d
TwoWayDict({3: 8, 8: 3, 7: 6, 6: 7})
但是在这个字典上调用 update
方法会导致奇怪的行为:
>>> d
TwoWayDict({3: 8, 8: 3, 7: 6, 6: 7})
>>> d.update({9: 7, 8: 2})
>>> d
TwoWayDict({3: 8, 8: 2, 7: 6, 6: 7, 9: 7})
添加 9: 7
应该移除 7: 6
和 6: 7
,添加 8: 2
应该移除 3: 8
和 8: 3
。
我们可以通过一个自定义的 update
方法来解决这个问题:
def update(self, items):
if isinstance(items, dict):
items = items.items()
for key, value in items:
self[key] = value
但是调用初始化器也不起作用:
>>> d = TwoWayDict({9: 7, 8: 2})
>>> d
TwoWayDict({9: 7, 8: 2})
所以我们将创建一个自定义的初始化方法,调用 update
:
def __init__(self, items=()):
self.update(items)
但是 pop
不起作用:
>>> d = TwoWayDict()
>>> d[9] = 7
>>> d
TwoWayDict({9: 7, 7: 9})
>>> d.pop(9)
7
>>> d
TwoWayDict({7: 9}
而 setdefault
也不会
>>> d = TwoWayDict()
>>> d.setdefault(4, 2)
2
>>> d
TwoWayDict({4: 2})
问题是 pop
方法实际上没有调用 __delitem__
,而 setdefault
方法实际上没有调用 __setitem__
。
如果我们想要解决这个问题,我们必须完全重新实现 pop
和 setdefault
:
DEFAULT = object()
class TwoWayDict(dict):
# ...
def pop(self, key, default=DEFAULT):
if key in self or default is DEFAULT:
value = self[key]
del self[key]
return value
else:
return default
def setdefault(self, key, value):
if key not in self:
self[key] = value
这一切都非常繁琐。当从 dict
继承以创建自定义字典时,我们期望 update
和 __init__
会调用 __setitem__
和 pop
,而 setdefault
会调用 __delitem__
。但事实并非如此!
同样, get
和 pop
并不会调用 __getitem__
,这可能不符合你的预期。
列表和集合有相同的问题
list
和 set
类与 dict
类存在类似的问题。让我们来看一个例子。
我们将创建一个自定义列表,继承自 list
构造函数,并重写 __delitem__
、 __iter__
和 __eq__
的行为。这个列表将自定义 __delitem__
,不会真正删除一个项目,而是在该项目原来的位置留下一个“空洞”。当比较两个 HoleList
类是否“相等”时, __iter__
和 __eq__
方法将跳过这个空洞。
这个类有点荒谬(不,它不是一个幸运的Python Morsels练习),但我们更关注继承 list
的问题
class HoleList(list):
HOLE = object()
def __delitem__(self, index):
self[index] = self.HOLE
def __iter__(self):
return (
item
for item in super().__iter__()
if item is not self.HOLE
)
def __eq__(self, other):
if isinstance(other, HoleList):
return all(
x == y
for x, y in zip(self, other)
)
return super().__eq__(other)
def __repr__(self):
return f"{type(self).__name__}({super().__repr__()})"
如果我们创建两个 HoleList
对象,并从中删除项目,使得它们具有相同的非空项目:
>>> x = HoleList([2, 1, 3, 4])
>>> y = HoleList([1, 2, 3, 5])
>>> del x[0]
>>> del y[1]
>>> del x[-1]
>>> del y[-1]
我们会看到它们是相等的
>>> x == y
True
>>> list(x), list(y)
([1, 3], [1, 3])
>>> x
HoleList([<object object at 0x7f56bdf38120>, 1, 3, <object object at 0x7f56bdf38120>])
>>> y
HoleList([1, <object object at 0x7f56bdf38120>, 3, <object object at 0x7f56bdf38120>])
但是如果我们问他们是否不相等,我们会发现它们既相等又不相等:
>>> x == y
True
>>> x != y
True
>>> list(x), list(y)
([1, 3], [1, 3])
>>> x
HoleList([<object object at 0x7f56bdf38120>, 1, 3, <object object at 0x7f56bdf38120>])
>>> y
HoleList([1, <object object at 0x7f56bdf38120>, 3, <object object at 0x7f56bdf38120>])
通常在Python 3中,覆盖 __eq__
会自定义相等性( ==
)和不相等性( !=
)检查的行为。但对于 list
或 dict
来说不是这样:它们定义了 __eq__
和 __ne__
方法,这意味着我们需要同时覆盖两个方法。
def __ne__(self, other):
return not (self == other)
字典也存在这个问题:存在 __ne__
,这意味着在继承它们时需要小心覆盖 __eq__
和 __ne__
。
也像字典一样,列表上的 remove
和 pop
方法不会调用 __delitem__
:
>>> y
HoleList([1, <object object at 0x7f56bdf38120>, 3, <object object at 0x7f56bdf38120>])
>>> y.remove(1)
>>> y
HoleList([<object object at 0x7f56bdf38120>, 3, <object object at 0x7f56bdf38120>])
>>> y.pop(0)
<object object at 0x7f56bdf38120>
>>> y
HoleList([3, <object object at 0x7f56bdf38120>])
我们可以通过重新实现 remove
和 pop
方法来修复这些问题:
def remove(self, value):
index = self.index(value)
del self[index]
def pop(self, index=-1):
value = self[index]
del self[index]
return value
但这真是一种痛苦。而且谁知道我们是否已经完成了呢?
每当我们在 list
或 dict
子类上自定义核心功能的一小部分时,我们需要确保自定义其他方法,这些方法也包含完全相同的功能(但不委托给我们重写的方法)。
Python开发者为什么这样做?
根据我的理解,内置的 list
、 dict
和 set
类型为了提高性能,在很多代码中进行了内联。基本上,它们在许多不同的函数之间复制粘贴了相同的代码,以避免额外的函数调用,使事情变得稍微快一点。
我在网上没有找到解释为什么做出这个决定以及选择其他方式的后果的参考资料。但我大多数情况下相信这是为了我作为Python开发者的利益而做出的。如果 dict
和 list
不是以这种方式更快,为什么核心开发者会选择这种奇怪的实现方式呢?
继承list和dict的替代方案是什么?
继承 list
来创建自定义列表很痛苦,继承 dict
来创建自定义字典也很痛苦。有什么替代方案吗?
如何创建一个不继承内置 dict
的自定义字典对象?
有几种方法可以创建自定义词典:
- 全面拥抱鸭子类型:弄清楚使你的数据结构类似于
dict
所需的一切,并创建一个完全自定义的类(像dict
一样行走和叫) - 继承一个辅助类,它将指导我们朝着正确的方向前进,并告诉我们对象需要具备哪些方法来实现
dict
-like的功能 - 寻找一个更具扩展性的重新实现
dict
,并从中继承
我们将跳过第一种方法:从头开始重新实现将需要一些时间,而Python有一些辅助工具可以使事情变得更容易。我们将先看一下那些指导我们正确方向的辅助工具(上述的2),然后再看那些可以完全替代的辅助工具(上述的3)。
抽象基类:它们将帮助你像鸭子一样嘎嘎叫
Python的collections.abc模块包含了一些抽象基类,可以帮助我们实现Python中常见的一些协议(也可以称为接口,就像Java中的接口一样)。
我们正在尝试创建一个类似字典的对象。字典是可变映射。类似字典的对象是一种映射。这个词“映射”来自于“哈希映射”,许多其他编程语言称这种数据结构为哈希映射。
所以我们想要创建一个可变映射。模块 collections.abc
为此提供了一个抽象基类: MutableMapping
!
如果我们继承这个抽象基类,我们会发现我们需要实现一些方法才能使其正常工作:
>>> from collections.abc import MutableMapping
>>> class TwoWayDict(MutableMapping):
... pass
...
>>> d = TwoWayDict()
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't instantiate abstract class TwoWayDict with abstract methods __delitem__, __getitem__, __iter__, __len__, __setitem__
MutableMapping
类要求我们说明如何获取、删除和设置项,如何进行迭代以及如何获取字典的长度。但一旦我们完成这些,我们将免费获得 pop
、 clear
、 update
和 setdefault
方法!
这是使用 MutableMapping
抽象基类重新实现的 TwoWayDict
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
不同,这些 update
和 setdefault
方法将调用我们的 __setitem__
方法,而 pop
和 clear
方法将调用我们的 __delitem__
方法。
抽象基类可能会让你觉得我们正在离开Python鸭子类型的美妙世界,进入某种强类型的面向对象编程世界。但是,抽象基类实际上增强了鸭子类型。从抽象基类继承帮助我们成为更好的鸭子。我们不必担心是否实现了所有使可变映射成为可能的行为,因为如果我们忘记指定某些基本行为,抽象基类会提醒我们。
我们之前创建的 HoleList
类需要继承自 MutableSequence
抽象基类。一个自定义的类似集合的类可能会继承自 MutableSet
抽象基类。
UserList/UserDict: 实际上可扩展的列表和字典
使用集合 ABCs, Mapping
, Sequence
, Set
(以及它们的可变子项),您经常会发现自己需要在现有数据结构的外部创建一个包装器。如果您正在实现类似字典的对象,使用字典作为底层数据结构会更加方便:对于列表和集合也是如此。
Python实际上包含了两个更高级的辅助类,用于创建类似列表和字典的类,它们包装了 list
和 dict
对象。这两个类位于collections模块中,分别是UserList和UserDict。
这是一个从 UserDict
继承的 TwoWayDict
的重新实现:
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
继承:
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__
方法有一些小的差异。
从这两个代码块来看, UserDict
只是一个更好的 dict
。但实际上并不完全正确: UserDict
并不是 dict
的替代品,而更像是一个 dict
的包装器。
UserDict
类实现了字典应该具有的接口,但在内部封装了一个实际的 dict
对象。
这是我们可以用另一种方式来编写上述代码的方法,而不需要任何调用
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
这两种方法都引用了 self.data
,而我们没有定义它。
UserDict
类的初始化器创建一个字典,并将其存储在 self.data
中。这个类似字典的 UserDict
类的所有方法都是围绕这个 self.data
字典进行的封装。 UserList
的工作方式相同,只是它的 data
属性是围绕一个 list
对象进行的封装。如果我们想要自定义这些类的 dict
或 list
方法之一,我们只需重写它并更改其功能即可。
您可以将 UserDict
和 UserList
视为包装类。当我们从这些类继承时,我们会围绕一个 data
属性进行包装,将所有方法查找代理到该属性上。
在花哨的面向对象编程术语中,我们可以将 UserDict
和 UserList
视为适配器类。
我应该使用抽象基类还是UserDict和UserList?
UserList
和 UserDict
类在 collections.abc
之前很久就被创建出来了。 UserList
和 UserDict
在Python 2.0发布之前就已经存在(至少以某种形式存在),但 collections.abc
抽象基类直到Python 2.6才出现。
UserList
和 UserDict
类是用于当您想要的东西几乎与列表或字典完全相同,但您只想自定义一小部分功能时使用的。
在 collections.abc
中的抽象基类在您想要一个序列或映射,但与列表或字典有足够不同以至于您应该创建自己的自定义类时非常有用。
从list和dict继承有时候有意义吗?
继承 list
和 dict
并不总是坏的。
例如,这是一个完全功能的 DefaultDict
版本(它的行为与 collections.defaultdict
有些不同):
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
使用 __missing__
方法来按照您的期望进行操作:
>>> 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
没有问题,因为我们没有覆盖存在于多个不同位置的功能。
如果您要更改的功能仅限于单个方法或添加自定义方法,那么直接继承 list
或 dict
可能是值得的。但是,如果您的更改需要在多个位置重复相同的功能(通常是这种情况),请考虑使用其中一种替代方案。
在创建自定义列表或字典时,请记住您有多种选择
创建自己的类似集合、列表或字典的对象时,请仔细考虑您需要对象如何工作。
如果您需要更改一些核心功能,继承自 list
、 dict
或 set
将会很麻烦,我建议不要这样做。
如果您正在制作 list
或 dict
的变体,并且需要自定义一小部分核心功能,请考虑继承自 collections.UserList
或 collections.UserDict
。
通常情况下,如果您正在创建自定义的内容,您通常会选择在 collections.abc
中使用抽象基类。例如,如果您正在创建稍微自定义的序列或映射(例如 collections.deque
, range
和可能 collections.Counter
),您将需要使用 MutableSequence
或 MutableMapping
。如果您正在创建自定义的类似集合的对象,您的选择只有 collections.abc.Set
或 collections.abc.MutableSet
(没有 UserSet
)。
在Python中,我们通常不需要经常创建自己的数据结构。当您确实需要创建自定义集合时,围绕一个数据结构进行封装是一个很好的主意。在需要时,请记住 collections
和 collections.abc
模块!