Python程式設計師的10個常見錯誤

果果夫斯基發表於2014-06-19

關於Python

Python是一門解釋性的,物件導向的,並具有動態語義的高階程式語言。它高階的內建資料結構,結合其動態型別和動態繫結的特性,使得它在快速應用程式開發Rapid Application Development)中頗為受歡迎,同時Python還能作為指令碼語言或者膠水語言講現成的元件或者服務結合起來。Python支援模組(modules)和包(packages),所以也鼓勵程式的模組化以及程式碼重用。

關於本文

Python簡單、易學的語法可能會誤導一些Python程式設計師(特別是那些剛接觸這門語言的人們),可能會忽略某些細微之處和這門語言的強大之處。

考慮到這點,本文列出了“十大”甚至是高階的Python程式設計師都可能犯的,卻又不容易發現的細微錯誤。

(注意:本文是針對比《Python程式設計師常見錯誤稍微高階一點讀者,對於更加新手一點的Python程式設計師,有興趣可以讀一讀那篇文章)

常見錯誤1:在函式引數中亂用表示式作為預設值

Python允許給一個函式的某個引數設定預設值以使該引數成為一個可選引數。儘管這是這門語言很棒的一個功能,但是這當這個預設值是可變物件mutable)時,那就有些麻煩了。例如,看下面這個Python函式定義:

人們常犯的一個錯誤是認為每次呼叫這個函式時不給這個可選引數賦值的話,它總是會被賦予這個預設表示式的值。例如,在上面的程式碼中,程式設計師可能會認為重複呼叫函式foo() (不傳引數bar給這個函式),這個函式會總是返回‘baz’,因為我們假定認為每次呼叫foo()的時候(不傳bar),引數bar會被置為[](即,一個空的列表)。

那麼我們來看看這麼做的時候究竟會發生什麼:

嗯?為什麼每次呼叫foo()的時候,這個函式總是在一個已經存在的列表後面新增我們的預設值“baz”,而不是每次都建立一個的列表?

答案是一個函式引數的預設值,僅僅在該函式定義的時候,被賦值一次。如此,只有當函式foo()第一次被定義的時候,才講引數bar的預設值初始化到它的預設值(即一個空的列表)。當呼叫foo()的時候(不給引數bar),會繼續使用bar最早初始化時的那個列表。

由此,可以有如下的解決辦法:

常見錯誤2:不正確的使用類變數

看下面一個例子:

看起來沒有問題。

嗯哈,還是和預想的一樣。

我了個去。只是改變了A.x,為啥C.x也變了?

在Python裡,類變數通常在內部被當做字典來處理並遵循通常所說的方法解析順序Method Resolution Order (MRO))。因此在上面的程式碼中,因為屬性x在類C中找不到,因此它會往上去它的基類中查詢(在上面的例子中只有A這個類,當然Python是支援多重繼承(multiple inheritance)的)。換句話說,C沒有它自己獨立於A的屬性x。因此對C.x的引用實際上是對A.x的引用。(B.x不是對A.x的引用是因為在第二步裡B.x=2將B.x引用到了2這個物件上,倘若沒有如此,B.x仍然是引用到A.x上的。——譯者注)

常見錯誤3:在異常處理時錯誤的使用引數

假設你有如下的程式碼:

這裡的問題在於except語句不會像這樣去接受一系列的異常。並且,在Python 2.x裡面,語法except Exception, e是用來將異常和這個可選的引數繫結起來(即這裡的e),以用來在後面檢視的。因此,在上面的程式碼中,IndexError異常不會被except語句捕捉到;而最終ValueError這個異常被繫結在了一個叫做IndexError的引數上。

在except語句中捕捉多個異常的正確做法是將所有想要捕捉的異常放在一個元組tuple)裡並作為第一個引數給except語句。並且,為移植性考慮,使用as關鍵字,因為Python 2和Python 3都支援這樣的語法,例如:

常見錯誤4:誤解Python作用域的規則

