PEP 234 – 迭代器
- 作者:
- Ka-Ping Yee <ping at zesty.ca>, Guido van Rossum <guido at python.org>
- 狀態:
- 最終 (Final)
- 類型:
- 標準軌跡 (Standards Track)
- 建立日期:
- 2001年1月30日
- Python 版本:
- 2.1
- 公告歷史:
- 2001年4月30日
摘要
本文提出了一種迭代介面,物件可以提供該介面來控制 for 迴圈的行為。透過提供一個能產生迭代器物件的方法,即可自定義迴圈。該迭代器提供了一個「獲取下一個值」的操作,每次呼叫時都會產生序列中的下一個項目,並在沒有更多項目可用時引發例外。
此外,文中還提出了針對字典鍵值以及檔案行數的特定迭代器,並提議允許將 dict.has_key(key) 寫作 key in dict。
註:本文是由第二作者對此 PEP 進行的近乎完整的重寫,描述了已檢入 Python 2.2 CVS 樹主幹的實際實作。它仍開放討論。原始版本中一些較晦澀的提議暫時被撤回;這些內容未來可能會成為另一個獨立 PEP 的主題。
C API 規範
定義了一個新的例外 StopIteration,可用於表示迭代結束。
在型別物件結構中新增了一個名為 tp_iter 的槽位(slot),用於請求迭代器。這應該是一個接收一個 PyObject * 參數並回傳一個 PyObject * 或 NULL 的函式。為了使用此槽位,新增了一個新的 C API 函式 PyObject_GetIter(),其簽章與 tp_iter 槽位函式相同。
在型別結構中新增了另一個名為 tp_iternext 的槽位,用於獲取迭代中的下一個值。為了使用此槽位,新增了一個新的 C API 函式 PyIter_Next()。槽位與 API 函式的簽章如下,儘管 NULL 回傳條件有所不同:參數為 PyObject *,回傳值亦然。當回傳值非 NULL 時,它是迭代中的下一個值。當其為 NULL 時,對於 tp_iternext 槽位有三種可能性:
- 未設定例外;這意味著迭代結束。
- 設定了
StopIteration例外(或衍生的例外類別);這意味著迭代結束。 - 設定了其他例外;這意味著發生了應該正常傳播的錯誤。
更高級別的 PyIter_Next() 函式會在 StopIteration 例外(或衍生例外)發生時清除它,因此其 NULL 回傳條件較為簡單:
- 未設定例外;這意味著迭代已結束。
- 設定了某個例外;這意味著發生了錯誤,應正常傳播。
以 C 實作的迭代器*不應該*實作具有與 tp_iternext 槽位語義相似的 next() 方法!當型別的字典初始化時(透過 PyType_Ready()),tp_iternext 槽位的存在會導致一個包裝該槽位的 next() 方法被新增到型別的 tp_dict 中。(例外:如果型別不使用 PyObject_GenericGetAttr() 來存取實例屬性,則該型別 tp_dict 中的 next() 方法可能無法被看到。)(由於本 PEP 原始文字的誤解,在 Python 2.2 中,所有迭代器型別都實作了一個被包裝器覆寫的 next() 方法;這已在 Python 2.3 中修復。)
為了確保二進位向後相容性,在 tp_flags 欄位的一組旗標中以及預設旗標巨集中新增了一個名為 Py_TPFLAGS_HAVE_ITER 的新旗標。在存取 tp_iter 或 tp_iternext 槽位之前,必須測試此旗標。巨集 PyIter_Check() 會測試物件是否設定了適當的旗標,且具有非 NULL 的 tp_iternext 槽位。tp_iter 槽位沒有類似的巨集(因為唯一引用此槽位的地方應該是 PyObject_GetIter(),它可以直接檢查 Py_TPFLAGS_HAVE_ITER 旗標)。
(註:tp_iter 槽位可以存在於任何物件上;tp_iternext 槽位僅應存在於作為迭代器的物件上。)
為了向後相容,PyObject_GetIter() 函式在參數為不實作 tp_iter 函式的序列時會實作後備語義:在這種情況下會建構一個輕量級序列迭代器物件,該物件會按自然順序迭代序列中的項目。
為 for 迴圈產生的 Python 位元組碼已更改為使用新的運算碼 GET_ITER 和 FOR_ITER,這些運算碼使用迭代器協定而不是序列協定來獲取迴圈變數的下一個值。這使得可以使用 for 迴圈來遍歷支援 tp_iter 槽位的非序列物件。直譯器遍歷序列值的其他地方也應更改為使用迭代器。
迭代器應實作回傳自身引用的 tp_iter 槽位;這對於能夠在 for 迴圈中使用迭代器(相對於序列)是必要的。
迭代器實作(在 C 或 Python 中)應保證一旦迭代器發出耗盡訊號,後續對 tp_iternext 或 next() 方法的呼叫將繼續這樣做。目前未指定迭代器在引發異常(StopIteration 除外)時是否應進入耗盡狀態。請注意,Python 無法保證使用者定義或第三方迭代器能正確實作此要求。
Python API 規範
StopIteration 例外被設為可見的標準例外之一,它衍生自 Exception。
定義了一個新的內建函式 iter(),可以用兩種方式呼叫:
iter(obj)呼叫PyObject_GetIter(obj)。iter(callable, sentinel)回傳一種特殊的迭代器,該迭代器呼叫 callable 來產生一個新值,並將回傳值與 sentinel 值進行比較。如果回傳值等於 sentinel,則表示迭代結束並引發StopIteration,而不是正常回傳;如果回傳值不等於 sentinel,則將其作為迭代器的下一個值回傳。如果 callable 引發了例外,則會正常傳播;特別是,允許函式引發StopIteration作為結束迭代的替代方式。(此功能可從 C API 作為PyCallIter_New(callable, sentinel)使用。)
由任一種 iter() 形式回傳的迭代器物件都有一個 next() 方法。此方法要麼回傳迭代中的下一個值,要麼引發 StopIteration(或衍生的例外類別)來表示迭代結束。任何其他例外都應被視為錯誤並應正常傳播,而不應被視為迭代結束。
類別可以透過定義 __iter__() 方法來定義如何進行迭代;該方法不應接受額外參數並回傳一個有效的迭代器物件。想要成為迭代器的類別應實作兩個方法:一個如上述行為的 next() 方法,以及一個回傳 self 的 __iter__() 方法。
這兩個方法對應於兩個不同的協定:
- 如果物件實作了
__iter__()或__getitem__(),則可以用for進行迭代。 - 如果物件實作了
next(),則可以作為迭代器運作。
容器類物件通常支援協定 1。目前要求迭代器必須同時支援這兩個協定。迭代的語義僅來自協定 2;協定 1 的存在是為了讓迭代器表現得像序列;特別是為了讓接收迭代器的程式碼可以使用針對該迭代器的 for 迴圈。
字典迭代器
- 字典實作了一個
sq_contains槽位,其執行與has_key()方法相同的測試。這意味著我們可以寫:if k in dict: ...
這等同於:
if dict.has_key(k): ...
- 字典實作了一個
tp_iter槽位,回傳一個高效的迭代器,該迭代器遍歷字典的鍵。在這種迭代過程中,字典不應被修改,僅允許設定現有鍵的值(不允許刪除或新增,update()方法亦不允許)。這意味著我們可以寫:for k in dict: ...
這等同於以下內容,但速度快得多:
for k in dict.keys(): ...
只要不違反對字典修改的限制(無論是由迴圈還是由另一個執行緒進行)。
- 為字典新增顯式回傳不同類型迭代器的方法:
for key in dict.iterkeys(): ... for value in dict.itervalues(): ... for key, value in dict.iteritems(): ...
這意味著
for x in dict是for x in dict.iterkeys()的簡寫。
其他映射型別(如果支援迭代器的話)也應遍歷鍵。然而,這不應被視為一條絕對規則;特定應用程式可能有不同的要求。
檔案迭代器
以下提案很有用,因為它為我們提供了一個很好的答案,解決了關於遍歷檔案行數的慣用語既醜陋又緩慢的抱怨。
- 檔案實作了一個
tp_iter槽位,等同於iter(f.readline, "")。這意味著我們可以寫:for line in file: ...
作為以下內容的簡寫:
for line in iter(file.readline, ""): ...
這等同於以下內容,但比其更快:
while 1: line = file.readline() if not line: break ...
這也表明某些迭代器具有破壞性:它們消耗了所有值,並且不容易建立另一個可以獨立遍歷相同值的迭代器。你可以第二次開啟該檔案,或 seek() 到開頭,但這些解決方案並不適用於所有檔案類型,例如,當開啟的檔案物件實際上代表管道或串流 Socket 時,它們就無法運作。
因為檔案迭代器使用內部緩衝區,將其與其他檔案操作(例如 file.readline())混用不會得到正確結果。此外,以下程式碼:
for line in file:
if line == "\n":
break
for line in file:
print line,
無法如你所預期般運作,因為第二個 for 迴圈建立的迭代器沒有考慮到第一個 for 迴圈所預讀的緩衝區。編寫此程式碼的正確方式是:
it = iter(file)
for line in it:
if line == "\n":
break
for line in it:
print line,
(這些限制的理由是,for line in file 應該成為遍歷檔案行的建議標準方式,並且這應該儘可能地快。由於迭代器中的內部緩衝區,迭代器版本比呼叫 readline() 快得多。)
原理
如果包含提案的所有部分,這將以一致且靈活的方式解決許多擔憂。其主要優點有以下四點——不,五點——不,六點:
- 它提供了一個可擴展的迭代器介面。
- 它允許對列表迭代進行效能增強。
- 它允許對字典迭代進行巨大的效能增強。
- 它允許僅提供迭代介面,而不必假裝提供對元素的隨機存取。
- 它與所有現有的模擬序列和映射的使用者定義類別和擴充物件向後相容,即使是那些僅實作了 {
__getitem__,keys,values,items} 子集的映射也是如此。 - 它使得遍歷非序列集合的程式碼更簡潔且易讀。
已解決的問題
以下主題已透過共識或 BDFL(終身仁慈獨裁者)宣告決定。
- 有人提議但否決了兩種
next()的替代拼寫:__next__(),因為它對應於一個型別物件槽位(tp_iternext);以及__call__(),因為這是唯一的操作。反對
__next__()的論點:雖然許多迭代器用於 for 迴圈,但預期使用者程式碼也會直接呼叫next(),因此必須寫成__next__()很醜陋;此外,協定的一個可能的擴充是允許prev()、current()和reset()操作;我們當然不想使用__prev__()、__current__()、__reset__()。反對
__call__()(原始提案)的論點:脫離上下文後,x()不太易讀,而x.next()很清楚;存在一種危險,即每個特殊用途的物件都想對其最常見的操作使用__call__(),導致混淆大於清晰。(回想起來,選擇
__next__()並有一個新的內建函式next(it)(呼叫it.__next__())可能會更好。但遺憾的是,太遲了;這已自 2001 年 12 月起在 Python 2.2 中部署。) - 有些人要求能夠重啟迭代器。這應該透過重複對序列呼叫
iter()來處理,而不是透過迭代器協定本身。(參見下方的請求擴充。) - 有人質疑使用例外來表示迭代結束是否成本過高。已經有人提出了
StopIteration例外的幾種替代方案:一個特殊值End來表示結束,一個end()函式來測試迭代器是否完成,甚至重複使用IndexError例外。- 特殊值的問題在於,如果序列中曾經包含該特殊值,那麼對該序列的迴圈將在沒有任何警告的情況下提前結束。如果 null 終止的 C 字串經驗沒能教會我們這可能導致的問題,試想一個 Python 內省工具在遍歷所有內建名稱列表時,若假設特殊的
End值正好是一個內建名稱,那會有多大的麻煩! - 呼叫
end()函式每次迭代需要兩次呼叫。兩次呼叫比一次呼叫加上測試例外要昂貴得多。特別是時間關鍵的 for 迴圈可以非常廉價地測試例外。 - 重複使用
IndexError可能會導致混淆,因為它可能是一個真正的錯誤,如果提前結束迴圈,錯誤將被遮蔽。
- 特殊值的問題在於,如果序列中曾經包含該特殊值,那麼對該序列的迴圈將在沒有任何警告的情況下提前結束。如果 null 終止的 C 字串經驗沒能教會我們這可能導致的問題,試想一個 Python 內省工具在遍歷所有內建名稱列表時,若假設特殊的
- 有些人要求標準迭代器型別。大概所有迭代器都必須從該型別衍生。但這不是 Python 的方式:字典之所以是映射,是因為它們支援
__getitem__()和少數其他操作,而不是因為它們從抽象映射型別衍生。 - 關於
if key in dict:毫無疑問,dict.has_key(x)對x in dict的解釋是迄今為止最有用的解釋,可能也是唯一有用的解釋。對此一直存在阻力,因為x in list檢查 *x* 是否存在於值中,而該提案使得x in dict檢查 *x* 是否存在於鍵中。考慮到列表和字典之間的對稱性非常弱,這個論點並沒有多大分量。 - 名稱
iter()是一個縮寫。提議的替代方案包括iterate()、traverse(),但這些看起來太長了。Python 有對常見內建函式使用縮寫的歷史,例如repr()、str()、len()。決議:就定為
iter()。 - 對兩種不同的操作(從物件取得迭代器,以及為帶有哨兵值的函式建立迭代器)使用相同的名稱有點醜陋。但我還沒看到第二種操作更好的名稱,而且因為它們都回傳迭代器,所以很容易記住。
決議:內建函式
iter()接受一個可選參數,即要尋找的哨兵值。 - 一旦特定的迭代器物件引發了
StopIteration,它是否也會在所有後續的next()呼叫中引發StopIteration?有人說要求這樣做是有用的,其他人則說將此留給個別迭代器處理是有用的。請注意,這對於某些迭代器實作(例如函數包裝迭代器)可能需要一個額外的狀態位元。決議:一旦引發
StopIteration,呼叫it.next()將繼續引發StopIteration。註:這實際上並未在 Python 2.2 中實作;有許多情況下迭代器的
next()方法會在一次呼叫中引發StopIteration,但在下一次呼叫中則不會。這已在 Python 2.3 中得到補救。 - 有人提議檔案物件應該成為它自己的迭代器,並有一個回傳下一行的
next()方法。這有一定的優點,並且更清楚地表明此迭代器具有破壞性。缺點是這會使得實作上一點中提議的「黏性 StopIteration」特性變得更加痛苦。決議:暫時拒絕(儘管仍有人為此辯論)。
- 有些人要求擴充迭代器協定,例如
prev()以取得前一個項目,current()以再次取得當前項目,finished()以測試迭代器是否完成,甚至可能還有其他,如rewind()、__len__()、position()。雖然其中一些很有用,但如果不增加任意緩衝,許多無法輕鬆地為所有迭代器類型實作,有時根本無法實作(或不合理)。例如,任何與反轉方向有關的操作在遍歷檔案或函式時都無法完成。也許可以草擬另一個 PEP,以便在這些操作可實作時標準化其名稱。
決議:拒絕。
- 關於以下內容有一場長時間的討論:
for x in dict: ...
應該將字典的連續鍵、值或項目指派給 *x*。在
if x in y和for x in y之間的對稱性表明它應該遍歷鍵。這種對稱性已被許多人獨立觀察到,甚至被用來「解釋」其中一個。這是因為對於序列,if x in y會遍歷 *y*,並將迭代值與 *x* 進行比較。如果我們採用上述兩個提議,這對於字典也將成立。反對使
for x in dict遍歷鍵的論點主要來自實用主義觀點:標準函式的掃描顯示,使用for x in dict.items()的次數與for x in dict.keys()大致相當,其中items()版本佔微弱多數。大概許多使用keys()的迴圈無論如何都會透過寫dict[x]來使用相應的值,所以(論點認為)透過使鍵和值都可用,我們可以支援最多的情況。雖然這是事實,但我(Guido)發現for x in dict和if x in dict之間的對應關係太有說服力了,無法打破,而且必須寫dict[x]來顯式取得值並沒有太大的開銷。若要快速迭代項目,請使用
for key, value in dict.iteritems()。我測量過以下兩者的區別:for key in dict: dict[key]
以及
for key, value in dict.iteritems(): pass
發現後者僅快了約 7%。
決議:根據 BDFL 宣告,
for x in dict遍歷鍵,並且字典擁有iteritems()、iterkeys()和itervalues()來回傳不同風格的字典迭代器。
郵件論壇
迭代器協定已在 SourceForge 的郵件論壇上進行了廣泛討論:
最初,部分討論是在 Yahoo 上進行的;存檔仍然可以存取:
版權
本文屬於公有領域。
來源:https://github.com/python/peps/blob/main/peps/pep-0234.rst