編寫高質量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: