python迭代器 想說懂你不容易

金色旭光發表於2021-11-23

關於python中迭代器,生成器介紹的文章不算少數,有些寫的也很透徹,但是更多的是碎片化的內容。本來可迭代物件、迭代器、生成器概念就很繞,又加上過於碎片的內容,更讓人摸不著頭腦。本篇嘗試用系統的介紹三者的概念和關係,希望能夠幫助需要的人。

可迭代物件、迭代器

概念簡介

迭代
首先看迭代的字面意思:

迭代的意思就是:迭代是一種行為,反覆執行的動作。在python中可以理解為反覆取值的動作。

可迭代物件:顧名思義就是可以從裡面迭代取值的物件,在python中容器類的資料結構都是可迭代物件,如列表,字典,集合,元組等。

迭代器:類似於從可迭代物件中取值的一種工具,嚴謹的說可以將可迭代物件中的值取出的物件。

可迭代物件

在python中,容器型別的資料結構都是可迭代物件,列舉如下:

  1. 列表
  2. 字典
  3. 元組
  4. 集合
  5. 字串

西遊記第一天團人物列表:

>>> arr = ['聖僧','大聖','天蓬','捲簾']
>>> for i in arr:
...     print(i)
... 
聖僧
大聖
天蓬
捲簾
>>> 

除了python自帶的資料結構是可迭代物件之外,模組裡的方法、自定義的類也可能是可迭代物件。那麼如何確認一個物件是否為可迭代物件呢?有一個標準,那就是可迭代物件都有方法__iter__,凡是具有該方法的物件都是可迭代物件。

>>> dir(arr)
['__add__', '__class__', '__contains__', '__delattr__', '__delitem__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getitem__', '__gt__', '__hash__', '__iadd__', '__imul__', '__init__', '__init_subclass__', '__iter__', '__le__', '__len__', '__lt__', '__mul__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__reversed__', '__rmul__', '__setattr__', '__setitem__', '__sizeof__', '__str__', '__subclasshook__', 'append', 'clear', 'copy', 'count', 'extend', 'index', 'insert', 'pop', 'remove', 'reverse', 'sort']

迭代器

常見迭代器是從可迭代物件建立而來。呼叫可迭代物件的__iter__方法就可以為該可迭代物件建立其專屬迭代器。使用iter()方法也可以建立迭代器,iter()本質上就是呼叫可迭代物件的__iter__方法。

>>> arr = ['聖僧','大聖','天蓬','捲簾']
>>> arr_iter = iter(arr)
>>> 
>>> for i in arr_iter:
...     print(i)
... 
聖僧
大聖
天蓬
捲簾
>>> 
>>>
>>> arr_iter = iter(arr)
>>> next(arr_iter)
'聖僧'
>>> next(arr_iter)
'大聖'
>>> next(arr_iter)
'天蓬'
>>> next(arr_iter)
'捲簾'
>>> next(arr_iter)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

可迭代物件只能通過for迴圈來遍歷,而迭代器除了可以通過for迴圈來遍歷,重要的是還可以通過next()方法來迭代出元素。呼叫一次迭代出一個元素,直到所有元素都迭代完,丟擲StopIteration錯誤。這個過程就像象棋中沒有過河的小卒子——只能前進不能後退,並且迭代完所有元素也無法再次遍歷。
簡單總結迭代器的特徵:

  1. 可以使用next()方法迭代取值
  2. 迭代的過程只能向前不能後退
  3. 迭代器是一次性的,迭代完所有元素就無法再次遍歷,需要再次遍歷只有新建迭代器

迭代器物件在python中很常見,比如開啟的檔案就是一個迭代器、map,filter,reduce等高階函式的返回也是迭代器。迭代器物件擁有兩個方法:__iter____next__next()方法能迭代出元素就是呼叫__next__來實現的。

>>> dir(arr_iter)
['__class__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__length_hint__', '__lt__', '__ne__', '__new__', '__next__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__setstate__', '__sizeof__', '__str__', '__subclasshook__']

