第一个问题:Mutable对象被误改
这个是在线上环境出现过的一个BUG
事后说起来很简单,服务端数据(放在dict里面的)被意外修改了,但查证的时候也花了许多时间,伪代码如下:
def routine(dct):
**if** high_propability:
sub_routine_no_change_dct(dct)
**else**:
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
>>> a= 5;id(a)
35170056
>>> a= 6;id(a)
35170044
>>> lst= [1,2,3]; id(lst)
39117168
>>> lst.append(4); id(lst)
39117168
或者这么说,对于非可变对象,在对象的生命周期内,没有办法改变对象所在内存地址上的值。
python中,不可变对象包括:int, long, float, bool, str, tuple, frozenset;而其他的dict list 自定义的对象等属于可变对象。注意: str也是不可变对象,这也是为什么在多个字符串连接操作的时候,推荐使用join而不是+
而且python没有机制,让一个可变对象不可被修改(此处类比的是C++中的const)
dict是可变对象!
那在python中,调用函数时的参数传递是什么意思呢,是传值、传引用?事实上都不正确,我不清楚有没有专业而统一的说法,但简单理解,就是形参(parameter)和实参(argument)都指向同一个对象,仅此而已。来看一下面的代码:
def **double**(v):
print 'argument before', id(v)
v *= 2
print 'argument after', id(v)
**return** v
def test_double(a):
print 'parameter bdfore', id(a), a
**double**(a)
print 'parameter after', id(a), a
**if** __name__=='__main__':
print 'test_double with int'
test_double(1)
print 'test_double with list'
test_double([1])
运行结果:
Python
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然后拷贝,例如:
Python
>>> 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中,对象的基本构造也是浅拷贝,例如
Python
dct= {1: [1]}; dct1= dict(dct)
正是由于浅拷贝与深拷贝本质上的区别,二者性能代价差异非常之大,即使对于被拷贝的对象来说毫无差异:
import copy
def test_copy(inv):
**return** copy.copy(inv)
def test_deepcopy(inv):
**return** copy.deepcopy(inv)
dct= {str(i): i **for** i **in** xrange(100)}
def timeit_copy():
import timeit
print timeit.Timer('test_copy(dct)', 'from __main__ import test_copy, dct').timeit(100000)
print timeit.Timer('test_deepcopy(dct)', 'from __main__ import test_deepcopy, dct').timeit(100000)
**if** __name__== '__main__':
timeit_copy()
运行结果:
Python
1.19009837668
113.11954377
在上面的示例中,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++中,参数传递大约有三种形式,传值、传指针、传引用(这里不考虑右值引用)。指针和引用虽然表现形式上差异,但效果上是差不多的,因此这里主要考虑传值和传引用。比如下面四个函数签名:
Python
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或者变量名来实现契约吧,比如:
Python
**def** func(dct_only_read):
“”“param: dct_only_read will be only read, never upate”“”
但是人是靠不住的,也是不可靠的,也许在这个函数的子函数(子函数的子函数,。。。)就会修改这个dict。怎么办,对可变类型强制copy(deepcopy),但拷贝又非常耗时。。。
第二个问题:参数检查
上一节说明没有签名 对 函数调用者是多么不爽,而本章节则说明没有签名对函数提供者有多么不爽。没有类型检查真的蛋疼,我也遇到过有人为了方便,给一个约定是int类型的形参传入了一个int的list,而可怕的是代码不报错,只是表现不正常。
来看一个例子:
def func(arg):
**if** arg:
print 'do lots of things here'
**else**:
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,由以下关于对序列布尔求值的规范:
Python
**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相同,另外还由两点比较有意思:
如果你需要区分false和None, 你应该用像 if not x and x is not None: 这样的语句.
处理整数时, 使用隐式false可能会得不偿失(即不小心将None当做0来处理). 你可以将一个已知是整型(且不是len()的返回结果)的值与0比较.
第二点我个人很赞同;但第一点就觉得很别扭,因为这样的语句一点不直观,难以表达其真实目的。
在pep20 the zen of python中,指出:
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留言指导!