高質量Python編程示例精選

簡介

代碼的易讀性對于編寫高質量軟件非常重要,例如:清晰的接口及函數定義,統(tǒng)一的代碼規(guī)范等。Python的定位就是簡單而優(yōu)雅,如何讓Python代碼寫得更高效、更易度、更符合Python編程風格顯得尤為重要。本文以實例的方式介紹如何以Python語言的最佳方式來編寫簡潔直觀且易讀的代碼。

示例

用列表推導代替map和filter

可以通過列表推導(list comprehension,簡寫為LC)根據一份列表(list)生成另一份列表,同時列表推導比for循環(huán)和map函數具有更高的效率。

  1. LC vs map:如例所示,計算列表中每個數字的平方
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# List comprehension
squares = [x**2 for x in a]
print(squares)
# >>> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]

# Map
squares = map(lambda x: x ** 2, a)
print(list(squares))
# >>> [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
  1. LC vs filter:如例所示,計算列表中可以被2整除的數字的平方
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

# List comprehension
even_squares = [x**2 for x in a if x % 2 == 0]
print(even_squares)
# >>> [4, 16, 36, 64, 100]

# filter
alt = map(lambda x: x**2, filter(lambda x: x % 2 == 0, a))
print (list(alt))
# >>> [4, 16, 36, 64, 100]
  1. LC for dict and set:字典和集合的列表表達式
chile_ranks = {'ghost': 1, 'habanero': 2, 'cayenne': 3}
rank_dict = {rank: name for name, rank in chile_ranks.items()}
print(rank_dict)
# >>> {1: 'ghost', 2: 'habanero', 3: 'cayenne'}

chile_len_set = {len(name) for name in rank_dict.values()}
print(chile_len_set)
# >>> {8, 5, 7}

要點:

  • 列表推導比內置的map和filter函數更清晰,因為無需額外的lambda表達式。
  • 列表推導可以跳過輸入列表中的某些元素,如果使用map,就需要filter函數來輔助實現。
  • 字典(dictionary)與集(set)也支持推導表達式。

數據量較大時用生成器表達式代替列表推導

列表推導(list comprehension,簡寫為LC)會給輸入列表中的每一個值都創(chuàng)建一個新的只包含一個元素的列表。當輸入數據較少時可能是很好用的,但是如果輸入的數據非常龐大,可能會消耗大量內存,甚至導致程序崩潰。
如例所示,讀取一個文件并且返回每行的字符數。如果使用列表推導,需要把文件每一行的長度都保存在內存中,如果這個文件很大或者數據來自一個可能永遠不會關閉的網絡套接字數據流,此時列表推導就會出問題。

# list comprehension
value = [len(x) for x in open('my_file.txt')]
print(value)
# >>> [77, 99, 21, 78, 29, 19, 73, 59, 16, 24]

上面的代碼只適合處理少量的輸入值,為了解決處理大數據的問題,Python提供了生成器表達式(generator expression),它是對列表推導和生成器的一種泛化。生成器表達式在運行過程中,不會把整個輸出序列都呈現出來,而是通過迭代器每次根據生成器表達式產生一項數據。
把實現列表推導的語法放在一對()中,就構成了生成器表達式,返回的是一個迭代器,而不是具體內容。通過調用內置的next()函數,即可使其按照生成器表達式來輸出下一個值,可以根據需要來操作數據,而無需擔心內存不夠用。

# Generator expression
it = (len(x) for x in open('my_file.txt'))
print(it)
# >>> <generator object <genexpr> at 0x0458CA30>
print(next(it))
# >>> 77
print(next(it))
# >>> 99

生成器表達式的另一個強大功能就在于其可以組合使用,如例所示,將剛才生成器表達式返回的迭代器用作另一個生成器表達式的輸入值。

roots = ((x, x**0.5) for x in it)
print(next(roots))
# >>> (21, 4.58257569495584)

