更深入的理解 Python 中的迭代

更深入的理解 Python 中的迭代

來自專欄 Linux

深入探討 Python 的 for 循環來看看它們在底層如何工作,以及為什麼它們會按照它們的方式工作。

Python 的 for 循環不會像其他語言中的 for 循環那樣工作。在這篇文章中,我們將深入探討 Python 的 for 循環來看看它們在底層如何工作,以及為什麼它們會按照它們的方式工作。

循環的問題

我們將通過看一些「陷阱」開始我們的旅程,在我們了解循環如何在 Python 中工作之後,我們將再次看看這些問題並解釋發生了什麼。

問題 1:循環兩次

假設我們有一個數字列表和一個生成器,生成器會返回這些數字的平方:

>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers)

我們可以將生成器對象傳遞給 tuple 構造器,從而使其變為一個元組:

>>> tuple(squares)(1, 4, 9, 25, 49)

如果我們使用相同的生成器對象並將其傳給 sum 函數,我們可能會期望得到這些數的和,即 88

>>> sum(squares)0

但是我們得到了 0

問題 2:包含的檢查

讓我們使用相同的數字列表和相同的生成器對象:

>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers)

如果我們詢問 9 是否在 squares 生成器中,Python 將會告訴我們 9 在 squares 中。但是如果我們再次詢問相同的問題,Python 會告訴我們 9 不在 squares 中。

>>> 9 in squaresTrue>>> 9 in squaresFalse

我們詢問相同的問題兩次,Python 給了兩個不同的答案。

問題 3 :拆包

這個字典有兩個鍵值對:

>>> counts = {apples: 2, oranges: 1}

讓我們使用多個變數來對這個字典進行拆包:

>>> x, y = counts

你可能會期望當我們對這個字典進行拆包時,我們會得到鍵值對或者得到一個錯誤。

但是解包字典不會引發錯誤,也不會返回鍵值對。當你解包一個字典時,你會得到鍵:

>>> xapples

回顧:Python 的 for 循環

在我們了解一些關於這些 Python 片段的邏輯之後,我們將回到這些問題。

Python 沒有傳統的 for 循環。為了解釋我的意思,讓我們看一看另一種編程語言的 for 循環。

這是一種傳統 C 風格的 for 循環,用 JavaScript 編寫:

let numbers = [1, 2, 3, 5, 7];for (let i = 0; i < numbers.length; i += 1) { print(numbers[i])}

JavaScript、 C、 C++、 Java、 PHP 和一大堆其他編程語言都有這種風格的 for 循環,但是 Python 確實沒有

Python 確實沒有 傳統 C 風格的 for 循環。在 Python 中確實有一些我們稱之為 for 循環的東西,但是它的工作方式類似於 foreach 循環。

這是 Python 的 for 循環的風格:

numbers = [1, 2, 3, 5, 7]for n in numbers: print(n)

與傳統 C 風格的 for 循環不同,Python 的 for 循環沒有索引變數,沒有索引變數初始化,邊界檢查,或者索引遞增。Python 的 for 循環完成了對我們的 numbers 列表進行遍歷的所有工作。

因此,當我們在 Python 中確實有 for 循環時,我們沒有傳統 C 風格的 for 循環。我們稱之為 for 循環的東西的工作機制與之相比有很大的不同。

定義:可迭代和序列

既然我們已經解決了 Python 世界中無索引的 for 循環,那麼讓我們在此之外來看一些定義。

可迭代是任何你可以用 Python 中的 for 循環遍歷的東西。可迭代意味著可以遍歷,任何可以遍歷的東西都是可迭代的。

for item in some_iterable: print(item)

序列是一種非常常見的可迭代類型,列表,元組和字元串都是序列。

>>> numbers = [1, 2, 3, 5, 7]>>> coordinates = (4, 5, 7)>>> words = "hello there"

序列是可迭代的,它有一些特定的特徵集。它們可以從 0 開始索引,以小於序列的長度結束,它們有一個長度並且它們可以被切分。列表,元組,字元串和其他所有序列都是這樣工作的。

