编写高质量Python程序(四)库

本系列文章为《编写高质量代码——改善Python程序的91个建议》的精华汇总。

按需选择 sort() 或者 sorted()

Python 中常用的排序函数有 sort()sorted()

两者的函数形式分别如下:

sorted(iterable[, cmp[, key[, reverse]]])
s.sort([cmp[, key[, reverse]]])

sort()sorted() 有3个共同的参数:

  • cmp用户定义的任何比较函数,函数的参数为两个可比较的元素(来自 iterable 或者 list ),函数根据第一个参数与第二个参数的关系依次返回 -1、0 或者 +1(第一个参数小于第二个参数则返回负数)。该参数默认值为 None
  • key 是一个带参数的函数,用来为每个元素提取比较值,默认为 None(即直接比较每个元素)
  • reverse 表示排序结果是否反转

两者对比:

  • sorted() 作用于任何可迭代的对象;而 sort() 一般作用于列表

  • sorted() 函数会返回一个排序后的列表,原有列表保持不变;而 sort() 函数会直接修改原有列表,函数返回为 None。实际应用过程中需要保留原有列表,使用 sorted() 函数较为合适,否则可以选择 sort() 函数,因为 sort() 函数不需要复制原有列表,消耗的内存较少,效率也较高。

  • 无论是 sort() 还是 sorted() 函数,传入参数 key 比传入参数 cmp 效率要高。cmp 传入的函数在整个排序过程中会调用多次,函数开销较大;而 key 针对每个元素仅做一次处理,因此使用 key 比使用 cmp 效率要高。

  • sorted() 功能非常强大,它可以对不同的数据结构进行排序,从而满足不同需求。

例:

对字典进行排序

>>> phone_book = {"Linda": "7750", "Bob": "9345", "Carol": "5834"}
>>> from operator import itemgetter
>>> sorted_pb = sorted(phone_book.items(), key=itemgetter(1))
>>> print(sorted_pb)
[('Carol', '5834'), ('Linda', '7750'), ('Bob', '9345')]

多维 List 排序:实际情况下也会碰到需要对多个字段进行排序的情况,这在 DB 里面用 SQL 语句很容易做到,但使用多维列表联合 sorted() 函数也可以轻易达到

>>> import operator
>>> game_result = [["Bob",95,"A"],["Alan",86,"C"],["Mandy",82.5,"A"],["Rob",86,"E"]]
>>> sorted(game_result, key=operator.itemgetter(2, 1))
[['Mandy', 82.5, 'A'], ['Bob', 95, 'A'], ['Alan', 86, 'C'], ['Rob', 86, 'E']]

字典中混合 List 排序:字典中的 key 或者值为列表,对列表中的某一个位置的元素排序

>>> my_dict = {"Li":["M",7],"Zhang":["E",2],"Wang":["P",3],"Du":["C",2],"Ma":["C",9],"Zhe":["H",7]}
>>> import operator
>>> sorted(my_dict.items(), key=lambda item:operator.itemgetter(1)(item[1]))
[('Du', ['C', 2]), ('Zhang', ['E', 2]), ('Wang', ['P', 3]), ('Zhe', ['H', 7]), ('Li', ['M', 7]), ('Ma', ['C', 9])]

List 中混合字典排序:列表中的每一个元素为字典形式,针对字典的多个 key 值进行排序

>>> import operator
>>> game_result = [{"name":"Bob","wins":10,"losses":3,"rating":75},{"name":"David","wins":3,"losses":5,"rating":57},{"name":"Carol","wins":4,"losses":5,"rating":57},{"name":"Patty","wins":9,"losses":3,"rating":71.48}]
>>> sorted(game_result, key=operator.itemgetter("rating","name"))
[{'losses': 5, 'name': 'Carol', 'rating': 57, 'wins': 4}, {'losses': 5, 'name': 'David', 'rating': 57, 'wins': 3}, {'losses': 3, 'name': 'Patty', 'rating': 71.48, 'wins': 9}, {'losses': 3, 'name': 'Bob', 'rating': 75, 'wins': 10}]

