###### tags: `Python` # for 的奧秘--可走訪 (可迭代, iterable) 物件 學過其他程式語言的人一開始看到 Python 也有 [`for`](https://docs.python.org/3.10/reference/compound_stmts.html#the-for-statement), 一定很想這樣寫: ```python >>> for i in 20: ... print(i) ... Traceback (most recent call last): File "<stdin>", line 1, in <module> TypeError: 'int' object is not iterable >>> ``` 不過馬上就會看到 Python 噴出錯誤訊息, 告訴你 20 這個整數 (int) 並不是**可走訪的 (iterable, 或稱『可迭代的』)** 物件, 那麼到底什麼是 [`iterable`](https://docs.python.org/3/library/stdtypes.html#iterator-types) 呢? ## 可走訪的 (iterable) 物件與走訪器 (iterator) 簡單的來說, 可走訪的物件就是任何抽象上**內含多項資料**, 並且符合**特定的規範**, 可以**一個一個輪流**取出資料的物件, 你可以很直覺地想到串列就是這樣的物件, 因此我們就藉由串列來說明可走訪物件。 首先我們先建立一個串列: ```python >>> a = [10, 20,30] ``` 剛剛提到可走訪物件必須符合**特定的規範**, 就是指它必須具有 [`__iter__()`](https://docs.python.org/3/library/stdtypes.html#container.__iter__) 方法, 這個方法要傳回一種特別的物件, 叫做**走訪器 (iterator, 或稱『迭代器』)**, 實際要一一取出資料靠的就是走訪器: ```python >>> it = a.__iter__() ``` 走訪器也和可走訪的物件一樣, 有它必須符合的規範, 它必須具有 [`__next__()`](https://docs.python.org/3/library/stdtypes.html#iterator.__next__) 方法, 每次叫用時會從容器中取得下一項資料, 如果已經全部取出, 就要引發 [`StopIteration`](https://docs.python.org/3/library/exceptions.html#StopIteration) 例外。以下就示範透過剛剛傳回的走訪器一一輪流取出串列中的資料: ```python >>> it.__next__() 10 >>> it.__next__() 20 >>> it.__next__() 30 >>> it.__next__() Traceback (most recent call last): File "<stdin>", line 1, in <module> StopIteration >>> ``` 既然串列是可走訪的物件, 當然就可以搭配 `for` 使用: ```python >>> for i in a: ... print(i) ... 10 20 30 >>> ``` `for` 會捕捉 `StopIteration` 例外, 不會在取出所有資料時讓程式意外結束。實際上你也可以用 `try` 和 `while` 完成一樣的動作: ```python >>> it = a.__iter__() >>> try: ... while True: ... i = it.__next__() ... print(i) ... except StopIteration: ... pass ... 10 20 30 >>> ``` 走訪器之所以要在最後引發例外, 是要讓程式可以分辨是否有取完所有資料, 在 `for` 敘述裡就可以加上 `else` 來處理發生例外的狀況: ```python >>> for i in a: ... print(i) ... else: ... print('completed') ... 10 20 30 completed >>> ``` 只要有完整走訪所有的資料, 沒有因為 `break` 跳離迴圈, 就會執行 `else` 部分。相同的功能以 `try` 和 `whle` 實作如下: ```python >>> it = a.__iter__() >>> try: ... while True: ... i = it.__next__() ... print(i) ... except StopIteration: ... print('complted') ... 10 20 30 complted >>> ``` ## range 是抽象的容器 可走訪物件並不一定要是真的容器, 只要抽象上可以一一取出資料即可, 像是 [`range()`](https://docs.python.org/3/library/stdtypes.html?highlight=range#range) 建立的物件就是最常見的例子: ```python >>> r = range(4) >>> type(r) <class 'range'> ``` `range()` 因為不符 Python 類別名稱首字母大寫的慣例, 看起來像是叫用函式, 但其實它是建立物件, 你可以看到傳回的物件是 `range` 類別。`range` 就是一種抽象的容器, 它實際上並不會真的產生所有的資料, 而是在走訪時才依照規則產生目前項目的資料: ```python >>> 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 >>> ``` ## 走訪器也必須是可走訪的物件 走訪器除了必須具備 `__next__()` 以外, 也必須具備 `__iter__()`, 也就是走訪器自己也要是可走訪的物件, 它的 `__iter__()` 要傳回自己: ```python >>> r = range(4) >>> it = r.__iter__() >>> it1 = it.__iter__() >>> it is it1 True ``` 你可以看到透過走訪器取得的走訪器就是它自己, 因此不管是使用 `it` 還是 `it1` 都可以走訪資料: ```python >>> 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 >>> ``` 不過因為兩個名稱指的都是同一個走訪器, 所以走訪結束後即使換另一個名稱也不能再繼續走訪了: ```python >>> it.__next__() Traceback (most recent call last): File "<stdin>", line 1, in <module> StopIteration >>> ``` ## 使用內建函式走訪資料 以 '\_\_' 開頭並且結尾的方法其實並不是要讓我們直接叫用, 而是配合 Python 整體運作自動被叫用, 這類方法統稱為[**特別方法 (special method)**](https://docs.python.org/3/glossary.html#term-special-method)。以走訪物件來說, `for` 會幫我們叫用 `__iter__()` 以及 `__next__()`, 如果我們要自己透過走訪器走訪資料, 正規的做法應該是要叫用內建函式 [`iter()`](https://docs.python.org/3/library/functions.html#iter) 與 [`next()`](https://docs.python.org/3/library/functions.html#next), 由它們幫我們叫用 `__iter__()` 以及 `__next__()`, 例如: ```python >>> 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 >>> ``` 像是可走訪物件以及走訪器這樣必須符合特定的規範, 也就是必須具備某些特定功用的方法, 搭配對應機制運作的作法常常會運用在 Python 的不同機制中, 你也可以仿照相同的做法運用在自己的程式中。 ## 設計自己的可走訪物件 了解了可走訪物件的機制後, 我們也可以設計自己的可走訪物件, 底下我們以一個能產生指定範圍內隨機整數的物件為例, 它會在產生的亂數剛好位於指定範圍的中間時引發例外停止走訪。由於走訪器自己就必須是可走訪物件, 所以可走訪物件最簡單的實作方法就是直接實作成走訪器: ```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 ... >>> ``` 先來測試看看: ```python >>> 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 >>> ``` 由於指定的範圍是 0~4, 可以看到產生的亂數是中間的 2 時, 就會引發 `StopIteration` 例外。確認無誤後, 就可以試著和 `for` 搭配看看: ```python >>> for i in r_iter(4): ... print(i) ... 0 0 0 0 3 >>> ``` 看起來沒問題, 再多試幾次: ```python >>> for i in r_iter(4): ... print(i) ... >>> ``` 由於是亂數, 所以也有可能一開始就產生中間值結束走訪, 每次都不一樣: ```python >>> for i in r_iter(4): ... print(i) ... 3 3 0 1 3 0 0 1 0 3 >>> ``` 你可以看到要實作可走訪物件並不難, 而且搭配 `for` 運作看起來就像是 Python 原生的物件。另外, 有些教材會說 `for` 是用來建立固定次數的迴圈, 但其實這主要是看走訪的物件而定, 迴圈次數並非絕對是固定的。 ## 生成器 (generator) 也是走訪器 其實要實作剛剛的亂數走訪器, 使用[**生成器 (generator)**](https://docs.python.org/3/glossary.html#term-generator) 會比較簡單, 像是以下就是改用生成器實作產生亂數的走訪器: ```python >>> def r_generator(stop): ... random.seed() ... while True: ... r = random.randrange(stop) ... if r == int(stop / 2): ... return ... else: ... yield(r) ... >>> ``` 叫用生成器的傳回值就是一個走訪器, 我們可以從它同時具備 `__iter__()` 以及 `__next__()` 來確認: ```python >>> r = r_generator(4) >>> r.__iter__ <method-wrapper '__iter__' of generator object at 0x000001777D0E35A0> >>> r.__next__ <method-wrapper '__next__' of generator object at 0x000001777D0E35A0> >>> ``` 既然是走訪器, 當然是可以依照前面所說一一走訪: ```python >>> 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 >>> ``` 也可以搭配 `for`運作: ```python >>> 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 >>> ``` 除了自己實作生成器, 也可以利用**生成式 (generator expression)** 產生生成器後取得走訪器, 例如: ```python >>> 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 >>> ``` 你可以看到生成器就跟 `range` 物件一樣是抽象的容器, 都是在實際走訪時才產生目前的資料。 ## 使用 collections.abc 模組檢查物件是否可走訪 要判斷某個物件是否可走訪, 可以透過 [`collections.abc`](https://docs.python.org/3.10/library/collections.abc.html) 模組: ```python >>> import collections.abc >>> ``` 雖然可走訪物件以及走訪器不必是特定類別的物件, 但是在 `collections.abs` 中提供有 [`Iterator`](https://docs.python.org/3.10/library/collections.abc.html#collections.abc.Iterator) 以及 [`Iterable`](https://docs.python.org/3.10/library/collections.abc.html#collections.abc.Iterable) 類別可以搭配 [`isinstance()`](https://docs.python.org/3/library/functions.html#isinstance) 以及 [`issubclass()`](https://docs.python.org/3/library/functions.html#issubclass) 內建函式判斷是否為走訪器或是可走訪的物件, 例如: ```python >>> 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 >>> ``` 可以看到串列是可走訪物件, 但不是走訪器, 一定要先取得串列的走訪器才能走訪內含的物件。相同的道理, `range` 物件也是如此: ```python >>> 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 >>> ``` 我們也可以用同樣的方法檢查前面自己設計的走訪器: ```python >>> 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 >>> ``` 你可以看到雖然在定義 `r_iter` 類別時並沒有繼承 `Iterator` 和 `Iterable` 類別, 但是叫用 `issubclass()` 依然是傳回 `True`。如果覺得這樣很怪, 也可以在定義類別的時候明確的繼承 `Iterator`。 ## 小結 可走訪的物件在 Python 中常常會看到, 像是在 [`list()`](https://docs.python.org/3/library/functions.html#func-list) 的說明中, 就告訴你必須傳入可走訪的物件, 因此本文所提到的各種物件都可以用 `list()` 建立串列, 甚至是我們自己設計的走訪器也可以, 例如: ```python >>> 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] >>> ``` 只要多了解 Python 的這些機制, 就可以讓你自己設計的物件像是內建的物件一樣, 完美融合在 Python 的語法之中, 也可以和內建的函式合作無間。