>>> numbers[0]1>>> coordinates[2]7>>> words[4]o

Python 中很多東西都是可迭代的,但不是所有可迭代的東西都是序列。集合、字典、文件和生成器都是可迭代的,但是它們都不是序列。

>>> my_set = {1, 2, 3}>>> my_dict = {k1: v1, k2: v2}>>> my_file = open(some_file.txt)>>> squares = (n**2 for n in my_set)

因此,任何可以用 for 循環遍歷的東西都是可迭代的,序列只是一種可迭代的類型,但是 Python 也有許多其他種類的迭代器。

Python 的 for 循環不使用索引

你可能認為,Python 的 for 循環在底層使用了索引進行循環。在這裡我們使用 while 循環和索引手動遍歷:

numbers = [1, 2, 3, 5, 7]i = 0while i < len(numbers): print(numbers[i]) i += 1

這適用於列表,但它不會對所有東西都起作用。這種循環方式只適用於序列

如果我們嘗試用索引去手動遍歷一個集合,我們會得到一個錯誤:

>>> fruits = {lemon, apple, orange, watermelon}>>> i = 0>>> while i < len(fruits):... print(fruits[i])... i += 1...Traceback (most recent call last):File "<stdin>", line 2, in <module>TypeError: set object does not support indexing

集合不是序列,所以它們不支持索引。

我們不能使用索引手動對 Python 中的每一個迭代對象進行遍歷。對於那些不是序列的迭代器來說,這是行不通的。

迭代器驅動 for 循環

因此,我們已經看到,Python 的 for 循環在底層不使用索引。相反,Python 的 for 循環使用迭代器

迭代器就是可以驅動可迭代對象的東西。你可以從任何可迭代對象中獲得迭代器,你也可以使用迭代器來手動對它的迭代進行遍歷。

讓我們來看看它是如何工作的。

這裡有三個可迭代對象:一個集合,一個元組和一個字元串。

>>> numbers = {1, 2, 3, 5, 7}>>> coordinates = (4, 5, 7)>>> words = "hello there"

我們可以使用 Python 的內置 iter 函數來訪問這些迭代器,將一個迭代器傳遞給 iter 函數總會給我們返回一個迭代器,無論我們正在使用哪種類型的迭代器。

>>> iter(numbers)<set_iterator object at 0x7f2b9271c860>>>> iter(coordinates)<tuple_iterator object at 0x7f2b9271ce80>>>> iter(words)<str_iterator object at 0x7f2b9271c860>

一旦我們有了迭代器,我們可以做的事情就是通過將它傳遞給內置的 next 函數來獲取它的下一項。

>>> numbers = [1, 2, 3]>>> my_iterator = iter(numbers)>>> next(my_iterator)1>>> next(my_iterator)2

迭代器是有狀態的,這意味著一旦你從它們中消耗了一項,它就消失了。

如果你從迭代器中請求 next 項,但是其中沒有更多的項了,你將得到一個 StopIteration 異常:

>>> next(my_iterator)3>>> next(my_iterator)Traceback (most recent call last): File "<stdin>", line 1, in <module>StopIteration

所以你可以從每個迭代中獲得一個迭代器,迭代器唯一能做的事情就是用 next 函數請求它們的下一項。如果你將它們傳遞給 next,但它們沒有下一項了,那麼就會引發 StopIteration 異常。

你可以將迭代器想像成 Pez 分配器(LCTT 譯註:Pez 是一個結合玩具的獨特複合式糖果),不能重新分配。你可以把 Pez 拿出去,但是一旦 Pez 被移走,它就不能被放回去,一旦分配器空了,它就沒用了。

沒有 for 的循環

既然我們已經了解了迭代器和 iter 以及 next 函數,我們將嘗試在不使用 for 循環的情況下手動遍歷迭代器。

我們將通過嘗試將這個 for 循環變為 while 循環:

def funky_for_loop(iterable, action_to_do): for item in iterable: action_to_do(item)

為了做到這點,我們需要:

  1. 從給定的可迭代對象中獲得迭代器
  2. 反覆從迭代器中獲得下一項
  3. 如果我們成功獲得下一項,就執行 for 循環的主體
  4. 如果我們在獲得下一項時得到了一個 StopIteration 異常,那麼就停止循環

def funky_for_loop(iterable, action_to_do): iterator = iter(iterable) done_looping = False while not done_looping: try: item = next(iterator) except StopIteration: done_looping = True else: action_to_do(item)

我們只是通過使用 while 循環和迭代器重新定義了 for 循環。

上面的代碼基本上定義了 Python 在底層循環的工作方式。如果你理解內置的 iternext 函數的遍歷循環的工作方式,那麼你就會理解 Python 的 for 循環是如何工作的。

事實上,你不僅僅會理解 for 循環在 Python 中是如何工作的,所有形式的遍歷一個可迭代對象都是這樣工作的。

迭代器協議(iterator protocol) 是一種很好表示 「在 Python 中遍歷迭代器是如何工作的」的方式。它本質上是對 iternext 函數在 Python 中是如何工作的定義。Python 中所有形式的迭代都是由迭代器協議驅動的。

迭代器協議被 for 循環使用(正如我們已經看到的那樣):

for n in numbers: print(n)

多重賦值也使用迭代器協議:

x, y, z = coordinates

星型表達式也是用迭代器協議:

a, b, *rest = numbersprint(*numbers)

許多內置函數依賴於迭代器協議:

unique_numbers = set(numbers)

在 Python 中任何與迭代器一起工作的東西都可能以某種方式使用迭代器協議。每當你在 Python 中遍歷一個可迭代對象時,你將依賴於迭代器協議。

生成器是迭代器

所以你可能會想:迭代器看起來很酷,但它們看起來像一個實現細節,我們作為 Python 的使用者,可能不需要關心它們。

我有消息告訴你:在 Python 中直接使用迭代器是很常見的。

這裡的 squares 對象是一個生成器:

>>> numbers = [1, 2, 3]>>> squares = (n**2 for n in numbers)

生成器是迭代器,這意味著你可以在生成器上調用 next 來獲得它的下一項:

>>> next(squares)1>>> next(squares)4

但是如果你以前用過生成器,你可能也知道可以循環遍歷生成器:

>>> squares = (n**2 for n in numbers)>>> for n in squares:... print(n)...149

如果你可以在 Python 中循環遍歷某些東西,那麼它就是可迭代的

所以生成器是迭代器,但是生成器也是可迭代的,這又是怎麼回事呢?

我欺騙了你

所以在我之前解釋迭代器如何工作時,我跳過了它們的某些重要的細節。

生成器是可迭代的

我再說一遍:Python 中的每一個迭代器都是可迭代的,意味著你可以循環遍歷迭代器。

因為迭代器也是可迭代的,所以你可以使用內置 next 函數從可迭代對象中獲得迭代器:

>>> numbers = [1, 2, 3]>>> iterator1 = iter(numbers)>>> iterator2 = iter(iterator1)

請記住,當我們在可迭代對象上調用 iter 時,它會給我們返回一個迭代器。

當我們在迭代器上調用 iter 時,它會給我們返回它自己:

>>> iterator1 is iterator2True

迭代器是可迭代的,所有的迭代器都是它們自己的迭代器。

def is_iterator(iterable): return iter(iterable) is iterable

迷惑了嗎?

讓我們回顧一些這些措辭。

  • 一個可迭代對象是你可以迭代的東西
  • 一個迭代對象器是一種實際上遍歷可迭代對象的代理

此外,在 Python 中迭代器也是可迭代的,它們充當它們自己的迭代器。

所以迭代器是可迭代的,但是它們沒有一些可迭代對象擁有的各種特性。

迭代器沒有長度,它們不能被索引:

>>> numbers = [1, 2, 3, 5, 7]>>> iterator = iter(numbers)>>> len(iterator)TypeError: object of type list_iterator has no len()>>> iterator[0]TypeError: list_iterator object is not subscriptable

從我們作為 Python 程序員的角度來看,你可以使用迭代器來做的唯一有用的事情是將其傳遞給內置的 next 函數,或者對其進行循環遍歷:

>>> next(iterator)1>>> list(iterator)[2, 3, 5, 7]

如果我們第二次循環遍歷迭代器,我們將一無所獲:

>>> list(iterator)[]

你可以把迭代器看作是惰性迭代器,它們是一次性使用,這意味著它們只能循環遍歷一次。

正如你在下面的真值表中所看到的,可迭代對象並不總是迭代器,但是迭代器總是可迭代的:

對象可迭代?迭代器?可迭代對象V?迭代器VV生成器VV列表VX

全部的迭代器協議

讓我們從 Python 的角度來定義迭代器是如何工作的。

可迭代對象可以被傳遞給 iter 函數,以便為它們獲得迭代器。

迭代器:

  • 可以傳遞給 next 函數,它將給出下一項,如果沒有下一項,那麼它將會引發 StopIteration 異常
  • 可以傳遞給 iter 函數,它會返回一個自身的迭代器

這些語句反過來也是正確的:

  • 任何可以在不引發 TypeError 異常的情況下傳遞給 iter 的東西都是可迭代的
  • 任何可以在不引發 TypeError 異常的情況下傳遞給 next 的東西都是一個迭代器
  • 當傳遞給 iter 時,任何返回自身的東西都是一個迭代器

這就是 Python 中的迭代器協議。

迭代器的惰性

迭代器允許我們一起工作,創建惰性可迭代對象,即在我們要求它們提供下一項之前,它們不做任何事情。因為可以創建惰性迭代器,所以我們可以創建無限長的迭代器。我們可以創建對系統資源比較保守的迭代器,可以節省我們的內存,節省 CPU 時間。

迭代器無處不在

你已經在 Python 中看到過許多迭代器,我也提到過生成器是迭代器。Python 的許多內置類型也是迭代器。例如,Python 的 enumeratereversed 對象就是迭代器。

>>> letters = [a, b, c]>>> e = enumerate(letters)>>> e<enumerate object at 0x7f112b0e6510>>>> next(e)(0, a)

在 Python 3 中,zip, mapfilter 也是迭代器。

>>> numbers = [1, 2, 3, 5, 7]>>> letters = [a, b, c]>>> z = zip(numbers, letters)>>> z<zip object at 0x7f112cc6ce48>>>> next(z)(1, a)

Python 中的文件對象也是迭代器。

>>> next(open(hello.txt))hello world

在 Python 標準庫和第三方庫中內置了大量的迭代器。這些迭代器首先惰性迭代器一樣,延遲工作直到你請求它們下一項。

創建你自己的迭代器

知道你已經在使用迭代器是很有用的,但是我希望你也知道,你可以創建自己的迭代器和你自己的惰性迭代器。

下面這個類構造了一個迭代器接受一個可迭代的數字,並在循環結束時提供每個數字的平方。

class square_all: def __init__(self, numbers): self.numbers = iter(numbers) def __next__(self): return next(self.numbers) * 2 def __iter__(self): return self

但是在我們開始對該類的實例進行循環遍歷之前,沒有任何工作要做。

這裡,我們有一個無限長的可迭代對象 count,你可以看到 square_all 接受 count 而不用完全循環遍歷這個無限長的迭代:

>>> from itertools import count>>> numbers = count(5)>>> squares = square_all(numbers)>>> next(squares)25>>> next(squares)36

這個迭代器類是有效的,但我們通常不會這樣做。通常,當我們想要做一個定製的迭代器時,我們會生成一個生成器函數:

def square_all(numbers): for n in numbers: yield n**2

這個生成器函數等價於我們上面所做的類,它的工作原理是一樣的。