區分可迭代物件和迭代器

如何區分可迭代物件和迭代器呢?在python的資料型別增強模組collections中有可迭代物件和迭代器資料型別,通過isinstance型別比較即可區分出兩者。

>>> from collections import Iterable, Iterator
>>> arr = [1,2,3,4]
>>> isinstance(arr, Iterable)
True
>>> isinstance(arr, Iterator)
False
>>> 
>>> 
>>> arr_iter = iter(arr)
>>> isinstance(arr_iter, Iterable)
True
>>> isinstance(arr_iter, Iterator)
True
>>> 

arr:可迭代物件。是可迭代物件型別,不是迭代器型別
arr_iter:迭代器。既是可迭代物件型別,又是迭代器型別

可迭代物件和迭代器的關係

從迭代器的建立就能大致看出。可迭代物件就是一個集合,而迭代器就是為這個集合建立的迭代方法。迭代器迭代時是直接從可迭代物件集合裡取值。可以用如下模型來理解兩者之間的關係:

>>> arr = [1,2,3,4]
>>> iter_arr = iter(arr) 
>>> 
>>> arr.append(100)
>>> arr.append(200)
>>> arr.append(300)
>>> 
>>> for i in iter_arr:
...     print(i)
... 
1
2
3
4
100
200
300
>>>  

可以看到這裡的流程是:

  • 先建立可迭代物件arr
  • 然後從arr建立的arr_iter迭代器
  • 再向arr列表追加元素
  • 最後迭代出來的元素包括後追加的元素。

可以說明迭代器並不是copy了可迭代物件的元素,而是引用了可迭代物件的元素。在迭代取值時直接使用了可迭代物件的元素。

可迭代物件和迭代器的工作機制

首先整理一下兩者的方法
可迭代物件: 物件中有__iter__ 方法
迭代器:物件中有__iter____next__方法
在迭代器的建立時提到過__iter__方法是返回一個迭代器,__next__是從元素中取值。所以,關於兩者方法的功能:
可迭代物件
__iter__方法的作用是返回一個迭代器

迭代器
__iter__方法的作用是返回一個迭代器,就是自己。
__next__方法的作用是返回集合中下一個元素

可迭代物件是一個元素集合,本身沒有自帶取值的方法,可迭代物件就像老話說的茶壺裡的餃子,有貨倒不出。

>>> arr = [1,2,3,4]
>>> 
>>> next(arr)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'list' object is not an iterator

既然餃子倒不出來,又想吃怎麼辦?那就得找筷子一樣的工具來夾出來對吧。而迭代器就是給用來給可迭代物件取值的工具。
給可迭代物件arr建立的迭代器arr_iter,可以通過next取值,將arr中值全部迭代出來,直到沒有元素丟擲異常StopIteration

>>> arr_iter = iter(arr)
>>> 
>>> next(arr_iter)
1
>>> next(arr_iter)
2
>>> next(arr_iter)
3
>>> next(arr_iter)
4
>>> next(arr_iter)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>

for 迴圈本質

>>> arr = [1,2,3]
>>> for i in arr:
...     print(i)
... 
1
2
3

以上通過for迴圈遍歷出arr中所有值。我們知道列表arr是可迭代物件,本身無法取值,for迴圈如何迭代出所有元素呢?
for迴圈的本質就是給arr建立一個迭代器,然後不斷呼叫next()方法取出元素,複製給變數i,直到沒有元素丟擲捕獲StopIteration的異常,退出迴圈。可以通過模擬for迴圈更直觀的說明:

arr = [1,2,3]

# 給arr生成一個迭代器
arr_iter = iter(arr)

while True:
    try:
        # 不斷呼叫迭代器next方法,並捕獲異常,然後退出
        print(next(arr_iter))
    except StopIteration:
        break
>>
1
2
3

到這裡大概就講完了可迭代物件和迭代器的工作機制,簡單總結:

可迭代物件: 儲存元素,但自身無法取值。可以呼叫自己的__iter__方法建立一個專屬迭代器來取值。
迭代器:擁有__next__方法,可以從指向的可迭代物件中取值。只能遍歷一遍,並且只能前進不能後退。

自己動手建立可迭代物件和迭代器

榴蓮好不好吃,只有嘗一嘗才知道。迭代器好不好理解,動手實現一次就清楚。下面自定義可迭代物件和迭代器。

如果自定義一個可迭代物件,那麼需要實現__iter__方法;
如果要自定義一個迭代器,那麼就需要實現__iter____next__方法。

可迭代物件:實現__iter__方法,功能是呼叫該方法返回迭代器
迭代器:實現__iter__,功能是返回迭代器,也就是自身;實現__next__,功能是迭代取值直到丟擲異常。

from collections import Iterable, Iterator

# 可迭代物件
class MyArr():

    def __init__(self):
        self.elements = [1,2,3]
    
    # 返回一個迭代器,並將自己元素的引用傳遞給迭代器
    def __iter__(self):
        return MyArrIterator(self.elements)


# 迭代器
class MyArrIterator():

    def __init__(self, elements):
        self.index = 0
        self.elements = elements
    
    # 返回self,self就是例項化的物件,也就是呼叫者自己。
    def __iter__(self):
        return self
    
    # 實現取值
    def __next__(self):
        # 迭代完所有元素丟擲異常
        if self.index >= len(self.elements):
            raise StopIteration
        value = self.elements[self.index]
        self.index += 1
        return value


arr = MyArr()
print(f'arr 是可迭代物件:{isinstance(arr, Iterable)}')
print(f'arr 是迭代器:{isinstance(arr, Iterator)}')

# 返回了迭代器
arr_iter = arr.__iter__()
print(f'arr_iter 是可迭代物件:{isinstance(arr_iter, Iterable)}')
print(f'arr_iter 是迭代器:{isinstance(arr_iter, Iterator)}')

print(next(arr_iter))
print(next(arr_iter))
print(next(arr_iter))
print(next(arr_iter))

結果:

arr 是可迭代物件:True
arr 是迭代器:False

arr_iter 是可迭代物件:True
arr_iter 是迭代器:True

1
2
3
Traceback (most recent call last):
  File "myarr.py", line 40, in <module>
    print(next(arr_iter))
  File "myarr.py", line 23, in __next__
    raise StopIteration
StopIteration

從這個列子就能清晰的認識可迭代物件的迭代器的實現。可迭代物件的__iter__方法返回值就是一個例項化的迭代器的物件。這個迭代器的物件儲存了可迭代物件的元素的引用,也實現了取值的方法,所以可以通過next()方法取值。這是一個值得細品的程式碼,比如說有幾個問題可以留給讀者思考:

  1. 為什麼next()只能前進不能後退
  2. 為什麼迭代器只能遍歷一次就失效
  3. 如果for迴圈的目標是迭代器,工作機制是怎樣

迭代器的優勢

設計模式之迭代模式

迭代器的優勢是:提供了一種通用不依賴索引的迭代取值方式

迭代器的設計來源於設計模式之迭代模式。迭代模式的思想是:提供一種方法順序地訪問一個容器中的元素,而又不需要暴露該物件的內部細節。

迭代模式具體到python的迭代器中就是能夠將遍歷序列的操作和序列底層相分離,提供一種通用的方法去遍歷元素。
如列表、字典、集合、元組、字串。這些資料結構的底層資料模型都不一樣,但是同樣都可以使用for迴圈來遍歷。正是因為每一種資料結構都可以生成迭代器,都可以通過next()方法迭代,所以在使用的時候不需要關心元素的在底層如何儲存,不需要考慮內部細節。

同樣如果是自定的資料型別,即使是內部實現比較複雜,只需要實現迭代器,也就不需要關心複雜的結構,使用通用的next方法即可遍歷元素。

複雜資料結構的通用取值實現

比如我們構造一個複雜的資料結構:{(x,x):value},一個字典,key是元組,value是數字。按照迭代的設計模式,實現通用取值方法。

