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

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

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

以笔者的知识水平,远远不足以评价动态语言与静态语言的优劣。本文也只是记录在我使用Python这门动态语言的时候,由于语言的灵活性,由于动态类型,踩过的坑,一点思考,以及困惑。

第一个问题:Mutable对象被误改

这个是在线上环境出现过的一个BUG

事后说起来很简单,服务端数据(放在dict里面的)被意外修改了,但查证的时候也花了许多时间,伪代码如下:

 
 
1
2
3
4
5
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

 
1
2
3
4
5
6
7
8
9
>>> 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)都指向同一个对象,仅此而已。来看一下面的代码:

 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

 
1
2
3
4
5
6
7
8
9
10
11
12
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):

  • shallow copy constructs a new compound object and then (to the extent possible) inserts references into it to the objects found in the original.
  • deep copy constructs a new compound object and then, recursively, inserts copies into it of the objects found in the original.

简单来说,深拷贝会递归拷贝,遍历任何compound object然后拷贝,例如:

 
 
 
 
 

Python

 
1
2
3
4
5
6
7
8
9
>>> 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

 
1
dct = {1: [1]}; dct1 = dict(dct)

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

 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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
2
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

 
1
2
3
4
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

 
1
2
3
def func(dct_only_read):
 
      “”“param: dct_only_read will be only read, never upate”“”

但是人是靠不住的,也是不可靠的,也许在这个函数的子函数(子函数的子函数,。。。)就会修改这个dict。怎么办,对可变类型强制copy(deepcopy),但拷贝又非常耗时。。。

第二个问题:参数检查

上一节说明没有签名 对 函数调用者是多么不爽,而本章节则说明没有签名对函数提供者有多么不爽。没有类型检查真的蛋疼,我也遇到过有人为了方便,给一个约定是int类型的形参传入了一个int的list,而可怕的是代码不报错,只是表现不正常。

来看一个例子:

 
 
1
2
3
4
5
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

 
1
2
3
4
5
6
7
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

 
1
Explicit is better than implicit.

这句话简单但实用!代码是写给人读的,清晰的表达代码的意图比什么都重要。也许有的人觉得代码写得复杂隐晦就显得牛逼,比如python中嵌套几层的list comprehension,且不知这样害人又害己。

回到布尔表达式求值这个问题,我觉得很多时候直接使用if arg:这种形式都不是好主意,因为不直观而且容易出错。比如参数是int类型的情况,

 
 
1
2
3
4
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留言指导!

动态语言的灵活性是把双刃剑 -- 以 Python 语言为例的更多相关文章

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

    本文有些零碎,总题来说,包括两个问题:(1)可变对象(最常见的是list dict)被意外修改的问题,(2)对参数(parameter)的检查问题.这两个问题,本质都是因为动态语言(动态类型语言)的特 ...

  2. 5分钟了解Python语言的简单介绍(转载)

    < 转载于Python数据之道 - 本公众号秉承“让数据更有价值”的理念,主要分享数据相关的内容,包括数据分析,挖掘,可视化,机器学习,深度学习等.>                    ...

  3. 010 深入理解Python语言

    目录 一.概述 二.计算机技术的演进 2.1 计算机技术的演进过程 三.编程语言的多样初心 3.1 编程语言有哪些? 3.2 不同编程语言的初心和适用对象 3.3 2018年以后的计算环境- 四.Py ...

  4. 第三章 深入理解python语言

    计算机技术的演进过程 1946-1981年 计算机系统结构时代(35年) 解决计算机能力的问题 1981-2008年 网络和视窗时代(27年) 解决交互问题 2008-2016年 复杂信息系统时代(8 ...

  5. 动态绑定允许我们在程序运行的过程中动态给class加上功能,这在静态语言中很难实现

    https://www.liaoxuefeng.com/wiki/ # 正常情况下,当我们定义了一个class,创建了一个class的实例后,我们可以给该实例绑定任何属性和方法, # 这就是动态语言的 ...

  6. Python语言、编译解释、动态库静态库、编译过程、头文件

    学习Python这门语言首先要了解 什么是编译与解释,什么是连接,什么是动态库与静态库, 什么是编译: 编译就是先把高级语言设计的程序翻译成二进制的机器语言,然后CPU直接执行机器码就可以了.一把翻译 ...

  7. Python 语言特性:编译+解释、动态类型语言、动态语言

    1. 解释性语言和编译性语言 1.1 定义 1.2 Python 属于编译型还是解释型? 1.3 收获 2. 动态类型语言 2.1 定义 2.2 比较 2. 动态语言(动态编程语言) 3.1 定义 3 ...

  8. 第 2 章 Python 语言入⻔

    目录 2.1低而长的学习曲线 2.2Python的优势 2.3在你的计算机中安装Python 2.4如何运行Python程序 2.5文本编辑器 2.6寻求帮助 Python语言是一种流行的编程语言,在 ...

  9. 【学习笔记】PYTHON语言程序设计(北理工 嵩天)

    1 Python基本语法元素 1.1 程序设计基本方法 计算机发展历史上最重要的预测法则     摩尔定律:单位面积集成电路上可容纳晶体管数量约2年翻倍 cpu/gpu.内存.硬盘.电子产品价格等都遵 ...

随机推荐

  1. case when then else end 与 decode 的区别

    case when  then else end : 条件可以有 等于 ,大于 ,小于 与 decode : 条件只可以有等于的.

  2. vue项目获取当前地址栏参数(非路由传参)

    项目中遇到一个需求,就是另一个管理系统带参直接单纯的跳转跳转到vue pc项目中的某个页面,后再初始化查询数据,参数以地址栏的形式传入 管理系统:打开新地址地址 let obj = { id: 21, ...

  3. CentOS7.6离线安装Tomcat8.5

    准备好tomcat安装文件: 官网下载apache-tomcat-8.5.39.tar.gz文件并复制到/usr/tomcat文件夹中. 解压tomcat安装文件: 进入/usr/tomcat文件:c ...

  4. 【DB2数据库在windows平台上的安装】

  5. ps加强总结

    快捷键 1.ctrl+alt+z返回 2.ctrl+z撤销 3.ctrl+s保存 4.ctrl+shift+s另存为 5.shift是有序的选择   ctrl键是无序的选择 6.ctrl+g打组   ...

  6. VB错误说明

    1001 800A03E9 内存不足 1002 800A03EA 语法错误 1003 800A03EB 缺少“:” 1005 800A03ED 需要 '(' 1006 800A03EE 需要 ')' ...

  7. 解决Stm32出现error: #20: identifier "GPIO_InitTypeDef" is undefined异常

    该错误是我在移植sd卡程序时出现的,错误如下: error:#20,查看错误,可以发现,这些变量都是系统定义过的,没有修改过.并且该变量也能成功跳转被找到.那么到底是什么原因呢?逛了一些帖子,尝试了好 ...

  8. Oracle入门第二天(下)——单行函数

    一.概述 以下内容完整参阅,参考官方文档函数手册部分:https://docs.oracle.com/cd/E11882_01/nav/portal_5.htm 离线chm手册英文版:链接:https ...

  9. 20155206 《Java程序设计》实验三实验报告

    20155206 <Java程序设计>实验三实验报告 实验内容 Java敏捷开发与XP实践 实验内容 XP基础 XP核心实践 相关工具 实验步骤 提交一: 提交二: 提交三: 提交四:

  10. 20155222 2016-2017-2 《Java程序设计》实验三

    20155222 2016-2017-2 <Java程序设计>实验三 1 在IDEA中使用工具(Code->Reformate Code)把下面代码重新格式化,再研究一下Code菜单 ...