本文有些零碎,總題來說,包括兩個問題:(1)可變對象(最常見的是list dict)被意外修改的問題,(2)對參數(parameter)的檢查問題。這兩個問題,本質都是因為動態語言(動態類型語言)的特性造成了,動態語言的好處就不細說了,本文是要討論因為動態--這種靈活性帶來的一些問題。
什么是動態語言(Dynamic Programming language)呢,是相對於靜態語言而言,將很多靜態語言編譯(compilation)時期所做的事情推遲到運行時,在運行時修改代碼的行為,比如添加新的對象和函數,修改既有代碼的功能,改變類型。絕大多數動態語言都是動態類型(Dynamic Typed),所謂動態類型,是在運行時確定數據類型,變量使用之前不需要類型聲明,通常變量的類型是被賦值的那個值的類型。Python就是屬於典型的動態語言。
動態語言的魅力在於讓開發人員更好的關注需要解決的問題本身,而不是冗雜的語言規范,也不用干啥都得寫個類。運行時改變代碼的行為也是非常有用,比如python的熱更新,可以做到不關服務器就替換代碼的邏輯,而靜態語言如C++就很難做到這一點。筆者使用得最多的就是C++和Python,C++中的一些復雜的點,比如模板(泛型編程)、設計模式(比如template method),在Python中使用起來非常自然。我也看到過有一些文章指出,設計模式往往是特定靜態語言的補丁 -- 為了彌補語言的缺陷或者限制。
以筆者的知識水平,遠遠不足以評價動態語言與靜態語言的優劣。本文也只是記錄在我使用Python這門動態語言的時候,由於語言的靈活性,由於動態類型,踩過的坑,一點思考,以及困惑。
本文地址:http://www.cnblogs.com/xybaby/p/7208496.html
第一個問題:Mutable對象被誤改
這個是在線上環境出現過的一個BUG
事后說起來很簡單,服務端數據(放在dict里面的)被意外修改了,但查證的時候也花了許多時間,偽代碼如下:
1 def routine(dct): 2 if high_propability: 3 sub_routine_no_change_dct(dct) 4 else: 5 sub_routine_will_change_dct(dct)
上述的代碼很簡單,dct是一個dict,極大概率會調用一個不用修改dct的子函數,極小概率出會調用到可能修改dct的子函數。問題就在於,調用routine函數的參數是服務端全局變量,理論上是不能被修改的。當然,上述的代碼簡單到一眼就能看出問題,但在實際環境中,調用鏈有七八層,而且,在routine這個函數的doc里面,聲明不會修改dct,該函數本身確實沒有修改dct,但調用的子函數或者子函數的子函數沒有遵守這個約定。
從python語言特性看這個問題
本小節解釋上面的代碼為什么會出問題,簡單來說兩點:dict是mutable對象; dict實例作為參數傳入函數,然后被函數修改了。
Python中一切都是對象(evething is object),不管是int str dict 還是類。比如 a =5, 5是一個整數類型的對象(實例);那么a是什么,a是5這個對象嗎? 不是的,a只是一個名字,這個名字暫時指向(綁定、映射)到5這個對象。b = a 是什么意思呢, 是b指向a指向的對象,即a, b都指向整數5這個對象
那么什么是mutable 什么是immutable呢,mutable是說這個對象是可以修改的,immutable是說這個對象是不可修改的(廢話)。還是看Python官方怎么說的吧
Mutable objects can change their value but keep their
id().Immutable:An object with a fixed value. Immutable objects include numbers, strings and tuples. Such an object cannot be altered. A new object has to be created if a different value has to be stored. They play an important role in places where a constant hash value is needed, for example as a key in a dictionary.
承接上面的例子(a = 5),int類型就是immutable,你可能說不對啊,比如對a賦值, a=6, 現在a不是變成6了嗎?是的,a現在"變成"6了,但本質是a指向了6這個對象 -- a不再指向5了
檢驗對象的唯一標准是id,id函數返回對象的地址,每個對象在都有唯一的地址。看下面兩個例子就知道了
python中,不可變對象包括:int, long, float, bool, str, tuple, frozenset;而其他的dict list 自定義的對象等屬於可變對象。注意: str也是不可變對象,這也是為什么在多個字符串連接操作的時候,推薦使用join而不是+
而且python沒有機制,讓一個可變對象不可被修改(此處類比的是C++中的const)
dict是可變對象!
那在python中,調用函數時的參數傳遞是什么意思呢,是傳值、傳引用?事實上都不正確,我不清楚有沒有專業而統一的說法,但簡單理解,就是形參(parameter)和實參(argument)都指向同一個對象,僅此而已。來看一下面的代碼:
1 def double(v): 2 print 'argument before', id(v) 3 v *= 2 4 print 'argument after', id(v) 5 return v 6 7 def test_double(a): 8 print 'parameter bdfore', id(a), a 9 double(a) 10 print 'parameter after', id(a), a 11 12 if __name__=='__main__': 13 print 'test_double with int' 14 test_double(1) 15 print 'test_double with list' 16 test_double([1])
運行結果:
test_double with int
parameter bdfore 30516936 1
argument before 30516936
argument after 30516924
parameter after 30516936 1
test_double with list
parameter bdfore 37758256 [1]
argument before 37758256
argument after 37758256
parameter after 37758256 [1, 1]
可以看到,剛進入子函數double的時候,a,v指向的同一個對象(相同的id)。對於test int的例子,v因為v*=2,指向了另外一個對象,但對實參a是沒有任何影響的。對於testlst的時候,v*=2是通過v修改了v指向的對象(也是a指向的對象),因此函數調用完之后,a指向的對象內容發生了變化。
如何防止mutable對象被函數誤改:
為了防止傳入到子函數中的可變對象被修改,最簡單的就是使用copy模塊拷貝一份數據。具體來說,包括copy.copy, copy.deepcopy, 前者是淺拷貝,后者是深拷貝。二者的區別在於:
The difference between shallow and deep copying is only relevant for compound objects (objects that contain other objects, like lists or class instances):
- A shallow copy constructs a new compound object and then (to the extent possible) inserts references into it to the objects found in the original.
- A deep copy constructs a new compound object and then, recursively, inserts copies into it of the objects found in the original.
簡單來說,深拷貝會遞歸拷貝,遍歷任何compound object然后拷貝,例如:
>>> lst = [1, [2]]
>>> import copy
>>> lst1 = copy.copy(lst)
>>> lst2 = copy.deepcopy(lst)
>>> print id(lst[1]), id(lst1[1]), id(lst2[1])
4402825264 4402825264 4402988816
>>> lst[1].append(3)
>>> print lst, lst1,lst2
[1, [2, 3]] [1, [2, 3]] [1, [2]]
從例子可以看出淺拷貝的局限性,Python中,對象的基本構造也是淺拷貝,例如 dct = {1: [1]}; dct1 = dict(dct)
正是由於淺拷貝與深拷貝本質上的區別,二者性能代價差異非常之大,即使對於被拷貝的對象來說毫無差異:
1 import copy 2 def test_copy(inv): 3 return copy.copy(inv) 4 def test_deepcopy(inv): 5 return copy.deepcopy(inv) 6 dct = {str(i): i for i in xrange(100)} 7 8 def timeit_copy(): 9 import timeit 10 11 print timeit.Timer('test_copy(dct)', 'from __main__ import test_copy, dct').timeit(100000) 12 print timeit.Timer('test_deepcopy(dct)', 'from __main__ import test_deepcopy, dct').timeit(100000) 13 14 if __name__ == '__main__': 15 timeit_copy()
運行結果:
在上面的示例中,dct這個dict的values都是int類型,immutable對象,因為無論淺拷貝 深拷貝效果都是一樣的,但是耗時差異巨大。如果在dct中存在自定義的對象,差異會更大
那么為了安全起見,應該使用深拷貝;為了性能,應該使用淺拷貝。如果compound object包含的元素都是immutable,那么淺拷貝既安全又高效,but,對於python這種靈活性極強的語言,很可能某天某人就加入了一個mutable元素。
好的API
好的API應該是easy to use right; hard to use wrong。API應該提供一種契約,約定如果使用者按照特定的方式調用,那么API就能實現預期的效果。
在靜態語言如C++中,函數簽名就是最好的契約。
在C++中,參數傳遞大約有三種形式,傳值、傳指針、傳引用(這里不考慮右值引用)。指針和引用雖然表現形式上差異,但效果上是差不多的,因此這里主要考慮傳值和傳引用。比如下面四個函數簽名:
int func(int a)int func(const int a)int func(int &a)int func(const int &a)
對於第1、2個函數,對於調用者來說都是一樣的,因為都會進行拷貝(深拷貝),無論func函數內部怎么操作,都不會影響到實參。二者的區別在於函數中能否對a進行修改,比如能否寫 a *= 2。
第3個函數,非const引用,任何對a的修改都會影響到實參。調用者看到這個API就知道預期的行為:函數會改變實參的值。
第4個函數,const引用,函數承諾絕對不會修改實參,因此調用者可以放心大膽的傳引用,無需拷貝。
從上面幾個API,可以看到,通過函數簽名,調用者就能知道函數調用對傳入的參數有沒有影響。
python是動態類型檢查,除了運行時,沒法做參數做任何檢查。有人說,那就通過python doc或者變量名來實現契約吧,比如:
def func(dct_only_read):
但是人是靠不住的,也是不可靠的,也許在這個函數的子函數(子函數的子函數,。。。)就會修改這個dict。怎么辦,對可變類型強制copy(deepcopy),但拷貝又非常耗時。。。
第二個問題:參數檢查
上一節說明沒有簽名 對 函數調用者是多么不爽,而本章節則說明沒有簽名對函數提供者有多么不爽。沒有類型檢查真的蛋疼,我也遇到過有人為了方便,給一個約定是int類型的形參傳入了一個int的list,而可怕的是代碼不報錯,只是表現不正常。
來看一個例子:
1 def func(arg): 2 if arg: 3 print 'do lots of things here' 4 else: 5 print 'do anothers'
上述的代碼很糟糕,根本沒法“望名知意”,也看不出有關形參 arg的任何信息。但事實上這樣的代碼是存在的,而且還有比這更嚴重的,比如掛羊頭賣狗肉。
這里有一個問題,函數期望arg是某種類型,是否應該寫代碼判斷呢,比如:isinstance(arg, str)。因為沒有編譯器靜態來做參數檢查,那么要不要檢查,如何檢查就完全是函數提供者的事情。如果檢查,那么影響性能,也容易違背python的靈活性 -- duck typing; 不檢查,又容易被誤用。
但在這里,考慮的是另一個問題,看代碼的第二行: if arg。python中,幾乎是一切對象都可以當作布爾表達式求值,即這里的arg可以是一切python對象,可以是bool、int、dict、list以及任何自定義對象。不同的類型為“真”的條件不一樣,比如數值類型(int float)非0即為真;序列類型(str、list、dict)非空即為真;而對於自定義對象,在python2.7種則是看是否定義了__nonzero__ 、__len__,如果這兩個函數都沒有定義,那么實例的布爾求值一定返回真。
在PEP8,由以下關於對序列布爾求值的規范:
For sequences, (strings, lists, tuples), use the fact that empty sequences are false.
Yes: if not seq: if seq: No: if len(seq): if not len(seq):
在google python styleguide中也有一節專門關於bool表達式,指出“盡可能使用隱式的false”。 對於序列,推薦的判斷方法與pep8相同,另外還由兩點比較有意思:
1 如果你需要區分false和None, 你應該用像
if not x and x is not None:這樣的語句.2 處理整數時, 使用隱式false可能會得不償失(即不小心將None當做0來處理). 你可以將一個已知是整型(且不是len()的返回結果)的值與0比較.
第二點我個人很贊同;但第一點就覺得很別扭,因為這樣的語句一點不直觀,難以表達其真實目的。
在pep20 the zen of python中,指出:
Explicit is better than implicit.
這句話簡單但實用!代碼是寫給人讀的,清晰的表達代碼的意圖比什么都重要。也許有的人覺得代碼寫得復雜隱晦就顯得牛逼,比如python中嵌套幾層的list comprehension,且不知這樣害人又害己。
回到布爾表達式求值這個問題,我覺得很多時候直接使用if arg:這種形式都不是好主意,因為不直觀而且容易出錯。比如參數是int類型的情況,
def handle_age(age): if not age: return # do lots with age
很難說當age=0時是不是一個合理的輸入,上面的代碼對None、0一視同仁,看代碼的人也搞不清傳入0是否正確。
另外一個具有爭議性的例子就是對序列進行布爾求值,推薦的都是直接使用if seq: 的形式,但這種形式違背了”Explicit is better than implicit.“,因為這樣寫根本無法區分None和空序列,而這二者往往是由區別的,很多時候,空序列是一個合理的輸入,而None不是。這個問題,stackoverflow上也有相關的討論“如何檢查列表為空”,誠然,如果寫成 seq == [] 是不那么好的代碼, 因為不那么靈活 -- 如果seq是tuple類型代碼就不能工作了。python語言是典型的duck typing,不管你傳入什么類型,只要具備相應的函數,那么代碼就可以工作,但是否正確地工作就完完全全取決於使用者。個人覺得存在寬泛的約束比較好,比如Python中的ABC(abstract base class), 既滿足了靈活性需求,后能做一些規范檢查。
總結
以上兩個問題,是我使用Python語言以來遇到的諸多問題之二,也是我在同一個地方跌倒過兩次的問題。Python語言以開發效率見長,但是我覺得需要良好的規范才能保證在大型線上項目中使用。而且,我也傾向於假設:人是不可靠的,不會永遠遵守擬定的規范,不會每次修改代碼之后更新docstring ...
因此,為了保證代碼的可持續發展,需要做到以下幾點
第一:擬定並遵守代碼規范
代碼規范最好在項目啟動時就應該擬定好,可以參照PEP8和google python styleguild。很多時候風格沒有優劣之說,但是保證項目內的一致性很重要。並保持定期review、對新人review!
第二:靜態代碼分析
只要能靜態發現的bug不要放到線上,比如對參數、返回值的檢查,在python3.x中可以使用注解(Function Annotations),python2.x也可以自行封裝decorator來做檢查。對代碼行為,既可以使用Coverity這種高大上的商業軟件,或者王垠大神的Pysonar2,也可以使用ast編寫簡單的檢查代碼。
第三:單元測試
單元測試的重要性想必大家都知道,在python中出了官方自帶的doctest、unittest,還有許多更強大的框架,比如nose、mock。
第四:100%的覆蓋率測試
對於python這種動態語言,出了執行代碼,幾乎沒有其他比較好的檢查代碼錯誤的手段,所以覆蓋率測試是非常重要的。可以使用python原生的sys.settrace、sys.gettrace,也可以使用coverage等跟更高級的工具。
雖然我已經寫了幾年Python了,但是在Python使用規范上還是很欠缺。我也不知道在其他公司、項目中,是如何使用好Python的,如何揚長避短的。歡迎pythoner留言指導!
