# Python 3.10 的首个 PEP 诞生，内置类型 zip() 迎来新特性


译者前言：相信凡是用过 zip() 内置函数的人，都会赞同它很有用，但是，它的最大问题是可能会产生出非预期的结果。PEP-618 提出给它增加一个参数，可以有效地解决大家的痛点。

这是 Python 3.10 版本正式采纳的第一个 PEP，「Python猫」一直有跟进社区最新动态的习惯，所以翻译了出来给大家尝鲜，强烈推荐一读。（PS：严格来说，zip() 是一个内置类（built-in type），而不是一个内置函数（built-in function），但我们一般都称它为一个内置函数。）

**PEP原文 ：** [https://www.python.org/dev/peps/pep-0618/](https://www.python.org/dev/peps/pep-0618/)

**PEP标题：** Add Optional Length-Checking To zip

**PEP作者：** Brandt Bucher

**创建日期：** 2020-05-01

**合入版本：** 3.10

**译者** ：[豌豆花下猫](https://zhuanlan.zhihu.com/pythonCat) @Python猫公众号

[PEP翻译计划](https://github.com/chinesehuazhou/peps-cn)

## 摘要

本 PEP 建议给内置的 `zip` 添加一个可选的 strict 布尔关键字参数。当启用时，如果其中一个参数先被用尽了，则会引发 ValueError 。

## 动机

从作者的个人经验和一份[对标准库的调查](https://www.python.org/dev/peps/pep-0618/%23examples#examples) 来看，明显有很多（如果不是绝大多数）zip 用例要求可迭代对象必须是等长的。有时候，周围代码的上下文可以保证这点，但是要 zip 处理的数据通常是由调用者传入的、单独提供的或者以某种方式生成的。在这些情况下，zip 的默认行为意味着错误的重构或逻辑错误，很容易悄悄地导致数据丢失。这些 bug 不仅难以定位，甚至难以被觉察到。

很容易想到造成这种问题的简单案例。例如，以下代码在 items 为一个序列（sequence）时可以良好地运行，但是如果调用者将 item 重构为一个可消耗的迭代器，则代码会悄悄地产生缩短的、不匹配的结果：

```python
def apply_calculations(items):
    transformed = transform(items)
    for i, t in zip(items, transformed):
        yield calculate(i, t)
```

zip 还有几种常见用法。惯用的技巧性用法特别容易出问题，因为它们经常被不完全了解代码工作方式的用户使用。下面是一个示例，解包到 zip 中以转化成嵌套的可迭代对象：

```python
>>> x = [[1, 2, 3], ["one" "two" "three"]]
>>> xt = list(zip(*x))
```

另一个例子是将数据“分块”成大小相等的组：

```python
>>> n = 3
>>> x = range(n ** 2),
>>> xn = list(zip(*[iter(x)] * n))
```

在第一个例子中，非矩形数据通常会导致逻辑错误。在第二个例子中，长度不是 n 的倍数的数据通常也是错误。因为这两个习惯用法都会悄悄地忽略不匹配的尾部元素。

最有说服力的例子来自使用了 zip 的标准库`ast` ，它在 literal_eval 里产生过一个 bug，[会直接丢弃不匹配的节点](https://bugs.python.org/issue40355)：

```python
>>> from ast import Constant, Dict, literal_eval
>>> nasty_dict = Dict(keys=[Constant(None)], values=[])
>>> literal_eval(nasty_dict)  # Like eval("{None: }")
{}
```

实际上，笔者已经在 Python 的标准库和工具中[找出了许多调用点，](https://www.python.org/dev/peps/pep-0618/%23examples#examples) 立即在这些位置启用此新特性是恰当的。

## 基本原理

一些评论者声称：布尔开关常量是一种“代码坏气味（code-smell）”，或者与 Python 的设计哲学背道而驰。

但是，Python 当前在内置函数上有几个布尔关键字参数的用法，它们通常使用编译期常量来调用：

- `compile(..., dont_inherit=True)`
- `open(..., closefd=False)`
- `print(..., flush=True)`
- `sorted(..., reverse=True)`

标准库中还有许多类似用法。

这个新参数的想法和名称[最初是](https://mail.python.org/archives/list/python-ideas%40python.org/message/6GFUADSQ5JTF7W7OGWF7XF2NH2XUTUQM)由 Ram Rachum 提出的。该议题收到了 100 多个回复，而候选的“equal”也获得了相近的支持数。

笔者对它们没有很强烈的偏好，尽管“equal equals” 读起来有点尴尬。它还可能（错误地）暗示了 zip 的对象是相等的：

```python
>>> z = zip([2.0, 4.0, 6.0], [2, 4, 8], equal=True)
```

## 规范

当用关键字参数 strict=True 调用内置类 zip 时，如果参数的长度不同，则生成的迭代器会引发 ValueError。这个异常就发生在迭代器正常停止迭代的地方。

## 向上兼容

此项更改是完全向上兼容的。当前的 zip 不接受关键字参数，默认省略 strict 的“非严格”用法会保持不变。

## 参考实现

笔者设计了一个[C 实现](https://github.com/python/cpython/pull/20921)。

用 Python 大致翻译如下：

```python
def zip(*iterables, strict=False):
    if not iterables:
        return
    iterators = tuple(iter(iterable) for iterable in iterables)
    try:
        while True:
            items = []
            for iterator in iterators:
                items.append(next(iterator))
            yield tuple(items)
    except StopIteration:
        if not strict:
            return
    if items:
        i = len(items)
        plural = " " if i == 1 else "s 1-"
        msg = f"zip() argument {i+1} is shorter than argument{plural}{i}"
        raise ValueError(msg)
    sentinel = object()
    for i, iterator in enumerate(iterators[1:], 1):
        if next(iterator, sentinel) is not sentinel:
            plural = " " if i == 1 else "s 1-"
            msg = f"zip() argument {i+1} is longer than argument{plural}{i}"
            raise ValueError(msg)
```

## 被拒绝的意见

### （1）添加 itertools.zip_strict

这是 Python-Ideas 邮件列表上获得最多支持的替代方案，因此值得在此处加以讨论。它没有任何严重的缺陷，如果本 PEP 被否绝，它是一个很好的替代。

虽然考虑到这一点，但是在 zip 中添加可选参数可以用较小的更改而更好地解决诱发此 PEP 的问题。

### （2）依照先例

itertools 中有一个 zip_longest，这似乎让人很有动机再添加一个 zip_strict。但是，zip_longest 在许多方面是一个更加复杂且特定的程序：它负责填写缺失的值，但其它函数都不需要操心这种事。

如果 zip 和 zip_longest 同时放在 itertools 中，或者都作为内置函数，那么在相同的地方添加 zip_strict 就确实是一个更有效的论点。然而，新的“strict”用法在接口和行为方面，相比起 zip_longest，更接近于 zip 的概念，但又不足以成为内置对象。考虑到这个原因，令 zip 就地扩展出一个新的选项，似乎是最自然的选择。

### （3）易用性

如果 zip 能够防止此类 bug，那么用户在调用的地方启动检查，就会变得非常简单。与其编写一套繁重的逻辑来处理，不如用这个新特性来直接检查。

有人还认为，在标准库中放一个新的函数，相比在一个内置函数上加关键字参数，更“容易发现（discoverable）”。笔者不同意这一论断。

### （4）维护成本

尽管在提升易用性时，具体的实现是个次要问题，但重要的是要认识到，添加新的程序比修改原有程序复杂得多。与此 PEP 一起提供的 CPython 实现非常简单，并且对 zip 的默认行为没有显著的性能影响，而在 itertools 中添加一个全新的程序将需要：

- 复制 zip 的许多现有逻辑，zip_longest 就是这么干的。
- 大刀阔斧地重构 zip 或 zip_longest 或这两者，以便共享一个公共的或者继承性的实现（这可能会影响性能）。

### （5）添加多个“模式”以供切换

如果预期有三个或更多模式（mode），这个建议才会比二元标志更有意义。最显而易见的三种模式是：“最短的”（当前 zip 的行为），“严格的”（本 PEP 提议的行为）和“最长的”（itertools.zip_longest 的行为）。

但是，除了当前的默认值以及本提案的“strict”模式，似乎不需要再添加其它模式。最可能的是添加一个“最长的”模式，但这需要一个新的 fillvalue 参数（它对于前两种模式都没有意义），另外，itertools.zip_longest 已经完美地处理了这种模式，若在 zip 中添加该模式，将会造成重复。目前尚不清楚哪一个是“显而易见的”选择：内置 zip 上的 mode 参数，还是已经长期存在于 itertools 中的 zip_longest。

### （6）给 zip 添加方法或者构造函数

考虑以下两个被提出来的做法：

```python
>>> zm = zip(*iters).strict()
>>> zd = zip.strict(*iters)
```

尚不清楚哪个更好，或者哪个更差。如果 zip.strict 作为一个方法来实现，则 zm 没问题，但是 zd 会出现几种令人困惑的情况：

- 返回不包装在元组中的结果（如果 iters 仅包含一个元素，一个 zip 迭代器）。
- 参数类型错误时抛出 TypeError（如果 iters 只包含一个元素，不是一个 zip 迭代器）。
- 否则，参数数量不对时抛出 TypeError。

如果 zip.strict 是作为 classmethod  或 staticmethod 实现，则 zd 将成功执行，而 zm 将不产生任何结果（这正是我们最初要避免的问题）。

本提案还面临着更为复杂的问题，因为 CPython 中 zip 内置类的实现细节是未文档化的。这意味着若选择以上的某种行为，当前的实现就会被“锁定”（或至少要求对其进行仿真）。

### （7）变更 zip 的默认行为

zip 的默认行为没有什么“错” ，因为在许多情况下，这确实是正确处理大小不等的输入的方法。例如，在处理无限迭代器时，它非常有用。

itertools.zip_longest 已经用在仍然需要“额外”尾端数据的情况。

### （8）使用回调来处理剩余对象

尽管基本上可以执行用户需要的任何操作，但此解决方案在处理常见问题时（例如舍弃不匹配的长度），变得不必要的复杂且不直观。

### （9）引发一个 AssertionError

没有内置函数或内置类的 API 会引发 AssertionError。此外，[官方文档](https://docs.python.org/3.9/library/exceptions.html%3Fhighlight%3Dassertionerror%23AssertionError#AssertionError) 这么写的（它的全部）：

> Raised when an `assert` statement fails.

由于此功能与 Python 的 assert 语句无关，因此不应该引发 AssertionError。用户若希望在优化模式下禁用检查（像一个 assert 语句），可以改用 strict = \_\_debug\_\_。

### （10）在 map 上添加类似的特性

本 PEP 不建议对 map 作任何更改，因为很少使用带有多个可迭代参数的map。但是，本 PEP 的裁定可作为将来讨论类似特性的先例（应该出现）。

如果本 PEP 被拒绝，则 map 的那种特性实际上也不值得追求。如果通过了，则对 map 的更改不需要新的 PEP（尽管像所有提案一样，都应仔细考虑其有用性）。为了保持一致性，它应遵循此处讨论的跟 zip 相同的 API 和语义。

### （11）什么也不做

此建议可能最没有吸引力。

悄悄地将数据截断是一种特别令人讨厌的 bug，而手写一个健壮的解决方案却[并非易事](https://stackoverflow.com/questions/32954486/zip-iterators-asserting-for-equal-length-in-python)。Python 自己的标准库（前文提到的 ast）是有现实意义的反例，很容易就陷入本 PEP 试图避免的那种陷阱。

