译者前言: 相信凡是用过 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/
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 .
动机
从作者的个人经验和一份对标准库的调查 来看, 明显有很多(如果不是绝大多数)zip 用例要求可迭代对象必须是等长的. 有时候, 周围代码的上下文可以保证这点, 但是要 zip 处理的数据通常是由调用者传入的, 单独提供的或者以某种方式生成的. 在这些情况下, zip 的默认行为意味着错误的重构或逻辑错误, 很容易悄悄地导致数据丢失. 这些 bug 不仅难以定位, 甚至难以被觉察到.
很容易想到造成这种问题的简单案例. 例如, 以下代码在 items 为一个序列 (sequence) 时可以良好地运行, 但是如果调用者将 item 重构为一个可消耗的迭代器, 则代码会悄悄地产生缩短的, 不匹配的结果:
- def apply_calculations(items):
- transformed = transform(items)
- for i, t in zip(items, transformed):
- yield calculate(i, t)
zip 还有几种常见用法. 惯用的技巧性用法特别容易出问题, 因为它们经常被不完全了解代码工作方式的用户使用. 下面是一个示例, 解包到 zip 中以转化成嵌套的可迭代对象:
- >>> x = [[1, 2, 3], ["one" "two" "three"]]
- >>> xt = list(zip(*x))
另一个例子是将数据 "分块" 成大小相等的组:
- >>> n = 3
- >>> x = range(n ** 2),
- >>> xn = list(zip(*[iter(x)] * n))
在第一个例子中, 非矩形数据通常会导致逻辑错误. 在第二个例子中, 长度不是 n 的倍数的数据通常也是错误. 因为这两个习惯用法都会悄悄地忽略不匹配的尾部元素.
最有说服力的例子来自使用了 zip 的标准库 ast , 它在 literal_eval 里产生过一个 bug, 会直接丢弃不匹配的节点 https://bugs.python.org/issue40355 :
- >>> from ast import Constant, Dict, literal_eval
- >>> nasty_dict = Dict(keys=[Constant(None)], values=[])
- >>> literal_eval(nasty_dict) # Like eval("{None: }")
- {
- }
实际上, 笔者已经在 Python 的标准库和工具中找出了许多调用点, 立即在这些位置启用此新特性是恰当的.
基本原理
一些评论者声称: 布尔开关常量是一种 "代码坏气味(code-smell)", 或者与 Python 的设计哲学背道而驰.
但是, Python 当前在内置函数上有几个布尔关键字参数的用法, 它们通常使用编译期常量来调用:
- compile(..., dont_inherit=True)
- open(..., closefd=False)
- print(..., flush=True)
- sorted(..., reverse=True)
标准库中还有许多类似用法.
这个新参数的想法和名称最初是由 Ram Rachum 提出的. 该议题收到了 100 多个回复, 而候选的 "equal" 也获得了相近的支持数.
笔者对它们没有很强烈的偏好, 尽管 "equal equals" 读起来有点尴尬. 它还可能 (错误地) 暗示了 zip 的对象是相等的:
>>> 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 大致翻译如下:
- 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 添加方法或者构造函数
考虑以下两个被提出来的做法:
- >>> 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. 此外, 官方文档 这么写的(它的全部):
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, 而手写一个健壮的解决方案却并非易事. Python 自己的标准库 (前文提到的 ast) 是有现实意义的反例, 很容易就陷入本 PEP 试图避免的那种陷阱.
来源: https://www.cnblogs.com/pythonista/p/13227932.html