Python魔法方法漫游指南:容器

3062 views, 2023/10/17 updated   Go to Comments

容器是 Python 中的一个抽象概念,可以简单理解为包含其他对象的对象。常见的四种内置容器类型为列表、元组、字典、集合。

除了内置容器类型外,Python 也允许你通过实现对应的魔法方法,来自定义容器。

让我们展开探讨。

不可变序列

序列是指一种包含有序对象的容器,比如列表。

要实现一个最简单的序列,只需要实现 __len____getitem__ 方法即可:

class Seq:
    def __init__(self, values=[]):
        self.values = values

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

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

因为没有规定如何去修改容器中的对象,因此这是一个不可变序列。其中的 __len__ 方法定义序列的大小,__getitem__ 方法定义如何对容器进行取值。

测试下:

>>> seq = Seq(values=[1, 2, 3])
>>> len(seq)
3
>>> seq[1]
2
>>> seq[0:2] + seq[1:3]
[1, 2, 2, 3]

这已经表现得有点像普通的列表了,对吧?

除此之外,对序列进行迭代是非常基本的需求,上面这个自定义序列似乎没有哪里实现了迭代的功能,它能够正常迭代吗?

再试试:

>>> for item in seq:
...     print(item)

# 输出:
1
2
3

居然很顺利的迭代出了容器中的值。原因在于 Python 处理迭代时的流程:

  • 首先检查对象是否实现了 __iter__ 方法。
  • 若实现了 __iter__,则通过此方法返回的迭代器进行迭代。
  • 若未实现 __iter__ ,则遍历 __getitem__ 中所有的值进行迭代。

也就是说,虽然没实现 __iter__ 方法,但是 Python 的“保底机制”让迭代得以成功。

类似的还有判断容器是否包含某元素的 in 语句:

>>> 1 in seq
True
>>> 4 in seq
False

in 语句的执行流程:

  • 如果容器定义了 __contains__ ,则根据此方法判断是否包含当前元素。
  • 如果未定义 __contains__ 但定义了 __iter__ ,则遍历迭代器的值进行判断。
  • 如果都没定义,那就遍历 __getitem__ 中的值进行判断。

除此之外,用于颠倒元素顺序的 reversed() 也可以用:

>>> re = reversed(seq)
>>> for item in re:
...     print(item)

# 输出:
3
2
1

它也是同样的道理,由 __len____getitem__ 配合,隐式实现了 __reversed__ 方法。

虽然上述方法可以隐式实现,但从效率的角度考虑,还是建议手动实现(Python 隐式机制使用穷举,但是手动实现可以运用优化取值的方法)。把它们都补充完整,则一个简单的不可变序列差不多是这样:

class Seq:
    def __init__(self, values=[]):
        self.values = values

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

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

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

    def __reversed__(self):
        return reversed(self.values)

    def __contains__(self, item):
        return item in self.values

可变序列

可变序列只需要在不可变序列的基础上,增加 __setitem____delitem__ 以定义如何修改和删除容器中的元素。

此外,也推荐实现 append()insert()pop() 等方法,和 Python 内置的序列保持一致。

比如下面这个自定义的可变序列:

class Seq:
    def __init__(self, values=[]):
        self.values = values

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

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

    def __setitem__(self, key, value):
        self.values[key] = value

    def __delitem__(self, key):
        del self.values[key]

    def append(self, value):
        self.values.append(value)

自带电池

虽然我们可以通过实现魔法方法的形式来自定义几乎所有类型的容器,但那实在是没有必要,因为 Python 有丰富的标准库,开箱即用非常强大。

比方说要实现可变字典,不需要自己造轮子实现底层细节,直接继承 collections.abc.MutableMapping 并实现上面那几个基础的魔法方法即可:

from collections.abc import MutableMapping

class MyDict(MutableMapping):
    def __init__(self, **kwargs):
        self.data = kwargs

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

    def __delitem__(self, key):
        del self.data[key]

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

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

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

    def __repr__(self):
        return repr(self.data)

my_dict = MyDict(a=1, b=2)
my_dict.update(c=3)
my_dict['d'] = 4
my_dict.pop('b')

print(my_dict)
# 输出:
# {'a': 1, 'c': 3, 'd': 4}

这个字典类自动从父类 MutableMapping 里继承了 .update().pop().get() 等基础的方法。

除了 MutableMapping 外,标准库还提供了更高层级的封装,即 UserDict

from collections import UserDict

class MyDict(UserDict):
    def __setitem__(self, key, value):
        super().__setitem__(key, value * 10)


my_dict = MyDict(a=1, b=2)
print(my_dict)
# 输出:
# {'a': 10, 'b': 20}