使用 copy 模块深拷贝对象

  • 浅拷贝(shallow copy):构造一个新的复合对象,并将从原对象中发现的引用插入该对象中。浅拷贝的实现方式有多种,如工厂函数、切片操作、copy 模块中的 copy 操作等。
  • 深拷贝(deep copy):也构造一个新的复合对象,但是遇到引用会继续递归拷贝其所指向的具体内容,也就是说它会针对引用所指向的对象继续执行拷贝,因此产生的对象不受其他引用对象操作的影响。深拷贝的实现需要依赖 copy 模块的 deepcopy() 操作。

浅拷贝并不能进行彻底的拷贝,当存在列表、字典等不可变对象的时候,它仅仅拷贝其引用地址。要解决上述问题需要用到深拷贝,深拷贝不仅拷贝引用也拷贝引用所指向的对象,因此深拷贝得到的对象和原对象是相互独立的。

使用 Counter 进行计数统计

计数统计就是统计某一项出现的次数。可以使用不同数据结构来进行实现:

  • 例如,使用 defaultdict实现
from collections import defaultdict
some_data = ["a", "2", 2, 4, 5, "2", "b", 4, 7, "a", 5, "d", "a", "z"]
count_frq = defaultdict(int)
for item in some_data:
    count_frq[item] += 1
print(count_frq)
# defaultdict(<class 'int'>, {'a': 3, '2': 2, 2: 1, 4: 2, 5: 2, 'b': 1, 7: 1, 'd': 1, 'z': 1})

更优雅,更 Pythonic 的解决方法是使用 collections.Counter

from collections import Counter
some_data = ["a", "2", 2, 4, 5, "2", "b", 4, 7, "a", 5, "d", "z", "a"]
print(Counter(some_data))
# Counter({'a': 3, '2': 2, 4: 2, 5: 2, 2: 1, 'b': 1, 7: 1, 'd': 1, 'z': 1})

深入掌握 ConfigParser

常见的配置文件格式有 XML 和 ini 等,其中在 MS Windows 系统上,ini 文件格式用得尤其多,甚至操作系统的 API 也都提供了相关的接口函数来支持它。类似 ini 的文件格式,在 Linux 等操作系统中也是极常用的,比如 pylint 的配置文件就是这个格式。Python 有个标准库来支持它,也就是 ConfigParser。

ConfigParser 的基本用法通过手册可以掌握,但仍然有几个知识点值得注意。首先就是 getboolean() 这个函数。getboolean() 根据一定的规则将配置项的值转换为布尔值,如以下的配置:

[section1]
option1=0

当调用 getboolean("section1", "option1") 时,将返回 False。

getboolean() 的真值规则: 除了 0 以外,no、false 和 off 都会被转义为 False,而对应的 1、yes、true 和 on 则都被转义为 True,其他值都会导致抛出 ValueError 异常。

还需要注意的是配置项的查找规则。首先,在 ConfigParser 支持的配置文件格式里,有一个 [DEFAULT] 节,当读取的配置项不在指定的节里时,ConfigParser 将会到 [DEFAULT] 节中查找。
除此之外,还有一些机制导致项目对配置项的查找更复杂,这就是 class ConfigParser 构造函数中的 defaults 形参以及其 get(section, option[, raw[, vars]]) 中的全名参数 vars。如果把这些机制全部用上,那么配置项值的查找规则

  • 如果找不到节名,就抛出 NoSectionError
  • 如果给定的配置项出现在 get() 方法的 var 参数中,则返回 var 参数中的值
  • 如果在指定的节中含有给定的配置项,则返回其值
  • 如果在 【DEFAULT】中有指定的配置项,则返回其值
  • 如果在构造函数的 defaults 参数中有指定的配置项,则返回其值
  • 抛出 NoOptionError

