Python學習之路33-上下文管理器和else塊

VPointer發表於2019-02-14

《流暢的Python》筆記。

本篇主要討論Python使用者常忽略掉的一些流程控制特性,包括上下文管理器和else塊。內容包括else與非if關鍵字的搭配;Python中的上下文管理器,如何自定義上下文管理器,以及contextlib模組中@contextmanager裝飾器的用法。

1. if語句之外的else塊

else除了和if搭配之外,在Python中,它還能與forwhiletry搭配:

  • for:僅當for迴圈執行完畢時才執行else
  • while:僅當while迴圈因為條件為假而退出時才執行else
  • try:僅當try塊中沒有丟擲異常時才執行else塊,且else塊中丟擲的異常不會被前面的except子句處理
  • 在上述三個情況中,如果異常、returnbreakcontinue語句導致控制權跳到了複合語句的主塊之外,else子句會被跳過。

在這些語句中使用else字塊有事能讓程式碼更易讀,而且能省去一些麻煩,不用設定控制標誌或者新增額外的if語句,尤其是在和try複合時。try塊中的程式碼應該只含有預計會丟擲異常的語句,以下是兩種寫法的對比:

# 程式碼1.1,只有dangerous_all()可能會丟擲異常
# 寫法1
try:
    dangerous_all()
    after_call()
except OSError:
    log("OSError...")

# 寫法2,此寫法比上述寫法更明確
try:
    dangerous_all()
except OSError:
    log("OSError...")
else:   # 但其實這麼寫也是多餘的
    after_call()
複製程式碼

但是,並不建議大家在這些關鍵字後面加else,因為這很容易造成歧異,比如筆者第一眼看到for/else時的理解是:如果不能進入for塊,則執行else中的內容,但實際剛好相反。在其他語言中,此時的else一般由關鍵字then代替,但Python的建立人非常討厭新增新關鍵字,所以讓else擔起了這個職責。許多程式設計規範的書中也不建議在這些關鍵字後面新增else塊。

***補充:***在Python中,try/except不僅用於錯誤處理,還和if/else一樣,常用於控制流程,因此,這就形成了兩種程式碼風格:

  • EAFP:“取得原諒比獲得許可更容易”(Easier to Ask for Forgiveness than Permission),通俗講就是“不管會不會拋異常,先執行再說,等丟擲了異常再處理”,這種風格的特點就是程式碼中有很多try/except塊;
  • LBYL:“三思而後行”(Look Before You Leap),這種風格就是顯式測試前提條件,通俗講就是“必須合規後才能執行”,這種風格的特點就是程式碼中有很多if/else塊。

2. 上下文管理器和with塊

說到上下文管理器,那首先就得說說什麼是上下文。筆者第一次接觸這個概念的時候很費解,筆者是按語文裡的概念來理解的:不就是前一句話後一句話,前一段話後一段話嗎,這有什麼可管理的?雖然至今筆者也沒看到關於“上下文”這個概念的準確定義,但用多了之後,大致能理解為:

某段程式碼B將整個程式分成了3段,從前到後分別為A,B,C。當執行程式碼段B時,程式執行環境的某些設定需要發生改變;當退出程式碼段B後,這些被改變的設定需恢復原樣,即保持A和C的一致性。A和B,B和C就稱之為上下文。由於某些原因(如程式設計師大意、丟擲異常強制退出等),B中所改變的設定並不總能手動恢復回去,所以,通常將這些設定交由某些物件統一管理,這些物件就叫做上下文管理器

2.1 Python中的上下文管理器

上下文管理器採用的是鴨子型別技術,實現了__enter____exit__兩個抽象方法的物件就是上下文管理器。

上下文管理器物件的存在目的是為了管理with語句,而with語句的目的是簡化try/finally模式。

with塊的經典用法之一就是讀寫檔案:

# 程式碼2.1
>>> with open("text.txt") as fp:  # 變數fp還有一個稱呼,叫"控制程式碼"
...      pass
...
>>> fp
<_io.TextIOWrapper name="text.txt" mode="r" encoding="UTF-8">
複製程式碼

解釋

  • with後面的表示式(不包括as部分)得到的結果就是一個上下文管理器。此處open()函式返回了一個TextIOWrapper物件,Python直譯器會臨時儲存這個物件,我們這裡將其取名為a
  • with語句塊中,Python得到上下文管理器後會首先呼叫它的__enter__方法,如果with後面跟了as關鍵字,則該方法的返回值會賦給as後面的變數。上述程式碼中,當Python得到了a後,呼叫它的__enter__方法,該方法返回a物件自身(return self),然後變數fp接收這個值。但請注意,並不是所有的上下文管理器的__enter__都返回例項自身
  • 當退出with塊時,Python會呼叫上下文管理器__exit__方法,做最後處理。上述程式碼中,Python並不是呼叫fp.__exit__(),而是呼叫a.__exit__()
  • 與函式和模組不同,with塊沒有定義新的作用域,所以即便退出了with塊,變數fp依然存在。

2.2 自定義上下文管理器

下面我們自定義一個上下文管理器來說明上述四條解釋:

# 程式碼2.2
class LookingGlass:
    def __enter__(self):  # 該方法只要self一個引數
        import sys
        self.original_write = sys.stdout.write  # 儲存原方法
        sys.stdout.write = self.reverse_write   # 猴子補丁,臨時替換原本的方法
        return "JABBERWOCKY"  # 並不一定是返回self!

    def reverse_write(self, text):
        self.original_write(text[::-1])   # 反轉text內容

    def __exit__(self, exc_type, exc_val, exc_tb):  # 該方法有4個引數!
        import sys  # 由於Python會快取匯入的模組,重複匯入不會消耗很多資源
        sys.stdout.write = self.original_write  # 恢復到原本的方法
        if exc_type is ZeroDivisionError:
            print("Please DO NOT divide by zero!")
            return True   # 返回True,表示異常已經正常處理

