python可迭代物件

陳順吉發表於2018-02-08

本身實現了迭代方法的物件稱之為可迭代物件,可迭代物件特點:

  • 支援每次返回自己所包含的一個成員的物件;
  • 物件實現了 __iter__ 方法:
  • 所有資料結構都是可迭代物件;
  • for 迴圈要求物件必須是一個可迭代物件;
  • 使用者自定義的一些包含了 __iter__()__getitem__() 方法的類。

它與一般的序列型別(list, tuple 等)有什麼區別呢?它一次只返回一個資料項,佔用更少的記憶體,但它需要記住當前的狀態,以便返回下一資料項。

迭代器

迭代器(iterator)就是一種可迭代物件。所謂的 迭代器就是重複做一件事,它又稱為遊標(cursor),它是程式設計的軟體設計模式,是一種可在容器物件(container,如列表等)上實現元素遍歷的介面。迭代器是一種特殊的資料結構,在 python 中,它也是以物件的形式存在的。

簡單來說,在 python2 中存在 next 方法的可迭代物件是迭代器;而在 python3 中則變成了 __next__ 方法。因此迭代器同時具有 __iter____next__ 這兩種方法。

通過 python 內建函式 iter 可以將一個可迭代物件轉換成一個迭代器。為什麼要將可迭代物件轉換成迭代器呢?因為只有迭代器才能使用 python 內建函式 next。

迭代器會儲存一個指標,指向可迭代物件的當前元素。呼叫 next 函式的時候,會返回當前元素,並將指標指向下一個元素。當沒有下一個元素的時候,它會丟擲 StopIteration 異常。

一個簡單的迭代器用法:

lst = [['m', 2, 4, 5], ['x', 3, 4, 5]]

for x in lst:
    key = x[0]
    for v in x[1:]:
        print()

for x in lst:
    it = iter(x)
    key = next(it)
    for v in it:
        print()
複製程式碼

使用第二種迴圈也就是迭代器會比第一種更有效率,因為切片將列表複製一份,佔用的記憶體更多。

for 迴圈對於可迭代物件首先會呼叫 iter 方法將之轉換為迭代器,然後不斷的呼叫 next 方法,直到丟擲 StopIteration 異常。

it = iter(itratable)
while True:
    try:
        next(it)
    except StopIteration:
        return
複製程式碼

生成器

生成器也是函式,函式中只要有 yield 關鍵字,那麼它就是生成器函式,返回值為生成器。生成器存在 __iter____next__ 這兩種方法,因此它是一個迭代器。生成器應用非常廣泛,官方的非同步 IO 基本上都是基於 yield 做的。當我們在 async def 定義的函式中使用 yield,那麼這個函式就被稱為非同步生成器。

當我們呼叫生成器函式的時候,它會返回一個生成器物件,我們要使用一個變數去接受它,然後通過操作這個變數去操作生成器。生成器也是函式,函式都是從上到下執行,當執行到 yield 語句時,這個函式就停止了,並且會將此次的返回值返回。如果 yield 語句後沒有任何值,那麼它的返回值就是 None;如果有值,會將這個值返回給呼叫者。如果使用了生成器的 send 方法(下面會提到),那麼返回值將是通過這個方法傳遞進去的值(前提是 yield 語句後沒有任何值)。

所有的這些特性讓生成器看起來和協程非常相似:可以多次呼叫、有多個切入點、執行可以被暫停。唯一的區別是生成器函式無法控制 yield 之後應繼續執行的位置,因為控制權在呼叫者的手中。

對於一個沒有呼叫結束的生成器,我們可以使用 close 方法將其關閉,可以將其寫在 try 的 finally 語句中。

當使用 yield from <expr> 時,它將提供的表示式視為子迭代器,該子迭代器生成的所有值直接傳遞給當前生成器函式的呼叫者。任何傳遞給 send() 的值和通過throw() 傳入的異常都會傳遞給基礎迭代器(如果它有適當的方法去接收)。如果不是這種情況,那麼 send() 會引發 AttributeError 或 TypeError,而 throw() 會立即引發傳入的異常。

定義一個生成器:

>>> def fn():
...     for i in range(10):
...         yield i
...
>>> fn() # 可以看到它是一個生成器
Out[3]: <generator object fn at 0x7f667fa5d0a0>
>>> f = fn() # 我們得先接收這個生成器
>>> next(f) # 然後再對生成器進行操作
Out[6]: 0
>>> next(f)
Out[7]: 1
複製程式碼

