副作用还是Feature?

我们的Python应用需要一个全局变量保存一些公用的值,但是不希望其他人随意往里面添加属性,导致这个对象很乱。于是我们是这样定义的:

很多人提到“限制类的属性”就会很自然的想到__slots__,我认为这并不合适。__slots__的初衷是节省对象占用的内存,如果我们的app中某个对象可能有上百万个,就要考虑到将该对象变成__slots__定义的了。禁止赋予对象__slots__声明之外的属性名,这只是节省内存的Feature所带来的一个副作用。它是用来优化程序的,并不是来约束程序员的。

如果这么写,可能带来的缺点有:

  1. __slots__并不会继承,也就是说,如果子类继承了有__slots__的类,子类不会有__slots__存在,你要记住在每一个子类都写上。
  2. __slots__存在之后,该类就不能成为弱引用的目标(具体原因可以看弱引用的原理),除非将__weakref__加入到__slots__中。但是这样做将会污染__slots__变量,其他看到这个东西的时候需要分辨哪些是app的变量,哪些语言需要的变量。
  3. 前面已经说到了,__slots__存在的目标是为了优化存储空间。如果有一天,Python发现可以动态地向对象添加属性而依然节省内存的方法,可能就会破坏我们的程序。换句话说,Python是不会保证未来依然保留“节省内存”所带来的这个副作用的。

我觉得这个地方争取的实现应该是用魔术方法 __setattr__每次赋值都会经过该方法:

是不是更加Pythonic?

另一个例子是Python的dict,在Python3.6中,提到内置的dict遍历的时候会保持插入的顺序,但是又强调这是一个为了节省dict内存而带来的一个副作用,并不是语言设计的标准,不应该被依赖:

The dict type has been reimplemented to use a more compact representation based on a proposal by Raymond Hettinger and similar to the PyPy dict implementation. This resulted in dictionaries using 20% to 25% less memory when compared to Python 3.5.

The order-preserving aspect of this new implementation is considered an implementation detail and should not be relied upon。

有关是否能使用Python3.6的dict保持插入顺序,这里有个很好的讨论。我也认为依赖这个“副作用”是个严重错误。

  1. 这不是Python语言的标准,不兼容其他解释器
  2. Python并不知道你是否依赖了dict的顺序,如果有错误,Python解释器层面不会报出错误
  3. 鉴于这不是一个语言标准,很可能被碰巧知道这个“实现细节”的人运用了这个“副作用”,但是别人却不知道(不知道实现细节也可以是一个合格Python程序员),未来修改代码可能不会注意其实个地方遍历是要根据插入顺序的,留下了隐患。

Python3.7中,这成为了一个语言特性,所以3.7+我们就可以放心使用啦!

Make it so. “Dict keeps insertion order” is the ruling. Thanks!

Guido van Rossum


 

今天在论坛看到一个有意思的问题:if foobar != None 和 if foobar is not None 是完全等价的吗?

挺有意思,这个问题我又想了一下:为什么大家比较 None 的时候用 is ,但是比价字符串(字符串也会有驻留)却用 == 。我自己的思考是:None是文档写明的全局变量,而字符串的驻留确是一个解释器为了优化而带来的副作用,不能依赖,解释器可能在某个时候决定不再缓存某个字符串,所以这是不可靠的。正好这个问题和本文的主题比较切合,我就贴一下自己的回答:

楼上 @gwki 说的很清楚了!

但是对于 None 来说有一点区别,你看很多 Python 代码就会发现:大部分情况下我们用 if foo is None 来做判断,因为 None 在 Python 中是一个全局唯一变量。官方文档中说:Since None is a singleton, testing for object identity (using == in C) is sufficient. 所以官方是推荐用 id 来 check 的。

即:None 只有一个,不存在值为 None 但是与 id(None) 不相等的情况。

写作 if foo != None 有点不 Pythonic (反正我是没这么见过哈哈哈)。

问题 2:

foo = 0
if foo 判断为假,
if foo is not None 判断为真。所以 is 判断的是 id 相同(对于 None 来说判断 id 相同和判断值相同没有太大区别,反正只有 1 个)。

