使用 with as
May 4, 2022經常地,在使用 try、finally 嘗試關閉資源時,會發現程式撰寫的流程是類似的,就如〈使用 else、finally〉示範的,在 try 中進行指定的動作,最後在 finally 中關閉檔案。
with as
為了應付之後類似的需求,你可以自定義一個 with_file 函式。例如:
def with_file(f, consume):
    try:
        consume(f)
    finally:
        f.close()
有了 with_file 函式,就可以運用這個 with_file 函式來改寫方才的範例:
import sys
for arg in sys.argv[1:]:
    try:
        f = open(arg, 'r')
    except FileNotFoundError:
        print('找不到檔案', arg)
    else:
        with_file(f, lambda f: print(arg, ' 有 ', len(f.readlines()), ' 行 '))
對於其他的需求,也可以重用這個 with_file 函式。例如:
import sys, logging
def print_each_line(file):
    try:
        # 檔案物件可以使用 for in
        #下一章會說明
        for line in file:
            print(line, end = '')
    except:
        logger = logging.getLogger(__name__)
        logger.exception('未處理的例外')
try:
    with_file(open(sys.argv[1], 'r'), print_each_line)
except IndexError:
    print('請提供檔案名稱')
    print('範例:')
    print('    python read.py your_file')
except FileNotFoundError:
    print('找不到檔案 {0}'.format(sys.argv[1]))
實際上,不用自行定義 with_file 這樣的函式,Python 提供了 with as 語法來解決這類需求。例如:
import sys
for arg in sys.argv[1:]:
    try:
        with open(arg, 'r') as f:
            print(arg, ' 有 ', len(f.readlines()), ' 行 ')
    except FileNotFoundError:
        print('找不到檔案', arg)
with 之後銜接的資源實例,可以透過 as 來指定給一個變數,with as 語法是用來表示,銜接的資源實例會處於某個情境,就檔案來說,這個情境是指 with as 區塊結束後,會將 with 銜接的檔案關閉。
如果需要同時使用 with 來管理多個資源,可以使用逗號「,」區隔。例如:
with open(file_name1, 'r') as f1, open(file_name2, 'r') as f2:
    print(file_name1, ' 有 ', len(f1.readlines()), ' 行 ')
    print(file_name2, ' 有 ', len(f2.readlines()), ' 行 ')
with as 的 as 不一定需要。例如:
f = open(file_name, 'r')
with f:
    print(file_name, ' 有 ', len(f.readlines()), ' 行 ')
情境管理器
實際上,with as 不限使用於檔案,只要物件支援情境管理協定(Context Management Protocol),就可以使用 with as 語句。
支援情境管理協定的物件,必須實作 __enter__ 與 __exit__ 兩個方法,這樣的物件稱為情境管理器(Context Manager)。
with 陳述句一開始執行,就會進行 __enter__ 方法,該方法傳回的物件,可以使用 as 指定給變數(如果有的話),接著就執行 with 區塊中的程式碼,以下是個簡單示範:
class Resource:
    def __init__(self, name):
        self.name = name
    def __enter__(self):
        print(self.name, ' __enter__')
        return self
    def __exit__(self, exc_type, exc_value, traceback):
        print(self.name, ' __exit__')
        return False
with Resource('res') as resource:
    print(resource.name)
如果 with 區塊執行完畢,會執行 __exit__ 方法,若是因例外而離開 with 區塊,會傳入三個引數,這三個引數是例外的類型、實例以及 traceback 物件,__exit__ 方法若傳回 False,例外會被重新引發,否則例外就停止傳播,通常 __exit__ 會傳回 False,以便在 with 之後還可以處理例外。
如果 with 區塊中沒有發生例外而執行完畢,也是執行 __exit__ 方法,此時 __exit__ 的三個參數都接收到 None。就上面的例子來說,會如下依序顯示:
res  __enter__
res
res  __exit__
open 函式傳回的檔案物件,本身就實作了 __enter__ 與 __exit__,因此方才就能搭配 with as。
@contextmanager
雖然可以直接實作 __enter__、__exit__ 方法,讓物件能支援 with as,不過將資源的設定與清除,分開在兩個方法中實作,顯得不夠直覺,可以使用 contextlib 模組的 @contextmanager 來實作,讓資源的設定與清除更為直覺。
方才談到,with as 語法是用來表示,銜接的資源實例是處於某個情境,就檔案來說,這個情境是指 with as 區塊結束後,會將 with 銜接的檔案關閉,來假裝一下 open 函式傳回的檔案物件,沒有 __enter__ 與 __exit__,若使用 @contextmanager 來管理檔案關閉的話,該怎麼寫:
import sys
from contextlib import contextmanager
@contextmanager
def file_reader(filename):
    try:
        f = open(filename, 'r')
        yield f
    finally:
        f.close()
with file_reader(sys.argv[1]) as f:
    for line in f:
        print(line, end='')
現在呼叫 file_reader 後傳回的物件,會實現 __enter__ 與 __exit__ 方法,with 呼叫該物件的 __enter__ 方法後,會執行 file_reader 定義的流程,yield 的物件,將會是 as 後指定的變數參考之物件,with as 區塊執行完畢或因為例外而離開區塊,會執行 __exit__ 方法,流程會回到 file_reader 中 yield 進行執行,結果就是最後關閉檔案。
由於這個 file_reader 沒有捕捉例外,若是因為例外而進入 __exit__,執行了 file_reader,在 finally 關閉檔案後,例外會往外傳播。
with as 語法是用來表示,銜接的資源實例是處於某個情境,自動關閉檔案的情境只是其中一種情況,有興趣的話,可以看看 contextlib 的 suppress、closing 等,看看它們封裝了何種情境、該如何使用,有空時也可以研究一下它們的原始碼。