例子實現

class MyArrIterator():

    def __init__(self):
        self.index = 1
        self.elements = {(1,1):100, (2,2):200, (3,3):300}
    
    def __iter__(self):
        return self

    def __next__(self):
        if self.index > len(self.elements):
            raise StopIteration
        value = self.elements[(self.index, self.index)]
        self.index += 1
        return value

arr_iter = MyArrIterator()

print(next(arr_iter))
print(next(arr_iter))
print(next(arr_iter))
print(next(arr_iter))
100
200
300
Traceback (most recent call last):
  File "iter_two.py", line 22, in <module>
    print(next(arr_iter))
  File "iter_two.py", line 12, in __next__
    raise StopIteration
StopIteration

只要實現了__next__方法就可以通過next()取值,不管資料結構多麼複雜,__next__遮蔽了底層細節。這種設計思想是一個比較常見的思想,比如驅動設計,第三方平臺介入設計等都是遮蔽差異,提供一個統一的呼叫方法。

迭代器的缺點和誤區

缺點

在上面的介紹中也提到了迭代器的缺點,集中說一下:

  1. 取值不夠靈活。next方法只能往後取值,不能往前。取值不如按照索引的方式靈活,不能取指定的某一個值
  2. 無法預測迭代器的長度。迭代器通過next()方法取值,並不能提前知道要迭代出的個數
  3. 用完一次就失效

誤區

迭代器的優勢和缺點已經說的清晰了,現在討論一個普遍對迭代器的一個誤區:迭代器是不能節省記憶體的
給這句話加一個前提:這裡的迭代器是指普通的迭代器,而非生成器,因為生成器也是一種特殊的迭代器。
這可能是一個認識的誤區,認為建立一個功能相同的可迭代物件和迭代器,迭代器的記憶體佔用小於可迭代物件。例如:

>>> arr = [1,2,3,4]
>>> arr_iter = iter([1,2,3,4])
>>> 
>>> arr.__sizeof__()
72
>>> arr_iter.__sizeof__()
32

咋一看確實是迭代器佔用的記憶體小於可迭代物件,可仔細想一下迭代器的實現,它是引用了可迭代物件的元素,也就是說建立迭代器arr_iter同時也建立了一個列表[1,2,3,4],迭代器只是儲存了列表的引用,所以迭代器的arr_iter實際的記憶體是[1,2,3,4] + 32= 72 + 32 = 104位元組。
arr_iter本質上是一個類的物件,因為python變數是儲存地址的特性,所以物件的的地址大小都是32位元組。

後面有專門關於迭代器和生成器佔用記憶體的分析,能夠用數字來證明這個觀點。

python自帶的迭代器工具itertools

迭代器在python佔有重要的位置,所以python內建了迭代器功能模組itertools。itertools中所有的方法都是迭代器,可以使用next()取值。方法主要可以分為三類,分別是無限迭代器,有限迭代器,組合迭代器
無限迭代器
count():建立一個無限的迭代器,類似於無限長度的列表,可以從中取值
有限迭代器
chain():可以把多個可迭代物件組合起來,形成一個更大的迭代器
組合迭代器
product():得到的是可迭代物件的笛卡兒積

關於更多itertools的使用可參考:https://zhuanlan.zhihu.com/p/51003123

生成器

生成器是一種特殊的迭代器,它既具有迭代器的功能:能夠通過next方法迭代出元素,又有自己的特殊之處:節省記憶體。

生成器的建立方法

生成器有兩種建立方法,分別是:

  1. ()語法,將列表生成式的[]換成()就可以建立生成器
  2. 使用yield關鍵字將普通函式變成生成器函式

()語法

>>> gen = (i for i in range(3))
>>> type(gen)
<class 'generator'>
>>> from collections import Iterable,Iterator
>>> 
>>> isinstance(gen, Iterable)
True
>>> isinstance(gen, Iterator)
True
>>> 
>>> next(gen)
0
>>> next(gen)
1
>>> next(gen)
2
>>> next(gen)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>> 