這種 yield 語句似乎很神奇,但它非常強大:yield 允許我們在調用 next 函數之間暫停生成器函數。yield 語句是將生成器函數與常規函數分離的東西。

另一種實現相同迭代器的方法是使用生成器表達式。

def square_all(numbers): return (n**2 for n in numbers)

這和我們的生成器函數確實是一樣的,但是它使用的語法看起來像是一個列表推導一樣。如果你需要在代碼中使用惰性迭代,請考慮迭代器,並考慮使用生成器函數或生成器表達式。

迭代器如何改進你的代碼

一旦你已經接受了在代碼中使用惰性迭代器的想法,你就會發現有很多可能來發現或創建輔助函數,以此來幫助你循環遍歷和處理數據。

惰性求和

這是一個 for 循環,它對 Django queryset 中的所有工作時間求和:

hours_worked = 0for event in events: if event.is_billable(): hours_worked += event.duration

下面是使用生成器表達式進行惰性評估的代碼:

billable_times = ( event.duration for event in events if event.is_billable())hours_worked = sum(billable_times)

請注意,我們代碼的形狀發生了巨大變化。

將我們的計算工作時間變成一個惰性迭代器允許我們能夠命名以前未命名(billable_times)的東西。這也允許我們使用 sum 函數,我們以前不能使用 sum 函數是因為我們甚至沒有一個可迭代對象傳遞給它。迭代器允許你從根本上改變你組織代碼的方式。

惰性和打破循環

這段代碼列印出日誌文件的前 10 行:

for i, line in enumerate(log_file): if i >= 10: break print(line)

這段代碼做了同樣的事情,但是我們使用的是 itertools.islice 函數來惰性地抓取文件中的前 10 行:

from itertools import islicefirst_ten_lines = islice(log_file, 10)for line in first_ten_lines: print(line)

我們定義的 first_ten_lines 變數是迭代器,同樣,使用迭代器允許我們給以前未命名的東西命名(first_ten_lines)。命名事物可以使我們的代碼更具描述性,更具可讀性。

作為獎勵,我們還消除了在循環中使用 break 語句的需要,因為 islice 實用函數為我們處理了中斷。

你可以在標準庫中的 itertools 中找到更多的迭代輔助函數,以及諸如 boltons 和 more-itertools 之類的第三方庫。

創建自己的迭代輔助函數

你可以在標準庫和第三方庫中找到用於循環的輔助函數,但你也可以自己創建!

這段代碼列出了序列中連續值之間的差值列表。

current = readings[0]for next_item in readings[1:]: differences.append(next_item - current) current = next_item

請注意,這段代碼中有一個額外的變數,我們每次循環時都要指定它。還要注意,這段代碼只適用於我們可以切片的東西,比如序列。如果 readings 是一個生成器,一個 zip 對象或其他任何類型的迭代器,那麼這段代碼就會失敗。

讓我們編寫一個輔助函數來修復代碼。

這是一個生成器函數,它為給定的迭代中的每個項目提供了當前項和下一項:

def with_next(iterable): """Yield (current, next_item) tuples for each item in iterable.""" iterator = iter(iterable) current = next(iterator) for next_item in iterator: yield current, next_item current = next_item

我們從可迭代對象中手動獲取一個迭代器,在它上面調用 next 來獲取第一項,然後循環遍歷迭代器獲取後續所有的項目,跟蹤後一個項目。這個函數不僅適用於序列,而且適用於任何類型迭代。

這段代碼和以前代碼是一樣的,但是我們使用的是輔助函數而不是手動跟蹤 next_item

differences = []for current, next_item in with_next(readings): differences.append(next_item - current)

請注意,這段代碼不會掛在我們循環周圍的 next_item 上,with_next 生成器函數處理跟蹤 next_item的工作。

還要注意,這段代碼已足夠緊湊,如果我們願意,我們甚至可以將方法複製到列表推導中來。

differences = [ (next_item - current) for current, next_item in with_next(readings)]

再次回顧循環問題

