理解zip函數的工做流程

zip函數是Python的內置函數,在拙做《跟老齊學Python:輕鬆入門》有必定的介紹,可是,考慮到那本書屬於Python的入門讀物,並無講太深。可是,並不意味着這個函數不能應用的很深刻,特別是結合迭代器來理解此函數,會讓人有豁然開朗的感受。同時,可以巧妙地解決某些問題。html

本文試圖對zip進行深刻探討,同時兼顧對迭代器的理解。python

1、理解zip函數

看下面的操做。數組

>>> list(zip([1, 2, 3], ['a', 'b', 'c']))
[(1, 'a'), (2, 'b'), (3, 'c')]
複製代碼

[1, 2, 3]['a', 'b', 'c']是列表,全部列表都是可迭代的。這意味着能夠一次返回一個元素。bash

zip函數最終獲得一個zip對象——一個迭代器對象。而這個迭代器對象是由若干個元組組成的。函數

那麼這些元組是如何生成的呢?測試

對照上面的代碼,從開始算起。按照Python中的技術習慣,開始的那個元組是用0來計數的,即第0個。ui

  1. 第0個元組中索引爲0的元素,來自於zip函數第0個參數([1,2,3])當前指針所指的值,剛開始讀取,那就是1;
  2. 第0個元組中索引爲1的元素,來自於zip函數中第1個參數(['a', 'b', 'c'])當前指針所指的值,也是剛剛開始讀取,應該是a;因而組成了zip對象的第0個元組(1, 'a')
  3. 第0個元組中索引爲2的元素,來自於zip函數中第2個參數——沒有第二個,上面的參數總共有0、1兩個。那麼這個元組就組建完畢了,也就沒有索引爲2的元素。接下來再組建下一個元組,按照這裏的計數順序應該是第1個元組。
  4. zip對象第1個元組中索引爲0的元素,來自於zip函數第0個參數當前指針所指的值。注意,由於上次已經度去過1了,這時候對應的值是2。
  5. 同上面的道理,這時候指針所指的值是'b'。因而組成了zip對象第1個元組(2, 'b')
  6. 如此重複,就獲得了最終的結果。

請注意上面的敘述,若是把元組中的組成對象來源歸納一句話,那就是:元組中的第i個元素就來自於第i個參數中指針在當前所指的元素對象——請細細品味這句話的含義,後面有大用途。編碼

對於zip函數,上面的過程,貌似「壓縮」同樣,那麼,是否有反過程——解壓縮。例如從上面示例的結果中分別恢復出來原來的兩個對象。lua

有。通常認爲是這這麼作:spa

>>> result = zip([1, 2, 3], ["a", "b", "c"])
>>> c, v = zip(*result)
>>> print(c, v)
(1, 2, 3) ('a', 'b', 'c')
複製代碼

這是什麼原理。

result應用的是一個迭代器對象,不過爲了可以顯示的明白,也能夠理解爲是[(1, 'a'), (2, 'b'), (3, 'c')]。接下來使用了zip(*result),這裏的符號*的做用是收集參數,在函數的參數中,有對此詳細闡述(請參閱《跟老齊學Python:輕鬆入門》)。

>>> def foo(*a): print(a)
...
>>> lst = [(1,2), (3,4)]
>>> foo(*lst)
((1, 2), (3, 4))
>>> foo((1,2), (3,4))
((1, 2), (3, 4))
複製代碼

仿照這個示例,就能明晰下面兩個操做是等效的。

>>> lst = [(1, 'a'), (2, 'b'), (3, 'c')]
>>> zip(*lst)
<zip object at 0x104c8fb48>
>>> zip((1, 'a'), (2, 'b'), (3, 'c'))
<zip object at 0x104f27308>
複製代碼

從返回的對象內存編碼也能夠看出,兩個是一樣的對象。

既然如此,咱們就能夠經過理解zip((1, 'a'), (2, 'b'), (3, 'c'))的結果產生過程來理解zip(*lst)了。而前者生成結果的過程前面已經闡述過了,此處再也不贅述。

原來,所謂的「解壓縮」和「壓縮」,計算的方法是同樣的。豁然開朗。

其它經常使用函數

除了zip函數,還有一個內置函數iter,它以可迭代對象爲參數,會返回迭代器對象。

>>> iter([1, 2, 3, 4])
<list_iterator object at 0x7fa80af0d898>
複製代碼

本質上,iter函數調用參數的每一個元素,而後藉助於__next__函數返回迭代器對象,並把結果集成到一個元組中。

內置函數map是另一個返回迭代器對象的函數,它以只有一個參數的函數對象爲參數,這個函數每次從可迭代對象中取一個元素。

>>> list(map(len, ['abc', 'de', 'fghi']))
[3, 2, 4]
複製代碼

map函數的執行原理是:用__iter__函數調用第二個參數,並用__next__函數返回執行結果。在上面的例子中,len函數要調用後面的列表中的每一個元素,並返回一個迭代器對象。

既然迭代器是可迭代的,就能夠把zip返回的迭代器對象用到map函數的參數中了。例如,用下面的方式計算兩個列表中對應元素的和。

>>> list(map(sum, zip([1, 2, 3], [4, 5, 6])))
[5, 7, 9]
複製代碼

迭代器對象有兩個主要功效,一是節省內存,而是提升執行效率。

典型問題

有一個列表,由一些正整數組成,如[1, 2, 3, 4, 5, 6],寫一個函數,函數的一個參數n表示要將列表中幾個元素劃爲一組,假設n=2,則將兩個元素爲一組,最終返回[(1, 2), (3, 4), (5, 6)]

若是用簡單的方式,能夠這樣寫此函數:

def naive_grouper(inputs, n):
    num_groups = len(inputs) // n
    return [tuple(inputs[i*n:(i+1)*n]) for i in range(num_groups)]
複製代碼

測試一下,此函數可以如咱們所願那樣工做。

>>> nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> naive_grouper(nums, 2)
[(1, 2), (3, 4), (5, 6), (7, 8), (9, 10)]
複製代碼

可是,在上面的測試中,所傳入的列表元素個數是比較小的,若是列表元素個數不少,好比有100萬個。這就須要有比較大的內存了,不然沒法執行運算。

可是,若是這樣執行此程序:

def naive_grouper(inputs, n):
    num_groups = len(inputs) // n
    return [tuple(inputs[i*n:(i+1)*n]) for i in range(num_groups)]
 
for _ in naive_grouper(range(100000000), 10):
    pass
複製代碼

把上面的程序保存爲文件naive.py。能夠用下面的指令,測量運行程序時所佔用的內存空間和耗費的時長。注意,要確保你本地機器的內存至少5G。

$ time -f "Memory used (kB): %M\nUser time (seconds): %U" python3 naive.py
Memory used (kB): 4551872
User time (seconds): 11.04
複製代碼

注意:Ubuntu系統中,你可能要執行 /usr/bin/time

把列表或者元素傳入naïve_grouper函數,須要計算機提供4.5GB的內存空間,才能執行range(100000000)的循環。

若是採用迭代器對象,就會有很大變化了。

def better_grouper(inputs, n):
    iters = [iter(inputs)] * n
    return zip(*iters)
複製代碼

這個簡短的函數中,內涵仍是很豐富的。因此咱們要逐行解釋。

表達式[iters(inputs)] * n建立一個迭代器對象,它包含了n個一樣的列表對象。

下面以n=2爲例說明。

>>> nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> iters = [iter(nums)] * 2
>>> list(id(itr) for itr in iters)  # 內存地址是同樣的
[139949748267160, 139949748267160]
複製代碼

iters中的兩個迭代器對象是同一個對象——認識到這一點很是重要。

結合前面對zip的理解,zip(*iters)zip(iter(nums), iter(nums))是同樣的。爲了可以以更直觀的方式進行說明,就能夠認爲是zip((1, 2, 3, 4, 5, 6, 7, 8, 9, 10), (1, 2, 3, 4, 5, 6, 7, 8, 9, 10))。按照前文所述的zip工做流程,其計算過程以下:

  1. 第0個元組中的第0個元素,來自於第0個參數中指針當前所指對象,是1;
  2. 第0個元組中的第1個元素,來自於第1個參數中指針當前所指對象,特別注意,兩個參數是同一個對象,此時指針所指的是2。
  3. 第0個元組中的第2個元素,來自於第2個參數——沒有。因而乎獲得了元組(1, 2)
  4. 接下來是第1個元組中的第0個元素,來自第0個參數中指針當前所指的對象,仍是由於同一個對象的元素,此時指針所指的是3。
  5. 依次類推,獲得(3,4)等元組。
>>> nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> list(better_grouper(nums, 2))
[(1, 2), (3, 4), (5, 6), (7, 8), (9, 10)]
複製代碼

上面的函數better_grouper()的優勢在於:

  • 不使用內置函數len(),可以以任何可迭代對象爲參數
  • 返回的是可迭代對象,不是列表。這樣更少佔用內存

把上述流程保存爲文件better.py

def better_grouper(inputs, n):
    iters = [iter(inputs)] * n
    return zip(*iters)
  
for _ in better_grouper(range(100000000), 10):
    pass
複製代碼

而後使用 time在終端執行。

$ time -f "Memory used (kB): %M\nUser time (seconds): %U" python3 better.py
Memory used (kB): 7224
User time (seconds): 2.48
複製代碼

對比前面執行 naive.py,不論在內存仍是執行時間上,都表現很是優秀。

進一步研究

對於上面的better_grouper函數,深刻分析一下,發現它還有問題。它只能分割可以被列表長度整除的列表中的數字,若是不能整除的話,就會有一些元素被捨棄。例如:

>>> nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> list(better_grouper(nums, 4))
[(1, 2, 3, 4), (5, 6, 7, 8)]
複製代碼

若是要4個元素一組,就會有9和10不能分組了。之因此,仍是由於zip函數。

>>> list(zip([1, 2, 3], ['a', 'b', 'c', 'd']))
[(1, 'a'), (2, 'b'), (3, 'c')]
複製代碼

最終獲得的zip對象中的元組數量,是參數中長度最小的對象決定。

若是你感受這樣作不爽,可使用itertools.zip_longest(),這個函數是以最長的參數爲基準,若是有不足的,默認用None填充,固然也能夠經過參數fillvalue指定填充對象。

>>> import itertools
>>> x = [1, 2, 3]
>>> y = ["a", "b", "c", "d"]
>>> list(itertools.zip_longest(x, y))
[(1, 'a'), (2, 'b'), (3, 'c'), (None, 'd')]
複製代碼

那麼,就能夠將better_grouper函數中的zipzip_longest()替代了。

import itertools as it
  
def grouper(inputs, n, fillvalue=None):
    iters = [iter(inputs)] * n
    return it.zip_longest(*iters, fillvalue=fillvalue)
複製代碼

再跑一下,就是這樣的結果了。

>>> nums = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
>>> print(list(grouper(nums, 4)))
[(1, 2, 3, 4), (5, 6, 7, 8), (9, 10, None, None)]
複製代碼

《跟老齊學Python:輕鬆入門》

相關文章
相關標籤/搜索