一篇夯實一個知識點系列--python生成

藥少敏發表於2020-07-11

寫在前面

本系列目的:一篇文章,不求鞭辟入裡,但使得心應手。

  • 迭代是資料處理的基石,在掃描記憶體無法裝載的資料集時,我們需要一種惰性獲取資料的能力(即一次獲取一部分資料到記憶體)。在Python中,具有這種能力的物件就是迭代器。生成器是迭代器的一種特殊表現形式。

  • 個人認為生成器是Python中最有用的高階特性之一(甚至沒有之一)。雖然初級編碼中使用寥寥,但隨著學習深入,會發現生成器是協程,非同步等高階知識的基石。Python最有野心的asyncio庫,就是用協程砌造的。

    注:生成器和協程本質相同。PEP342(Python增強提案)增加了生成器的send()方法,使其變身為協程。如此之後,生成器生成資料,協程消費資料。雖然本質相同,但是由於從理念上說協程跟迭代沒有關係,並且糾纏生成器和協程的區別與聯絡會引爆自己的大腦,所以應該將這兩個概念區分。此處說本質相同意為:理解生成器原理之後,理解增加了send方法,但是實現方式幾乎相同的協程會更加輕鬆(這段話看不懂沒有關係,船到橋頭自然直,學到協程自然懂)。

  • Python的一致性是其最迷人的地方。瞭解了Python生成器,迭代器的實現。就會對Python的一致性設計有更加強烈的感知。本文讀完之後,遇到面試官提問為什麼列表可以迭代,字典可以迭代,甚至文字檔案都可以迭代時,你就可以穩(huang)得一批。

  • 閱讀本文之前,如果你對Python的一致性有一些瞭解,如鴨子型別,或者Cpython的PyObject結構體,那真是太棒了。不過鑑於筆者深厚的文字功底,沒有這些知識也不打緊。