現在我們準備回到之前看到的那些奇怪的例子並試著找出到底發生了什麼。

問題 1:耗盡的迭代器

這裡我們有一個生成器對象 squares

>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers)

如果我們把這個生成器傳遞給 tuple 構造函數,我們將會得到它的一個元組:

>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers)>>> tuple(squares)(1, 4, 9, 25, 49)

如果我們試著計算這個生成器中數字的和,使用 sum,我們就會得到 0

>>> sum(squares)0

這個生成器現在是空的:我們已經把它耗盡了。如果我們試著再次創建一個元組,我們會得到一個空元組:

>>> tuple(squares)()

生成器是迭代器,迭代器是一次性的。它們就像 Hello Kitty Pez 分配器那樣不能重新載入。

問題 2:部分消耗一個迭代器

再次使用那個生成器對象 squares

>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers)

如果我們詢問 9 是否在 squares 生成器中,我們會得到 True

>>> 9 in squaresTrue

但是我們再次詢問相同的問題,我們會得到 False

>>> 9 in squaresFalse

當我們詢問 9 是否在迭代器中時,Python 必須對這個生成器進行循環遍歷來找到 9。如果我們在檢查了 9 之後繼續循環遍歷,我們只會得到最後兩個數字,因為我們已經在找到 9 之前消耗了這些數字:

>>> numbers = [1, 2, 3, 5, 7]>>> squares = (n**2 for n in numbers)>>> 9 in squaresTrue>>> list(squares)[25, 49]

詢問迭代器中是否包含某些東西將會部分地消耗迭代器。如果沒有循環遍歷迭代器,那麼是沒有辦法知道某個東西是否在迭代器中。

問題 3:拆包是迭代

當你在字典上循環時,你會得到鍵:

>>> counts = {apples: 2, oranges: 1}>>> for key in counts:... print(key)...applesoranges

當你對一個字典進行拆包時,你也會得到鍵:

>>> x, y = counts>>> x, y(apples, oranges)

循環依賴於迭代器協議,可迭代對象拆包也依賴於有迭代器協議。拆包一個字典與在字典上循環遍歷是一樣的,兩者都使用迭代器協議,所以在這兩種情況下都得到相同的結果。

回顧

序列是迭代器,但是不是所有的迭代器都是序列。當有人說「迭代器」這個詞時,你只能假設他們的意思是「你可以迭代的東西」。不要假設迭代器可以被循環遍歷兩次、詢問它們的長度或者索引。

迭代器是 Python 中最基本的可迭代形式。如果你想在代碼中做一個惰性迭代,請考慮迭代器,並考慮使用生成器函數或生成器表達式。

最後,請記住,Python 中的每一種迭代都依賴於迭代器協議,因此理解迭代器協議是理解 Python 中的循環的關鍵。

這裡有一些我推薦的相關文章和視頻:

  • Loop Like a Native, Ned Batchelder 在 PyCon 2013 的講演
  • Loop Better ,這篇文章是基於這個講演的
  • The Iterator Protocol: How For Loops Work,我寫的關於迭代器協議的短文
  • Comprehensible Comprehensions,關於推導和迭代器表達器的講演
  • Python: Range is Not an Iterator,我關於範圍和迭代器的文章
  • Looping Like a Pro in Python,DB 的 PyCon 2017 講演

本文是基於作者去年在 DjangoCon AU、 PyGotham 和 North Bay Python 中發表的 Loop Better 演講。有關更多內容,請參加將於 2018 年 5 月 9 日至 17 日在 Columbus, Ohio 舉辦的 PYCON。


via: opensource.com/article/

作者:Trey Hunner 譯者:MjSeven 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出


推薦閱讀:

0基礎學Python之九:循環語句(下)
用ASP.NET MVC5 +SQLSERVER2014搭建多層架構的資料庫管理系統
想要孩子認真學編程?可以這樣在家上課
OpenCV零碎點筆記
新手配置webmagic爬蟲開發環境

TAG:Python | 編程 | 編程語言 |