使用 argparse 处理命令行参数

尽管应用程序通常能够通过配置文件在不修改代码的情况下改变行为,但提供灵活易用的命令行参数仍然非常有意义,比如:减轻用户的学习成本,通常命令行参数的用法只需要在应用程序名后面加 –help 参数就能获得,而配置文件的配置方法通常需要通读手册才能掌握。

关于命令行处理,现阶段最好用的参数处理标准库是 argparse。

  • add_argument() 方法用以增加一个参数声明。
import argparse

parser = argparse.ArgumentParser(description='Process some integers.')
parser.add_argument('integers', metavar='N', type=int, nargs='+',
                    help='an integer for the accumulator')
parser.add_argument('--sum', dest='accumulate', action='store_const',
                    const=sum, default=max,
                    help='sum the integers (default: find the max)')

args = parser.parse_args()
print(args.accumulate(args.integers))
  • 除了支持常规的 int/float 等基本数值类型外,argparse 还支持文件类型,只要参数合法,程序就能够使用相应的文件描述符。
parser = argparse.ArgumentParser()
parser.add_argument("bar", type=argparse.FileType("w"))
parser.parse_args(["out.txt"])
  • 扩展类型也变得更加容易,任何可调用对象,比如函数,都可以作为 type 的实参。另外 choices 参数也支持更多的类型,比如: parser.add_argument("door", type=int, choices=range(1, 4))
  • 此外,add_argument() 提供了对必填参数的支持,只要把 required 参数设置为 True 传递进去,当缺失这一参数时,argparse 就会自动退出程序,并提示用户。
  • 还支持参数分组。add_argument_group() 可以在输出帮助信息时更加清晰,这在用法复杂的 CLI 应用程序中非常有帮助:
parser = argparse.ArgumentParser(prog="PROG", add_help=False)
group1 = parser.add_argument_group("group1", "group1 description")
group1.add_argument("foo", help="foo help")
group2 = parser.add_argument_group("group2", "group2 description")
group2.add_argument("--bar", help="bar help")
parser.print_help()
  • 另外还有 add_mutually_exclusive_group(required=False) 非常实用:它确保组中的参数至少有一个或者只有一个(required=True)。
  • argparse 也支持子命令,比如 pip 就有 install/uninstall/freeze/list/show 等子命令,这些子命令又接受不同的参数,使用 ArgumentParser.add_subparsers() 就可以实现类似的功能。
import argparse
parser = argparse.ArgumentParser(prog="PROG")
subparsers = parser.add_subparsers(help="sub-command help")
parser_a = subparsers.add_parser("a", help="a help")
parser_a.add_argument("--bar", type=int, help="bar help")
parser.parse_args(["a", "--bar", "1"])
  • 除了参数处理之外,当出现非法参数时,用户还需要做一些处理,处理完成后,一般是输出提示信息并退出应用程序。ArgumentParser 提供了两个方法函数,分别是 exit(status=0, message=None)error(message),可以省了 import sys 再调用 sys.exit() 的步骤。

理解模块 pickle 优劣

序列化,简单地说就是把内存中的数据结构在不丢失其身份和类型信息的情况下转换成对象的文本或二进制表示的过程。对象序列化后的形式经过反序列化过程应该能恢复原有对象。

Python 中有很多支持序列化的模块,如 pickle、json、marshal 和 shelve 等。

pickle 是最通用的序列化模块,它还有个 C 语言的实现 cPickle,相比 pickle 来说具有较好的性能,其速度大概是 pickle 的 1000 倍,因此在大多数应用程序中应该优先使用 cPickle(注:cPickle 除了不能被继承之外,它们两者的使用基本上区别不大)。pickle 中最主要的两个函数对为 dump()load(),分别用来进行对象的序列化和反序列化。

