DEV Community

codemee
codemee

Posted on • Edited on

for 的奧秘--可走訪 (可迭代, iterable) 物件

tags: Python

學過其他程式語言的人一開始看到 Python 也有 for, 一定很想這樣寫:

>>> for i in 20:
...     print(i)
...
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: 'int' object is not iterable
>>>
Enter fullscreen mode Exit fullscreen mode

不過馬上就會看到 Python 噴出錯誤訊息, 告訴你 20 這個整數 (int) 並不是可走訪的 (iterable, 或稱『可迭代的』) 物件, 那麼到底什麼是 iterable 呢?

可走訪的 (iterable) 物件與走訪器 (iterator)

簡單的來說, 可走訪的物件就是任何抽象上內含多項資料, 並且符合特定的規範, 可以一個一個輪流取出資料的物件, 你可以很直覺地想到串列就是這樣的物件, 因此我們就藉由串列來說明可走訪物件。

首先我們先建立一個串列:

>>> a = [10, 20,30]
Enter fullscreen mode Exit fullscreen mode

剛剛提到可走訪物件必須符合特定的規範, 就是指它必須具有 __iter__() 方法, 這個方法要傳回一種特別的物件, 叫做走訪器 (iterator, 或稱『迭代器』), 實際要一一取出資料靠的就是走訪器:

>>> it = a.__iter__()
Enter fullscreen mode Exit fullscreen mode

走訪器也和可走訪的物件一樣, 有它必須符合的規範, 它必須具有 __next__() 方法, 每次叫用時會從容器中取得下一項資料, 如果已經全部取出, 就要引發 StopIteration 例外。以下就示範透過剛剛傳回的走訪器一一輪流取出串列中的資料:

>>> it.__next__()
10
>>> it.__next__()
20
>>> it.__next__()
30
>>> it.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>
Enter fullscreen mode Exit fullscreen mode

既然串列是可走訪的物件, 當然就可以搭配 for 使用:

>>> for i in a:
...     print(i)
...
10
20
30
>>>
Enter fullscreen mode Exit fullscreen mode

for 會捕捉 StopIteration 例外, 不會在取出所有資料時讓程式意外結束。實際上你也可以用 trywhile 完成一樣的動作:

>>> it = a.__iter__()
>>> try:
...     while True:
...         i = it.__next__()
...         print(i)
... except StopIteration:
...     pass
...
10
20
30
>>>
Enter fullscreen mode Exit fullscreen mode

走訪器之所以要在最後引發例外, 是要讓程式可以分辨是否有取完所有資料, 在 for 敘述裡就可以加上 else 來處理發生例外的狀況:

>>> for i in a:
...     print(i)
... else:
...     print('completed')
...
10
20
30
completed
>>>
Enter fullscreen mode Exit fullscreen mode

只要有完整走訪所有的資料, 沒有因為 break 跳離迴圈, 就會執行 else 部分。相同的功能以 trywhle 實作如下:

>>> it = a.__iter__()
>>> try:
...     while True:
...         i = it.__next__()
...         print(i)
... except StopIteration:
...     print('complted')
...
10
20
30
complted
>>>
Enter fullscreen mode Exit fullscreen mode

range 是抽象的容器

可走訪物件並不一定要是真的容器, 只要抽象上可以一一取出資料即可, 像是 range() 建立的物件就是最常見的例子:

>>> r = range(4)
>>> type(r)
<class 'range'>
Enter fullscreen mode Exit fullscreen mode

range() 因為不符 Python 類別名稱首字母大寫的慣例, 看起來像是叫用函式, 但其實它是建立物件, 你可以看到傳回的物件是 range 類別。range 就是一種抽象的容器, 它實際上並不會真的產生所有的資料, 而是在走訪時才依照規則產生目前項目的資料:

>>> it = r.__iter__()
>>> it.__next__()
0
>>> it.__next__()
1
>>> it.__next__()
2
>>> it.__next__()
3
>>> it.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>
Enter fullscreen mode Exit fullscreen mode

走訪器也必須是可走訪的物件

走訪器除了必須具備 __next__() 以外, 也必須具備 __iter__(), 也就是走訪器自己也要是可走訪的物件, 它的 __iter__() 要傳回自己:

>>> r = range(4)
>>> it = r.__iter__()
>>> it1 = it.__iter__()
>>> it is it1
True
Enter fullscreen mode Exit fullscreen mode

你可以看到透過走訪器取得的走訪器就是它自己, 因此不管是使用 it 還是 it1 都可以走訪資料:

>>> it1.__next__()
0
>>> it1.__next__()
1
>>> it1.__next__()
2
>>> it1.__next__()
3
>>> it1.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>
Enter fullscreen mode Exit fullscreen mode

不過因為兩個名稱指的都是同一個走訪器, 所以走訪結束後即使換另一個名稱也不能再繼續走訪了:

>>> it.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>
Enter fullscreen mode Exit fullscreen mode

使用內建函式走訪資料

以 '__' 開頭並且結尾的方法其實並不是要讓我們直接叫用, 而是配合 Python 整體運作自動被叫用, 這類方法統稱為特別方法 (special method)。以走訪物件來說, for 會幫我們叫用 __iter__() 以及 __next__(), 如果我們要自己透過走訪器走訪資料, 正規的做法應該是要叫用內建函式 iter()next(), 由它們幫我們叫用 __iter__() 以及 __next__(), 例如:

>>> r = range(4)
>>> it = iter(r)
>>> next(it)
0
>>> next(it)
1
>>> next(it)
2
>>> next(it)
3
>>> next(it)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>
Enter fullscreen mode Exit fullscreen mode

像是可走訪物件以及走訪器這樣必須符合特定的規範, 也就是必須具備某些特定功用的方法, 搭配對應機制運作的作法常常會運用在 Python 的不同機制中, 你也可以仿照相同的做法運用在自己的程式中。

設計自己的可走訪物件

了解了可走訪物件的機制後, 我們也可以設計自己的可走訪物件, 底下我們以一個能產生指定範圍內隨機整數的物件為例, 它會在產生的亂數剛好位於指定範圍的中間時引發例外停止走訪。由於走訪器自己就必須是可走訪物件, 所以可走訪物件最簡單的實作方法就是直接實作成走訪器:

>>> class r_iter:
...     def __init__(self, stop):
...         self.stop = stop
...         random.seed()
...     def __iter__(self):
...         return self
...     def __next__(self):
...         r = random.randrange(self.stop)
...         if r == int(self.stop / 2):
...             raise StopIteration(F'Stopped@{r}')
...         else:
...             return r
...
>>>
Enter fullscreen mode Exit fullscreen mode

先來測試看看:

>>> r = r_iter(4)
>>> r.__next__()
3
>>> r.__next__()
3
>>> r.__next__()
0
>>> r.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 10, in __next__
StopIteration: Stopped@2
>>>
Enter fullscreen mode Exit fullscreen mode

由於指定的範圍是 0~4, 可以看到產生的亂數是中間的 2 時, 就會引發 StopIteration 例外。確認無誤後, 就可以試著和 for 搭配看看:

>>> for i in r_iter(4):
...     print(i)
...
0
0
0
0
3
>>>
Enter fullscreen mode Exit fullscreen mode

看起來沒問題, 再多試幾次:

>>> for i in r_iter(4):
...     print(i)
...
>>>
Enter fullscreen mode Exit fullscreen mode

由於是亂數, 所以也有可能一開始就產生中間值結束走訪, 每次都不一樣:

>>> for i in r_iter(4):
...     print(i)
...
3
3
0
1
3
0
0
1
0
3
>>>
Enter fullscreen mode Exit fullscreen mode

你可以看到要實作可走訪物件並不難, 而且搭配 for 運作看起來就像是 Python 原生的物件。另外, 有些教材會說 for 是用來建立固定次數的迴圈, 但其實這主要是看走訪的物件而定, 迴圈次數並非絕對是固定的。

生成器 (generator) 也是走訪器

其實要實作剛剛的亂數走訪器, 使用生成器 (generator) 會比較簡單, 像是以下就是改用生成器實作產生亂數的走訪器:

>>> def r_generator(stop):
...     random.seed()
...     while True:
...         r = random.randrange(stop)
...         if r == int(stop / 2):
...              return
...         else:
...              yield(r)
...
>>>
Enter fullscreen mode Exit fullscreen mode

叫用生成器的傳回值就是一個走訪器, 我們可以從它同時具備 __iter__() 以及 __next__() 來確認:

>>> r = r_generator(4)
>>> r.__iter__
<method-wrapper '__iter__' of generator object at 0x000001777D0E35A0>
>>> r.__next__
<method-wrapper '__next__' of generator object at 0x000001777D0E35A0>
>>>
Enter fullscreen mode Exit fullscreen mode

既然是走訪器, 當然是可以依照前面所說一一走訪:

>>> r = r_generator(4)
>>> r.__next__()
3
>>> r.__next__()
3
>>> r.__next__()
1
>>> r.__next__()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration
>>>
Enter fullscreen mode Exit fullscreen mode

也可以搭配 for運作:

>>> for i in r_generator(4):
...     print()
...
KeyboardInterrupt
>>> for i in r_generator(4):
...     print(i)
...
1
>>> for i in r_generator(4):
...     print(i)
...
1
1
>>> for i in r_generator(4):
...     print(i)
...
3
0
>>>
Enter fullscreen mode Exit fullscreen mode

除了自己實作生成器, 也可以利用生成式 (generator expression) 產生生成器後取得走訪器, 例如:

>>> g = (i ** 2 for i in [1, 2, 3])
>>> g.__iter__
<method-wrapper '__iter__' of generator object at 0x000001777D0E2810>
>>> g.__next__
<method-wrapper '__next__' of generator object at 0x000001777D0E2810>
>>> for i in g:
...     print(i)
...
1
4
9
>>>
Enter fullscreen mode Exit fullscreen mode

你可以看到生成器就跟 range 物件一樣是抽象的容器, 都是在實際走訪時才產生目前的資料。

使用 collections.abc 模組檢查物件是否可走訪

要判斷某個物件是否可走訪, 可以透過 collections.abc 模組:

>>> import collections.abc
>>>
Enter fullscreen mode Exit fullscreen mode

雖然可走訪物件以及走訪器不必是特定類別的物件, 但是在 collections.abs 中提供有 Iterator 以及 Iterable 類別可以搭配 isinstance() 以及 issubclass() 內建函式判斷是否為走訪器或是可走訪的物件, 例如:

>>> a = [1, 2, 3]
>>> isinstance(a, collections.abc.Iterable)
True
>>> isinstance(a, collections.abc.Iterator)
False
>>> it = a.__iter__()
>>> isinstance(it, collections.abc.Iterator)
True
>>>
Enter fullscreen mode Exit fullscreen mode

可以看到串列是可走訪物件, 但不是走訪器, 一定要先取得串列的走訪器才能走訪內含的物件。相同的道理, range 物件也是如此:

>>> issubclass(range, collections.abc.Iterable)
True
>>> issubclass(range, collections.abc.Iterator)
False
>>> r = range(4)
>>> isinstance(r, collections.abc.Iterable)
True
>>> isinstance(r, collections.abc.Iterator)
False
>>> it = r.__iter__()
>>> isinstance(it, collections.abc.Iterable)
True
>>> isinstance(it, collections.abc.Iterator)
True
>>>
Enter fullscreen mode Exit fullscreen mode

我們也可以用同樣的方法檢查前面自己設計的走訪器:

>>> r = r_iter(4)
>>> isinstance(r, collections.abc.Iterable)
True
>>> isinstance(r, collections.abc.Iterator)
True
>>> issubclass(r_iter, collections.abc.Iterator)
True
>>> issubclass(r_iter, collections.abc.Iterable)
True
>>>
Enter fullscreen mode Exit fullscreen mode

你可以看到雖然在定義 r_iter 類別時並沒有繼承 IteratorIterable 類別, 但是叫用 issubclass() 依然是傳回 True。如果覺得這樣很怪, 也可以在定義類別的時候明確的繼承 Iterator

小結

可走訪的物件在 Python 中常常會看到, 像是在 list() 的說明中, 就告訴你必須傳入可走訪的物件, 因此本文所提到的各種物件都可以用 list() 建立串列, 甚至是我們自己設計的走訪器也可以, 例如:

>>> list(r_iter(4))
[0, 0, 1]
>>> list(r_iter(4))
[0, 3]
>>> list(r_iter(4))
[3, 0, 1, 3, 1, 1, 1, 3]
>>>
Enter fullscreen mode Exit fullscreen mode

只要多了解 Python 的這些機制, 就可以讓你自己設計的物件像是內建的物件一樣, 完美融合在 Python 的語法之中, 也可以和內建的函式合作無間。

Top comments (0)