关于python list和dict继承的讨论

2,429次阅读
没有评论

共计 8925 个字符,预计需要花费 23 分钟才能阅读完成。

关于python list和dict继承的讨论

在创建和解决许多涉及自定义集合的练习时,我意识到从 listdictset 继承通常会带来一些微妙的困扰。我写这篇文章是为了解释为什么我通常不建议在Python中从这些内置类继承。

我的示例将重点放在 dictlist 上,因为这些很可能更常见地被子类化。

创建一个自定义词典

我们想要创建一个双向字典。当添加一个键值对时,键映射到值,同时值也映射到键。

这个字典中的元素总是偶数个。如果 d[k] == vTrue ,那么 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: 66: 7 ,添加 8: 2 应该移除 3: 88: 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__

如果我们想要解决这个问题,我们必须完全重新实现 popsetdefault

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__ 。但事实并非如此!

同样, getpop 并不会调用 __getitem__ ,这可能不符合你的预期。

列表和集合有相同的问题

listset 类与 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__ 会自定义相等性( == )和不相等性( != )检查的行为。但对于 listdict 来说不是这样:它们定义了 __eq____ne__ 方法,这意味着我们需要同时覆盖两个方法。

def __ne__(self, other):
    return not (self == other)

字典也存在这个问题:存在 __ne__ ,这意味着在继承它们时需要小心覆盖 __eq____ne__

也像字典一样,列表上的 removepop 方法不会调用 __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>])

我们可以通过重新实现 removepop 方法来修复这些问题:

def remove(self, value):
    index = self.index(value)
    del self[index]
def pop(self, index=-1):
    value = self[index]
    del self[index]
    return value

但这真是一种痛苦。而且谁知道我们是否已经完成了呢?

每当我们在 listdict 子类上自定义核心功能的一小部分时,我们需要确保自定义其他方法,这些方法也包含完全相同的功能(但不委托给我们重写的方法)。

Python开发者为什么这样做?

根据我的理解,内置的 listdictset 类型为了提高性能,在很多代码中进行了内联。基本上,它们在许多不同的函数之间复制粘贴了相同的代码,以避免额外的函数调用,使事情变得稍微快一点。

我在网上没有找到解释为什么做出这个决定以及选择其他方式的后果的参考资料。但我大多数情况下相信这是为了我作为Python开发者的利益而做出的。如果 dictlist 不是以这种方式更快,为什么核心开发者会选择这种奇怪的实现方式呢?

继承list和dict的替代方案是什么?

继承 list 来创建自定义列表很痛苦,继承 dict 来创建自定义字典也很痛苦。有什么替代方案吗?

如何创建一个不继承内置 dict 的自定义字典对象?

有几种方法可以创建自定义词典:

  1. 全面拥抱鸭子类型:弄清楚使你的数据结构类似于 dict 所需的一切,并创建一个完全自定义的类(像 dict 一样行走和叫)
  2. 继承一个辅助类,它将指导我们朝着正确的方向前进,并告诉我们对象需要具备哪些方法来实现 dict -like的功能
  3. 寻找一个更具扩展性的重新实现 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 类要求我们说明如何获取、删除和设置项,如何进行迭代以及如何获取字典的长度。但一旦我们完成这些,我们将免费获得 popclearupdatesetdefault 方法!

这是使用 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 不同,这些 updatesetdefault 方法将调用我们的 __setitem__ 方法,而 popclear 方法将调用我们的 __delitem__ 方法。

抽象基类可能会让你觉得我们正在离开Python鸭子类型的美妙世界,进入某种强类型的面向对象编程世界。但是,抽象基类实际上增强了鸭子类型。从抽象基类继承帮助我们成为更好的鸭子。我们不必担心是否实现了所有使可变映射成为可能的行为,因为如果我们忘记指定某些基本行为,抽象基类会提醒我们。

我们之前创建的 HoleList 类需要继承自 MutableSequence 抽象基类。一个自定义的类似集合的类可能会继承自 MutableSet 抽象基类。

UserList/UserDict: 实际上可扩展的列表和字典

使用集合 ABCs, MappingSequenceSet (以及它们的可变子项),您经常会发现自己需要在现有数据结构的外部创建一个包装器。如果您正在实现类似字典的对象,使用字典作为底层数据结构会更加方便:对于列表和集合也是如此。

Python实际上包含了两个更高级的辅助类,用于创建类似列表和字典的类,它们包装了 listdict 对象。这两个类位于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 对象进行的封装。如果我们想要自定义这些类的 dictlist 方法之一,我们只需重写它并更改其功能即可。

您可以将 UserDictUserList 视为包装类。当我们从这些类继承时,我们会围绕一个 data 属性进行包装,将所有方法查找代理到该属性上。

在花哨的面向对象编程术语中,我们可以将 UserDictUserList 视为适配器类。

我应该使用抽象基类还是UserDict和UserList?

UserListUserDict 类在 collections.abc 之前很久就被创建出来了。 UserListUserDict 在Python 2.0发布之前就已经存在(至少以某种形式存在),但 collections.abc 抽象基类直到Python 2.6才出现。

UserListUserDict 类是用于当您想要的东西几乎与列表或字典完全相同,但您只想自定义一小部分功能时使用的。

collections.abc 中的抽象基类在您想要一个序列或映射,但与列表或字典有足够不同以至于您应该创建自己的自定义类时非常有用。

从list和dict继承有时候有意义吗?

继承 listdict 并不总是坏的。

例如,这是一个完全功能的 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 没有问题,因为我们没有覆盖存在于多个不同位置的功能。

如果您要更改的功能仅限于单个方法或添加自定义方法,那么直接继承 listdict 可能是值得的。但是,如果您的更改需要在多个位置重复相同的功能(通常是这种情况),请考虑使用其中一种替代方案。

在创建自定义列表或字典时,请记住您有多种选择

创建自己的类似集合、列表或字典的对象时,请仔细考虑您需要对象如何工作。

如果您需要更改一些核心功能,继承自 listdictset 将会很麻烦,我建议不要这样做。

如果您正在制作 listdict 的变体,并且需要自定义一小部分核心功能,请考虑继承自 collections.UserListcollections.UserDict

通常情况下,如果您正在创建自定义的内容,您通常会选择在 collections.abc 中使用抽象基类。例如,如果您正在创建稍微自定义的序列或映射(例如 collections.dequerange 和可能 collections.Counter ),您将需要使用 MutableSequenceMutableMapping 。如果您正在创建自定义的类似集合的对象,您的选择只有 collections.abc.Setcollections.abc.MutableSet (没有 UserSet )。

在Python中,我们通常不需要经常创建自己的数据结构。当您确实需要创建自定义集合时,围绕一个数据结构进行封装是一个很好的主意。在需要时,请记住 collectionscollections.abc 模块!

正文完
请博主喝杯咖啡吧!
post-qrcode
 
admin
版权声明:本文于2024-02-29转载自https://treyhunner.com/2019/04/why-you-shouldnt-inherit-from-list-and-dict-in-python/,共计8925字。
转载提示:此文章非本站原创文章,若需转载请联系原作者获得转载授权。
评论(没有评论)
验证码