pickle 良好的特性总结为以下几点:

  • 接口简单,容易使用。使用 dump()load() 便可轻易实现序列化和反序列化。

  • pickle 的存储格式具有通用性,能够被不同平台的 Python 解析器共享。比如 Linux 下序列化的格式文件可以在 Windows 平台的 Python 解析器上进行反序列化,兼容性较好。

  • 支持的数据类型广泛。如数字、布尔值、字符串,只包含可序列化对象的元组、字典、列表等,非嵌套的函数、类以及通过类的 __dict__ 或者 __getstate__() 可以返回序列化对象的实例等。

  • pickle 模块是可以扩展的。对于实例对象,pickle 在还原对象的时候一般是不调用 __init__() 函数的,如果要调用 __init__() 进行初始化,对于古典类可以在类定义中提供 __getinitargs__() 函数,并返回一个元组,当进行 unpickle 的时候,Python 就会自动调用 __init__(),并把 __getinitargs__() 中返回的元组作为参数传递给 __init__(),而对于新式类,可以提供 __getnewargs__() 来提供对象生成时候的参数,在 unpickle 的时候以 Class.__new__(Class, *arg) 的方式创建对象。对于不可序列化的对象,如 sockets、文件句柄、数据库连接等,也可以通过实现 pickle 协议来解决这些巨献,主要是通过特殊方法 __getstate__()__setstate__() 来返回实例在被 pickle 时的状态。

    示例:

    import cPickle as pickle
    class TextReader:
        def __init__(self, filename):
            self.filename = filename    # 文件名称
            self.file = open(filename)    # 打开文件的句柄
            self.postion = self.file.tell()    # 文件的位置
    
        def readline(self):
            line = self.file.readline()
            self.postion = self.file.tell()
            if not line:
                return None
            if line.endswith("\n"):
                line = line[:-1]
            return "{}: {}".format(self.postion, line)
    
        def __getstate__(self):    # 记录文件被 pickle 时候的状态
            state = self.__dict__.copy()    # 获取被 pickle 时的字典信息
            del state["file"]
            return state
    
        def __setstate__(self, state):    # 设置反序列化后的状态
            self.__dict__.update(state)
            file = open(self.filename)
            self.file = file
    
    reader = TextReader("zen.text")
    print(reader.readline())
    print(reader.readline())
    s = pickle.dumps(reader)    # 在 dumps 的时候会默认调用 __getstate__
    new_reader = pickle.loads(s)    # 在 loads 的时候会默认调用 __setstate__
    print(new_reader.readline())
    
  • 能够自动维护对象间的引用,如果一个对象上存在多个引用,pickle 后不会改变对象间的引用,并且能够自动处理循环和递归引用。

    >>> a = ["a", "b"]
    >>> b = a    # b 引用对象 a
    >>> b.append("c")
    >>> p = pickle.dumps((a, b))
    >>> a1, b1 = pickle.loads(p)
    >>> a1
    ["a", "b", "c"]
    >>> b1
    ["a", "b", "c"]
    >>> a1.append("d")    # 反序列化对 a1 对象的修改仍然会影响到 b1
    >>> b1
    ["a", "b", "c", "d"]
    

但 pickle 使用也存在以下一些限制:

  • pickle 不能保证操作的原子性。pickle 并不是原子操作,也就是说在一个 pickle 调用中如果发生异常,可能部分数据已经被保存,另外如果对象处于深递归状态,那么可能超出 Python 的最大递归深度。递归深度可以通过 sys.setrecursionlimit() 进行扩展。
  • pickle 存在安全性问题。Python 的文档清晰地表明它不提供安全性保证,因此对于一个从不可信的数据源接收到的数据不要轻易进行反序列化。由于 loads() 可以接收字符串作为参数,精心设计的字符串给入侵提供了一种可能。在 Python 解释器中输入代码 pickle.loads("cos\nsystem\n(S'dir\ntR.")便可以查看当前目录下所有文件。可以将 dir 替换为其他更具破坏性的命令。如果要进一步提高安全性,用户可以通过继承类 pickle.Unpickler 并重写 find_class() 方法来实现。
  • pickle 协议是 Python 特定的,不同语言之间的兼容性难以保障。用 Python 创建的 pickle 文件可能其他语言不能使用。