從函式的執行流程中可以知道,函式執行完畢之後現場應該被銷燬,但是生成器卻並不是這樣。

執行流程剖析,先定義一個函式:

>>> def g1():
...     print('a')
...     yield 1
...     print('b')
...     yield 2
...     print('c')
...     return 3
...
>>> g = g1() # 沒有輸出 a,證明執行生成器函式的時候不會執行函式體
>>> g # 可以看出是一個生成器,證明 return 沒有生效
Out[10]: <generator object g1 at 0x7f667e6fa990>
複製程式碼

通過 next 函式執行一把生成器:

>>> next(g) # 執行到第一個 yield 後,停止執行
a
Out[11]: 1
複製程式碼

再執行一次:

>>> next(g) # 從第一個 yield 之後執行,到第二個 yield 停止
b
Out[12]: 2
複製程式碼

繼續執行:

>>> next(g) # 從第二個 yield 之後執行,當沒有更多 yield 之後,丟擲異常,異常的值正好是函式的返回值
c # 下面的語句還是會執行的
Traceback (most recent call last):
  File "/usr/local/python/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2862, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-13-5f315c5de15b>", line 1, in <module>
    next(g)
StopIteration: 3
複製程式碼

生成器函式的特點:

  • 生成器函式執行的時候並不會執行函式體;
  • 當 next 生成器的時候,會從當前程式碼執行到之後的第一個 yield,會彈出值並暫停函式;
  • 當再次 next 生成器的時候,從上次暫停處開始向下執行;
  • 當沒有多餘 yield 的時候,會丟擲 StopIteration 異常,異常的 Value 是函式的返回值。

生成器是惰性求值的。比如我們可以定義一個計數器:

def make_inc():
    def counter():
        x = 0
        while True:
            x += 1
            yield x
    c = counter()
    return lambda: next(c)

>>> incr = make_inc()
>>> incr()
Out[9]: 1
>>> incr()
Out[10]: 2
複製程式碼

求斐波那契數列第 11 項:

def fib():
    a = 0
    b = 1
    while True:
        a, b = b, a+b
        yield a

>>> f = fib()
>>> for _ in range(10):
...     next(f)
...
>>> print(next(f))
89
複製程式碼

可以看到遞迴都可以通過生成器來解決,並且沒有遞迴深度的限制,也沒有遞迴慢的缺點,因為它不需要儲存現場。

以上都只是生成器的普通用法,協程才是生成器的高階用法。

程式和執行緒的排程是通過作業系統完成的,但是協程的排程是由使用者態,也就是使用者進行的。一旦函式執行到 yield 之後,它會暫停,暫停也就意味著讓出 cpu 了。那麼接下來就由使用者決定執行什麼程式碼。

當我們要對一個可迭代物件的前一項或幾項做特殊處理時,如果直接對其進行迴圈的話,我們還需要判斷是不是其第一個元素,或許我們還要在其外部定義一個計數器,這其實是一種和古老和 low 的方式。有了生成器之後,我們就可以在迴圈之前使用 next() 函式取出其中的第一個值,然後再對其進行 for 迴圈即可。如果無法對其直接使用 next 方法,那就呼叫它的 __iter__() 方法將其變成一個生成器後再繼續。

yield

函式中一旦使用了 yield,這個函式就變成了生成器函式。但 yield 不能和 return 共存,並且 yield 只能定義在函式中。當我們呼叫這個函式的時候,函式內部的程式碼並不立即執行,這個函式只是返回一個生成器物件。當我們使用 for 對其進行迭代的時候,函式內的程式碼才會被執行。

python3 新增了 yield from 語法,它相當於 for + yield。比如:

yield from a()

# 等同於下面
for i in a():
    yield i
複製程式碼

yield 和 return 的區別:

return 的時候這個函式的區域性變數都被銷燬了;
所有 return 是得到所有結果之後的返回;
yield 是產生了一個可以恢復的函式(生成器),恢復了區域性變數;
生成器只有在呼叫 .next() 時才執行函式生成一個結果。
複製程式碼

yield 會記住函式執行的位置,下次再次執行時會從上次的位置繼續向下執行。而如果在函式中使用 return,函式就直接退出了,無法繼續執行。定義一個生成器:

>>> def fun1(n):
...     for i in xrange(n):
...         yield i
...
複製程式碼

先執行一下:

>>> a = fun1(5)
>>> a.next()
0
複製程式碼