乾貨兒

  • 迭代器

    在學習生成器之前,先要了解迭代器。顧名思義,迭代器即具有迭代功能的物件。在Python中,可以認為迭代器可以通過不斷迭代,產生出一個又一個的物件。

    • 可迭代物件和迭代器

      Python的一致性是靠協議支撐的。一個物件只要遵循以下協議,它就是一個可迭代物件或迭代器。

      • Python中的一個物件,如果實現了iter方法,並且iter方法返回一個迭代器,那麼它就是可迭代物件。如果實現了iter和next方法,並且iter方法返回一個迭代器,那麼它就是迭代器(有點繞,按住不表,繼續學習)。

        注:如果物件實現了__getitem__方法,並且索引從0開始,那麼也是可迭代物件。此hack為相容性考慮。只需切記,如果你要實現可迭代物件和可迭代器,那麼請遵循以上協議。

      • 可迭代物件的iter返回迭代器,迭代器的iter方法返回自身(也是迭代器),迭代器的next方法實現迭代功能,不斷返回下一個元素,或者在元素為空時raise一個StopIteration終止迭代。

    • 可迭代物件與迭代器的關係

      話不多說,上程式碼。

      class Iterable:
          def __init__(self, *args):
              self.items = args
      
          def __iter__(self):
              return Iterator(self.items)       
      
      class Iterator:
          def __init__(self, items):
              self.items = items
              self.index = 0
      
          def __iter__(self):
              return self                       
      
          def __next__(self):                
              try:
                  item = self.items[self.index]
              except IndexError:
                  raise StopIteration()
              self.index += 1
              return item
      
      ins = Iterable(1,2,3,4,5)        # 1
      for i in ins:
          print(i)
      print('the end...')
      >>> 											 # 2
      1
      2
      3
      4
      5
      the end ...
      
      • 上述程式碼中,實現了可迭代物件Iterable和迭代器Iterator。遵循協議規定,Iterable實現了iter方法,且iter方法返回迭代器Iterator例項,迭代器實現了iter方法和next方法,iter返回自身(即sel,迭代器本身f),next方法返回迭代器中的元素或者引發StopIteration異常。執行上述程式碼,會看到#2處的輸出。

      • 通過上述程式碼迭代一個物件顯得十分囉嗦。比如在Iterable中,iter必須要返回一個迭代器。為什麼不能直接用Iterator迭代元素呢?假設我們通過迭代器來迭代元素,將上述程式碼中的#1處如下程式碼:

        ins = Iterator([1,2,3,4,5])
        for i in ins:														# 3
            print(i)
        for i in ins:														# 4
            print(i)
        next(ins)															# 5
        print('the end...')
        >>>  																	# 6
        1
        2
        3
        4
        5
        ...
        File "/home/disk/test/a.py", line 20, in __next__		# 7
            raise StopIteration()
        the end...
        

        執行上述程式碼,會看到#6處的輸出。疑惑的是,#3和#4處執行了兩次for迴圈,結果只列印一遍所有元素。解釋如下:

        • 上述程式碼中,ins是一個Iterator迭代器物件。那麼ins符合迭代器協議:每次呼叫next,會返回下一個元素,直到迭代器元素為空,raise一個StopIteration異常。

        • #3處第一次通過for迴圈迭代ins,相當於不斷呼叫ins的next方法,不斷返回下一個元素,輸出如#6所示。當元素為空時,迭代器raise了StopIterator。而這個異常會被for迴圈捕獲,不會暴露給使用者,所以我們就認為資料迭代完成,並且沒有出現異常。

        • 迭代器ins內的元素已經被#3處的for迴圈消耗完,並且raise了StopIteration(只不過被for迴圈捕獲靜默處理,沒有暴露給使用者)。此時ins已經是元素消耗殆盡的“空”狀態。在#4處第二次通過for迴圈迭代ins,因為ins內的元素為空,繼續呼叫ins的next方法,那麼還是會raise一個StopIteration,而且又被for迴圈靜默處理,所以沒有異常,也沒有輸出。

        • 接下來,#5處通過next方法獲取ins的下一個元素,同上,繼續raise一個StopIteration異常。由於此處通過next呼叫而不是for迴圈,異常不會被處理,所以丟擲到使用者層面,即#7輸出。

        • 重新編寫上述程式碼中#3處for迴圈和#4處for迴圈,可以看到對應輸出驗證了我們的結論。第一次for迴圈在迭代到元素為2時跳出迴圈,第二次for迴圈繼續迭代同一個迭代器,那麼會繼續上次迭代器結束位置繼續迭代元素。程式碼如下:

          ins = Iterator([1,2,3,4,5])
          print('the first for:')
          for i in ins:								    # 3  the first for
            print(i)
            if i == 2:
                break
          print('the second for:')
          for i in ins:								   # 4 the second for
                print(i)
          print('the end...')
          >>>												# the output
          the first for:
          1
          2
          the second for:
          3
          4
          5
          the end...
          

          所以我們可以得到如下結論:

          • 一個迭代器物件只能迭代一遍。多次迭代,相當於不停對一個空迭代器呼叫next方法,會不停raise StopIteration異常。
          • 由於迭代器實現了iter方法,並且iter方法返回了迭代器,那麼迭代器也是一個可迭代物件(廢話,不是可迭代物件,上述程式碼中如何可以用for迴圈迭代呢)
          • 綜上來說,可迭代物件和迭代器明顯是一個多型的問題。迭代器是一個可迭代物件,可以迭代返回元素,由於iter返回self(即自身例項),所以只能迭代一遍,迭代到末尾就會丟擲異常。而每次迭代可迭代物件,iter都會返回一個新的迭代器例項。所以可迭代物件是支援多次迭代的。比如l=[i for i in range(10)]生成的list物件就是一個可迭代物件,可以被多次迭代。l=(i for i in range(10))生成的是一個迭代器,只能被迭代一遍。
    • 迭代器支援

      引用流暢的Python中的原話,迭代器支援以下6個功能。由於篇幅所限,點到為止。大家只要理解了迭代器的原理,理解以下功能自然是水到渠成。

      • for迴圈

        上述程式碼已經有舉例,可參考

      • 構建和擴充套件集合型別

        from collections improt abc
        
        class NewIterator(abc.Iterator):
            pass													# 放飛自我,實現新的型別
        
      • 列表推導,字典推導和集合推導

        l = [i for i in range(10)]			# list
        d = {i:i for i in range(10)}	  # dict
        s = {i for i in range(10)}		   # set
        
      • 遍歷文字檔案

        with open ('a.txt') as f:
            for line in f:
                print(line)
        
      • 元祖拆包

        for i, j in [(1, 2), (3, 4)]:
            print(i,  j)
        >>>
        1 2
        3 4
        
      • 呼叫函式時,使用*拆包實參

        def func(a, b, c):
            print(a, b, c)
        
        func(*[1, 2, 3])  # 會將[1, 2, 3]這個list拆開成三個實參,對應a, b, c三個形參傳給func函式
        
  • 生成器

    Python之禪曾經說過,simple is better than complex。鑑於以上程式碼中迭代器複雜的實現方式。Python提供了一個更加pythonic的實現方式——生成器。生成器函式就是含有yield關鍵字的函式(目前這種說法是正確的,之後會學到yield from等句法,那麼這個說法就就需要更正了),生成器物件就是呼叫生成器函式返回的物件。

    • 生成器的實現

      將上述程式碼修改為生成器實現,如下:

      class Iterable:
          def __init__(self, *args):
              self.items = args
      
          def __iter__(self):							# 8
              for item in self.items:
                  yield item
      
      ins = Iterable(1, 2, 3, 4, 5)
      print('the first for')
      for i in ins:
          print(i)
      print('the second for')
      for i in ins:
          print(i)
      print('the end...')
      
      >>>															# 9							
      the first for
      1
      2
      3
      4
      5
      the second for
      1
      2
      3
      4
      5
      the end...
      

      上述程式碼中,可迭代物件的iter方法並沒有只用了短短數行,就完成了之前Iterator迭代器功能,點贊!

    • yield關鍵字

      要理解以上程式碼,就需要理解yield關鍵字,先來看以下最簡單的生成器函式實現

      def func():
          yield 1																
          yield 2
          yield 3
      
      ins1 = func()
      ins2 = func()
      print(func)
      print(ins1)
      print(ins2)
      
      for i in ins1:
          print(i)
      for i in ins1:
          print(i)
      
      print(next(ins2))
      print(next(ins2))
      print(next(ins2))
      print(next(ins2))
      
      >>> 
      <function func at 0x7fcb1e4bde18>
      <generator object func at 0x7fcb1cc7c0a0>
      <generator object func at 0x7fcb1cc7c0f8>
      1
      2
      3
      1
      2
      3
        File "/home/disk/test/a.py", line 18, in <module>
          print(next(ins2))
      StopIteration
      

      從以上程式碼可以看出:

      • func是一個函式,但是呼叫func會返回一個生成器物件,並且通過列印的地址看,每次呼叫生成器函式會返回一個新的生成器物件。
      • 生成器物件和迭代器物件相似,都可以被for迴圈迭代,都只能被迭代一遍,通過next呼叫,都會在生成器元素為空時raise一個StopIteration異常。

      那麼含有yield關鍵字的生成器函式體是如何執行的呢?請看如下程式碼:

      def f_gen():							# 10
          print('start')
          yield 1									# 11
          print('stop')
          yield 2									# 12
          print('next')
          yield 3									# 13
          print('end')
      
      for i in f_gen():					# 14
          print(i)
      
      >>>
      start
      1
      stop
      2
      next
      3
      end
      

      從上述程式碼及其列印結果,我們可以得出如下結論:

      • #10處程式碼表明,生成器函式定義與普通函式無二,只是需要包含有yield關鍵字
      • #14for 迴圈隱形呼叫next的時候,會執行到#11處,列印start,然後產出值 1返回給for迴圈,列印
      • for 迴圈繼續呼叫next,從#11處執行到#12處#,列印stop,然後產出值 2返回給for迴圈,列印
      • for 迴圈繼續呼叫next,從#12處執行到#13處#,列印next,然後產出值 3返回給for迴圈,列印
      • for 迴圈繼續呼叫next,從#13處執行到函式尾#,列印end,然後raise一個StopIteration,由於for迴圈捕獲異常,程式正常執行
      • 綜上所述,yield具有暫停的功能,每次迭代生成器,生成器函式體都會前進到yield語句處,並將yield之後的值丟擲(無值拋None)。生成器函式作為一個工廠函式,實現了可迭代物件中iter函式的功能,可以每次產出一個新的迭代器例項。由於使用了特殊的yield關鍵字,它擁有與區別於迭代器的新名字——生成器,它其實與迭代器並無二致
  • 生成器表示式

    將列表推導式中的[]改為(),即為生成器表示式。返回的是一個生成器物件。一般使用者列表推導但是又不需要立馬產生所有值的情景中。

    gen = (i for i in range(10))
    
    for i in gen:
        print(i)
    
    for i in gen:						# 只能被消費一遍,第二遍無輸出
        print(i)
    print('the end...')
    
    >>> 
    0
    1
    2
    3
    4
    5
    6
    7
    8
    9
    the end...
    
  • itertools

    python的內建模組itertools提供了對生成器的諸多支援。這裡列舉一個,其它支援請看文件

    gen = itertools.count(1, 2)    # 從1開始,步長為2,不斷產生數值
    
    >>> next(gen)
    1
    >>> next(gen)
    3
    >>> next(gen)
    5
    >>> next(gen)
    7
    >>> next(gen)
    9
    >>> next(gen)
    11
    
  • yield from 關鍵字

    yield from 是python3.3中出現的新句法。yield from句法可以實現委派生成器。

    def func():
        yield from (i for i in range(5))
    
    gen = func()
    
    for i in gen:
        print(i)
        
    >>>
    0
    1
    2
    3
    4
    

    如上所示,yield from把func作為了一個委派生成器。for迴圈可以通過委派生成器func直接迭代子生成器(i for i in range(5))。不過只是這個取巧遠遠不足以將yield from作為一個新句法加入到Python中。比起上述程式碼的迭代內層迴圈,新句法更加重要的功能是委派生成器為呼叫者和子生成器建立了一個管道。通過生成器的send方法就可以在管道中為兩端傳遞訊息。如果使用此方法在程式層面控制執行緒行為,就會迸發出強大的能量,它叫做協程。