所以二者是不一样的,除了 None 之外,文档( https://docs.python.org/3.6/library/stdtypes.html#truth-value-testing )还有下面的判断为假:

– constants defined to be false: None and False.
– zero of any numeric type: 0, 0.0, 0j, Decimal(0), Fraction(0, 1)
– empty sequences and collections: ”, (), [], {}, set(), range(0)

再啰嗦一点,对于不可变对象,为了避免重复创建,Python 做了驻留处理。比如下面代码:

>>> s1 = “ABC”
>>> s2 = “ABC”
>>> s1 is s2
True

但是我们实际比较二者的时候,应该用 s1 == s2。因为驻留操作是 CPython 的实现细节。副作用不应该被依赖。

 

无处不在的同化

电影《Detachment》里面,有一幕是Henry在黑板上写了两个词:assimilate,ubiquitousness。

Assimilate,同化,吸收周围的事物,融入。

Ubiquitousness,无处不在的,everywhere。

无处不在的同化。如果所有的事情都给你展现在眼前,你还有什么想象力呢?每天被巨大的信息冲击,告诉你应该做什么,应该怎样做。古代会为了统治宣传儒家思想,现代为了刺激经济给人们灌输结婚的潜意识,制造各种节日灌输各种观点来刺激消费。

如同《1984》描述的那样,在无处不在的同化中,就产生了“双重思想”,明知是谎言,人们还是愿意去相信。这种例子在现在的生活中比比皆是:我要变漂亮,才会快乐;我应该马上结婚,才会有正常的人生;我要变瘦;我应该健身……

这是一种Marketing Holocaust。这种会大毁灭式的营销会在我们接下来的人生中,每天24小时,不停地冲击我们,让我们努力工作,变得沉默,每天在广告的夹缝中看到一丝信息,最终在沉默中灭亡。

所以我们必须保护自己的思想,与这种无处不在的、让我们变得沉默的同化力量抗争。我们必须学会阅读,激发自己的想象力,培养自己的独立意识和信仰,保护自己的思想。

 

Python对象序列化与反序列(Pickle)

我们最近想要对爬虫拿到的下载结果进行存档,这个结果是一个Python对象(我们并不想简单的存一个HTML或json,想要让整个下载过程可以还原),于是就想到了用Python内置的pickle库(腌黄瓜库),序列化对象成bytes,需要的时候可以反序列化。

通过下面的代码可以简单了解pickle的用法和功能。

可以看到pickle的用法和json有点像,但是有几点根本区别:

json是跨语言的通用的一种数据交换格式,一般用文本表示,人类可读。pickle是用来序列化Python对象,只是针对Python的,序列化的结果是二进制数据,人类不可读。而且json默认只可以序列化一部分的内置类型,pickle可以序列化相当多的数据。

另外还有一个古老的marshal也是内置的。但这个库主要是针对.pyc文件的。不支持自定义的类型,也不完善。比如不能处理循环应用,如果有一个对象引用了自己,那么用marshal的话Python解释器就挂了。

版本兼容问题

由于pickle是针对Python的,Python有不同的版本(并且2与3之间差异非常大),所以就要考虑到序列化出来的对象能不能被更高(或低?)版本的Python反序列化出来。

目前一共有5个pickle的协议版本,版本越高对应Pyhton的版本越高,0-2针对Python2,3-4针对Python3.

  • Protocol version 0 is the original “human-readable” protocol and is backwards compatible with earlier versions of Python.
  • Protocol version 1 is an old binary format which is also compatible with earlier versions of Python.
  • Protocol version 2 was introduced in Python 2.3. It provides much more efficient pickling of new-style classes. Refer to PEP 307for information about improvements brought by protocol 2. (从这个版本往后,性能有显著提高)
  • Protocol version 3 was added in Python 3.0. It has explicit support for bytes objects and cannot be unpickled by Python 2.x. This is the default protocol, and the recommended protocol when compatibility with other Python 3 versions is required.
  • Protocol version 4 was added in Python 3.4. It adds support for very large objects, pickling more kinds of objects, and some data format optimizations. Refer to PEP 3154 for information about improvements brought by protocol 4.

pickle的大多数入口函数(例如dump()dumps()Pickler构造器)都接受一个协议版本的参数,其中内置了两个变量:

  • pickle.HIGHEST_PROTOCOL目前是4
  • pickle.DEFAULT_PROTOCOL目前是3

用法

和内置的json模块接口类似,dumps()用于返回序列化结果,dump()用于序列化然后写入文件。同理也有load()loads()。其中,序列化dump(s)的时候可以指定协议的版本,反序列化的时候就不用了,会自动识别版本。这个和zip命令很像。

内置类型的序列化

大多数内置的类型都支持序列化和反序列化。需要特殊注意的是函数。函数的序列化只是取其名字和所在的module。函数的代码和属性(Python的函数是第一等对象,可以有属性)都不会被序列化。这就要求函数所在的模块在unpickle的环境中必须是可以import的,否则会出现ImportErrorAttributeError

这里有个地方很有意思:所有的lambda函数都是不可Pickle的。因为它们的名字都叫做<lambda>

自定义类型的序列化

如同本文开头的实验代码一样,对我们自定义的对象在大部分情况下都不需要额外的操作就可以实现序列化/反序列化的操作。需要注意的是,在反序列化的过程中,并不是调用class的__init__()来初始化出一个对象,而是新建出一个未被初始化的实例,然后恢复它的属性(非常巧妙)。伪代码如下:

如果希望在序列化的过程中做一些额外操作,例如保存对象的状态,可以使用pickle协议的魔术方法,最常见的是__setstate__()__getstate__()

安全问题(!)

pickle文档的开头就说:千万不要unpickle一个未知来源的二进制。考虑下面的代码:

这段代码unpickle出来就是导入了os.system()然后调用echo。并没有啥副作用。但如果是rm -rf /·呢?

文档给出的建议是在Unpickler.find_class()里面实现检查逻辑。函数方法在要求全局变量的时候必然调用。

压缩

pickle之后并不会自动压缩的,我觉得这个设计非常好,解耦,pickle就干pickle的事情,压缩交给别的库去做。而且你自己也可以发现,pickle之后的文件尽管不可读,但内容依然是以ascii码呈现的,并不是乱码。需要调用压缩库的compress。实测压缩之后,体积是之前的1/3左右,非常可观。

我目前已知的坑:

全局变量要保持可以导入的,这个有点难。要面对的问题是:我今天pickle的东西,在将来某一天需要打开,是否还能打开呢?

这里有几个版本:项目的版本、python的版本,pickle协议版本,项目依赖的包版本。其中python的版本和pickle版本我觉得可以放心依赖他们的向后兼容,容易解决。主要是项目和版本和依赖的版本,如果要Pickle的对象非常复杂,那么很可能老版本的备份无法兼容新版本。可能的解决办法就是对所有的依赖完全锁定,例如记录他们的hash值。如果要恢复某一个二进制序列,那么就还原出当时的特定依赖、项目的特定commit。

但是目前来说,我们的需求基本上就是pickle一个requests.Response对象。我觉得是可以依赖它们的向后兼容的。如果有一天requests有了breaking change,那么就算我们的pickle能兼容,代码也不可能兼容了,那时候可以考虑别的策略。

 

一次Mongodb的admin数据库导致的事故

Mongodb的gridfs一次插入数据的时候会自动创建几个索引,我们程序里面的账号没有createIndex权限,我需要手动创建一下。结果连接到mongo服务器之后忘记执行use xxxdb来切换数据库了,于是在admin数据库里面创建了一个索引,结果导出一边的程序报出来很多验证问题。

Mongo的admin数据库太脆弱了,只是创建一个索引就挂了。长个教训,以后千万不要手动修改它,更不要用admin保存数据

反思一下,这次操作失误其实爆出我平时一些不好的习惯。

首先,连接mongo应该指定目标数据。而我之前都是连接到admin,然后用use切换到目标数据库。这样难免会忘记。

第二,错误的在admin数据库执行createIndex,返回的结果明确显示索引创建成功。

但是我忽略了,继续在正确的数据库创建索引。不然可以早一些发现问题。

最后,创建索引应该自动化,比如gridfs这种对md5, filename创建索引的。

 

理解Python的Iterable和Iterator

今天看到一篇很有意思的文章,说Pyhton3的range是返回的什么?

很多人都会不假思索的说,这还不简单,在Python2中range()会返回list,到了Python3range已经使用xrange替换,返回的是一个迭代器(Iterator)。

恭喜你,答错了。

range()返回的是一个Iterable,并不是一个Iterator.

原理很简单,先简单说一下Iterable和Iterator,不要试图比较二者有什么不同,因为二者根本就是不同的概念。二者字面意思都非常明确:Iterable就是一个可迭代的对象,对其调用iter(Iterable)将会得到一个迭代器;而Iterator就是一个迭代器,对其调用next(Iterator)将会得到下一个元素。

Python推崇协议,说白了就是鸭子类型。你如果实现了__iter__(),(即对你调用iter()可以得到一个Iterator)那你就是一个Iterable;如果实现了__next__()__iter__()就是一个Iterator。

等等,Iterator不就是调用next()得到下一个元素就可以了?为什么Iterator也要实现Iterable的__iter__()方法,这不纯粹啊!

为什么Python的Iterator要实现__iter__()呢(通常的实现都是return self)。官方文档中说的相当清楚。

Iterators are required to have an __iter__() method that returns the iterator object itself so every iterator is also iterable and may be used in most places where other iterables are accepted.

简单翻译一下,就是说Iterator也要求实现__iter__(),因为很多地方接收的参数是一个Iterable,如果所有的Iterator都是Iterable,那么这些用Iterable地方都可以无障碍地使用Iterator了。比如说for循环吧,关于for循环,我在Python的wiki中(已经比较老了)找到这样的描述:

 Basically, any object with an iterable method can be used in a for loop. Even strings, despite not having an iterable method – but we’ll not get on to that here.

即,for循环拿到一个Iterable的Iterator,然后使用这个Iterator进行迭代。如果Iterator实现了__iter__()方法,那么for循环就可以无障碍地对Iterator进行迭代了,Neat!想象一下,Python的生成器也是Iterator,如果for循环不能支持对Iterator迭代,生不如死啊。

所以对Iterator就有了这样一个“过分”的要求。我们可以认为,所有的Iterator都是Iterable。那么回到最初的问题,为什么range()反回的是一个Iterable而不是Iterator呢?

Iterator是有状态的,只能遍历一次,是“消费型”的,不可以“二次消费”。Iterable是没有状态的(这里不太严谨,这句话暂且不提是Iterator的Iterable),每一次对Iterable调用iter()都会得到一个新的迭代器。

考虑我们平时使用range(),我们认为这是一个表示范围的一个容器。可以使用这个容器去初始化成别的容器,这没有任何问题。

倘若range()返回的是迭代器,那么上面这个看起来在正常不过的代码就有麻烦了:

 

读到这里,你应该完全理解Iterable和Iterator了,再说点题外话。

有人可能觉得(我之前也这么觉得),这样的实现太不纯粹了,要是Iterator就是Iterator,Iterable就是Iterable不好吗?规定接口只接受Iterator,如果有Iterable的话必须手动获得一个新的Iterator然后使用。可以是可以,但是想象一下每次都初始化一个新的Iterator多费劲啊。并且肯定有地方会定义一个临时变量,万一忍不住忘记这已经迭代完了,被迭代第二次呢?

对Iterator进行迭代,是很符合人类思维的事情,因为迭代器本来就是可以迭代的。

对Iterable进行迭代,也是很符合人类思维的事情,”可迭代的对象“本来也是可迭代的,每次迭代的时候在内部初始化一个Iterator进行迭代。

所以现在想想,其实Iterator的这种行为就非常合理了。

Python是一门讲求实用主义的语言。随着越来越多地接触和使用这一门语言,我也越来越体会到这句话。另一个例子是Pyhton的元祖,这个数据结构的灵感来自ABC语言的compoundscompounds可以作为字典(在ABC中字典叫table)的合成键,也支持平行赋值。仅此而已。如果你想使用它,要么使用平行赋值取出全部的元素,要么作为一个整体来使用。这样compounds的作用变得非常纯粹——一个没有字段名字的记录的集合。而Python的tuple,却支持了迭代、下标、切片,给人的印象更像是frozenlist!概念上已经失去了compounds的纯粹。

但是这种实用主义的哲学让Python比ABC更灵活,更好用也更成功。

Although practicality beats purity.

Python 之禅如是说。