如果不用“with”,那麼Python會在何時關閉檔案呢?答案是:視情況而定。
Python程式設計師最初學到的東西里有一點就是可以通過迭代法很容易地遍歷一個開啟檔案的全文:
1 2 3 |
f = open('/etc/passwd') for line in f: print(line) |
注意上面的程式碼具有可行性,因為我們的檔案物件“f”是一個迭代器。換句話說,“f“ 知道在一個迴圈或者任何其他的迭代上下文中做什麼,比如像列表解析。
我的Python課堂上的大多數學生都具有其他程式語言背景,在使用以前所熟悉的語言時,他們總是在完成檔案操作時被期望關閉檔案。因此,在我向他們介紹了Python檔案操作的內容不久後他們問起如何在Python中關閉檔案時,我一點都不驚訝。
最簡單的回答就是我們可以通過呼叫f.close()顯式地關閉檔案。一旦我們關閉了檔案,該檔案物件依然存在,但是我們無法再通過它來讀取檔案內容了,而且檔案物件返回的可列印內容也表明檔案已經被關閉。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
>>> f = open('/etc/passwd') >>> f <open file '/etc/passwd', mode 'r' at 0x10f023270> >>> f.read(5) '##n# ' f.close() >>> f <closed file '/etc/passwd', mode 'r' at 0x10f023270> f.read(5) --------------------------------------------------------------------------- ValueError Traceback (most recent call last) <ipython-input-11-ef8add6ff846> in <module>() ----> 1 f.read(5) ValueError: I/O operation on closed file |
所以是這樣,我在用Python程式設計的時候,很少明確地對檔案呼叫 “close” 方法。此外,你也很可能不想或不必那樣做。
開啟檔案的優選最佳實踐方式是使用 “with” 語句,就像如下所示:
1 2 3 |
with open('/etc/passwd') as f: for line in f: print(line) |
“with”語句對 “f” 檔案物件呼叫在Python中稱作“上下文管理器”的方法。也就是說,它指定 “f” 為指向 /etc/passwd 內容的新的檔案例項。在 “with” 開啟的程式碼塊內,檔案是開啟的,而且可以自由讀取。
然而,一旦Python程式碼從 “with” 負責的程式碼段退出,檔案會自動關閉。試圖在我們退出 “with”程式碼塊後從 f 中讀取內容會導致和上文一樣的 ValueError 異常。所以,通過使用 “with”,你避免了顯式地關閉檔案的操作。Python 會以一種不那麼有 Python 風格的方式在幕後神奇而靜靜地替你關閉檔案。
但是你不顯式地關閉檔案會怎樣?如果你有點懶,既不使用 “with” 程式碼塊也不呼叫f.close()怎麼辦?這時檔案會什麼時候關閉?何時應該關閉檔案?
我之所以問這個,是因為我教了這麼多年Python,確信努力教授“with”或上下文管理器的同時又教很多其它的話題超出了學生接受的範圍。在介紹性課程談及 “with” 時,我一般會告訴學生在他們職業生涯中遇到這個問題時,讓Python去關閉檔案就好,不論檔案物件的應用計數降為0還是Python退出時。
在我的Python檔案操作免費e-mail課程中,我並沒有在所有的解決方案中使用with,想看看如何。結果一些人質疑我,說不使用“with”會向人們展示一種糟糕的實踐方案並且會有資料未寫入磁碟的風險。
我收到了很多關於此話題的郵件,於是我問自己:如果我們沒有顯式地關閉檔案或者沒用“with”程式碼塊,那麼Python會何時關閉檔案?也就是說,如果我讓檔案自動關閉,那麼會發生什麼?
我總是假定當物件的引用計數降為0時,Python會關閉檔案,進而垃圾回收機制清理檔案物件。當我們讀檔案時很難證明或核實這一點,但寫入檔案時卻很容易。這是因為當寫入檔案時,內容並不會立即重新整理到磁碟(除非你向“open”方法的第三個可選引數傳入“False”),只有當檔案關閉時才會重新整理。
於是我決定做些實驗以便更好地理解Python到底能自動地為我做什麼。我的實驗包括開啟一個檔案、寫入資料、刪除引用和退出Python。我很好奇資料是什麼時候會被寫入,如果有的話。
我的實驗是這個樣子:
1 2 3 4 5 6 7 8 |
f = open('/tmp/output', 'w') f.write('abcn') f.write('defn') # check contents of /tmp/output (1) del(f) # check contents of /tmp/output (2) # exit from Python # check contents of /tmp/output (3) |
我在Mac平臺上用Python 2.7.9 做了第一個實驗,報告顯示在階段一檔案存在但是是空的,階段二和階段三中檔案包含所有的內容。這樣,在CPython 2.7中我最初的直覺似乎是正確的:當一個檔案物件被垃圾回收時,它的 __del__ (或者等價的)方法會重新整理並關閉檔案。而且在我的IPython程式中呼叫“lsof”命令顯示檔案確實在引用物件移除後被關閉了。
那 Python3 如何呢?我在Mac上 Python 3.4.2 環境下做了以上的實驗,得到了相同的結果。移除對檔案物件最後的引用後會導致檔案被重新整理並且被關閉。
這對於 Python 2.7 和 3.4 很好。但是在 PyPy 和 Jython下的替代實現會怎樣呢?或許情況會有些不同。
於是我在 PyPy 2.7.8 下做了相同的實驗。而這次,我得到了不同的結果!刪除檔案物件的引用後——也就是在階段2,並沒有導致檔案內容被刷入磁碟。我不得不假設這和垃圾回收機制的不同或其他在 PyPy 和 CPython中工作機制的不同有關係。但是如果你在 PyPy中執行程式,就絕不要指望僅僅因為檔案物件的引用結束,檔案就會被重新整理和關閉。命令 lsof 顯示直到Python程式退出時檔案才會被釋放。
為了好玩,我決定嘗試一下 Jython 2.7b3. 結果Jython 表現出了和PyPy一樣的行為。也就是說,從 Python 退出確實會確保快取中的資料寫入磁碟。
我重做了這些實驗,但是我把 “abcn”和 “defn”換成了 “abcn”*1000 和“defn”*1000.
在 Python 2.7 的環境下,“abcn” * 1000 語句執行後沒有任何東西寫入。但“defn” * 1000 語句執行後,檔案包含有4096個位元組——可能代表緩衝區的大小。呼叫 del(f) 刪除檔案物件的引用導致資料被刷入磁碟和檔案關閉,此時檔案中共有8000位元組的資料。所以忽略字串大小的話 Python 2.7 的行為表現基本相同。唯一不同的是如果超出了緩衝區的大小,那麼一些資料將在最後檔案關閉資料重新整理前寫入磁碟。
換做是Python 3的話,情況就有些不同了。f.write執行後沒有任何資料會寫入。但是檔案物件引用一旦結束,檔案就會重新整理並關閉。這可能是緩衝區很大的緣故。但毫無疑問,刪除檔案物件引用會使檔案重新整理並關閉。
至於 PyPy 和 Jython,對大檔案和小檔案的操作結果都一樣:檔案在 PyPy 或 Jython 程式結束的時候重新整理並關閉,而不是在檔案物件的引用結束的時候。
為了再次確認,我又使用 “with” 進行了實驗。在所有情況下,我們都能夠輕鬆的預測檔案是何時被重新整理和關閉的——就是當退出程式碼段,並且上下文管理器在後臺呼叫合適方法的時候。
換句話說,如果你不使用“with”,那麼至少在非常簡單的情形下,你的資料不一定有丟失的危險。然而你還是不能確定資料到底是在檔案物件引用結束還是程式退出的時候被儲存的。如果你假定因為對檔案唯一的引用是一個本地變數所以檔案在函式返回時會關閉,那麼事實一定會讓你感到吃驚。如果你有多個程式或執行緒同時對一個檔案進行寫操作,那麼你真的要非常小心了。
或許這個行為可以更好地定義不就可以在不同的平臺上表現得基本一致了嗎?也許我們甚至可以看看Python規範的開始,而不是指著CPython說“Yeah,不管版本如何總是對的”。
我依然覺得“with”和上下文管理器很棒。而且我想對於Python新手,理解“with”的工作原理很難。但我還是不得不提醒新手開發者注意:如果他們決定使用Python的其他可選版本,那麼會出現很多不同於CPython的古怪情況而且如果他們不夠小心,甚至會深受其害。
如果你喜歡這篇文章內容,那麼不妨看看我的關於Python檔案操作的 free e-mail course 或者我的電子書,“Practice Makes Python”,其中包含50個經過實際測試的Python程式設計練習。