1. 程式人生 > 其它 >巧用上下文管理器和With語句精簡程式碼

巧用上下文管理器和With語句精簡程式碼

巧用上下文管理器和With語句精簡程式碼

我想你對 Python 中的 with 語句一定不陌生,尤其是在檔案的輸入輸出操作中,不過我想,大部分人可能習慣了它的使用,卻並不知道隱藏在其背後的“祕密”。

那麼,究竟 with 語句要怎麼用,與之相關的上下文管理器(context manager)是什麼,它們之間又有著怎樣的聯絡呢?這節課,我就帶你一起揭開它們的神祕面紗。

什麼是上下文管理器?

在任何一門程式語言中,檔案的輸入輸出、資料庫的連線斷開等,都是很常見的資源管理操作。但資源都是有限的,在寫程式時,我們必須保證這些資源在使用過後得到釋放,不然就容易造成資源洩露,輕者使得系統處理緩慢,重則會使系統崩潰。

光說這些概念,你可能體會不到這一點,我們可以看看下面的例子:

for x in range(10000000): 
    f = open('test.txt', 'w')
    f.write('hello') 

這裡我們一共打開了 10000000 個檔案,但是用完以後都沒有關閉它們,如果你執行該段程式碼,便會報錯:

OSError:[Errno23]Toomanyopenfilesinsystem:'test.txt'

這就是一個典型的資源洩露的例子。因為程式中同時打開了太多的檔案,佔據了太多的資源,造成系統崩潰。

為了解決這個問題,不同的程式語言都引入了不同的機制。而在 Python 中,對應的解決方式便是上下文管理器(context manager)。上下文管理器,能夠幫助你自動分配並且釋放資源,其中最典型的應用便是 with 語句

。所以,上面程式碼的正確寫法應該如下所示:

for x in range(10000000):
    with open('test.txt', 'w') as f:
        f.write('hello')

這樣,我們每次開啟檔案“test.txt”,並寫入‘hello’之後,這個檔案便會自動關閉,相應的資源也可以得到釋放,防止資源洩露。當然,with 語句的程式碼,也可以用下面的形式表示:

f = open('test.txt', 'w')
try:
     f.write('hello')
finally:
     f.close()

要注意的是,最後的 finally 尤其重要,哪怕在寫入檔案時發生錯誤異常,它也可以保證該檔案最終被關閉。不過與 with 語句相比,這樣的程式碼就顯得冗餘了,並且還容易漏寫,因此我們一般更傾向於使用 with 語句。

另外一個典型的例子,是 Python 中的 threading.lock 類。舉個例子,比如我想要獲取一個鎖,執行相應的操作,完成後再釋放,那麼程式碼就可以寫成下面這樣:

some_lock = threading.Lock()
some_lock.acquire()
try:
    ...
finally:
    some_lock.release()

而對應的 with 語句,同樣非常簡潔:

some_lock = threading.Lock()
with somelock:

    ...

我們可以從這兩個例子中看到,with 語句的使用,可以簡化了程式碼,有效避免資源洩露的發生。

上下文管理器的實現

基於類的上下文管理器

瞭解了上下文管理的概念和優點後,下面我們就通過具體的例子,一起來看看上下文管理器的原理,搞清楚它的內部實現。這裡,我自定義了一個上下文管理類 FileManager,模擬 Python 的開啟、關閉檔案操作:

class FileManager:
    def __init__(self, name, mode):
        print('calling __init__ method')
        self.name = name
        self.mode = mode 
        self.file = None
        
    def __enter__(self):
        print('calling __enter__ method')
        self.file = open(self.name, self.mode)
        return self.file


    def __exit__(self, exc_type, exc_val, exc_tb):
        print('calling __exit__ method')
        if self.file:
            self.file.close()
            
with FileManager('test.txt', 'w') as f:
    print('ready to write to file')
    f.write('hello world')
    
## 輸出
# calling __init__ method
# calling __enter__ method
# ready to write to file
# calling __exit__ method

需要注意的是,當我們用類來建立上下文管理器時,必須保證這個類包括方法__enter__()和方法__exit__()。其中,方法__enter__()返回需要被管理的資源,方法__exit__()裡通常會存在一些釋放、清理資源的操作,比如這個例子中的關閉檔案等等。

而當我們用 with 語句,執行這個上下文管理器時:

with FileManager('test.txt', 'w') as f: 
    f.write('hello world')

下面這四步操作會依次發生:

  1. 方法__init__()被呼叫,程式初始化物件 FileManager,使得檔名(name)是”test.txt”,檔案模式 (mode) 是’w’;

  2. 方法__enter__()被呼叫,檔案“test.txt”以寫入的模式被開啟,並且返回 FileManager 物件賦予變數 f;

  3. 字串“hello world”被寫入檔案“test.txt”;

  4. 方法__exit__()被呼叫,負責關閉之前開啟的檔案流。

因此,這個程式的輸出是:

calling __init__ method
calling __enter__ method
ready to write to file
calling __exit__ meth

另外,值得一提的是,方法__exit__()中的引數“exc_type, exc_val, exc_tb”,分別表示 exception_type、exception_value 和 traceback。當我們執行含有上下文管理器的 with 語句時,如果有異常丟擲,異常的資訊就會包含在這三個變數中,傳入方法__exit__()

因此,如果你需要處理可能發生的異常,可以在__exit__()新增相應的程式碼,比如下面這樣來寫:

class Foo:
    def __init__(self):
        print('__init__ called')        

    def __enter__(self):
        print('__enter__ called')
        return self
    
    def __exit__(self, exc_type, exc_value, exc_tb):
        print('__exit__ called')
        if exc_type:
            print(f'exc_type: {exc_type}')
            print(f'exc_value: {exc_value}')
            print(f'exc_traceback: {exc_tb}')
            print('exception handled')
        return True
    
with Foo() as obj:
    raise Exception('exception raised').with_traceback(None)

# 輸出
__init__ called
__enter__ called
__exit__ called
exc_type: <class 'Exception'>
exc_value: exception raised
exc_traceback: <traceback object at 0x1046036c8>
exception handled

這裡,我們在 with 語句中手動丟擲了異常“exception raised”,你可以看到,__exit__()方法中異常,被順利捕捉並進行了處理。不過需要注意的是,如果方法__exit__()沒有返回 True,異常仍然會被丟擲。因此,如果你確定異常已經被處理了,請在__exit__()的最後,加上“return True”這條語句。

同樣的,資料庫的連線操作,也常常用上下文管理器來表示,這裡我給出了比較簡化的程式碼:

class DBConnectionManager: 
    def __init__(self, hostname, port): 
        self.hostname = hostname 
        self.port = port 
        self.connection = None
  
    def __enter__(self): 
        self.connection = DBClient(self.hostname, self.port) 
        return self
  
    def __exit__(self, exc_type, exc_val, exc_tb): 
        self.connection.close() 
  
with DBConnectionManager('localhost', '8080') as db_client: 

與前面 FileManager 的例子類似:

  • 方法__init__()負責對資料庫進行初始化,也就是將主機名、介面(這裡是 localhost 和 8080)分別賦予變數 hostname 和 port;

  • 方法__enter__()連線資料庫,並且返回物件 DBConnectionManager;

  • 方法__exit__()則負責關閉資料庫的連線。

這樣一來,只要你寫完了 DBconnectionManager 這個類,那麼在程式每次連線資料庫時,我們都只需要簡單地呼叫 with 語句即可,並不需要關心資料庫的關閉、異常等等,顯然大大提高了開發的效率。

同樣的,資料庫的連線操作,也常常用上下文管理器來表示

這裡我給出了比較簡化的程式碼:

class DBConnectionManager: 
    def __init__(self, hostname, port): 
        self.hostname = hostname 
        self.port = port 
        self.connection = None
  
    def __enter__(self): 
        self.connection = DBClient(self.hostname, self.port) 
        return self
  
    def __exit__(self, exc_type, exc_val, exc_tb): 
        self.connection.close() 
  
with DBConnectionManager('localhost', '8080') as db_client: 
  ....

基於生成器的上下文管理器

誠然,基於類的上下文管理器,在 Python 中應用廣泛,也是我們經常看到的形式,不過 Python 中的上下文管理器並不侷限於此。除了基於類,它還可以基於生成器實現。

接下來我們來看一個例子。

比如,你可以使用裝飾器 contextlib.contextmanager,來定義自己所需的基於生成器的上下文管理器,用以支援 with 語句。

還是拿前面的類上下文管理器 FileManager 來說,我們也可以用下面形式來表示:

from contextlib import contextmanager

@contextmanager
def file_manager(name, mode):
    try:
        f = open(name, mode)
        yield f
    finally:
        f.close()
        
with file_manager('test.txt', 'w') as f:
    f.write('hello world')

這段程式碼中,函式 file_manager() 是一個生成器,當我們執行 with 語句時,便會開啟檔案,並返回檔案物件 f;當 with 語句執行完後,finally block 中的關閉檔案操作便會執行。

你可以看到,使用基於生成器的上下文管理器時,我們不再用定義__enter__()__exit__()方法,但請務必加上裝飾器 @contextmanager,這一點新手很容易疏忽。

講完這兩種不同原理的上下文管理器後,還需要強調的是,基於類的上下文管理器和基於生成器的上下文管理器,這兩者在功能上是一致的。只不過,

  • 基於類的上下文管理器更加 flexible,適用於大型的系統開發;

  • 而基於生成器的上下文管理器更加方便、簡潔,適用於中小型程式。

無論你使用哪一種,請不用忘記在方法__exit__()或者是 finally block 中釋放資源,這一點尤其重要。

總結

這節課,我們先通過一個簡單的例子,瞭解了資源洩露的易發生性,和其帶來的嚴重後果,從而引入了應對方案——即上下文管理器的概念。上下文管理器,通常應用在檔案的開啟關閉和資料庫的連線關閉等場景中,可以確保用過的資源得到迅速釋放,有效提高了程式的安全性,

接著,我們通過自定義上下文管理的例項,瞭解了上下文管理工作的原理,並一起學習了基於類的上下文管理器和基於生成器的上下文管理器,這兩者的功能相同,具體用哪個,取決於你的具體使用場景。

另外,上下文管理器通常和 with 語句一起使用,大大提高了程式的簡潔度。需要注意的是,當我們用 with 語句執行上下文管理器的操作時,一旦有異常丟擲,異常的型別、值等具體資訊,都會通過引數傳入__exit__()函式中。你可以自行定義相關的操作對異常進行處理,而處理完異常後,也別忘了加上“return True”這條語句,否則仍然會丟擲異常。