# 控制檯中執行
>>> from mirror import LookingGlass
>>> with LookingGlass() as what:
...     print("Alice, Kitty and SnowDrop")
...     print(what)
...    
porDwonS dna yttiK ,ecilA
YKCOWREBBAJ
>>> what
`JABBERWOCKY`
>>> print("Back to normal!")
Back to normal!
複製程式碼

解釋:

  • __enter__方法只有一個引數,即隱式的self

  • __exit__有四個引數,第一個引數是self,其餘三個引數主要用於處理with塊執行期間發生的異常,分別是:

    • exc_type:異常
    • exc_val:異常例項with塊中發生異常時丟擲的物件。如果__exit__想要向上丟擲異常,那麼在建立異常物件時傳入的某些引數可從exc_val.args中獲取,比如錯誤資訊。
    • exc_tbtraceback物件。

    如果with塊中沒有丟擲異常,Python呼叫__exit__方法時傳入的引數是三個None,否則傳入異常資料。

  • with塊中發生異常時:如果__exit__返回True,表示異常已正確處理,Python直譯器會壓制異常;如果返回的是其它值,with塊中的任何異常都會向上冒泡。如果with塊中沒有發生異常,則不用關注__exit__的返回值。

2.3 contextlib模組

該模組包含了很多管理上下文的使用工具,下面列舉出5個:

  • closing:如果物件提供了close()方法,但沒有實現__enter__/__exit__協議,則可以使用這個函式構建上下文管理器
  • suppress:構建臨時忽略指定異常的上下文管理器
  • @contextmanager這個裝飾器很常用,它把簡單的生成器函式變成上下文管理器,這樣就不用建立類去實現管理器協議
  • ContextDecorator:這是個基類,用於定義基於類的上下文管理器。這種上下文管理器也能用於裝飾函式,在受管理的上下文中執行整個函式
  • ExitStack:這個上下文管理器能儲存多個上下文管理器。它是一個棧,with結束時,依次呼叫棧中各個上下文管理器的__exit__方法。如果事先不知道with塊要進入多少個上下文管理器,可以使用這個類。例如,同時開啟任意一個檔案列表中的所有檔案。

2.4 @contextmanager

@contextmanager裝飾器能減少建立上下文管理器的樣板程式碼量,不用編寫一個完整的類,然後再實現__enter____exit__方法,而是隻需實現一個僅含單個yield語句的生成器,生成想讓__enter__方法返回的值。

在使用@contextmanager裝飾的生成器中,yield語句的作用是把函式的定義體分成兩部分:yield語句前面的所有程式碼在with塊開始時(即直譯器呼叫__enter__方法時)執行,yield之後的程式碼在with塊結束時(即呼叫__exit__方法時)執行。

下面我們將之前的LookingGlass類改寫為生成器版本:

# 程式碼2.3
from contextlib import contextmanager

@contextmanager
def looking_glass():
    import sys
    original_write = sys.stdout.write

    def reverse_write(text):
        original_write(text[::-1])

    sys.stdout.write = reverse_write
    msg = ""
    try:
        yield "JABBERWOCKY"  # 如果有異常,會在這裡丟擲
    except ZeroDivisionError:  
        # 該裝飾器預設所有異常都得到了處理,如果不想異常被壓制,請在此處丟擲
        msg = "Please DO NOT divide by zero!"
    finally:
        sys.stdout.write = original_write
        if msg:
            print(msg)

# 用法和之前的版本一樣:
>>> with looking_glass() as what:   # 這裡是唯一的變化
...     print("Alice, Kitty and SnowDrop")
...     print(what)
...    
porDwonS dna yttiK ,ecilA
YKCOWREBBAJ
>>> what
`JABBERWOCKY`
複製程式碼

contextlib.contextmanager裝飾器會把函式包裝成實現了__enter____exit__方法的類。

這個類的__enter__方法有如下作用:

  • 呼叫生成器函式,儲存生成器物件(這裡稱其為gen
  • 呼叫next(gen),執行到yield關鍵字所在的位置
  • 返回next(gen)生成的值,將其繫結到with/as語句中的目標變數上

它的__exit__方法有如下作用:

  • 檢查有沒有把異常傳給exc_type;如果有,呼叫gen.throw(exception),在生成器函式定義體中yield所在行丟擲異常
  • 否則,呼叫next(gen),將生成器函式中剩餘程式碼執行完。

前面說到,對一般的上下文管理器,如果with中丟擲了異常,Python直譯器會根據__exit__的返回值來決定是否壓制異常。但@contextmanager則不同:它提供的__exit__方法預設所有異常都得到了處理。如果不想讓@contextmanager,必須在被裝飾的函式中顯式重新丟擲異常。

3. 總結

本篇分為了兩個部分,首先介紹了elseforwhile以及try的搭配用法(但並不建議這麼做,只需要知道能這麼用就行了);隨後是上下文管理器的內容,介紹了什麼是“上下文”,什麼是“上下文管理器”,Python中的上下文管理器以及with塊,然後我們自定義了一個上下文管理器,最後介紹了contextlib模組,並用其中的@contextmanager裝飾器改寫了自定義的上下文管理器。

迎大家關注我的微信公眾號”程式碼港” & 個人網站 www.vpointer.net ~

Python學習之路33-上下文管理器和else塊

相關文章