可以看到生成器符合迭代器的特徵。

yield 關鍵字

yield是python的關鍵字,在函式中使用yield就能將普通函式變成生成器。函式中return是返回標識,程式碼執行到return就退出了。而程式碼執行到yield時也會返回yield後面的變數,但是程式只是暫停在當前位置,當再次執行程式時會從yield之後的部分開始執行。

from collections import Iterator,Iterable

def fun():
    a = 1
    yield a
    b = 100
    yield b


gen_fun = fun()

print(f'是可迭代物件:{isinstance(gen_fun, Iterable)}')
print(f'是迭代器:{isinstance(gen_fun, Iterator)}')

print(next(gen_fun))
print(next(gen_fun))
print(next(gen_fun))
是可迭代物件:True
是迭代器:True
1
100
Traceback (most recent call last):
  File "gen_fun.py", line 17, in <module>
    print(next(gen_fun))
StopIteration

執行第一個next()時,程式通過yield a返回了1,執行流程就暫停在這裡。
執行第二個next()時,程式從上次暫停的地方開始執行,然後通過yield b返回了100,最後退出,程式結束。
yield的魔力就是能夠記住執行位置,並且能夠從執行位置再次執行下去。

生成器方法

生成器既然是一種特殊的迭代器,那麼是否具有迭代器物件的兩個方法呢?檢視兩種生成器擁有的方法。
gen