Python的作用域解析是基於叫做LEGB(Local(本地),Enclosing(封閉),Global(全域性),Built-in(內建))的規則進行操作的。這看起來很直觀,對吧?事實上,在Python中這有一些細微的地方很容易出錯。看這個例子:

這是怎麼回事?

這是因為,在一個作用域裡面給一個變數賦值的時候,Python自動認為這個變數是這個作用域的本地變數,並遮蔽作用域外的同名的變數。

很多時候可能在一個函式裡新增一個賦值的語句會讓你從前本來工作的程式碼得到一個UnboundLocalError。(感興趣的話可以讀一讀這篇文章。)

在使用列表lists的時候,這種情況尤為突出。看下面這個例子:

嗯?為什麼foo2有問題,而foo1沒有問題?

答案和上一個例子一樣,但是更加不易察覺。foo1並沒有給lst賦值,但是foo2嘗試給lst賦值。注意lst+=[5]只是lst=lst+[5]的簡寫,由此可以看到我們嘗試給lst賦值(因此Python假設作用域為本地)。但是,這個要賦給lst的值是基於lst本身的(這裡的作用域仍然是本地),而lst卻沒有被定義,這就出錯了。

常見錯誤5:在遍歷列表的同時又在修改這個列表

下面這個例子中的程式碼應該比較明顯了:

遍歷一個列表或者陣列的同時又刪除裡面的元素,對任何有經驗的軟體開發人員來說這是個很明顯的錯誤。但是像上面的例子那樣明顯的錯誤,即使有經驗的程式設計師也可能不經意間在更加複雜的程式中不小心犯錯。

所幸,Python整合了一些優雅的程式設計正規化,如果使用得當,可以寫出相當簡化和精簡的程式碼。一個附加的好處是更簡單的程式碼更不容易遇到這種“不小心在遍歷列表時刪掉列表元素”的bug。例如列表推導式list comprehensions)就提供了這樣的正規化。再者,列表推導式在避免這樣的問題上特別有用,接下來這個對上面的程式碼的重新實現就相當完美:

常見錯誤6:搞不清楚在閉包(closures)中Python是怎樣繫結變數的

看這個例子:

期望得到下面的輸出:

但是實際上得到的是:

意外吧!

這是由於Python的後期繫結(late binding)機制導致的,這是指在閉包中使用的變數的值,是在內層函式被呼叫的時候查詢的。因此在上面的程式碼中,當任一返回函式被呼叫的時候,i的值是在它被呼叫時的周圍作用域中查詢(到那時,迴圈已經結束了,所以i已經被賦予了它最終的值4)。

解決的辦法比較巧妙:

這下對了!這裡利用了預設引數去產生匿名函式以達到期望的效果。有人會說這很優美,有人會說這很微妙,也有人會覺得反感。但是如果你是一名Python程式設計師,重要的是能理解任何的情況。

常見錯誤7:迴圈載入模組

假設你有兩個檔案,a.py和b.py,在這兩個檔案中互相載入對方,例如:

在a.py中:

在b.py中:

首先,我們試著載入a.py:

沒有問題。也許讓人吃驚,畢竟有個感覺應該是問題的迴圈載入在這兒。

事實上在Python中僅僅是表面上的出現迴圈載入並不是什麼問題。如果一個模組以及被載入了,Python不會傻到再去重新載入一遍。但是,當每個模組都想要互相訪問定義在對方里的函式或者變數時,問題就來了。

讓我們再回到之前的例子,當我們載入a.py時,它再載入b.py不會有問題,因為在載入b.py,它並不需要訪問a.py的任何東西,而在b.py中唯一的引用就是呼叫a.f()。但是這個呼叫是在函式g()中完成的,並且a.py或者b.py中沒有人呼叫g(),所以這會兒心情還是美麗的。

但是當我們試圖載入b.py時(之前沒有載入a.py),會發生什麼呢:

恭喜你,出錯了。這裡問題出在載入b.py的過程中,Python試圖載入a.py,並且在a.py中需要呼叫到f(),而函式f()又要訪問到b.x,但是這個時候b.x卻還沒有被定義。這就產生了AttributeError異常。

解決的方案可以做一點細微的改動。改一下b.py,使得它在g()裡面載入a.py:

這會兒當我們載入b.py的時候,一切安好:

常見錯誤8:與Python標準庫模組命名衝突

Python的一個優秀的地方在於它提供了豐富的庫模組。但是這樣的結果是,如果你不下意識的避免,很容易你會遇到你自己的模組的名字與某個隨Python附帶的標準庫的名字衝突的情況(比如,你的程式碼中可能有一個叫做email.py的模組,它就會與標準庫中同名的模組衝突)。

這會導致一些很粗糙的問題,例如當你想載入某個庫,這個庫需要載入Python標準庫裡的某個模組,結果呢,因為你有一個與標準庫裡的模組同名的模組,這個包錯誤的將你的模組載入了進去,而不是載入Python標準庫裡的那個模組。這樣一來就會有麻煩了。

所以在給模組起名字的時候要小心了,得避免與Python標準庫中的模組重名。相比起你提交一個“Python改進建議(Python Enhancement Proposal (PEP))”去向上要求改一個標準庫裡包的名字,並得到批准來說,你把自己的那個模組重新改個名字要簡單得多。

常見錯誤9:不能區分Python 2和Python 3

看下面這個檔案foo.py:

在Python 2裡,執行起來沒有問題:

但是如果拿到Python 3上面玩玩:

這是怎麼回事?“問題”在於,在Python 3裡,在except塊的作用域以外,異常物件(exception object)是不能被訪問的。(原因在於,如果不這樣的話,Python會在記憶體的堆疊裡保持一個引用鏈直到Python的垃圾處理將這些引用從記憶體中清除掉。更多的技術細節可以參考這裡。)

避免這樣的問題可以這樣做:保持在execpt塊作用域以外對異常物件的引用,這樣是可以訪問的。下面是用這個辦法對之前的例子做的改動,這樣在Python 2和Python 3裡面都執行都沒有問題。

在Py3k裡面執行:

耶!

(順帶提一下,我們的“Python招聘指南”裡討論了從Python 2移植程式碼到Python 3時需要注意的其他重要的不同之處。)

常見錯誤10:錯誤的使用__del__方法

假設有一個檔案mod.py中這樣使用:

然後試圖在another_mod.py裡這樣:

那麼你會得到一個噁心的AttributeError異常。

為啥呢?這是因為(參考這裡),當直譯器關閉時,模組所有的全域性變數會被置為空(None)。結果便如上例所示,當__del__被呼叫時,名字foo已經被置為空了。

使用atexit.register()可以解決這個問題。如此,當你的程式結束的時候(退出的時候),你的註冊的處理程式會在直譯器關閉之前處理。

這樣理解的話,對上面的mod.py可以做如下的修改:

這樣的實現方式為在程式正常終止時呼叫清除功能提供了一種乾淨可靠的辦法。顯然,需要foo.cleanup決定怎麼處理繫結在self.myhandle上的物件,但你知道怎麼做的。

總結

Python 是一門非常強大且靈活的語言,它眾多的機制和正規化能顯著的提高生產效率。不過,和任何一款軟體或者語言一樣,對它的理解或認識不足的話,常常是弊大於利的,並會處於一種“一知半解”的狀態。

多熟悉Python的一些關鍵的細微的地方,比如(但不侷限於)本文中提到的這些問題,可以幫你更好的使用這門語言的同時幫你避免一些常見的陷阱。

感興趣的話可以讀一讀這篇“Python面試指南(Insider’s Guide to Python Interviewing”,瞭解一些能夠區分Python程式設計師的面試題目。

希望您能在本文學到有用的地方,並歡迎您的反饋。

相關文章