UserDict 将整个数据结构及方法都默认实现了,要改哪个行为,直接覆写对应的方法就好了,很方便。

因此,如果你要自定义容器,非常推荐先在 collections 模块里找找现成的轮子,比如 UserDictUserListOrderedDict 等,顺便学习下源码。

再看容器

Python 是一门鸭子类型的语言:当看到一只鸟走起来像鸭子、游泳起来像鸭子、叫起来也像鸭子,那么这只鸟就可以被称为鸭子。

翻译成人话就是:只要一个对象满足了该类型的特定协议,那么此对象就可以被当成该类型使用。

这又引出另外一个问题,什么叫协议?协议即某种特定的规范。举个栗子,我们定义“会游泳的动物是鸭子”,这句话里面的“动物”是对象,“鸭子”是类型,“会游泳的”是协议。

理解了这个,那再来看看Python文档中,在协议层面上是如何定义容器的:只要对象满足 __contains__ 协议,那就认为是容器[注释1]。也就是说下面这就是个简单的容器:

class Container:
    def __contains__(self, value):
        if value == 1:
            return True
        return False

container = Container()

print(1 in container)
print(2 in container)
# 输出:
# True
# False

你可能会问:那前面实现的自定义序列也没实现 __contains__ 协议啊?原因是 Python 的幕后机制帮你实现了。

想了解具体机制请看官方文档成员测试机制

再看看几种内置的容器需要满足的协议:

  • 列表:满足 MutableSequenceSequenceReversibleCollection 等协议。
  • 元组:满足 SequenceReversibleCollection 等协议。
  • 字典:满足 MutableMappingMappingCollection 等协议。
  • 集合:满足 MutableSetSetCollection 等协议。

建议阅读官方的collections.abc这个文档,里面把各种容器所需要满足的协议规定得明明白白,还有很多提供给你做基类的宝藏,比如上面用到过的 MutableMapping

包括前面章节里出现过的魔法方法,都可以理解为某种协议的一部分。

明白了协议的概念后,我们就接触到面向对象编程的最重要的法则之一:面向协议编程,而非面向具体的实现

举个栗子。某日你要写一个函数,此函数接收一个仅有两个元素的列表,并将所有元素计算后返回。你的代码可能是这样:

def foo(container):
    container[0] = container[0] * 10
    container[1] = container[1] * 10
    return container

a = [1, 2]
print(foo(a))
# 输出:
# [10, 20]

这样写的问题是此函数严重依赖的列表这个容器的具体实现:foo() 接收的容器首先要有序,其次还要可变。万一客户提出个新需求,要求输入参数也可能是个集合,这函数就抓瞎了。

那应该怎么改?

首先让我们观察collections.abc里对容器的定义。你会发现几种容器都满足 Collection 协议,而此协议中又包含了 Iterable 协议。也就是说,几种内置的容器都是可迭代的。

因此,函数就应该在 Iterable 协议上做文章,输入的容器仅依赖此协议就OK了:

def foo(container):
    data = []
    for item in container:
        data.append(item * 10)
    same_cls = type(container)
    return same_cls(data)


a = [1, 2]
b = (3, 4)
c = set([5, 6])

for item in [a, b, c]:
    print(foo(item))

# 输出:
# [10, 20]    列表
# (30, 40)    元组
# {50, 60}    集合

新函数不再需要列表这个具体的对象,而是仅依赖可迭代协议,并且通过 type() 动态创建不同类型的容器,函数的复用性变得更好了。新函数能够同时提供对列表和集合的支持,甚至元组也没有问题。

希望通过这个拙劣的例子,可以帮你理解“面向协议编程”的优势。

关于 type() 动态生成类的探讨,请看我以前的文章Python元类入门

总结

本文通过对自定义容器中需要实现的魔法方法的探讨,介绍了关于面向协议编程的概念。总结如下:

  • 定义对应的魔法方法,可以定义自定义容器。
  • collections.abccollections 模块中包含丰富的用于自定义容器的基类。
  • Python 是鸭子类型的语言。
  • 面向协议而非面向具体实现进行编程。

注释

[1] 用 in 运算符来判断对象是否为容器并不严谨,比如生成器文件对象也是支持 in 的,但通常并不认为它们也是容器。更多讨论见SO

参考


本系列文章开源发布于 Github,传送门:Python魔法方法漫游指南

看完文章想吐槽?欢迎留言告诉我!




本文作者: 杜赛
发布时间: 2021年07月16日 - 11:35
最后更新: 2023年10月17日 - 11:35
转载请保留原文链接及作者