>>> dir(gen)
['__class__', '__del__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__name__', '__ne__', '__new__', '__next__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'gi_code', 'gi_frame', 'gi_running', 'gi_yieldfrom', 'send', 'throw']

gen_fun

['__class__', '__del__', '__delattr__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__iter__', '__le__', '__lt__', '__name__', '__ne__', '__new__', '__next__', '__qualname__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', 'close', 'gi_code', 'gi_frame', 'gi_running', 'gi_yieldfrom', 'send', 'throw']

兩種生成器都有用迭代器的__iter____next__方法。

生成器是特殊的迭代器,想要區分出生成器和迭代器就不能使用collectionsIterator了。可以使用isgenerator方法:

>>> from inspect import isgenerator
>>> arr_gen = (i for i in range(10))
>>> isgenerator(arr_gen)
True
>>> 
>>> arr = [i for i in range(10)]
>>> isgenerator(arr)
False
>>> 

生成器的優勢

生成器是一種特殊的迭代器,它的特殊之處就是它的優勢:節省記憶體。從名字就可以看出,生成器,通過生成的方法來支援迭代取值。

節省記憶體原理:
以遍歷列表為例,列表元素按照某種演算法推算出來,就可以在迴圈的過程中不斷推算出後續的元素,這樣就不必建立完整的列表,從而節省大量的空間。

以實現同樣的的功能為例,迭代出集合中的元素。集合為:[1,2,3,4]

迭代器的做法:

  1. 首先生成一個可迭代物件,列表[1,2,3,4]
  2. 然後建立迭代器,從可迭代物件中通過next()取值
arr = [1,2,3,4]
arr_iter = iter(arr)
next(arr_iter)
next(arr_iter)
next(arr_iter)
next(arr_iter)

生成器的做法:

  1. 建立一個生成器函式
  2. 通過next取值
def fun():
    n = 1
    while n <= 4:
        yield n
        n += 1

gen_fun = fun()
print(next(gen_fun))
print(next(gen_fun))
print(next(gen_fun))
print(next(gen_fun))

比較這兩種方法,迭代器需要建立一個列表來完成迭代,而生成器只需要一個數字就可以完成迭代。在資料量小的情況下還不能體現這個優勢,當資料量巨大時這個優勢能展現的淋漓盡致。比如同樣生成10w個數字,迭代器需要10w個元素的列表,而生成器只需要一個元素。當然就能節省記憶體。

生成器是一種以時間換空間的做法,迭代器是從已經在記憶體中建立好的集合中取值,所以消耗記憶體空間,而生成器只儲存一個值,取一次值就計算一次,消耗cpu但節省記憶體空間。

生成器應用場景

  1. 資料的資料規模巨大,記憶體消耗嚴重
  2. 數列有規律,但是依靠列表推導式描述不出來
  3. 協程。生成器和協程有著千絲萬縷的聯絡

生成器節省記憶體、迭代器不節省記憶體

實踐是檢驗真理的唯一標準,通過記錄記憶體的變化來檢測迭代器和生成器哪個能夠節省記憶體。

環境:
系統:Linux deepin 20.2.1
記憶體:8G
python版本: 3.7.3
記憶體監控工具free -b 以位元組為單位的記憶體展示
方法:生成100萬規模的列表,從0到100w,對比生成資料前後的記憶體變化

可迭代物件

>>> arr = [i for i in range(1000000)]
>>> 
>>> arr.__sizeof__()
8697440
>>> 

第一次free -b在生成列表之前;第二次在生成列表之後。下同

ljk@work:~$ free -b
              total        used        free      shared  buff/cache   available
Mem:     7978999808  1424216064  2386350080   362094592  4168433664  5884121088
Swap:             0           0           0
ljk@work:~$ free -b
              total        used        free      shared  buff/cache   available
Mem:     7978999808  1464410112  2352287744   355803136  4162301952  5850210304
Swap:             0           0           0

現象:記憶體增加:從1424216064位元組增加1464410112字節,增加 38.33 MB

迭代器

>>> a = iter([i for i in range(1000000)])
>>> 
>>> a.__sizeof__()
32
ljk@work:~$ free -b
              total        used        free      shared  buff/cache   available
Mem:     7978999808  1430233088  2385924096   355160064  4162842624  5885038592
Swap:             0           0           0
ljk@work:~$ free -b
              total        used        free      shared  buff/cache   available
Mem:     7978999808  1469304832  2346835968   355160064  4162859008  5845966848
Swap:             0           0           0

現象:記憶體增加:從1430233088位元組增加1469304832節,增加 37.26 MB

生成器

>>> arr = (i for i in range(1000000))
>>> 
>>> arr.__sizeof__()
96
>>> 

ljk@work:~$ free -b
              total        used        free      shared  buff/cache   available
Mem:     7978999808  1433968640  2373222400   362868736  4171808768  5873594368
Swap:             0           0           0
ljk@work:~$ free -b
              total        used        free      shared  buff/cache   available
Mem:     7978999808  1434963968  2378940416   356118528  4165095424  5879349248
Swap:             0           0           0

現象:記憶體增加:從1433968640位元組增加1434963968節,增加 0.9492 MB

小結:

- 系統記憶體 變數記憶體
可迭代物件 38.33MB 8.29MB
迭代器 37.26MB 32k
生成器 0.9492MB 96k

以上結論經過多次實現,基本儲存變數一致。從資料結果來看迭代器不能節省記憶體,生成器可以節省記憶體。生成100w規模的資料,迭代器的記憶體消耗是生成器的40倍左右,結果存在一定誤差。

總結

可迭代物件
屬性:一種容器物件
特點:能夠儲存元素集合,自己無法實現迭代取值,在外界的幫助下可以迭代取值
特徵:有__iter__方法

迭代器
屬性:一種工具物件
特點:可以實現迭代取值,取值的來源是可迭代物件儲存的集合
特徵:有__iter____next__方法
優點:實現通用的迭代方法

生成器
屬性:一種函式物件
特點:可以實現迭代取值,只儲存一個值,通過計算返回迭代的下一個值。以計算換記憶體。
特徵:有__iter____next__方法
優點:擁有迭代器特點同時能夠節省記憶體

關於可迭代物件迭代器生成器的內容講的比較多,不知道讀者是不是已經雲裡霧裡了?出道題檢驗一下:西遊記第一天團的人物名字是以誰的視角來稱呼的?

參考:
https://zhuanlan.zhihu.com/p/71703028
https://blog.csdn.net/mpu_nice/article/details/107299963

相關文章