Python 作為一門入門極易並容易上癮的語音,相信已經成為了很多人 “寫著玩” 的標配指令碼語言。但很多教材並沒有教授 Python 的進階和優化。本文作為進階系列的文章,從基礎的語法到函式、迭代器、類,還有之後系列的執行緒 / 程式、第三方庫、網路程式設計等內容,共同學習如何寫出更加 Pythonic 的程式碼部分提煉自書籍:《Effective Python》&《Python3 Cookbook》,但也做出了修改,並加上了我自己的理解和運用中的最佳實踐
Pythonic
列表切割
list[start:end:step]
- 如果從列表開頭開始切割,那麼忽略 start 位的 0,例如
list[:4]
- 如果一直切到列表尾部,則忽略 end 位的 0,例如
list[3:]
- 切割列表時,即便 start 或者 end 索引跨界也不會有問題
- 列表切片不會改變原列表。索引都留空時,會生成一份原列表的拷貝
1 2 |
b = a[:] assert b == a and b is not a # true |
列表推導式
- 使用列表推導式來取代
map
和filter
1 2 3 4 5 6 7 8 |
a = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] # use map squares = map(lambda x: x ** 2, a) # use list comprehension squares = [x ** 2 for x in a] # 一個很大的好處是,列表推導式可以對值進行判斷,比如 squares = [x ** 2 for x in a if x % 2 == 0] # 而如果這種情況要用 map 或者 filter 方法實現的話,則要多寫一些函式 |
- 不要使用含有兩個以上表示式的列表推導式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
# 有一個巢狀的列表,現在要把它裡面的所有元素扁平化輸出 list = [[ [1, 2, 3], [4, 5, 6] ]] # 使用列表推導式 flat_list = [x for list0 in list for list1 in list0 for x in list1] # [1, 2, 3, 4, 5, 6] # 可讀性太差,易出錯。這種時候更建議使用普通的迴圈 flat_list = [] for list0 in list: for list1 in list0: flat_list.extend(list1) |
- 資料多時,列表推導式可能會消耗大量記憶體,此時建議使用生成器表示式
1 2 3 4 |
# 在列表推導式的推導過程中,對於輸入序列的每個值來說,都可能要建立僅含一項元素的全新列表。因此資料量大時很耗效能。 # 使用生成器表示式 list = (x ** 2 for x in range(0, 1000000000)) # 生成器表示式返回的迭代器,只有在每次呼叫時才生成值,從而避免了記憶體佔用 |
迭代
- 需要獲取 index 時使用
enumerate
enumerate
可以接受第二個引數,作為迭代時加在index
上的數值
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
list = ['a', 'b', 'c', 'd'] for index, value in enumerate(list): print(index) # 0 # 1 # 2 # 3 for index, value in enumerate(list, 2): print(index) # 2 # 3 # 4 # 5 |
- 用
zip
同時遍歷兩個迭代器
1 2 3 4 5 6 7 8 |
list_a = ['a', 'b', 'c', 'd'] list_b = [1, 2, 3] # 雖然列表長度不一樣,但只要有一個列表耗盡,則迭代就會停止 for letter, number in zip(list_a, list_b): print(letter, number) # a 1 # b 2 # c 3 |
zip
遍歷時返回一個元組
1 2 3 4 5 6 7 8 |
a = [1, 2, 3] b = ['w', 'x', 'y', 'z'] for i in zip(a,b): print(i) # (1, 'w') # (2, 'x') # (3, 'y') |
- 關於
for
和while
迴圈後的else
塊- 迴圈正常結束之後會呼叫
else
內的程式碼 - 迴圈裡通過
break
跳出迴圈,則不會執行else
- 要遍歷的序列為空時,立即執行
else
- 迴圈正常結束之後會呼叫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
for i in range(2): print(i) else: print('loop finish') # 0 # 1 # loop finish for i in range(2): print(i) if i % 2 == 0: break else: print('loop finish') # 0 |
反向迭代
對於普通的序列(列表),我們可以通過內建的reversed()
函式進行反向迭代:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
list_example = [i for i in range(5)] iter_example = (i for i in range(5)) # 迭代器 set_example = {i for i in range(5)} # 集合 # 普通的正向迭代 # for i in list_example # 通過 reversed 進行反向迭代 for i in reversed(list_example): print(i) # 4 # 3 # 2 # 1 # 0 # 但無法作用於 集合 和 迭代器 reversed(iter_example) # TypeError: argument to reversed() must be a sequence |
除此以外,還可以通過實現類裡的__reversed__
方法,將類進行反向迭代:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class Countdown: def __init__(self, start): self.start = start # 正向迭代 def __iter__(self): n = self.start while n > 0: yield n n -= 1 # 反向迭代 def __reversed__(self): n = 1 while n <= self.start: yield n n += 1 for i in reversed(Countdown(4)): print(i) # 1 # 2 # 3 # 4 for i in Countdown(4): print(i) # 4 # 3 # 2 # 1 |
try/except/else/finally
- 如果
try
內沒有發生異常,則呼叫else
內的程式碼 else
會在finally
之前執行- 最終一定會執行
finally
,可以在其中進行清理工作
函式
使用裝飾器
裝飾器用於在不改變原函式程式碼的情況下修改已存在的函式。常見場景是增加一句除錯,或者為已有的函式增加log
監控
舉個例子:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
def decorator_fun(fun): def new_fun(*args, **kwargs): print('current fun:', fun.__name__) print('position arguments:', args) print('key arguments:', **kwargs) result = fun(*args, **kwargs) print(result) return result return new_fun @decorator_fun def add(a, b): return a + b add(3, 2) # current fun: add # position arguments: (3, 2) # key arguments: {} # 5 |
除此以外,還可以編寫接收引數的裝飾器,其實就是在原本的裝飾器上的外層又巢狀了一個函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
def read_file(filename='results.txt'): def decorator_fun(fun): def new_fun(*args, **kwargs): result = fun(*args, **kwargs) with open(filename, 'a') as f: f.write(result + '\n') return result return new_fun return decorator_fun # 使用裝飾器時代入引數 @read_file(filename='log.txt') def add(a, b): return a + b |
但是像上面那樣使用裝飾器的話有一個問題:
1 2 3 4 5 6 |
@decorator_fun def add(a, b): return a + b print(add.__name__) # new_fun |
也就是說原函式已經被裝飾器裡的new_fun
函式替代掉了。呼叫經過裝飾的函式,相當於呼叫一個新函式。檢視原函式的引數、註釋、甚至函式名的時候,只能看到裝飾器的相關資訊。為了解決這個問題,我們可以使用 Python 自帶的functools.wraps
方法。
functools.wraps
是個很 hack 的方法,它本事作為一個裝飾器,做用在裝飾器內部將要返回的函式上。也就是說,它是裝飾器的裝飾器,並且以原函式為引數,作用是保留原函式的各種資訊,使得我們之後檢視被裝飾了的原函式的資訊時,可以保持跟原函式一模一樣。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
from functools import wraps def decorator_fun(fun): @wraps(fun) def new_fun(*args, **kwargs): result = fun(*args, **kwargs) print(result) return result return new_fun @decorator_fun def add(a, b): return a + b print(add.__name__) # add |
此外,有時候我們的裝飾器裡可能會幹不止一個事情,此時應該把事件作為額外的函式分離出去。但是又因為它可能僅僅和該裝飾器有關,所以此時可以構造一個裝飾器類。原理很簡單,主要就是編寫類裡的__call__
方法,使類能夠像函式一樣的呼叫。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
from functools import wraps class logResult(object): def __init__(self, filename='results.txt'): self.filename = filename def __call__(self, fun): @wraps(fun) def new_fun(*args, **kwargs): result = fun(*args, **kwargs) with open(filename, 'a') as f: f.write(result + '\n') return result self.send_notification() return new_fun def send_notification(self): pass @logResult('log.txt') def add(a, b): return a + b |
使用生成器
考慮使用生成器來改寫直接返回列表的函式
1 2 3 4 5 6 7 |
# 定義一個函式,其作用是檢測字串裡所有 a 的索引位置,最終返回所有 index 組成的陣列 def get_a_indexs(string): result = [] for index, letter in enumerate(string): if letter == 'a': result.append(index) return result |
用這種方法有幾個小問題:
- 每次獲取到符合條件的結果,都要呼叫
append
方法。但實際上我們的關注點根本不在這個方法,它只是我們達成目的的手段,實際上只需要index
就好了 - 返回的
result
可以繼續優化 - 資料都存在
result
裡面,如果資料量很大的話,會比較佔用記憶體
因此,使用生成器generator
會更好。生成器是使用yield
表示式的函式,呼叫生成器時,它不會真的執行,而是返回一個迭代器,每次在迭代器上呼叫內建的next
函式時,迭代器會把生成器推進到下一個yield
表示式:
1 2 3 4 |
def get_a_indexs(string): for index, letter in enumerate(string): if letter == 'a': yield index |
獲取到一個生成器以後,可以正常的遍歷它:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
string = 'this is a test to find a\' index' indexs = get_a_indexs(string) # 可以這樣遍歷 for i in indexs: print(i) # 或者這樣 try: while True: print(next(indexs)) except StopIteration: print('finish!') # 生成器在獲取完之後如果繼續通過 next() 取值,則會觸發 StopIteration 錯誤 # 但通過 for 迴圈遍歷時會自動捕獲到這個錯誤 |
如果你還是需要一個列表,那麼可以將函式的呼叫結果作為引數,再呼叫list
方法
1 2 |
results = get_a_indexs('this is a test to check a') results_list = list(results) |
可迭代物件
需要注意的是,普通的迭代器只能迭代一輪,一輪之後重複呼叫是無效的。解決這種問題的方法是,你可以定義一個可迭代的容器類:
1 2 3 4 5 6 7 8 |
class LoopIter(object): def __init__(self, data): self.data = data # 必須在 __iter__ 中 yield 結果 def __iter__(self): for index, letter in enumerate(self.data): if letter == 'a': yield index |
這樣的話,將類的例項迭代重複多少次都沒問題:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
string = 'this is a test to find a\' index' indexs = LoopIter(string) print('loop 1') for _ in indexs: print(_) # loop 1 # 8 # 23 print('loop 2') for _ in indexs: print(_) # loop 2 # 8 # 23 |
但要注意的是,僅僅是實現__iter__
方法的迭代器,只能通過for
迴圈來迭代;想要通過next
方法迭代的話則需要使用iter
方法:
1 2 3 4 5 6 7 |
string = 'this is a test to find a\' index' indexs = LoopIter(string) next(indexs) # TypeError: 'LoopIter' object is not an iterator iter_indexs = iter(indexs) next(iter_indexs) # 8 |
使用位置引數
有時候,方法接收的引數數目可能不一定,比如定義一個求和的方法,至少要接收兩個引數:
1 2 3 4 5 6 7 |
def sum(a, b): return a + b # 正常使用 sum(1, 2) # 3 # 但如果我想求很多數的總和,而將引數全部代入是會報錯的,而一次一次代入又太麻煩 sum(1, 2, 3, 4, 5) # sum() takes 2 positional arguments but 5 were given |
對於這種接收引數數目不一定,而且不在乎引數傳入順序的函式,則應該利用位置引數*args
:
1 2 3 4 5 6 7 8 9 10 |
def sum(*args): result = 0 for num in args: result += num return result sum(1, 2) # 3 sum(1, 2, 3, 4, 5) # 15 # 同時,也可以直接把一個陣列帶入,在帶入時使用 * 進行解構 sum(*[1, 2, 3, 4, 5]) # 15 |
但要注意的是,不定長度的引數args
在傳遞給函式時,需要先轉換成元組tuple
。這意味著,如果你將一個生成器作為引數帶入到函式中,生成器將會先遍歷一遍,轉換為元組。這可能會消耗大量記憶體:
1 2 3 4 5 6 7 |
def get_nums(): for num in range(10): yield num nums = get_nums() sum(*nums) # 45 # 但在需要遍歷的數目較多時,會佔用大量記憶體 |
使用關鍵字引數
- 關鍵字引數可提高程式碼可讀性
- 可以通過關鍵字引數給函式提供預設值
- 便於擴充函式引數
定義只能使用關鍵字引數的函式
- 普通的方式,在呼叫時不會強制要求使用關鍵字引數
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# 定義一個方法,它的作用是遍歷一個陣列,找出等於(或不等於)目標元素的 index def get_indexs(array, target='', judge=True): for index, item in enumerate(array): if judge and item == target: yield index elif not judge and item != target: yield index array = [1, 2, 3, 4, 1] # 下面這些都是可行的 result = get_indexs(array, target=1, judge=True) print(list(result)) # [0, 4] result = get_indexs(array, 1, True) print(list(result)) # [0, 4] result = get_indexs(array, 1) print(list(result)) # [0, 4] |
- 使用 Python3 中強制關鍵字引數的方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# 定義一個方法,它的作用是遍歷一個陣列,找出等於(或不等於)目標元素的 index def get_indexs(array, *, target='', judge=True): for index, item in enumerate(array): if judge and item == target: yield index elif not judge and item != target: yield index array = [1, 2, 3, 4, 1] # 這樣可行 result = get_indexs(array, target=1, judge=True) print(list(result)) # [0, 4] # 也可以忽略有預設值的引數 result = get_indexs(array, target=1) print(list(result)) # [0, 4] # 但不指定關鍵字引數則報錯 get_indexs(array, 1, True) # TypeError: get_indexs() takes 1 positional argument but 3 were given |
- 使用 Python2 中強制關鍵字引數的方式
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
# 定義一個方法,它的作用是遍歷一個陣列,找出等於(或不等於)目標元素的 index # 使用 **kwargs,代表接收關鍵字引數,函式內的 kwargs 則是一個字典,傳入的關鍵字引數作為鍵值對的形式存在 def get_indexs(array, **kwargs): target = kwargs.pop('target', '') judge = kwargs.pop('judge', True) for index, item in enumerate(array): if judge and item == target: yield index elif not judge and item != target: yield index array = [1, 2, 3, 4, 1] # 這樣可行 result = get_indexs(array, target=1, judge=True) print(list(result)) # [0, 4] # 也可以忽略有預設值的引數 result = get_indexs(array, target=1) print(list(result)) # [0, 4] # 但不指定關鍵字引數則報錯 get_indexs(array, 1, True) # TypeError: get_indexs() takes 1 positional argument but 3 were given |
關於引數的預設值
算是老生常談了:函式的預設值只會在程式載入模組並讀取到該函式的定義時設定一次
也就是說,如果給某引數賦予動態的值( 比如[]
或者{}
),則如果之後在呼叫函式的時候給引數賦予了其他引數,則以後再呼叫這個函式的時候,之前定義的預設值將會改變,成為上一次呼叫時賦予的值:
1 2 3 4 5 6 7 8 9 |
def get_default(value=[]): return value result = get_default() result.append(1) result2 = get_default() result2.append(2) print(result) # [1, 2] print(result2) # [1, 2] |
因此,更推薦使用None
作為預設引數,在函式內進行判斷之後賦值:
1 2 3 4 5 6 7 8 9 10 11 |
def get_default(value=None): if value is None: return [] return value result = get_default() result.append(1) result2 = get_default() result2.append(2) print(result) # [1] print(result2) # [2] |
類
__slots__
預設情況下,Python 用一個字典來儲存一個物件的例項屬性。這使得我們可以在執行的時候動態的給類的例項新增新的屬性:
1 2 |
test = Test() test.new_key = 'new_value' |
然而這個字典浪費了多餘的空間 — 很多時候我們不會建立那麼多的屬性。因此通過__slots__
可以告訴 Python 不要使用字典而是固定集合來分配空間。
1 2 3 4 5 6 7 8 9 10 11 |
class Test(object): # 用列表羅列所有的屬性 __slots__ = ['name', 'value'] def __init__(self, name='test', value='0'): self.name = name self.value = value test = Test() # 此時再增加新的屬性則會報錯 test.new_key = 'new_value' # AttributeError: 'Test' object has no attribute 'new_key' |
__call__
通過定義類中的__call__
方法,可以使該類的例項能夠像普通函式一樣呼叫。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class AddNumber(object): def __init__(self): self.num = 0 def __call__(self, num=1): self.num += num add_number = AddNumber() print(add_number.num) # 0 add_number() # 像方法一樣的呼叫 print(add_number.num) # 1 add_number(3) print(add_number.num) # 4 |
通過這種方式實現的好處是,可以通過類的屬性來儲存狀態,而不必建立一個閉包或者全域性變數。
@classmethod
& @staticmethod
資料:
- Python @classmethod and @staticmethod for beginner
- Difference between staticmethod and classmethod in python
@classmethod
和@staticmethod
很像,但他們的使用場景並不一樣。
- 類內部普通的方法,都是以
self
作為第一個引數,代表著通過例項呼叫時,將例項的作用域傳入方法內; @classmethod
以cls
作為第一個引數,代表將類本身的作用域傳入。無論通過類來呼叫,還是通過類的例項呼叫,預設傳入的第一個引數都將是類本身@staticmethod
不需要傳入預設引數,類似於一個普通的函式
來通過例項瞭解它們的使用場景:
假設我們需要建立一個名為Date
的類,用於儲存 年/月/日 三個資料
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class Date(object): def __init__(self, year=0, month=0, day=0): self.year = year self.month = month self.day = day @property def time(self): return "{year}-{month}-{day}".format( year=self.year, month=self.month, day=self.day ) |
上述程式碼建立了Date
類,該類會在初始化時設定day/month/year
屬性,並且通過property
設定了一個getter
,可以在例項化之後,通過time
獲取儲存的時間:
1 2 |
date = Date('2016', '11', '09') date.time # 2016-11-09 |
但如果我們想改變屬性傳入的方式呢?畢竟,在初始化時就要傳入年/月/日三個屬性還是很煩人的。能否找到一個方法,在不改變現有介面和方法的情況下,可以通過傳入2016-11-09
這樣的字串來建立一個Date
例項?
你可能會想到這樣的方法:
1 2 3 |
date_string = '2016-11-09' year, month, day = map(str, date_string.split('-')) date = Date(year, month, day) |
但不夠好:
- 在類外額外多寫了一個方法,每次還得格式化以後獲取引數
- 這個方法也只跟
Date
類有關 - 沒有解決傳入引數過多的問題
此時就可以利用@classmethod
,在類的內部新建一個格式化字串,並返回類的例項的方法:
1 2 3 4 5 6 7 |
# 在 Date 內新增一個 classmethod @classmethod def from_string(cls, string): year, month, day = map(str, string.split('-')) # 在 classmethod 內可以通過 cls 來呼叫到類的方法,甚至建立例項 date = cls(year, month, day) return date |
這樣,我們就可以通過Date
類來呼叫from_string
方法建立例項,並且不侵略、修改舊的例項化方式:
1 2 3 |
date = Date.from_string('2016-11-09') # 舊的例項化方式仍可以使用 date_old = Date('2016', '11', '09') |
好處:
- 在
@classmethod
內,可以通過cls
引數,獲取到跟外部呼叫類時一樣的便利 - 可以在其中進一步封裝該方法,提高複用性
- 更加符合物件導向的程式設計方式
而@staticmethod
,因為其本身類似於普通的函式,所以可以把和這個類相關的 helper 方法作為@staticmethod
,放在類裡,然後直接通過類來呼叫這個方法。
1 2 3 4 |
# 在 Date 內新增一個 staticmethod @staticmethod def is_month_validate(month): return int(month) <= 12 and int(month) >= 1 |
將與日期相關的輔助類函式作為@staticmethod
方法放在Date
類內後,可以通過類來呼叫這些方法:
1 2 3 |
month = '08' if not Date.is_month_validate(month): print('{} is a validate month number'.format(month)) |
建立上下文管理器
上下文管理器,通俗的介紹就是:在程式碼塊執行前,先進行準備工作;在程式碼塊執行完成後,做收尾的處理工作。with
語句常伴隨上下文管理器一起出現,經典場景有:
1 2 3 |
with open('test.txt', 'r') as file: for line in file.readlines(): print(line) |
通過with
語句,程式碼完成了檔案開啟操作,並在呼叫結束,或者讀取發生異常時自動關閉檔案,即完成了檔案讀寫之後的處理工作。如果不通過上下文管理器的話,則會是這樣的程式碼:
1 2 3 4 5 6 |
file = open('test.txt', 'r') try: for line in file.readlines(): print(line) finally: file.close() |
比較繁瑣吧?所以說使用上下文管理器的好處就是,通過呼叫我們預先設定好的回撥,自動幫我們處理程式碼塊開始執行和執行完畢時的工作。而通過自定義類的__enter__
和__exit__
方法,我們可以自定義一個上下文管理器。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
class ReadFile(object): def __init__(self, filename): self.file = open(filename, 'r') def __enter__(self): return self.file def __exit__(self, type, value, traceback): # type, value, traceback 分別代表錯誤的型別、值、追蹤棧 self.file.close() # 返回 True 代表不丟擲錯誤 # 否則錯誤會被 with 語句丟擲 return True |
然後可以以這樣的方式進行呼叫:
1 2 3 |
with ReadFile('test.txt') as file_read: for line in file_read.readlines(): print(line) |
在呼叫的時候:
with
語句先暫存了ReadFile
類的__exit__
方法- 然後呼叫
ReadFile
類的__enter__
方法 __enter__
方法開啟檔案,並將結果返回給with
語句- 上一步的結果被傳遞給
file_read
引數 - 在
with
語句內對file_read
引數進行操作,讀取每一行 - 讀取完成之後,
with
語句呼叫之前暫存的__exit__
方法 __exit__
方法關閉了檔案
要注意的是,在__exit__
方法內,我們關閉了檔案,但最後返回True
,所以錯誤不會被with
語句丟擲。否則with
語句會丟擲一個對應的錯誤。