要點:

  • 當輸入數據量較大時,列表推導可能會因為占用過多內存而導致一些問題。
  • 生成器表達式通過迭代的方式逐次產生輸出項,可以防止出現內存危機。
  • 把某個生成器表達式所返回的迭代器,放到另一個生成器表達式中,執(zhí)行速度很快。

用enumerate代替range

可以使用for循環(huán)加range()函數的方式來迭代一個集合,同時獲得當前值在列表中的下標,另外Python也提供了一個枚舉函數enumerate()來包裝任何的迭代器,函數返回的一對字段代表了迭代器中當前項的下標及當前值,通過使用enumerate(),代碼顯得更加干凈整潔,并且效率更高。

flavor_list = ['vanilla', 'chocolate', 'pecan', 'strawberry']

# range()
for i in range(len(flavor_list)):
    flavor = flavor_list[i]
    print('%d: %s' % (i + 1, flavor))
'''
>>>
1: vanilla
2: chocolate
3:pecan
4:strawberry
'''

# enumerate()
for i, flavor in enumerate(flavor_list):
    print('%d: %s' % (i + 1, flavor))
'''
>>>
1: vanilla
2: chocolate
3:pecan
4:strawberry
'''

要點:

  • enumerate()提供了簡潔的語法,迭代時既能獲取列表的下標,也能獲取當前值。
  • 在索引化一個序列的時候,應該避免使用range(),而應該使用enumerate()。
  • 可以設置enumerate()的第二個參數來指定索引開始的序號,默認為0。

使用zip同時遍歷多個序列

可以使用zip()函數同時遍歷多個序列,每次分別從一個序列中取一個元素。

headers = ['name', 'shares', 'price']
values = ['ACME', 100, 490.1]
s = dict(zip(headers,values))
print(s)
# >>> {'name': 'ACME', 'shares': 100, 'price': 490.1}

a = [1, 2, 3]
b = ['w', 'x', 'y', 'z']
for i in zip(a,b):
    print(i)
'''
>>>
(1, 'w')
(2, 'x')
(3, 'y')
'''

要點:

  • zip()可以并行地遍歷多個迭代器。
  • Python 3中的zip()在遍歷過程中返回元組,而Python 2中的zip()返回一份包含所有元祖的列表。
  • zip()的迭代長度跟參數中最短序列長度一致。

使用try/except/else/finally結構

Python程序的異常處理需要考慮四種不同的情況,這些情況可以被try/except/else/finally塊進行處理。

  • try: 負責捕捉異常
  • except: 負責處理異常
  • finally: 負責清理工作,無論異常是否發(fā)生,都會被執(zhí)行。
  • else: 如果沒有發(fā)生異常,就會被執(zhí)行。else塊可以使得try塊中的代碼變得更加的簡潔,提升代碼的可讀性。
def divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError as e:
        print("Error: " + str(e))
        return None
    else:
        print("Result is %.1f" % result)
        return result
    finally:
        print("finally")

divide(2, 5)
'''
>>> 
Result is 0.4
finally
'''
divide(2, 0)
'''
>>>
Error: division by zero
finally
'''

函數發(fā)生異常時返回用異常代替None

在某些情況下函數返回None似乎很符合邏輯,例如:在計算兩數相除的函數中,在除數為零時返回None。

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        return None

調用這個函數的代碼就可以根據返回的結果來作進一步的處理,但是在一些條件表達式中,None和一些特殊值(0,空字符串等)都會返回False,使用None作為返回值容易讓調用者犯錯。

nums = [(1, 0), (0, 1)]
for x, y in nums:
    result = divide(x, y)
    if not result:
        print('Invalid inputs')
    else:
        print('Result is %.1f' % result)
'''
>>>
Invalid inputs
Invalid inputs
'''

更好的方法就是永遠不返回None值,而是在發(fā)生異常時上拋給調用方,讓調用方來處理。

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError as e:
        raise ValueError('Invalid inputs') from e

現在,調用方需要自行處理由于輸入值存在問題而引發(fā)的異常,并且調用方也不需要使用條件語句來判斷函數的返回值了。

