Effective Python:编写高质量Python代码的90个有效方法(原书第2版)
上QQ阅读APP看书,第一时间看更新

第15条 不要过分依赖给字典添加条目时所用的顺序

在Python 3.5与之前的版本中,迭代字典(dict)时所看到的顺序好像是任意的,不一定与当初把这些键值对添加到字典时的顺序相同。也就是说,字典不保证迭代顺序与插入顺序一致。下面创建这样一个字典,把每种动物的名字跟这种动物的幼兽的称呼(例如cat是猫,小猫叫作kittendog是狗,小狗叫作puppy)关联起来(参见第75条)。

069-01

创建字典时,先添加的是'cat',后添加的是'dog',但在打印的时候,'dog'却出现在了'cat'前面。这样的效果令人惊讶,而且每次看到的顺序不固定,因此很难在测试用例中使用。这会让调试工作变得困难,尤其是容易使Python新手感到困惑。

之所以出现这种效果,是因为字典类型以前是用哈希表算法来实现的(这个算法通过内置的hash函数与一个随机的种子数来运行,而该种子数会在每次启动Python解释器时确定)。所以,这样的机制导致这些键值对在字典中的存放顺序不一定会与添加时的顺序相同,而且每次运行程序的时候,存放顺序可能都不一样。

从Python 3.6开始,字典会保留这些键值对在添加时所用的顺序,而且Python 3.7版的语言规范正式确立了这条规则。于是,在新版的Python里,总是能够按照当初创建字典时的那套顺序来遍历这些键值对。

069-02

在Python 3.5与之前的版本中,dict所提供的许多方法(包括keysvaluesitemspopitem等)都不保证固定的顺序,所以让人觉得好像是随机处理的。

069-03

在新版的Python中,这些方法已经可以按照当初添加键值对时的顺序来处理了。

070-01

这项变化对Python中那些依赖字典类型及其实现细节的特性产生了很多影响。

函数的关键字参数(包括万能的**kwargs参数,参见第23条),以前是按照近乎随机的顺序出现的,这使函数调用操作变得很难调试。

070-02

现在,这些关键字参数总是能够保留调用函数时所指定的那套顺序。

070-03

另外,类也会利用字典来保存这个类的实例所具备的一些数据。在早前版本的Python中,对象(object)中的字段看上去好像是按随机顺序出现的。

070-04

同样,在新版的Python中,我们就可以认为这些字段在__dict__中出现的顺序应该与当初赋值时的顺序一样。

071-01

现在的Python语言规范已经要求,字典必须保留添加键值对时所依照的顺序。所以,我们可以利用这样的特征来实现一些功能,而且可以把它融入自己给类和函数所设计的API中。

提示

其实,内置的collections模块早就提供了这种能够保留插入顺序的字典,叫作OrderedDict。它的行为跟(Python 3.7以来的)标准dict类型很像,但性能上有很大区别。如果要频繁插入或弹出键值对(例如要实现least-recently-used缓存),那么OrderedDict可能比标准的Python dict类型更合适(如何判断是否应该换用这种类型,请参见第70条)。

处理字典的时候,不能总是假设所有的字典都能保留键值对插入时的顺序。在Python中,我们很容易就能定义出特制的容器类型,并且让这些容器也像标准的listdict等类型那样遵守相关的协议(参见第43条)。Python不是静态类型的语言,大多数代码都以鸭子类型(duck typing)机制运作(也就是说,对象支持什么样的行为,就可以当成什么样的数据使用,而不用执着于它在类体系中的地位)。这种特性可能会产生意想不到的问题。

例如,现在要写一个程序,统计各种小动物的受欢迎程度。我们可以设定一个字典,把每种动物和它得到的票数关联起来。

071-02

现在定义一个函数来处理投票数据。用户可以把空的字典传给这个函数,这样的话,它就会把每个动物及其排名放到这个字典中。这种字典可以充当数据模型,给带有用户界面(UI)的元素提供数据。

072-01

我们还需要写一个函数来查出人气最高的动物。这个函数假定populate_ranks总是会按照升序向字典写入键值对,这样第一个出现在字典里的就应该是排名最靠前的动物。

072-02

下面来验证刚才设计的函数,看它们能不能实现想要的结果。

072-03

结果没有问题。但是,假设现在的需求变了,我们现在想要按照字母顺序在UI中显示,而不是像原来那样按照名次显示。为了实现这种效果,我们用内置的collections.abc模块定义这样一个类。这个类的功能和字典一样,而且会按照字母顺序迭代其中的内容。

072-04

原来使用标准dict的地方,现在可以改用这个类的实例。我们这个SortedDict类与标准的字典遵循同一套协议,因此程序不会出错。但是,我们并没有得到预期的结果。

073-01

为什么会这样呢?因为get_winner函数总是假设,迭代字典时的顺序应该跟populate_ranks函数当初向字典中插入数据时的顺序一样。但是这次,我们用的是SortedDict实例,而不是标准的dict实例,所以这项假设不成立。因此,函数返回的数据是按字母顺序排列时最先出现的那个数据,也就是'fox'

这个问题有三种解决办法。第一种办法是重新实现get_winner函数,使它不再假设ranks字典总是能按照固定的顺序来迭代。这是最保险、最稳妥的一种方案。

073-02

第二种办法是在函数开头先判断ranks是不是预期的那种标准字典(dict)。如果不是,就抛出异常。这个办法的运行性能要比刚才那种好。

073-03

第三种办法是通过类型注解(type annotation)来保证传给get_winner函数的确实是个真正的dict实例,而不是那种行为跟标准字典类似的MutableMapping(参见第90条)。下面就采用严格(strict)模式,针对含有注解的代码运行mypy工具。

074-01

这样可以检查出类型不相符的问题,mypy会标出错误的用法,指出函数要求的是dict,但传入的却是MutableMapping。这个方案既能保证静态类型准确,又不会影响程序的运行效率。

要点

  • 从Python 3.7版开始,我们就可以确信迭代标准的字典时所看到的顺序跟这些键值对插入字典时的顺序一致。
  • 在Python代码中,我们很容易就能定义跟标准的字典很像但本身并不是dict实例的对象。对于这种类型的对象,不能假设迭代时看到的顺序必定与插入时的顺序相同。
  • 如果不想把这种跟标准字典很相似的类型也当成标准字典来处理,那么可以考虑这样三种办法。第一,不要依赖插入时的顺序编写代码;第二,在程序运行时明确判断它是不是标准的字典;第三,给代码添加类型注解并做静态分析。