Py学习  »  Python

动态语言的灵活性是把双刃剑:以 Python 语言为例

Python开发者 • 6 年前 • 494 次点击  

(点击上方公众号,可快速关注)


来源:xybaby

www.cnblogs.com/xybaby/p/7208496.html

如有好文章投稿,请点击 → 这里了解详情


本文有些零碎,总题来说,包括两个问题:(1)可变对象(最常见的是list dict)被意外修改的问题,(2)对参数(parameter)的检查问题。这两个问题,本质都是因为动态语言(动态类型语言)的特性造成了,动态语言的好处就不细说了,本文是要讨论因为动态--这种灵活性带来的一些问题。


什么是动态语言(Dynamic Programming language)呢,是相对于静态语言而言,将很多静态语言编译(compilation)时期所做的事情推迟到运行时,在运行时修改代码的行为,比如添加新的对象和函数,修改既有代码的功能,改变类型。绝大多数动态语言都是动态类型(Dynamic Typed),所谓动态类型,是在运行时确定数据类型,变量使用之前不需要类型声明,通常变量的类型是被赋值的那个值的类型。Python就是属于典型的动态语言。


动态语言的魅力在于让开发人员更好的关注需要解决的问题本身,而不是冗杂的语言规范,也不用干啥都得写个类。运行时改变代码的行为也是非常有用,比如python的热更新,可以做到不关服务器就替换代码的逻辑,而静态语言如C++就很难做到这一点。笔者使用得最多的就是C++和Python,C++中的一些复杂的点,比如模板(泛型编程)、设计模式(比如template method),在Python中使用起来非常自然。我也看到过有一些文章指出,设计模式往往是特定静态语言的补丁 — 为了弥补语言的缺陷或者限制。


以笔者的知识水平,远远不足以评价动态语言与静态语言的优劣。本文也只是记录在我使用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函数返回对象的地址,每个对象在都有唯一的地址。看下面两个例子就知道了


>>> 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])


运行结果:


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)


正是由于浅拷贝与深拷贝本质上的区别,二者性能代价差异非常之大,即使对于被拷贝的对象来说毫无差异:


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()


运行结果:


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++中,参数传递大约有三种形式,传值、传指针、传引用(这里不考虑右值引用)。指针和引用虽然表现形式上差异,但效果上是差不多的,因此这里主要考虑传值和传引用。比如下面四个函数签名:


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):

      “”“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,由以下关于对序列布尔求值的规范:


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留言指导!


references:


  • Dynamic Programming language

  • instagram-pycon-2017

  • https://www.python.org/dev/peps/pep-0008/

  • google python styleguide

  • the zen of python

  • best-way-to-check-if-a-list-is-empty


看完本文有收获?请转发分享给更多人

关注「Python开发者」,提升Python技能

淘口令复制以下红色内容,再打开手淘即可购买

范品社,使用¥极客T恤¥抢先预览(长按复制整段文案,打开手机淘宝即可进入活动内容)


今天看啥 - 高品质阅读平台
本文地址:http://www.jintiankansha.me/t/VAZaxkTXq3
Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/4244
 
494 次点击