x, y = 5, 2
try:
    result = divide(x, y)
except ValueError:
    print("Invalid inputs")
else:
    print("Result is %.1f"% result)
 # >>>  Result is 2.5

如果發(fā)生異常,處理的代碼也更加清晰。

x, y = 5, 0
try:
    result = divide(x, y)
except ValueError:
    print("Invalid inputs")
else:
    print("Result is %.1f"% result)
 # >>>  Invalid inputs

要點:

  • 函數返回None來表示特殊意義容易出錯,因為None和0及空字符串之類的值,在一些條件表達式中會被認作False。
  • 函數在遇到特殊情況時,應該拋出異常,而不是返回None,由調用者負責編寫相應的代碼處理異常。

用線程來執(zhí)行阻塞式I/O,但不要用來做并行計算

Python是一種編程語言,根據其實現的不同,有Cpython, Jython, Pypy等,其語法是想通的,但是類庫的實現是不同的。目前應用最廣泛的Python實現是CPython,即用C語言實現Python及其解釋器(JIT編譯器)。
CPython分兩步來運行Python程序,首先,把文本格式的源代碼解析并編譯成字節(jié)碼;然后,用一種基于棧的解釋器來運行這份字節(jié)碼。執(zhí)行Python程序時,字節(jié)碼解釋器采用GIL(Global Interpreter Lock,解釋器全局鎖)來確保協(xié)調一致,GIL實際上就是一把互斥鎖,防止某個線程在執(zhí)行過程中被另外一個線程打斷,這種線程間的干擾操作可能會破壞解釋器的狀態(tài),GIL可保證每條字節(jié)碼指令均能夠正確地與CPython實現及其擴展模塊協(xié)同運作。
GIL的副作用也很明顯,盡管Python程序也支持多線程,但是由于GIL的保護,同一時刻只有一條線程可以向前執(zhí)行,這就意味著,在Python中利用多線程來并行計算(parallel computation),并不能取得理想的結果。
如例所示,factorize()對一個數字進行因式分解。

def factorize(number):
    for i in range(1, number + 1):
        if number % i == 0:
            yield i

對數列逐個進行分解:

from time import time
numbers = [2139079, 1214759, 1516637, 1852285]

start = time()
for number in numbers:
    list(factorize(number))
end = time()
print('Took %.3f seconds' % (end - start))
# >>> Took 1.445 seconds

為數列中每一個數都啟動一個線程,在線程中進行分解:

from threading import Thread
numbers = [2139079, 1214759, 1516637, 1852285]
class FactorizeThread(Thread):
    def __init__(self, number):
        super().__init__()
        self.number = number
    def run(self):
        self.factors = list(factorize(self.number))

start = time()
threads = []
for number in numbers:
    thread = FactorizeThread(number)
    thread.start()
    threads.append(thread)

for thread in threads:
    thread.join()
end = time()
print('Took %.3f seconds' % (end - start))
# >>> Took 1.435 seconds

從執(zhí)行結果可以看出,使用多線程進行并行計算因為受到GIL的影響,在整體的執(zhí)行速度上與逐個執(zhí)行factorize()相比并沒有太大差別。但是Python程序在處理阻塞式I/O操作時必須花一些時間,如:讀寫文件,網絡間通信,與外設交互等,在這段時間內,Python多線程可以把程序與這些耗時操作隔離開。
如例所示,slow_systemcall()模擬一個耗時的系統(tǒng)調用,請求操作系統(tǒng)阻塞0.1秒,然后把控制權還給程序。

import select, socket
def slow_systemcall():
    select.select([socket.socket()], [], [], 0.1)

如果是逐個執(zhí)行slow_systemcall()函數,主程序在運行到slow_systemcall()函數時,必須等待函數退出才能繼續(xù)向下執(zhí)行,因此,程序所需的總時間將會隨著調用次數的增加而增加。

start = time()
for _ in range(100):
    slow_systemcall()
end = time()
print('Took %.3f seconds' % (end - start))
# >>> Took 10.092 seconds