然後再對其進行迴圈會從之前的地方繼續向下:

>>> for i in a:print i
...
1
2
3
4
複製程式碼

yield 的用處在於如果函式每次迴圈都會產生一個字串,如果想要將這些字串都傳遞給函式外的其他變數使用 return 是不行的,因為當函式第一次迴圈時碰到 return 語句整個函式就退出了,是不可能繼續迴圈的,也就是說只能傳遞一個字串出去。這顯然不符合我們的要求,這時就可以通過 yield 搞定了。

實現xrange:

def xrange(n):
    start = 0
    while True:
        if start >= n:
            return
        yield start
        start += 1
複製程式碼

具體案例:

import csv
from pyzabbix import ZabbixAPI

zapi = ZabbixAPI('http://127.0.0.1/api_jsonrpc.php')
zapi.login('uxeadmin', 'Uxe(00456)AdmIN.^??')

with open('_zabbix.csv', 'w', encoding='gbk') as f:
    spamwriter = csv.writer(f)
    for i in zapi.host.get(output=["host"]):
        item_info = zapi.item.get(hostids=i['hostid'], output=["name", 'status']).__iter__()
        for j in item_info:
            if not int(j['status']):
                spamwriter.writerow([i['host'], j['name']])
                break
        for j in item_info:
            if not int(j['status']):
                spamwriter.writerow(['', j['name']])
複製程式碼

生成器方法

請注意,在生成器已經執行時呼叫下面的任何生成器方法會引發 ValueError 異常。

__next__

開始執行一個生成器或者從上一次 yield 語句後繼續執行。當使用該方法繼續(注意是繼續而不是第一次執行)時,那麼當前 yield 的返回值為 None,直到執行到下一次的 yield 語句時,yield 語句後的表示式的結果才會返回給呼叫者。當迭代器結束時會丟擲 StopIteration 異常。

該方法會被 for 以及內建函式 next 隱式的呼叫。

send

繼續執行生成器(注意是繼續而不是第一次執行),併傳送一個值到生成器函式。send 方法的引數是下一個 yield 語句的返回值,前提是 yield 語句中要事先接收它傳遞的引數。如果使用該方法啟動(也就是第一次執行)生成器,必須使用 None 作為其引數,因為此時還沒有 yield 能夠接收它的值(畢竟接收該值的語句還沒有開始執行)。

def fn():
    a = 0
    while True:
        a += 1
        r = yield # r 就是接收 send 引數的變數
        print('{} => {}'.format(a, r))

>>> f = fn()
>>> f.send('a') # 不傳遞 None 的後果
Traceback (most recent call last):
  File "/opt/python3/lib/python3.6/site-packages/IPython/core/interactiveshell.py", line 2910, in run_code
    exec(code_obj, self.user_global_ns, self.user_ns)
  File "<ipython-input-31-6f758a7cad28>", line 1, in <module>
    f.send('a')
TypeError: can't send non-None value to a just-started generator
>>> next(f) # 也可以不傳遞 None 而是使用 next 執行,兩種方式都可以
>>> f.send('a')
1 => a
>>> f.send('b')
2 => b
複製程式碼

throw

用法:

throw(type[, value[, traceback]])
複製程式碼

傳遞一個 type 型別的異常給生成器,在生成器暫停的時候丟擲,並且返回下一次 yield 的值。

close

在生成器函式暫停的位置引發 GeneratorExit。如果生成器函式正常退出,已經關閉,或者引發 GeneratorExit(沒有捕獲該異常),關閉返回給呼叫者;如果生成器產生一個值,則引發一個 RuntimeError;如果生成器引發其他異常,則傳播給呼叫者;如果生成器由於異常或正常退出而退出,則 close() 不執行任何操作。

示例

>>> def echo(value=None):
...     print("Execution starts when 'next()' is called for the first time.")
...     try:
...         while True:
...             try:
...                 value = (yield value) # 不管 yield 後面是否有表示式,value 的值都是 send 傳遞進來的引數
...             except Exception as e:
...                 value = e
...     finally:
...         print("Don't forget to clean up when 'close()' is called.")
...
>>> generator = echo(1)
>>> print(next(generator))
Execution starts when 'next()' is called for the first time.
1
>>> print(next(generator))
None
>>> print(generator.send(2))
2
>>> generator.throw(TypeError, "spam")
TypeError('spam',)
>>> generator.close()
Don't forget to clean up when 'close()' is called.
複製程式碼

生成器解析

python3 中的 range 函式就是一個典型的生成器,無論給它一個多麼大的數,它佔用記憶體始終很小。但是下面的程式碼會返回一個佔用空間很大的列表:

[x ** 2 for x in range(100000)]
複製程式碼

當我們想讓它返回的結果也像生成器一樣可以將中括號換成小括號:

>>> (x ** 2 for x in range(100000))
<generator object <genexpr> at 0x7fb246656620>
複製程式碼

使用 next 函式就可以檢視裡面的每個值,當然 for 迴圈也可以。

因此將列表解析的中括號變成小括號就是生成器的語法。

生成器解析其實就是列表解析的擴充套件,當我們明確需要使用小標訪問的時候,使用列表解析。而如果只需要對結果進行迭代的時候,優先使用生成器解析。

還有一個場景,就是要對結果進行快取的時候,就只能使用列表解析了。不過使用生成器解析的場景確實要比列表解析來的多。

暴露生成器內的物件

如果你想讓你的生成器暴露外部狀態給使用者, 別忘了你可以簡單的將它實現為一個類,然後把生成器函式放到 __iter__() 方法中過去。比如:

from collections import deque

class linehistory:
    def __init__(self, lines, histlen=3):
        self.lines = lines
        self.history = deque(maxlen=histlen)

    def __iter__(self):
        for lineno, line in enumerate(self.lines, 1):
            self.history.append((lineno, line))
            yield line

    def clear(self):
        self.history.clear()
複製程式碼

為了使用這個類,你可以將它當做是一個普通的生成器函式。然而,由於可以建立一個例項物件,於是你可以訪問內部屬性值,比如 history 屬性或者是 clear() 方法。程式碼示例如下:

with open('somefile.txt') as f:
    lines = linehistory(f)
    for line in lines:
        if 'python' in line:
            for lineno, hline in lines.history:
                print('{}:{}'.format(lineno, hline), end='')
複製程式碼

如果行中包含了 python 這個關鍵字,那就列印該行和前三行的行號以及內容。

關於生成器,很容易掉進函式無所不能的陷阱。如果生成器函式需要跟你的程式其他部分打交道的話(比如暴露屬性值,允許通過方法呼叫來控制等等),可能會導致你的程式碼異常的複雜。如果是這種情況的話,可以考慮使用上面介紹的定義類的方式。在 __iter__() 方法中定義你的生成器不會改變你任何的演算法邏輯。由於它是類的一部分,所以允許你定義各種屬性和方法來供使用者使用。

一個需要注意的小地方是,如果你在迭代操作時不使用 for 迴圈語句,那麼你得先呼叫 iter() 函式。比如:

>>> f = open('somefile.txt')
>>> lines = linehistory(f)
>>> next(lines)
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: 'linehistory' object is not an iterator

>>> # Call iter() first, then start iterating
>>> it = iter(lines)
>>> next(it)
'hello world\n'
>>> next(it)
'this is a test\n'
>>>
複製程式碼

生成器切片

你想得到一個由迭代器生成的切片物件,但是標準切片操作並不能做到。函式 itertools.islice() 正好適用於在迭代器和生成器上做切片操作。比如:

>>> def count(n):
...     while True:
...         yield n
...         n += 1
...
>>> c = count(0)
>>> c[10:20]
Traceback (most recent call last):
    File "<stdin>", line 1, in <module>
TypeError: 'generator' object is not subscriptable

>>> # Now using islice()
>>> import itertools
>>> for x in itertools.islice(c, 10, 20):
...     print(x)
...
10
11
12
13
14
15
16
17
18
19
>>>
複製程式碼

迭代器和生成器不能使用標準的切片操作,因為它們的長度事先我們並不知道(並且也沒有實現索引)。函式 islice() 返回一個可以生成指定元素的迭代器,它通過遍歷並丟棄直到切片開始索引位置的所有元素。然後才開始一個個的返回元素,並直到切片結束索引位置。

這裡要著重強調的一點是 islice() 會消耗掉傳入的迭代器中的資料。必須考慮到迭代器是不可逆的這個事實。所以如果你需要之後再次訪問這個迭代器的話,那你就得先將它裡面的資料放入一個列表中。

跳過可迭代物件開始部分

你想遍歷一個可迭代物件,但是它開始的某些元素你並不感興趣,想跳過它們。itertools 模組中有一些函式可以完成這個任務。首先介紹的是 itertools.dropwhile() 函式。使用時,你給它傳遞一個函式物件和一個可迭代物件。它會返回一個迭代器物件,丟棄原有序列中直到函式返回 Flase 之前的所有元素,然後返回後面所有元素。

為了演示,假定你在讀取一個開始部分是幾行註釋的原始檔。比如:

>>> with open('/etc/passwd') as f:
... for line in f:
...     print(line, end='')
...
##
# User Database
#
# Note that this file is consulted directly only when the system is running
# in single-user mode. At other times, this information is provided by
# Open Directory.
...
##
nobody:*:-2:-2:Unprivileged User:/var/empty:/usr/bin/false
root:*:0:0:System Administrator:/var/root:/bin/sh
...
>>>
複製程式碼

如果你想跳過開始部分的註釋行的話,可以這樣做:

>>> from itertools import dropwhile
>>> with open('/etc/passwd') as f:
...     for line in dropwhile(lambda line: line.startswith('#'), f):
...         print(line, end='')
...
nobody:*:-2:-2:Unprivileged User:/var/empty:/usr/bin/false
root:*:0:0:System Administrator:/var/root:/bin/sh
...
>>>
複製程式碼

這個例子是基於根據某個測試函式跳過開始的元素。如果你已經明確知道了要跳過的元素的個數的話,那麼可以使用 itertools.islice() 來代替。比如:

>>> from itertools import islice
>>> items = ['a', 'b', 'c', 1, 4, 10, 15]
>>> for x in islice(items, 3, None):
...     print(x)
...
1
4
10
15
>>>
複製程式碼

在這個例子中,islice() 函式最後那個 None 引數指定了你要獲取從第 3 個到最後的所有元素。如果 None 和 3 的位置對調,意思就是僅僅獲取前三個元素,這個跟切片的相反操作 [3:][:3] 原理是一樣的。

函式 dropwhile()islice() 其實就是兩個幫助函式,為的就是避免寫出下面這種冗餘程式碼:

with open('/etc/passwd') as f:
    # Skip over initial comments
    while True:
        line = next(f, '')
        if not line.startswith('#'):
            break

    # Process remaining lines
    while line:
        # Replace with useful processing
        print(line, end='')
        line = next(f, None)
複製程式碼

跳過一個可迭代物件的開始部分跟通常的過濾是不同的。比如,上述程式碼的第一個部分可能會這樣重寫:

with open('/etc/passwd') as f:
    lines = (line for line in f if not line.startswith('#'))
    for line in lines:
        print(line, end='')
複製程式碼

這樣寫確實可以跳過開始部分的註釋行,但是同樣也會跳過檔案中其他所有的註釋行。換句話講,我們的解決方案是僅僅跳過開始部分滿足測試條件的行,在那以後,所有的元素不再進行測試和過濾了。

最後需要著重強調的一點是,本節的方案適用於所有可迭代物件,包括那些事先不能確定大小的,比如生成器,檔案及其類似的物件。

展開巢狀的序列

你想將一個多層巢狀的序列展開成一個單層列表,可以寫一個包含 yield from 語句的遞迴生成器來輕鬆解決這個問題。比如:

from collections import Iterable

def flatten(items, ignore_types=(str, bytes)):
    for x in items:
        if isinstance(x, Iterable) and not isinstance(x, ignore_types):
            yield from flatten(x)
        else:
            yield x

items = [1, 2, [3, 4, [5, 6], 7], 8]
# Produces 1 2 3 4 5 6 7 8
for x in flatten(items):
    print(x)
複製程式碼

在上面程式碼中,isinstance(x, Iterable) 檢查某個元素是否是可迭代的。如果是的話,yield from 就會返回所有子例程的值。最終返回結果就是一個沒有巢狀的簡單序列了。

額外的引數 ignore_types 和檢測語句 isinstance(x, ignore_types) 用來將字串和位元組排除在可迭代物件外,防止將它們再展開成單個的字元。 這樣的話字串陣列就能最終返回我們所期望的結果了。比如:

>>> items = ['Dave', 'Paula', ['Thomas', 'Lewis']]
>>> for x in flatten(items):
...     print(x)
...
Dave
Paula
Thomas
Lewis
>>>
複製程式碼

之前提到的對於字串和位元組的額外檢查是為了防止將它們再展開成單個字元。如果還有其他你不想展開的型別,修改引數 ignore_types 即可。

最後要注意的一點是,yield from 在涉及到基於協程和生成器的併發程式設計中扮演著更加重要的角色。

相關文章