序列化的另一个不错的选择——JSON

Python 的标准库 JSON 提供的最常用的方法与 pickle 类似,dump/dumps 用来序列化,load/loads 用来反序列化。需要注意 json 默认不支持非 ASCII-based 的编码,如 load 方法可能在处理中文字符时不能正常显示,则需要通过 encoding 参数指定对应的字符编码。在序列化方面,相比 pickle,JSON 具有以下优势:

  • 使用简单,支持多种数据类型。JSON 文档的构成非常简单,仅存在以下两大数据结构:
    • 名称/值对的集合。在各种语言中,它被实现为一个对象、记录、结构、字典、散列表、键列表或关联数组。
    • 值的有序列表。在大多数语言中,它被实现为数组、向量、列表或序列。在 Python 中对应支持的数据类型包括字典、列表、字符串、整数、浮点数、True、False、None 等。JSON 中数据结构和 Python 中的转换并不是完全一一对应,存在一定的差异。
  • 存储格式可读性更为友好,容易修改。相比于 pickle 来说,json 格式更加接近程序员的思维,阅读和修改上要容易得多。dumps() 函数提供了一个参数 indent 使生成的 json 文件可读性更好,0 意味着“每个值单独一行”;大于 0 的数字意味着“每个值单独一行并且使用这个数字的空格来缩进嵌套的数据结构”。但需要注意的是,这个参数是以文件大小变大为代价的。
  • json 支持跨平台跨语言操作。如 Python 中生成的 json 文件可以轻易使用 JavaScript 解析,互操作性更强,而 pickle 格式的文件只能在 Python 语言中支持。此外 json 原生的 JavaScript 支持,客户端浏览器不需要为此使用额外的解释器,特别适用于 Web 应用提供快速、紧凑、方便地序列化操作。此外,相比于 pickle,json 的存储格式更为紧凑,所占空间更小。
  • 具有较强的扩展性。json 模块还提供了编码(JSONEncoder)和解码类(JSONDecoder)以便用户对其默认不支持的序列化类型进行扩展。

Python 中标准模块 json 的性能比 pickle 与 cPickle 稍逊。如果对序列化性能要求非常高的场景,可以使用 cPickle 模块。

使用 threading 模块编写多线程程序

GIL 的存在使得 Python 多线程编程暂时无法充分利用多处理器的优势,并不能提高运行速率,但在以下几种情况,如等待外部资源返回,或者为了提高用户体验而建立反应灵活的用户界面,或者多用户应用程序中,多线程仍然是一个比较好的解决方案。

Python 为多线程编程提供了两个非常简单明了的模块:thread 和 threading。

thread 模块提供了多线程底层支持模块,以低级原始的方式来处理和控制线程,使用起来较为复杂;而 threading 模块基于 thread 进行包装,将线程的操作对象化,在语言层面提供了丰富的特性。实际应用中,推荐优先使用 threading 模块而不是 thread 模块。

  • 就线程的同步和互斥来说,threading 模块中不仅有 Lock 指令锁,RLock 可重入指令锁,还支持条件变量 Condition、信号量 Semaphore、BoundedSemaphore 以及 Event 事件等。

  • threading 模块主线程和子线程交互友好join() 方法能够阻塞当前上下文环境的线程,直到调用此方法的线程终止或到达指定的 timeout(可选参数)。利用该方法可以方便地控制主线程和子线程以及子线程之间的执行。

