【Python筆記】從一段Bug代碼來理解Python的Naming Rule

從Python文檔關於Naming and binding的說明可知,變量名是綁定到具體對象的,從這點來看,可以把它理解爲C++中的引用。考慮下面兩行語句:
a = 'test'
a = 'test_ext'
第1行執行後,Python解釋器會在內存中創建string類型的對象'test',這個對象一旦創建就不能再修改其值。賦值符號只是將變量名a綁定到這個對象上而已。
第2行執行後,同理,值爲'test_ext'的string對象被創建出來,變量名a重新綁定到這個新對象上。此時,'test'對象已經無法通過a訪問到了。
再次強調:這兩行語句的行爲與C/C++中的行爲完全不同!在C語言中,int a = 1執行後,會創建出名爲a且大小爲sizeof(int)的內存單元(當然,由於編譯器會對內存做alinging優化,所以實際申請的內存大小可能不只sizeof(int)),該內存單元存放int值1,而 a = 2執行後,a代表的內存單元地址不變,其值更新爲2。

關於上述Python Naming Rule的行爲,我之前寫過一篇筆記簡單分析過,這裏不再贅述。

上述規則單獨拿出來分析是容易理解的,但在實際工程項目中,很可能由於疏忽這種“賦值即綁定”的特性給代碼引入Bug(有經驗的C/C++碼農寫Python代碼時很容易犯這個錯誤),而這種Bug一旦引入,由於其隱蔽性(因爲它隱藏在我(們)認爲最不可能出錯的地方),想要在成千上萬行代碼中快速定位是很費時的。是的,Bug就在那裏,可偏偏看不出來到底是哪行代碼引入的,想想就有點抓狂。。。

本篇筆記的目的就是通過一段簡化的Bug代碼來加深對這個Naming Rule的理解。

直接上代碼(含bug):
#!/bin/env python
#-*- encoding: utf-8 -*-

def test():
    a_dict = {'a1' : {'s1' : ['foo']}, 'a2' : {'s1' : ['bar']}}
    b_dict = {}
    print 'begin: a_dict=%s' % (a_dict)
    print 'begin: b_dict=%s' % (b_dict)
    for k in a_dict.keys():
        for sk, sv in a_dict[k].items():
            if sk in b_dict:
                b_dict[sk].append(sv)
                b_dict[sk].append('addition')
            else:
                b_dict[sk] = sv
    print 'end: a_dict=%s' % (a_dict)
    print 'end: b_dict=%s' % (b_dict)

    return 0

if '__main__' == __name__:
    test()
上面代碼片段的本意是想通過a_dict來構造b_dict,具體而言,a_dict是個2級dict結構,其第2級dict的key作爲b_dict的key,若a_dict中存在相同的2級key,則它們的value做merge成list後再append一個'addition'元素,這個最終的list作爲b_dict在該2級key下的value。
我們可以思考一下上述代碼的執行結果,然後跟實際的運行結果做對比:
begin: a_dict={'a1': {'s1': ['foo']}, 'a2': {'s1': ['bar']}}
begin: b_dict={}
end: a_dict={'a1': {'s1': ['foo', ['bar'], 'addition']}, 'a2': {'s1': ['bar']}}
end: b_dict={'s1': ['foo', ['bar'], 'addition']}
注意上述結果中,a_dict的值也被改變了,這明顯不是預期的行爲!可見,上述代碼確實是有Bug的。
有Bug沒關係,可問題的關鍵是,到底是哪行語句引入的坑呢?(如果不看本文後續解釋,大家可以快速定位到問題根源麼)

沒錯,就是b_dict[sk] = sv這句(第15行),它的實際行爲是將b_dict[sk]綁定到sv這個結構上,而sv是與a_dict關聯的,所以對b_dict[sk]做的append操作相當於通過"引用"直接操作a_dict的內容!這種行爲是符合Python Naming Rule的,只是不符合我們的預期。。。

上面的代碼還說明,dict的kesy()/values()/items()方法返回的list與dict本身存在綁定關係(即不是通過“深拷貝”生成list),修改返回的list時,dict原來的內容也會被更新。
可以用更簡化的例子來驗證這個結論:
>>> a = {'k1' : ['v1'], 'k2' : ['v2']}
>>> a.items()
[('k2', ['v2']), ('k1', ['v1'])]  ## 初始時,a.items()的內容
>>> id(a.items())  
140251308636208
>>> x = a.items()
>>> id(x)
140251308636352  ## 由於items()每次返回一個新list,所以id(x)與id(a.items())值不一致是可以理解的,如果值一樣也只是巧合
>>> x
[('k2', ['v2']), ('k1', ['v1'])]  
>>> id(x[0][1])  
140251308636136
>>> id(a.items()[0][1])
140251308636136
>>> id(a['k2'])
140251308636136  ## 特別注意,a['k2']與x[0][1]的id值是一樣的,說明它們指向相同的內存地址
>>> x[0][1].append('test')
>>> a
{'k2': ['v2', 'test'], 'k1': ['v1']}  ## 通過x修改內容後,a的內容也被修改了!
從上面兩端代碼實例的分析可看到,Bug的根源是:賦值操作是綁定變量名與實際對象的過程,而非重新創建並初始化對象的過程。
認識到這一點後,Bug的修復就很簡單了。下面是無bug的代碼片段:
#!/bin/env python
#-*- encoding: utf-8 -*-

def test():
    a_dict = {'a1' : {'s1' : ['foo']}, 'a2' : {'s1' : ['bar']}}
    b_dict = {}
    print 'begin: a_dict=%s' % (a_dict)
    print 'begin: b_dict=%s' % (b_dict)
    for k in a_dict.keys():
        for sk, sv in a_dict[k].items():
            if sk in b_dict:
                b_dict[sk].append(sv)
                b_dict[sk].append('addition')
            else:
                b_dict[sk] = list(sv)  ## 注意這裏通過list()重新創建對象實現了“深拷貝”的效果
    print 'end: a_dict=%s' % (a_dict)
    print 'end: b_dict=%s' % (b_dict)

    return 0

if '__main__' == __name__:
    test()
上面的代碼只修改了一處地方(具體見註釋),就可以得到符合預期的結果:
begin: a_dict={'a1': {'s1': ['foo']}, 'a2': {'s1': ['bar']}}
begin: b_dict={}
end: a_dict={'a1': {'s1': ['foo']}, 'a2': {'s1': ['bar']}}
end: b_dict={'s1': ['foo', ['bar'], 'addition']}

【參考資料】
PythonDoc: Naming and binding 

==================== EOF ====================


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章