動態語言的靈活性是把雙刃劍 -- 以 Python 語言為例

發表於2017-11-19

本文有些零碎,總題來說,包括兩個問題:(1)可變物件(最常見的是list dict)被意外修改的問題,(2)對引數(parameter)的檢查問題。這兩個問題,本質都是因為動態語言(動態型別語言)的特性造成了,動態語言的好處就不細說了,本文是要討論因為動態--這種靈活性帶來的一些問題。

什麼是動態語言(Dynamic Programming language)呢,是相對於靜態語言而言,將很多靜態語言編譯(compilation)時期所做的事情推遲到執行時,在執行時修改程式碼的行為,比如新增新的物件和函式,修改既有程式碼的功能,改變型別。絕大多數動態語言都是動態型別(Dynamic Typed),所謂動態型別,是在執行時確定資料型別,變數使用之前不需要型別宣告,通常變數的型別是被賦值的那個值的型別。Python就是屬於典型的動態語言。

動態語言的魅力在於讓開發人員更好的關注需要解決的問題本身,而不是冗雜的語言規範,也不用幹啥都得寫個類。執行時改變程式碼的行為也是非常有用,比如python的熱更新,可以做到不關伺服器就替換程式碼的邏輯,而靜態語言如C++就很難做到這一點。筆者使用得最多的就是C++和Python,C++中的一些複雜的點,比如模板(泛型程式設計)、設計模式(比如template method),在Python中使用起來非常自然。我也看到過有一些文章指出,設計模式往往是特定靜態語言的補丁 — 為了彌補語言的缺陷或者限制。

以筆者的知識水平,遠遠不足以評價動態語言與靜態語言的優劣。本文也只是記錄在我使用Python這門動態語言的時候,由於語言的靈活性,由於動態型別,踩過的坑,一點思考,以及困惑。

第一個問題:Mutable物件被誤改

這個是線上上環境出現過的一個BUG

事後說起來很簡單,服務端資料(放在dict裡面的)被意外修改了,但查證的時候也花了許多時間,虛擬碼如下:

上述的程式碼很簡單,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)都指向同一個物件,僅此而已。來看一下面的程式碼:

執行結果:

可以看到,剛進入子函式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然後拷貝,例如:

從例子可以看出淺拷貝的侷限性,Python中,物件的基本構造也是淺拷貝,例如

正是由於淺拷貝與深拷貝本質上的區別,二者效能代價差異非常之大,即使對於被拷貝的物件來說毫無差異:

執行結果:

在上面的示例中,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++中,引數傳遞大約有三種形式,傳值、傳指標、傳引用(這裡不考慮右值引用)。指標和引用雖然表現形式上差異,但效果上是差不多的,因此這裡主要考慮傳值和傳引用。比如下面四個函式簽名:

對於第1、2個函式,對於呼叫者來說都是一樣的,因為都會進行拷貝(深拷貝),無論func函式內部怎麼操作,都不會影響到實參。二者的區別在於函式中能否對a進行修改,比如能否寫 a *= 2。

第3個函式,非const引用,任何對a的修改都會影響到實參。呼叫者看到這個API就知道預期的行為:函式會改變實參的值。

第4個函式,const引用,函式承諾絕對不會修改實參,因此呼叫者可以放心大膽的傳引用,無需拷貝。

從上面幾個API,可以看到,通過函式簽名,呼叫者就能知道函式呼叫對傳入的引數有沒有影響。

python是動態型別檢查,除了執行時,沒法做引數做任何檢查。有人說,那就通過python doc或者變數名來實現契約吧,比如:

但是人是靠不住的,也是不可靠的,也許在這個函式的子函式(子函式的子函式,。。。)就會修改這個dict。怎麼辦,對可變型別強制copy(deepcopy),但拷貝又非常耗時。。。

第二個問題:引數檢查

上一節說明沒有簽名 對 函式呼叫者是多麼不爽,而本章節則說明沒有簽名對函式提供者有多麼不爽。沒有型別檢查真的蛋疼,我也遇到過有人為了方便,給一個約定是int型別的形參傳入了一個int的list,而可怕的是程式碼不報錯,只是表現不正常。

來看一個例子:

上述的程式碼很糟糕,根本沒法“望名知意”,也看不出有關形參 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,由以下關於對序列布林求值的規範:

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中,指出:

這句話簡單但實用!程式碼是寫給人讀的,清晰的表達程式碼的意圖比什麼都重要。也許有的人覺得程式碼寫得複雜隱晦就顯得牛逼,比如python中巢狀幾層的list comprehension,且不知這樣害人又害己。

回到布林表示式求值這個問題,我覺得很多時候直接使用if arg:這種形式都不是好主意,因為不直觀而且容易出錯。比如引數是int型別的情況,

很難說當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留言指導!

references:

相關文章