实际上很多情况下我们可能希望主线程能够等待所有子线程都完成时才退出,这时使用 threading 模块守护线程,可以通过 setDaemon() 函数来设定线程的 daemon 属性。当 daemon 属性设置为 True 的时候表明主线程的退出可以不用等待子线程完成。默认情况下,daemon 标志为 False,所有的非守护线程结束后主线程才会结束。

import threading
import time
def myfunc(a, delay):
    print("I will calculate square of {} after delay for {}".format(a, delay))
    time.sleep(delay)
    print("calculate begins...")
    result = a * a
    print(result)
    return result

t1 = threading.Thread(target=myfunc, args=(2, 5))
t2 = threading.Thread(target=myfunc, args=(6, 8))
print(t1.isDaemon())
print(t2.isDaemon())
t2.setDaemon(True)
t1.start()
t2.start()

使用 Queue 使多线程编程更安全

多线程编程不是件容易的事情。线程间的同步和互斥,线程间数据的共享等这些都是涉及线程安全要考虑的问题。

Python 中的 Queue 模块提供了 3 种队列:

  • Queue.Queue(maxsize)先进先出,maxsize 为队列大小,其值为非正数的时候为无限循环队列

  • Queue.LifoQueue(maxsize)后进先出,相当于栈

  • Queue.PriorityQueue(maxsize)优先级队列

这 3 种队列支持以下方法:

  • Queue.qsize():返回队列大小。
  • Queue.empty():队列为空的时候返回 True,否则返回 False
  • Queue.full():当设定了队列大小的情况下,如果队列满则返回 True,否则返回 False。
  • Queue.put(item[, block[, timeout]]):往队列中添加元素 item,block 设置为 False 的时候,如果队列满则抛出 Full 异常。如果 block 设置为 True,timeout 为 None 的时候则会一直等待直到有空位置,否则会根据 timeout 的设定超时后抛出 Full 异常。
  • Queue.put_nowait(item):等于 put(item, False).block 设置为 False 的时候,如果队列空则抛出 Empty 异常。如果 block 设置为 True、timeout 为 None 的时候则会一直等到有元素可用,否则会根据 timeout 的设定超时后抛出 Empty 异常。
  • Queue.get([block[, timeout]]):从队列中删除元素并返回该元素的值
  • Queue.get_nowait():等价于 get(False)
  • Queue.task_done():发送信号表明入列任务已经完成,经常在消费者线程中用到
  • Queue.join():阻塞直至队列中所有的元素处理完毕

Queue 模块是线程安全的。需要注意的是, Queue 模块中的队列和 collections.deque 所表示的队列并不一样,前者主要用于不同线程之间的通信,它内部实现了线程的锁机制;而后者主要是数据结构上的概念。

多线程下载的例子:

import os
import Queue
import threading
import urllib2
class DownloadThread(threading.Thread):
    def __init__(self, queue):
        threading.Thread.__init__(self)
        self.queue = queue
    def run(self):
        while True:
            url = self.queue.get()    # 从队列中取出一个 url 元素
            print(self.name + "begin download" + url + "...")
            self.download_file(url)    # 进行文件下载
            self.queue.task_done()    # 下载完毕发送信号
            print(self.name + " download completed!!!")
    def download_file(self, url):    # 下载文件
        urlhandler = urllib2.urlopen(url)
        fname = os.path.basename(url) + ".html"    # 文件名称
        with open(fname, "wb") as f:    # 打开文件
            while True:
                chunk = urlhandler.read(1024)
                if not chunk:
                    break
                f.write(chunk)
if __name__ == "__main__":
    urls = ["//www.createspace.com/3611970","//wiki.python.org/moni.WebProgramming"]
    queue = Queue.Queue()
    # create a thread pool and give them a queue
    for i in range(5):
        t = DownloadThread(queue)    # 启动 5 个线程同时进行下载
        t.setDaemon(True)
        t.start()
​
    # give the queue some data
    for url in urls:
        queue.put(url)
​
    # wait for the queue to finish
    queue.join()
Tags: