大型线上项目中动态语言诸多问题之二 -- 以 Python 语言为例

第一个问题: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种则是看是否定义了nonzerolen,如果这两个函数都没有定义,那么实例的布尔求值一定返回真。

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相同,另外还由两点比较有意思:

  1. 如果你需要区分false和None, 你应该用像 if not x and x is not None: 这样的语句.

  2. 处理整数时, 使用隐式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留言指导!

点击居然老师公开课视频播放地址

    原文作者:扒皮狼
    原文地址: https://www.jianshu.com/p/7e841087e60a
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