Iterable:一个容易被忽视的Python编码细节

Type hints

近年来,越来越多的 Python 开发者愿意为变量声明类型了,变化非常明显。

def add(left, right):
    return left + right
from typing import TypeVar, Union

T = TypeVar('T', int, float)

def add_typed(left: T, right: T) -> T:
    return left + right

虽然 type hints 并不会在运行时进行类型检查,但它们足以让 IDE 在运行前就报出不少类型风险,也让阅读代码的人有了更多思考空间,不至于迷失在“这到底是什么类型”的疑惑中。

Iterator 和 Iterable 的问题

Python 对 Iterator 的要求只有一条:

An iterator object implements __next__, which is expected to return the next element of the iterable object that returned it, and to raise a StopIteration exception when no more elements are available.

一个迭代器对象需要实现 __next__ 方法,该方法应当返回其所属可迭代对象的下一个元素;当没有更多元素可返回时,应抛出 StopIteration 异常。

Python Wiki

Python 对 Iterable 的要求也很简单:

An iterable object is an object that implements __iter__, which is expected to return an iterator object.

一个可迭代对象是实现了 __iter__ 方法的对象,该方法应当返回一个迭代器对象。

Python Wiki

这两年我还写了不少 Rust。在 Rust 里,Python 的 Iterator 对应 std::iter::IteratorIterable 则对应 std::iter::IntoIterator。正是有了写 Rust 的经验,我才能一眼看出下面代码的问题:

def stream(
        self,
        *,
        # ...
        tools: Iterable[ChatCompletionToolParam] | NotGiven = NOT_GIVEN,
        # ...
    ) -> AsyncChatCompletionStreamManager[ResponseFormatT]:
    # ...
    _validate_input_tools(tools)
    # ...
    api_request = self.create(
            ...
            tools=tools,
            ...
        )
        return AsyncChatCompletionStreamManager(
            ...
            input_tools=tools,
        )

这段代码节选自 OpenAI 的 Python 接口 completions.py。问题出现在 #1129 这个 PR。

作为 Rust 选手的直觉告诉我,一个 Iterable 在这里被用到了三次,这就要求这个 Iterable 在多次迭代时本身状态不可变,否则无法保证三次迭代的结果一致。

通俗点说:这个 Iterable 必须保证多次调用 __iter__ 返回的迭代器,其迭代结果是一致的。

验证一下。对于普通容器当然没问题,但对于生成器就大有问题。生成器作为迭代器,其状态是会变的,所以三次调用的结果无法保证一致。

from typing import Iterable, List

def generate_from_list(lst: List[int]):
    for item in lst:
        yield item

# 容器可以多次迭代,产生相同结果
iterable0 = [1 ,2 ,3]
assert list(iterable0) == list(iterable0) == [1, 2, 3]

# 生成器迭代器只能迭代一次
iterable1 = generate_from_list([1, 2, 3])
assert list(iterable1) == [1, 2, 3]
assert list(iterable1) == []

# 容器外套一层map并不能保持容器可以多次迭代的特征,只能迭代一次
iterable2 = map(lambda x: x + 1, [1, 2, 3])
assert list(iterable2) == [2, 3, 4]
assert list(iterable2) == []

# 生成器额迭代器外套一层map自不必多说,只能迭代一次
iterable3 = map(lambda x: x + 1, generate_from_list([1, 2, 3]))
assert list(iterable3) == [2, 3, 4]
assert list(iterable3) == []

运行结果:

godbolt

反思

我不反思,我一眼就看出这个问题了,我为啥要反思。但需要反思的人还真不少。

写代码时一定要清楚,自己进行的每一个操作对前置操作有什么依赖,是否修改了某个状态,这个状态是否应该由这个操作修改。如果不确定,就必须按照最保守的策略来。

还是以 Iterable 为例,拿到一个可迭代对象后,如果不确定它是不是容器、能否多次迭代且结果一致,那就只允许自己对它做一次迭代。如果因为种种原因不得不多次迭代,那就把它转成 list,再在这个 list 上反复迭代。

#1129 的错误略有不同,它不是在写新代码时出的问题,而是在放宽已有代码的限制时,只考虑了容器的情况,没考虑到其他典型可迭代对象(生成器、map 等)。这个 PR 合并一年多了,似乎还没人踩坑,这恰恰说明大多数场景下传入的就是容器(List),原 PR 放宽约束意义不大,反而引入了潜在风险。

#1606 的错误则更直接。一个 validate_input_tools 函数本不该对输入数据做任何更改,但根据刚才的讨论,这里有潜在的对 Iterable 状态的修改,所以这个函数的类型标注就应该只接受容器,或者其他能表示不可变可迭代对象的类型。

总结

总之,Iterable 虽然是 Python 类型标注中常见的一个概念,但它的“可多次迭代且结果一致”这一点很容易被忽视。写代码时,尤其是在类型标注、接口设计和数据处理时,一定要明确区分“容器型可迭代对象”和“一次性可迭代对象”,避免踩坑。遇到不确定的情况,最保险的做法就是把它转成 list,这样既保证了多次迭代的一致性,也让代码的行为更加可控和易于理解。

(🤔感觉这个还是不要做面试题了)