把slow_systemcall()函數放到多個線程中執(zhí)行,保證主程序在運行到slow_systemcall()函數時,也能夠同時在主程序里執(zhí)行所需的計算工作。

start = time()
threads = []
for _ in range(100):
    thread = Thread(target=slow_systemcall)
    thread.start()
    threads.append(thread)

for thread in threads:
    thread.join()
end = time()
print('Took %.3f seconds' % (end - start))
# >>> Took 0.128 seconds

要點:

  • 因為GIL的限制,Python多線程不能在多核的CPU上并行執(zhí)行字節(jié)碼。
  • Python多線程可以使程序在執(zhí)行阻塞式I/O操作時,并行地執(zhí)行普通運算操作。

使用協(xié)程代替線程來實現并發(fā)運行多個函數

Python可以使用線程來模擬同時運行多個函數,但是線程有以下三個缺點:

  1. 為了確保數據安全,必須使用特殊的工具來協(xié)調這些線程,如:Lock,Queue等,對于復雜的多線程代碼,會變得難以擴展和維護。
  2. 線程需要占用大量內存,每個正在執(zhí)行的線程大約需要8MB內存。如果程序中線程過多,可能就會出現問題。
  3. 線程啟動開銷大,如果程序中創(chuàng)建新線程過于頻繁,就會降低整個程序的運行速度。
    Python中的協(xié)程(coroutine)可以避免上述問題,協(xié)程是生成器的一種擴展,啟動生成器所需的開銷,與調用函數的開銷差不多,處于活躍狀態(tài)的協(xié)程,只會占用不到1KB的內存。子程序切換不是線程切換,而是由程序自身控制,因此,沒有線程切換的開銷,和多線程比,線程數量越多,協(xié)程的性能優(yōu)勢就越明顯。
    協(xié)程通過函數中使用yield語句來實現,yield不但可以返回一個值,它還可以接收調用者發(fā)出的參數。每當生成器執(zhí)行到y(tǒng)ield表達式的時候,通過send()函數向生成器回傳一個值,而這個值代表了yield表達式的執(zhí)行結果。
def my_coroutine():
    while True:
        received = yield
        print('Received:', received)

it = my_coroutine()
next(it)             # Prime the coroutine
it.send('First')
it.send('Second')
'''
>>>
Received: First
Received: Second
'''

在生成器調用send()之前,需要調用一次next()函數,用來將生成器推進到第一條yield表達式那里準備接受第一個send()。
如例所示,編寫一個協(xié)程,并給它發(fā)送許多數值,協(xié)程每次收到一個數值后返回當前所統(tǒng)計到的最小值,并等待傳入下一個數值。

def minimize():
    current = yield
    while True:
        value = yield current
        current = min(value, current)

it = minimize()
next(it)            # Prime the generator
print(it.send(10))
print(it.send(4))
print(it.send(22))
print(it.send(-1))
'''
>>>
10
4
4
-1
'''

生成器函數minimize()會一直運行下去,每次調用send()之后都會產生新的值。協(xié)程與線程相似之處在于,它可以消耗由外部所傳入的輸入數據,并產生相應的輸出結果。協(xié)程與線程不同之處在于,協(xié)程會在生成器函數的每個yield表達式那里暫停,直到外部再次調用send()函數,它才會繼續(xù)執(zhí)行到下一條yield表達式。更為重要的是,程序可以通過生成器產生的輸出值,來推進其他的生成器函數,使得其他的生成器函數也執(zhí)行到它們各自的下一條yield表達式。接連推進多個獨立的生成器,即可模擬出Python線程的并發(fā)行為,讓程序看上去好像在同時運行多個函數。
傳統(tǒng)的生產者-消費者模型是一個線程寫消息,一個線程取消息,通過鎖機制控制隊列和等待,但一不小心就可能死鎖。如例所示,如果改用協(xié)程,生產者生產消息后,直接通過yield跳轉到消費者開始執(zhí)行,待消費者執(zhí)行完畢后,切換回生產者繼續(xù)生產,效率極高。

def consumer():
    r = ''
    while True:
        n = yield r
        if not n:
            return
        print('[CONSUMER] Consuming %s...' % n)
        r = '200 OK'

def produce(c):
    c.send(None)
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n)
        print('[PRODUCER] Consumer return: %s' % r)
    c.close()

c = consumer()
produce(c)
'''
>>>
[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK
'''

示例中consumer函數是一個generator,把一個consumer傳入produce后:

  1. 首先調用c.send(None)啟動生成器;
  2. 然后,一旦生產了東西,通過c.send(n)切換到consumer執(zhí)行;
  3. consumer通過yield拿到消息,處理,又通過yield把結果傳回;
  4. produce拿到consumer處理的結果,繼續(xù)生產下一條消息;
  5. produce決定不生產了,通過c.close()關閉consumer,整個過程結束。
    整個流程無鎖,由一個線程執(zhí)行,produce和consumer協(xié)作完成任務,所以稱為“協(xié)程”,而非線程的搶占式多任務。

要點:

  1. 協(xié)程提供了一種有效的方式,讓程序看上去好像能夠哦同時運行大量函數。
  2. 協(xié)程是一個強大的工具,可以把程序的核心邏輯,與程序與外部交互時所用的代碼相分離。
  3. 協(xié)程不具有線程的某些優(yōu)點,例如,如果在執(zhí)行I/O阻塞程序時, 協(xié)程會讓整個任務掛起直到操作完成。

用unnittest來測試代碼

Python沒有靜態(tài)類型檢查機制,編譯器不能保證程序一定會在運行時被正確地執(zhí)行Python語言的動態(tài)特性使得開發(fā)者能夠非常容易地編寫測試代碼,unittest是Python自帶的單元測試模塊,為構建和執(zhí)行測試提供了非常豐富的工具集。
如例所示,to_str()將bytes類型轉化為str類型:

def to_str(data):
    if isinstance(data, str):
        return data
    elif isinstance(data, bytes):
        return data.decode('utf-8')
    else:
        raise TypeError('Must supply str or bytes, '
                        'found: %r' % data)

編寫to_str()的測試用例MyTest,MyTest是unittest.TestCase的子類,三個獨立的測試用例以test開頭,用這樣的命名規(guī)則來約定哪些方法是test runner需要執(zhí)行的。我們也可以自定義一些輔助方法,這些方法的函數名不能以test開頭。
如果需要在運行測試用例前后,執(zhí)行一些初始化及清理工作,可以覆寫setUp()和tearDown()。系統(tǒng)在執(zhí)行每個測試前,調用一次setUp(),若 setUp() 方法引發(fā)異常,測試框架會認為測試發(fā)生了錯誤,因此測試方法不會被運行。若 setUp() 成功運行,無論測試方法是否成功,都會運行一次tearDown()。

from unittest import TestCase, main
class MyTest(TestCase):
    def setUp(self):
        print("setup")
    def tearDown(self):
        print("teardown")
    # Test methods follow
    def test_to_str_bytes(self):
        self.assertEqual('hello', to_str(b'hello'))
    def test_to_str_str(self):
        self.assertEqual('hello', to_str('hello'))
    def test_to_str_bad(self):
        self.assertRaises(TypeError, to_str, object())

if __name__ == '__main__':
    main()
'''
>>>
setup
teardown
.setup
teardown
.setup
teardown
.
----------------------------------------------------------------------
Ran 3 tests in 0.055s

OK
'''

要點:

  • 要想確信Python程序能夠正常運行,最好的辦法就是編寫測試代碼。
  • 內置的unittest模塊提供了測試所需的很多功能,足以滿足大部分用戶的需求。
  • 可以在TestCase子類中為每一個需要測試的用例,定義對應的測試方法,該測試方法的名稱必須以test開頭,使用assert*()方法來檢查結果。

參考鏈接

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內容

友情鏈接更多精彩內容