寫在最後


  • 注意事項

    迭代器與生成器功能強大,不過使用中還是有幾點要注意:

    • 迭代器應該實現iter方法,雖然很多時候不實現此方法頁不會影響程式碼執行。實現此方法的最主要原因有二:
      - 迭代器協議規定需要實現此方法
      - 可以通過issubclass檢查物件是否是迭代器
    • 不要把可迭代物件變為迭代器。原因有二:
      • 這不符合迭代器協議規定,造就了一個四不像。
      • 可迭代物件應該是可以重複遍歷的,如果變為了迭代器,那麼只能遍歷一次。
  • tips

    個人覺得迭代器有趣的點

    • os.walk

      os.walk迭代器可以深度遍歷目錄,是個大殺器,你值得擁有,快去試試吧。

    • iter

      iter可以接受兩個位置引數:callable和flag。callable()可以不斷產出值,如果等於flag,則終止。如下是一個小例子

      gen = (i for i in range(10))
      for i in iter(lambda: next(gen), 4):				# 執行ntext(gen), 不斷返回生成器中的值,等於4則停止
          print(i)
      
      >>> 
      0
      1
      2
      3
      the end...
      
    • yield可以接收值

      yield可以接收send傳送的值。如下程式碼中,#16處send的值,會傳給#15中的yield,然後賦值給res。

      def func():
          res = yield 1				#15
          print(res)			
      
      f = func()
      f.send(None)			  # 預激
      f.send(5)						# 16
      

希望大家可以通過本文掌握裝飾器這個殺手級特性。歡迎關注個人部落格:藥少敏的部落格

相關文章