更深入的理解 Python 中的迭代

Trey Hunner發表於2018-05-26

更深入的理解 Python 中的迭代

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

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

迴圈的問題

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

問題 1:迴圈兩次

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

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

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

但是我們得到了 0

問題 2:包含的檢查

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

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

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

問題 3 :拆包

這個字典有兩個鍵值對:

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

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

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

回顧:Python 的 for 迴圈

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

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

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

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

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

這是 Python 的 for 迴圈的風格:

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

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

定義:可迭代和序列

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

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

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

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

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

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

Python 的 for 迴圈不使用索引

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

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

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

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

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

迭代器驅動 for 迴圈

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

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

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

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

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

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

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

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

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

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

沒有 for 的迴圈

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

我們將通過嘗試將這個 for 迴圈變為 while 迴圈:

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

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

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

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

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

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

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

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

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

許多內建函式依賴於迭代器協議:

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

生成器是迭代器

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

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

這裡的 squares 物件是一個生成器:

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

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

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

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

我欺騙了你

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

生成器是可迭代的

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

因為迭代器也是可迭代的,所以你可以使用內建 next 函式從可迭代物件中獲得迭代器:

請記住,當我們在可迭代物件上呼叫 iter 時,它會給我們返回一個迭代器。

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

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

迷惑了嗎?

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

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

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

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

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

從我們作為 Python 程式設計師的角度來看,你可以使用迭代器來做的唯一有用的事情是將其傳遞給內建的 next 函式,或者對其進行迴圈遍歷:

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

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

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

物件 可迭代? 迭代器?
可迭代物件 V ?
迭代器 V V
生成器 V V
列表 V X

全部的迭代器協議

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

可迭代物件可以被傳遞給 iter 函式,以便為它們獲得迭代器。

迭代器:

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

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

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

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

迭代器的惰性

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

迭代器無處不在

你已經在 Python 中看到過許多迭代器,我也提到過生成器是迭代器。Python 的許多內建型別也是迭代器。例如,Python 的 enumeratereversed 物件就是迭代器。

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

Python 中的檔案物件也是迭代器。

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

建立你自己的迭代器

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

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

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

這裡,我們有一個無限長的可迭代物件 count,你可以看到 square_all 接受 count 而不用完全迴圈遍歷這個無限長的迭代:

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

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

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

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

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

迭代器如何改進你的程式碼

一旦你已經接受了在程式碼中使用惰性迭代器的想法,你就會發現有很多可能來發現或建立輔助函式,以此來幫助你迴圈遍歷和處理資料。

惰性求和

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

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

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

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

惰性和打破迴圈

這段程式碼列印出日誌檔案的前 10 行:

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

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

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

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

建立自己的迭代輔助函式

你可以在標準庫和第三方庫中找到用於迴圈的輔助函式,但你也可以自己建立!

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

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

讓我們編寫一個輔助函式來修復程式碼。

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

我們從可迭代物件中手動獲取一個迭代器,在它上面呼叫 next 來獲取第一項,然後迴圈遍歷迭代器獲取後續所有的專案,跟蹤後一個專案。這個函式不僅適用於序列,而且適用於任何型別迭代。

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

請注意,這段程式碼不會掛在我們迴圈周圍的 next_item 上,with_next 生成器函式處理跟蹤 next_item 的工作。

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

再次回顧迴圈問題

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

問題 1:耗盡的迭代器

這裡我們有一個生成器物件 squares

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

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

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

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

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

再次使用那個生成器物件 squares

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

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

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

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

問題 3:拆包是迭代

當你在字典上迴圈時,你會得到鍵:

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

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

回顧

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

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

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

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


相關文章