《Python工匠》是一本案例、技巧與工程實踐的指導書,該書不是python基礎語法的教程,而是python中最佳實踐的教程,屬於python進階類的書籍。可以將本書當做PEP8編程規範的補充,書同時中描述了很多python編程優雅的實踐方法,既有廣度也有深度,是值得python開發者反覆閱讀的書。我感嘆於作者豐富的實踐和廣博的積累,從中獲益良多,所以將書中精華內容整理出來,希望閱讀者也能從中獲益。
一、變量與註釋
變量解包
變量解包是Python中的一種特殊賦值操作,允許我們把一個列表的所有成員,一次性賦值給多個變量:
>>> arr = ["one", "two"]
>>>
>>> first, second = arr
>>>
>>> first
'one'
>>> second
'two'
出了普通解包之外,還支持更靈活的動態解包語法。用星號(*datas)作爲變量,貪婪的捕獲儘可能多的對象,並將捕獲到的內容作爲列表賦值給變量data
>>> arr = ["one", "two", "three", "four", "five"]
>>>
>>> first, *datas, last = arr
>>> first
'one'
>>> datas
['two', 'three', 'four']
>>> last
'five'
>>>
動態解包的方式比切片獲取更加直觀
first, *datas, last = arr
first, datas, last = arr[0], arr[1:-1], arr[-1]
變量命名規則
- 遵循PEP8規則
- 描述性要強:在可接受的長度範圍內,變量名所指向的內容描述越精確越好。如 value 不如 total_number
- 要匹配類型:
匹配布爾類型的變量名,以is,has, allow等開頭命名布爾類型;
匹配數值類型的變量名,釋義爲數字,例:port;以id結尾例:user_id,以length/count開頭或結尾的單詞,例:users_count - 儘量要短,爲變量名命名要結合代碼情景和上下文,如類,函數的內部的變量就不要重複出現相同的含義。變量名最好在4個單詞以內
- 可以將高頻長變量命名成較短變量名,但要嚴格控制數量
註釋
註釋分爲代碼註釋和接口註釋。註釋常見的三種錯誤:
- 用註釋屏蔽代碼
- 用註釋複述代碼
- 接口註釋描述函數實現細節
編程建議
- 保持變量一致,同一個事物在項目中始終保持同一個名字
- 變量定義儘量靠近使用
- 面對複雜的邏輯,定義臨時變量提升可讀性。如if判斷條件過多
- 同一作用域不要有太多變量
- 能避免定義的變量儘量避免,減少閱讀者記憶負擔
- 不要使用locals返回所有變量
- 適當位置插入空行,隔離邏輯
- 先寫註釋再寫代碼
二、數值與字符串
字符串格式化
字符串格式化至少有三種方式:
- C語言風格的基於百分號%的格式化語句 'hello, %s' % 'word'
- 新式字符串格式化 str.format 'hello, {}'.format("word")
- f-string 字符串字面量格式化表達式 name = "word" f"hello {name}"
首選f-string,配合format
字符串和字節串
字符串:普通字符串,也稱文本,是給人看的,對應python中的字符串 str,使用unicode標準
字節串:二進制字符串,給計算機看,對應python的字節串bytes。
通常來說不需要操作字節串,以下有兩種情況可能涉及:
- 程序從文件或其他外部存儲讀取字節串內容,將其解碼爲字符串
- 程序完成處理,要把字符串寫入文件或其他外部存儲,將其編碼爲字節串
三、容器類型
用按需返回替換容器
用生成器替換列表。生成器具有節省內存的優點,可以將一次性生成全部數據的列表換成生成器返回,節省內存。
def get_item_list():
pass
for i in get_item_list():
print(i)
在這種情況下,可以有兩種處理方法
第一種,返回列表:
def get_item_list():
arr = [i for i in range(100)]
return arr
第二種,返回生成器:
def get_item_list():
for i in range(100):
yield i
由於生成器一邊生成一遍返回,所以最終只使用一個元素的內存,而列表使用100個元素的內存。
編程建議
- 避開列表的性能陷阱。列表的頭插比尾插要慢很多,如果需要頭插可以使用collections.deque來替換列表類型
- 使用集合判斷成員是否存在。集合底層使用了哈希表數據結構,使用in操作判斷,時間複雜度爲常數
- 快速合併字典而不破壞原字典的方法:
d1={"name": "apple"} d2={"price": 18} d3={**d1, **d2}
- 使用有序字典去重。要去重又要保留原有順序,可以使用OrderedDict完成
- 禁止在遍歷列表時同步修改。遍歷過程索引不斷增加,而列表成員如果在減少就會導致一些成員不會被訪問到
- 繼承
MutableMapping
方便的創建自定義字典,封裝處理邏輯。
from collections.abc import MutableMapping
class PerfLevelDict(MutableMapping):
def __init__(self):
pass
def __getitem__(self, key):
pass
def __setitem__(self, key, value):
pass
def __delitem__(self, key):
pass
def __iter__(self):
pass
def __len__(self):
pass
爲什麼要自定義字典?
基礎數據結構的字典只能通過key獲取value,不支持更加複雜的運算。而自定義字典可以在取值的方法__getitem__
中實現更加複雜的操作
爲什麼不直接繼承dict而是MutableMapping?
兩個原因:
- 直接繼承dict會出現更新操作行爲不一致的現象。如
PerfLevelDict[key] = value
和PerfLevelDict.update({key:value})
同樣是更新操作,行爲卻不一致 - MutableMapping是抽象基類,必須要實現6個方法,保證了行爲的統一
四、條件分支控制流
使用bisect優化範圍分支判斷
def func(score):
if score >= 90:
return "S"
elif score >= 80:
return "A"
elif score >= 70:
return "B"
elif score >= 60:
return "c"
else:
return "D"
print(func(85))
import bisect
def func_bisect(score):
breakpoints = [60, 70, 80, 90]
grades= ["D", "C", "B", "A", "S"]
index = bisect.bisect(breakpoints, score)
return grades[index]
print(func_bisect(85))
>>>
A
A
編程建議
- 儘量避免多層分支嵌套。可以通過提前返回減少else分支
- 避免太複雜的條件分支
- 降低不同分支中代碼的相似性。將相同邏輯移除分支。不同分支代碼相似會增加讀者的理解負擔。
- 使用摩根定律。not A or not B ==> not (A and B)
- 使用all/any函數構建條件表達式。all和any可以容納多個條件,讓條件判斷減少ifelse
- 留意 and 和 or 的優先級。(True or False) and False ==> False 和 True or False and False ==> True 。and優先級高於or
- 避免or運算的陷阱。or 操作有短路特性。
True or (1/0),這個表達式永遠不會錯誤。因爲有or的判斷,當第一個條件爲True之後,就不會執行第二個條件。
如果第一個條件爲False,就會返回第二個條件的結果。利用這個特性,也可以減少ifelse判斷。
如當配置項爲空時獲取默認值
default_num = None
num = default_num or 30
但這種寫法有一個陷阱,就是如果default_num = 0時,或得的默認值是30而不是0,因爲真值判斷擴大了範圍。本來只有None才能觸發的條件,現在可以通過False,0,{},[]等觸發。
五、異常處理
優先使用異常,獲取原諒比許可更簡單
例如以函數接口數據校驗爲例,使用if判斷的方式稱之爲LBYL(look before you leap),而使用異常捕獲的方式被稱之爲EAFP(easier to forgiveness than permissoin)。前者稱之爲獲取許可,後者稱之爲獲取原諒。Python 社區明顯更加偏愛基於異常捕獲的EAFP風格。
拋出異常而不是返回錯誤
返回錯誤:
def create_item(name):
if len(name) > 10:
return None, "name of item is too long"
if len(name) < 5:
return None, "name of item is too small"
return name
def create_from_input():
name = input()
item, err_msg = create_item(name)
if err_msg:
print(f"create item failed:{err_msg}")
else:
print(f"item<{name}> created")
create_from_input()
拋出異常
class CreateItemError(Exception):
pass
def create_item(name):
if len(name) > 10:
raise CreateItemError("name of item is too long")
if len(name) < 5:
raise CreateItemError("name of item is too small")
return name
def create_from_input():
name = input()
try:
item = create_item(name)
except CreateItemError as e:
print(f"create item failed :{e}")
else:
print(f"item<{name}> created")
create_from_input()
拋出異常比返回錯誤更好的地方:
- 拋出異常擁有更穩定的返回值類型,永遠只返回Item類型或異常類型
- 拋出異常可以層層上報,因此create_from_input可以不處理異常,交給上層處理,但此處有風險。如果一直沒有人處理就會讓整個程序終止
異常或不異常都是由編程設計者盡心更多方取捨之後的結果,更多時候不存在絕對的優劣之分。單就python而言,使用異常來表達錯誤更符合python的哲學,更應該受到推崇。
使用上下文管理器
- 用於替代finally語句清理資源
- 用於忽略異常。對於需要忽略的異常使用try except會顯得凌亂,在
__exit__
中更加方便 - 使用contextmanager裝飾器可以更加簡單的完成上下文管理器
爲什麼需要異常捕獲:
捕獲異常表面上是避免程序因爲異常發生而直接崩潰,但它的核心,其實是編碼者對處於程序主流程之外的、已知
或未知
情況的一種妥當處置。
異常捕獲建議
- 不要隨意忽略異常
- 不要手動做數據校驗
- 手動校驗數據就是手動做數據類型的判斷,如判斷數據是否爲字符串,長度小於12等。推薦使用專業的校驗模塊,如pydantic。
- 拋出可區分的異常
- 要繼承Exception 而不是BaseException
- 異常類型要以Error或Exception結尾
- 不要使用assert來檢查參數合法性。assert是一個專供開發者調試程序的關鍵字,它提供的斷言檢查可以使用-o選項直接跳過
- 在可拋可不拋錯誤的情況下,儘量不要拋出錯誤,減少調用方處理錯誤的次數,減輕各方的心智負擔。可以通過返回空對象而不是異常,這樣調用者方程序不會中斷。
- 不要讓函數返回錯誤信息,直接拋出自定義異常
- 過於模糊和寬泛的異常捕獲可能會讓程序免於崩潰,但是也可能帶來更大的麻煩
- 異常捕獲貴在精確,只不厚可能拋出異常的語句,支部或可能的異常類型
什麼時候需要拋出異常:
- 核心代碼,底層代碼
- 不可容忍的情況發生,繼續執行毫無意義
六 循環和可迭代對象
使用 itertools 模塊優化循環
- product() 扁平化多層嵌套循環。prouct可以返回多個列表的笛卡爾積
target = 28
list1 = list2 = list3 = list(range(10))
for x in list1:
for y in list2:
for z in list3:
if x + y + z == target:
print(x,y,z)
from itertools import product
target = 20
list1 = list2 = list3 = list(range(10))
for x,y,z in product(list1, list2, list3):
if x + y + z == target:
print(x, y, z)
- islice 可以對迭代器進行切片
- takeswhile 條件篩選可迭代對象中的元素,只要元素爲真就返回,第一次遇到不符合的條件就退出。可以在循環中替換break語句
輸出符合條件的數據,遇到不符合的數據就退出
arr = [10,34,5,6,78,44,9]
for i in arr:
if i>= 10:
print(i)
else:
break
takewhile的寫法
from itertools import takewhile
arr = [10,34,5,6,78,44,9]
res = takewhile(lambda x: x>=10, arr)
for i in res:
print(i)
正確讀取文件的方法
- 讀取小文件使用
with open(file_path) as file:
for line in file:
pass
該方法的缺點是:文件是一行一行返回的,不會佔用太多內存。但是如果讀取的文件沒有換行符,那就會一次生成一個巨大的字符串對象。
補充:
python中文件的讀取方法有四種:
with open("d.txt") as file:
res = file.read()
print(res)
print("-" * 20)
with open("d.txt") as file:
res = file.readline()
while res:
res = file.readline()
print(res)
print("+" * 20)
with open("d.txt") as file:
res = file.readlines()
print(res)
print("*" * 20)
with open("d.txt") as file:
for i in file:
print(i)
>>>
aaa
bbb
ccc
ddd
--------------------
bbb
ccc
ddd
++++++++++++++++++++
['aaa\n', 'bbb\n', 'ccc\n', 'ddd\n']
********************
aaa
bbb
ccc
ddd
讀取文件總的來說兩大類方法,一種是使用文件描述符的三種方法,read
,readline
,readlines
;另一種是直接迭代文件描述符,默認使用換行符作爲一個迭代元素。
每次讀取一行看起來是一個好主意,但是如果一個文件就是一行就會讓每次讀取一行退化成讀取全文,所以要控制每次讀取的數據量。
- 讀取大文件
with open (file_path) as file:
block_size = 1024 * 8
while True:
chunk = file.read(block_size)
if not chunk:
break
該方法的優點:使用了while循環來讀取文件內容,每次最多讀取8kb,程序不需要在內存中保存大量的字符串,避免內存佔用過大
除此之外,還可以用iter改寫這個方法
iter還有一個使用方法:
iter(callable, sentinel)
生成一個特殊的迭代器對象,如果循環遍歷這個迭代器,iter就會不斷調用callable,返回結果,如果結果等於sentinel就終止迭代
while open(file_path) as file:
_read = partial(fp.read, block_size)
for chunk in iter(_read, ''):
pass
編程建議
- 中斷嵌套循環的正確方式:在多重循環中,使用return終止多層循環
- 生成器函數可以用來解耦循環代碼,提升可複用性。
裝飾可迭代對象 是指用生成器在循環外部包裝原本的循環主體,完成一些原本必須在循環內部執行的工作,如過濾特定成員,提供額外結果等,以此簡化循環代碼。
arr = [10,34,5,6,78,44,9]
def max_than_10():
for i in arr:
if i>= 10:
yield i
for i in max_than_10():
print(i)
- 當心被耗盡的迭代器
>>> number = (i for i in range(4))
>>> 1 in number
True
>>> 1 in number
False
兩次同樣的操作結果卻不相同,原因是:
第一次in操作觸發了生成器遍歷,找到4返回True;
第二次in操作,生成器已經遍歷完了,無法再次遍歷找到4,所以返回結果False
除了生成器函數、生成器表達式之外,還有返回生成的函數,如map,filter,reduce等都要小心。
七、函數
函數技巧
- 禁止將可變類型作爲函數參數
- 定義僅限關鍵字參數
函數定義了參數,但是可以通過關鍵字和位置參數傳遞,爲了限制只能通過關鍵字傳遞,可以在參數定義時使用*
def query_users(limit, offset, *, min_followers_count, inculde_profile)
在*
之後的參數必須都要通過關鍵字傳遞。
也可以定義只能通過位置參數傳遞,用/
標識。在/
之前的都必須用位置參數傳遞。
- 儘量只返回一種類型
- 謹慎返回None
需要返回None的情況有如下三種:
- 操作類函數的默認返回值
- 意料之中的缺失值
- 在執行失敗時代表錯誤
返回None的函數需要滿足一下兩點:
- 函數的名稱和函數必須表達 "結果可能缺失" 的意思
- 如果函數執行無法產生結果,調用方也不關心具體的原因
除此之外對大部分函數來說,返回None不是一個好的做法。可以通過拋出異常來替代返回None更爲合理。
- 早返回,多返回
單一出口的編程風格
def func(num):
if num == 0:
pass
elif num == 1:
pass
else:
pass
return
推薦多返回的編程風格
def func(num):
if num == 0:
return
if num == 1:
return
return
編程建議:
- 別寫太複雜的函數,可以衡量的標準:
- 長度。不能超過200
- 圈複雜度.不能超過10
- 一個函數只能有符合的抽象
- 編寫一個函數是需要考慮函數內代碼和抽象界別的關係,假如一個函數內同時包含了多個抽象級別的內容,會引發一些列的問題。如:
- 函數的說明性不夠。很難搞清楚的主流程,複雜度大,難理解
- 函數的可複用性差,多層抽象雜糅在一起,無法複用某一個抽象。
面對這個問題需要基於抽象重構代碼。抽象和分層思想可以幫我們更好的構建和管理複雜的系統
- 優先使用列表推導式。列表推導式的描述性更強
例如獲取所有活躍狀態的用戶積分
函數式編程:
points = list(map(query_points, filter(lambda user: user.is_active(), users)))
列表推倒式:
points = [query_points(user) for user in users if user.is_active()]
- 函數與狀態
如果一個函數根據執行的次數而表現不一致就是有狀態的函數,存在這種情況需要注意:
- 避免使用全局變量給函數增加狀態
- 當函數狀態簡單時,可以使用閉包技巧
- 當函數需要較爲複雜的狀態管理時,建議定義類來管理狀態
思想:
雖然函數可以消除重複代碼,但決不能只把它看成一種複用代碼的工具,函數最重要的價值是創建合適的抽象,而提供複用價值可以說是抽象帶來的一個副作用。所以想要寫出好的函數,祕訣就在於設計好的抽象。
八、裝飾器
裝飾器是一種通過包裝目標函數來修改其行爲的特殊高階函數,絕大多數裝飾器是利用函數的閉包原理實現的
不帶參數裝飾器
import time
import random
def timer(func):
def decorated(*args, **kwargs):
st = time.time()
ret = func(*args, **kwargs)
print("time cost: {} seconds".format(time.time() - st))
return ret
return decorated
@timer
def random_sleep():
time.sleep(random.randint(0,5))
random_sleep()
>>> time cost: 2.0021181106567383 seconds
def random_sleep():
time.sleep(random.randint(0,5))
decorated = timer(random_sleep)
decorated()
>>> time cost: 2.1457672119140625e-05 seconds
帶參數裝飾器
def timer(print_args=False):
def decorator(func):
def wrapper(*args, **kwargs):
st = time.time()
ret = func(*args, **kwargs)
if print_args:
print(f"{func.__name__}, args: {args}, kwargs: {kwargs}")
print("time cost: {} seconds".format(time.time() - st))
return ret
return wrapper
return decorator
@timer(print_args=True)
def random_sleep():
time.sleep(random.randint(0,5))
random_sleep()
>>> random_sleep, args: (), kwargs: {}
time cost: 9.34600830078125e-05 seconds
def random_sleep():
time.sleep(random.randint(0,5))
# 傳入參數, 獲得內一層函數
decorator = timer(print_args=True)
# 真正的裝飾器,返回新的函數
wrapper = decorator(random_sleep)
wrapper()
>>> random_sleep, args: (), kwargs: {}
time cost: 2.0015923976898193 seconds
裝飾器缺點是會丟失被裝飾函數的元數據,所以通過functools.wraps可以避免這個問題
用類實現裝飾器(函數替換)
class timer:
def __init__(self, print_args):
self.print_args = print_args
def __call__(self, func):
def decorator(*args, **kwargs):
st = time.time()
ret = func(*args, **kwargs)
if self.print_args:
print(f"{func.__name__}, args: {args}, kwargs: {kwargs}")
print("time cost: {} seconds".format(time.time() - st))
return ret
return decorator
@timer(print_args=True)
def random_sleep():
time.sleep(random.randint(0,5))
random_sleep()
>>> random_sleep, args: (), kwargs: {}
time cost: 5.004449367523193 seconds
def random_sleep():
time.sleep(random.randint(0,5))
timer_instance = timer(print_args=True)
decorator = timer_instance(random_sleep)
decorator()
>>>
random_sleep, args: (), kwargs: {}
time cost: 1.000887393951416 seconds
用類實例實現裝飾器(實例替換)
class DelayedStart:
def __init__(self, func):
self.func = func
def __call__(self, *args, **kwargs):
time.sleep(1)
print("wait for 1 second")
return self.func(*args, **kwargs)
def eager_call(self, *args, **kwargs):
print("call without delay")
return self.func(*args, **kwargs)
# demo已經變成DelayedStart的一個實例,等同於 demo = DelayedStart()
@DelayedStart
def demo():
print("hello world")
# 相當於調用__call__方法
# demo()
# 調用額外方法
# demo.eager_call()
編程建議
- 裝飾器的意義
裝飾器帶來的改變,主要在於把修改函數的調用提前到函數定義處,而這一點位置上的小變化,重塑了讀者理解代碼的整個過程
裝飾器適合的場景:
-
運行時校驗:在執行階段進行特定校驗,當檢驗不通過時終止執行
適合原因:裝飾器可以方便地在函數執行前介入,並且可以讀取所有參數輔助校驗
代表樣例:Django 框架中用戶登錄態校驗裝飾器@login_required
-
注入額外參數:在函數調用時自動注入額外的調用參數
適合原因:裝飾器的位置在函數頭部,非常靠近參數被定義的位置,關聯性強
代表樣例:unittest.mock模塊的裝飾器@patch
-
緩存執行結果:通過調用參數等輸入信息,直接緩存函數執行結果
適合原因:添加緩存不需要侵入函數內部邏輯,並且功能非常獨立和通用
代表樣例:funcools模塊的緩存裝飾器@lru_cache
-
註冊函數
適合原因:定義函數可以直接完成註冊,關聯性強
代表樣例:Flask框架的路由註冊裝飾器@app.route
-
替換爲複雜對象:將原函數替換爲更加複雜的對象,比如類實例或特殊的描述符對象
適合原因:在執行替換操作時,裝飾器語法天然比foo=staticmethod(foo)的寫法要直觀很多
代表樣例:靜態類方法裝飾器@staticmethod
裝飾器和裝飾器模式
裝飾器模式和裝飾器是截然不同的東西。
裝飾器模式是通過組合的方式使用各種類,通過類與類之間的層層包裝來實現複雜的功能(將一個類傳遞到另一個類)
九、面向對象
私有屬性是君子協定
python中所有類的屬性和方法默認都是公開的,可以通過添加雙下劃線前綴將其標註爲私有,僅爲標註沒有語法上的保護。
__{var}
定義的私有屬性,python只是給它重命名了爲_{class}__{var}
,任然可以通過這個別名來訪問。
私有屬性最大的用途,就是在父類中定義一個不容易被子類重寫的受保護屬性。實際編程中極少使用
實例內容都在字典裏
類實例所有的成員都保存在__dict__
屬性中,類也有__dict__
,保存類的文檔、方法等數據
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def say(self):
print("hi, My name is {self.name}, I'm {self.age}")
p = Person("ljk", 20)
print(p.__dict__)
>>>
{'name': 'ljk', 'age': 20}
print(Person.__dict__)
>>>
{'__module__': '__main__', '__init__': <function Person.__init__ at 0x7f78e6451670>, 'say': <function Person.say at 0x7f78e6331dc0>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}
類方法
用@classmethod
裝飾器定義一種特殊方法:類方法,它屬於類但是無需實例化也可以調用
靜態方法
用@stamethod
裝飾器定義靜態方法。靜態方法依附於類,但是和類的其他屬性和方法沒有關係,多用於獨立函數的創建
屬性方法
類屬性通常要使用inst.method()
方法調用,使用@property
裝飾器將一個方法變成屬性,通過inst.method
來調用
class DemolayedStart:
@propety
def func(self):
pass
@func.setter
def func(self):
pass
@func.deleter
def func(self):
pass
@property
裝飾,func已經從一個普通方法變成一個屬性
定義setter方法,該方法會在對屬性複製時被調用
定義deleter方法,該方法會在刪除屬性時被調用
使用場景:
屬性方法用於迅速拿到接口的場景,可以是對變量的轉化或處理,不適用於耗時較長的方法
鴨子類型
鴨子類型是一種編程風格,而不是語言的類型。通俗的解釋:
當看到一隻鳥走起來像鴨子、游泳起來像鴨子、叫起來也像鴨子,那麼這隻鳥就是鴨子
在鴨子類型風格下,如果要操作某些對象,不需要判斷它的類型,直接判斷它有沒有需要的方法,甚至可以直接調用需要的方法。
鴨子類型的侷限性:
- 缺乏標準:使用鴨子類型需要頻繁判斷對象是否支持某個行爲,並且沒有統一的判斷標準
- 過於隱式:對象的真實類型不再重要,取而代之的是對象提供的接口變得非常重要。但是所有接口都是隱式,全都藏在代碼和函數註釋中
鴨子類型的約束:抽象類。python中沒有接口這個概念,但是可以通過抽象類實現類似接口的功能。繼承自抽象類的子類需要實現抽象類中所有的方法,讓鴨子類型更加清晰。
多重繼承
python中一個類可以同時繼承多個父類,在解決多重繼承的方法優先級問題時,Python使用了一種名爲MRO的算法,該算法會遍歷類的所有父類,並將它們按照優先級從高到底排序
如上D繼承B和C,那麼在尋找一個方法時,遍歷順序是D->B->c->A
,這條優先級鏈中只要找到就結束。
super 是一個用來調用父類方法的工具函數,super其實使用的不是當前類的父類,而是MRO鏈條裏的上一個類
其他知識
面向對象的建模方式:針對事務的行爲建模,而不是對事物本身建模。多數情況下基於事務的行爲建模,可以孵化出更好、更靈活的模型設計。
例如:一個類person,擁有方法say,子類Tom繼承person。Tom有一個speech的能力,依賴於父類say。如果Person的say被修改,那麼Tom的speech也會被影響。這個問題的原因就在於繼承雖然能最低成本的複用功能,但是也會帶來連鎖反應。
繼承是一種類與類之間精密的耦合關係,讓子類繼承父類,雖然看上去毫無成本的獲取了父類的全部能力,但是也同時意味着,從此以後父類的所有改動都會影響子類。可以通過組合來解決複用的問題。
所謂組合就是將一個類當做參數傳入另一個類中。
將事務的行爲抽象一個類,通過傳參的方式使用該類,這樣也能複用,將不同點在自身邏輯中實現,這樣能更加靈活的使用抽象。
多態
利用多態有效解決過多的邏輯判斷
邏輯判斷寫法
class Save:
def __init__(self, file_type):
self.file_type = file_type
def save_log(self):
if self.file_type == "txt":
print("txt")
elif self.file_type == "tar":
print("tar")
elif self.file_type == "ini":
print("ini")
file_save = Save("tar")
file_save.save_log()
>>> tar
file_save = Save("ini")
file_save.save_log()
>> ini
file_save = Save("txt")
file_save.save_log()
>> txt
多態寫法
class SaveFactory:
def __init__(self, save_obj):
self.save_obj = save_obj
def save_log(self):
self.save_obj().save_log()
class Txt:
def save_log(self):
print("txt")
class Tar:
def save_log(self):
print("tar")
txt = SaveFactory(Txt)
txt.save_log()
使用多態的時機
當你發現自己的代碼出現以下特徵時:
- 有許多if/else的判斷,並且這些判斷語句的條件都非常類似
- 有許多針對類型的
isinstance()
判斷邏輯
有序組織類
__init__
實例化方法應該總是放在類的最前面,__new__
方法類似- 公有方法應該放在類的前面,因爲他們是其他模塊調用類的入口
- 以
_
開頭的私有方法,大部分是類自身的實現細節,應該放在靠後的位置 - 類方法,靜態方法和屬性對象參考公有私有方法的思路即可
- 以
__
開頭的魔法方法,通常要按照方法的重要程度決定他們的位置。如__iter__
應該放在非常靠前的位置
面向對象設計的兩個技巧
- 將類的實例化變成函數調用,降低API使用成本
request
庫的實現是一個類,但是爲方便調用,提供函數方法request.get
,request.post
等。函數作爲一種簡化API的工具,封裝了複雜的面向對象功能,大大降低使用成本
- 用全局實例替換多次實例化
項目啓動時要從配置文件中讀取所有配置項,然後將其加載到內存中供其他模塊使用。由於程序只需要一個全局的配置對象,因此非常適合使用經典設計模式:單例模式。
python中實現單例模式的方法有很多種,如重寫類的方法__new__
。但是python中最簡單的單例模式就是import機制。在python中執行import語句,無論import導入模塊多少次,只會在內存中存在一份。因此要實現單例模式,只需要在模塊裏創建一個全局對象並導入即可。如下:
class AppConfig:
def __init__(self):
pass
_config == AppConfig()
十/十一、面向對象的設計原則
面向對象設計原則中最有名的是SOLID
。SOLID中五個字母分別代表5條設計原則:
- S:singel responsibility principle 單一職責原則 SRP
- O: open-closed principle 開放-關閉原則 OCP
- L:Liskov substitution priciple 裏式替換原則 LSP
- I: iterface segregation principle 接口隔離原則 ISP
- D: dependency inversion principle 依賴倒置原則 DIP
單一職責原則
定義:一個類只做一件事情,僅有一個被修改的理由。
所謂修改的理由是指功能發生變化需要修改,那麼單一職責原則是如果有新功能要完成,只會有一個地方被修改,只有一處會變化。
優點:
- 功能互相獨立,耦合性降低
- 複用性增強
- 功能之間修改不會互相影響
實現方法:
- 將大類拆解成小類
- 使用組合方式組裝各個小類
開放-關閉原則
定義:類應該對擴展開放,對修改關閉。
修改關閉是指不修改代碼,擴展開放是指新增功能。OCP原則的意思就是在不修改代碼的前提下新增功能
優點:
- 代碼改動量少
- 新增功能效率高
實現方法:
將會頻繁變動的代碼抽象出來,通過功能覆蓋而實現支持變化
- 通過繼承改造代碼。在子類中繼承基本方法,複寫變動的方法,實現功能覆蓋
- 使用組合與依賴注入。將變動部分的功能通過參數的方式傳遞到對象中
- 使用數據驅動。將經常變動的部分以數據的方式抽離出來,當需求變化時只改動數據,代碼邏輯保持不變
繼承實現OCP
class News:
def fetch(self, url):
res = request.get(url)
def get_data(self):
res = self.fetch("www.baidu.com")
# 數據處理
class Blog:
def get_data(self):
res = self.fetch("www.sina.com")
組合與依賴實現OCP
class Fetch:
def __init__(self, handler):
self.handler = handler
def fetch(self):
res = self.handler.get_data()
return res
class Sina:
def get_data(self):
res = requests.get("www.sina.com")
return res
class Baidu:
def get_data(self):
res = requests.get("www.baidu.com")
return res
sina_obj = Sina()
sina_data = Fetch(sina_obj)
baidu_obj = Baidu()
baidu_data = Fetch(baidu_obj)
數據驅動實現OCP
class Fetch:
def __init__(self, url):
self.url = url
def fetch(self):
res = request.get(url)
return res
sina = Fetch("www.sina.com")
baidu = Fetch("www.baidu.com")
裏式替換原則
定義:在某一個功能變動時子類對象可不做修改的替換父類對象
子類如果想不做修改的替換父類,就需要子類的參數、返回值類型、拋出的異常要和父類一致
優點:可以將多態的潛能發揮出來
實現方法:
- 子類拋出的異常父類也認識
- 子類方法的返回值類型和父類一致
- 子類的方法參數和父類一致,或者比父類寬鬆
依賴倒置原則
定義:高層模塊不應該直接依賴底層模塊,兩者都應該依賴抽象
高層模塊不再直接依賴底層模塊,因爲底層模塊變動就會讓高層報錯,高層模塊要依賴處於中間的抽象層,底層模塊也依賴抽象層,這樣能解耦高層模塊和底層模塊
優點:解耦模塊依賴關係、讓代碼變的靈活
實現方法:定義抽象類
接口隔離原則
定義:一個接口提供的方法應該剛好滿足使用方的需求,一個不多,一個不少。
例如,當判斷用戶是不是新用戶時傳入request對象,其實只使用了裏面的cookie屬性,這就是不符合接口隔離原則。那麼下次再使用這個接口時需要傳入的就是整個對象,而不是一個cookie屬性。
優點:減少依賴,參數容易構造。
實現方法:拆分接口,實現小類,小接口。
十二、數據模型和描述符
數據模型就是python自有的數據類型,及其包含的特殊方法。所有與數據模型有關的方法,基本都是以雙下劃線開頭和結尾,通常也被稱爲魔法方法
__str__
方法定義了對象的表現形式
class Person:
def __init__(self, name):
self.name = name
p = Person("zhangsan")
print(p)
>>>
<__main__.Person object at 0x7f6030f24470>
class Person:
def __init__(self, name):
self.name = name
def __str__(self):
return self.name
p = Person("zhangsan")
print(p)
>>>
zhangsan
比較運算符重載
比較運算符是指專門用來對比兩個對象的運算符,如 ==
!=
>
等。在python中可以通過魔法方法來重載它們的行爲用於兩個對象之間的需要比較的場景。
如兩個正方形對象的比較
class Square:
def __init__(self, length):
self.length = length
def area(self):
return self.length ** 2
sq1 = Square(4)
sq2 = Square(4)
print(sq1 == sq2)
>>> False
加入魔法方法__eq__
class Square:
def __init__(self, length):
self.length = length
def area(self):
return self.length ** 2
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.length == other.length
sq1 = Square(4)
sq2 = Square(4)
print(sq1 == sq2)
>>> True
魔法方法
在python中,以雙下劃線開頭和結尾的方法叫魔法方法,魔法方法可以理解爲:對類中的內置方法的重載。
常見的魔法方法:
__init__
: 對象的初始化
__new__
: 對象的創建
__del__
: 對象刪除觸發
__str__
: 改變對象顯示
__eq__
: 比較對象
__setattr__
: 設置實例屬性不存在時調用
__getattr__
: 獲取實例屬性時調用
__getattribute__
: 訪問實例屬性調用
__getitem__
:對象取值
__setitem__
:設置對象的鍵值對
class Demo:
def __init__(self, arr):
self.arr = arr
def __len__(self):
return len(self.arr) * 10
# 使用[]取值時,返回所給鍵對應的值。
# 當對象是序列時,鍵是整數。
# 當對象是字典,鍵是任意值
# __getitem__方法和__iter__都可以將對象變成可迭代對象。且__iter__優先級更高
def __getitem__(self, index):
return self.arr[index]
# 設置給定鍵的值
def __setitem__(self, key, value):
if len(self.arr) < key:
print("index 不存在")
else:
print(f"設置index:{key}")
self.arr[key] = value
# 對象取值時,取值的順序爲:
# 1. 先從object裏__getattribute__中找
# 2. 第二步從對象的屬性中找
# 3. 第三步從對應類屬性中找
# 4. 第四步從父類中找
# 5. 第五步從__getattr__中找,如果沒有,直接拋出異常。
def __getattr__(self, attr):
return f"{attr} not found"
# 設置屬性時調用
def __setattr__(self, key, value):
if isinstance(value, int):
value = abs(value)
object.__setattr__(self, key, value)
# 訪問實例的屬性時就是調用這個方法
def __getattribute__(self, attr):
print("__getattribute__ 被調用")
return object.__getattribute__(self, attr)
demo = Demo([1,2,3,4,5])
print(len(demo))
>>>
__getattribute__ 被調用
50
print(demo[3])
>>>
__getattribute__ 被調用
4
print(demo.arr)
>>>
__getattribute__ 被調用
[1, 2, 3, 4, 5]
print(demo.abc)
>>>
__getattribute__ 被調用
abc not found
demo.abc = -100
print(demo.abc)
>>>
__getattribute__ 被調用
100
demo[6] = 100
>>>
__getattribute__ 被調用
index 不存在
demo[2] = 100
>>>
__getattribute__ 被調用
設置index:2
描述符
在 Python 中,允許把一個屬性託管給一個類,這個類就是一個描述符,也叫描述符類。描述符類擁有__get__
,__set__
,__delete__
等方法。
把描述符理解爲:對象的屬性不再是一個具體的值,而是交給了一個類去定義。
class Ten:
def __get__(self, obj, objtype=None):
return 10
class A:
x = Ten() # 屬性換成了一個類
print(A.x) # 10
描述符的優點:
我們就可以在方法內實現自己的邏輯,最簡單的,我們可以根據不同的條件,在方法內給屬性賦予不同的值
class Age:
def __get__(self, obj, objtype=None):
if obj.name == 'zhangsan':
return 20
elif obj.name == 'lisi':
return 25
else:
return ValueError("unknow")
class Person:
age = Age()
def __init__(self, name):
self.name = name
p1 = Person('zhangsan')
print(p1.age) # 20
p2 = Person('lisi')
print(p2.age) # 25
p3 = Person('wangwu')
print(p3.age) # unknow
>>>
20
25
unknow
__get__(self, obj, type=None) -> value
__set__(self, obj, value) -> None
__delete__(self, obj) -> None
只要是實現了以上幾個方法的其中一個,那麼這個類屬性就可以稱作描述符。
另外,描述符又可以分爲數據描述符
和非數據描述符
:
- 只定義了
__get___
,叫做非數據描述符 - 除了定義
__get__
之外,還定義了__set__
或__delete__
,叫做數據描述符
數據描述器和非數據描述器的區別在於:它們相對於實例的字典的優先級不同。
實例字典中有與描述符同名的屬性:
- 描述符是數據描述符,優先使用數據描述符;
- 非數據描述符,優先使用字典中的屬性。
描述符原理
描述符的參數說明
class Desc(object):
def __get__(self, instance, owner):
print("__get__...")
print("self : \t\t", self)
print("instance : \t", instance)
print("owner : \t", owner)
print('='*40, "\n")
def __set__(self, instance, value):
print('__set__...')
print("self : \t\t", self)
print("instance : \t", instance)
print("value : \t", value)
print('='*40, "\n")
class TestDesc(object):
x = Desc()
#以下爲測試代碼
t = TestDesc()
t.x
#以下爲輸出信息:
>>>
__get__...
self : <__main__.Desc object at 0x0000000002B0B828>
instance : <__main__.TestDesc object at 0x0000000002B0BA20>
owner : <class '__main__.TestDesc'>
可以看到,實例化類TestDesc後,調用對象t訪問其屬性x,會自動調用類Desc的 __get__
方法,由輸出信息可以看出:
① self: Desc的實例,其實就是TestDesc的屬性x
② instance: TestDesc的實例,其實就是t
③ owner: 即誰擁有這些東西,當然是 TestDesc這個類,它是最高統治者,其他的一些都是包含在它的內部或者由它生出來的
說明:
Desc類就是是一個描述符(描述符是一個類),因爲類Desc定義了方法 __get__
,__set__
。
描述符類的訪問規則:
t爲實例,訪問t.x時,根據常規順序,
- 首先:訪問TestDesc的
__getattribute__()
方法訪問實例屬性,發現沒有,然後去訪問TestDesc類屬性,找到了! - 其次:判斷屬性 x 爲一個描述符,此時,它就會做一些變動了,將 TestDesc.x 轉化爲
TestDesc.__dict__['x'].__get__()
來訪問 - 然後:進入類Desc的
__get__()
方法,進行相應的操作
描述符做實例屬性
#代碼 2
class Desc(object):
def __init__(self, name):
self.name = name
def __get__(self, instance, owner):
print("__get__...")
print('name = ',self.name)
print('='*40, "\n")
class TestDesc(object):
x = Desc('x')
def __init__(self):
self.y = Desc('y')
#以下爲測試代碼
t = TestDesc()
t.x
t.y
#以下爲輸出結果:
__get__...
name = x
========================================
調用 t.y 時刻,首先會去調用TestDesc(即Owner)的 __getattribute__()
方法,由於在t這個實例中找到了y,就不會再去Desc中找了
類屬性和實例屬性同時存在
#代碼 3
class Desc(object):
def __init__(self, name):
self.name = name
print("__init__(): name = ",self.name)
def __get__(self, instance, owner):
print("__get__() ...")
return self.name
def __set__(self, instance, value):
self.value = value
class TestDesc(object):
_x = Desc('x')
def __init__(self, x):
self._x = x
#以下爲測試代碼
t = TestDesc(10)
t._x
#輸入結果
__init__(): name = x
__get__() ...
這就牽扯到了一個查找順序問題:當Python解釋器發現實例對象的字典中,有與描述符同名的屬性時,描述符優先,會覆蓋掉實例屬性。
非數據描述符
#代碼 4
class Desc(object):
def __init__(self, name):
self.name = name
print("__init__(): name = ",self.name)
def __get__(self, instance, owner):
print("__get__() ...")
return self.name
class TestDesc(object):
_x = Desc('x')
def __init__(self, x):
self._x = x
#以下爲測試代碼
t = TestDesc(10)
t._x
#以下爲輸出:
__init__(): name = x
屬性查找優先級惹的禍,只是定義一個 __get__()
方法,爲非數據描述符。非數據描述符,描述符優先級低於實例屬性。所有查找到的是實例的屬性
數據描述符
和非數據描述符
:
一個類,如果只定義了 __get__()
方法,而沒有定義 __set__()
, __delete__()
方法,則認爲是非數據描述符; 反之,則成爲數據描述符
屬性描述符的使用場景
描述符的主要作用是用於複雜屬性的行爲控制,適合的場景如下:
- 用於參數校驗,屬性校驗
- 利用描述符實現緩存和只讀屬性
對象的set去重
通常使用集合來去除重複的元素,凡是可以hash的數據類型都可以,如數字,字符串等。如果要對一個對象使用集合去重就需要特殊的操作。
使用集合去重對象,會存在一個問題,就是即使兩個參數都相同的對象,比較結果是不同。
class Demo:
def __init__(self, name, age):
self.name = name
self.age = age
demo1 = Demo("tom", 20)
demo2 = Demo("tom", 20)
print(demo1 == demo2)
>>> False
因爲對象之間比較的是內存空間,所以兩個對象的內存空間肯定不一樣。那麼就需要重寫__eq__
方法,重載對象的比較。
class Demo:
def __init__(self, name, age):
self.name = name
self.age = age
def __eq__(self, other):
if isinstance(other, self.__class__):
return (self.name, self.age) == (other.name, other.age)
demo1 = Demo("tom", 20)
demo2 = Demo("tom", 20)
print(demo1 == demo2)
>>> True
通過重載__eq__
方法可以讓兩個對象之間支持比較,但是會遇到一個新的問題,那就是對象不可以hash。
class Demo:
def __init__(self, name, age):
self.name = name
self.age = age
def __eq__(self, other):
if isinstance(other, self.__class__):
return (self.name, self.age) == (other.name, other.age)
demo1 = Demo("tom", 20)
demo2 = Demo("tom", 20)
print(demo1 == demo2)
demo_set = set()
demo_set.add(demo1)
>>>
True
Traceback (most recent call last):
File "rule_demo.py", line 95, in <module>
demo_set.add(demo1)
TypeError: unhashable type: 'Demo'
原因是對象在hash時使用的是對象的ID,現在重寫了__eq__
方法,就會出現一個現象:兩個對象在邏輯上相同,但是他們的hash值不同。這是一個嚴重的悖論,所以python強制要求,如果重寫了__eq__
方法直接將對象變成不可hash,強制要求重新設計hash值算法。
class Demo:
def __init__(self, name, age):
self.name = name
self.age = age
def __eq__(self, other):
if isinstance(other, self.__class__):
return (self.name, self.age) == (other.name, other.age)
def __hash__(self):
return hash((self.name, self.age))
demo1 = Demo("tom", 20)
demo2 = Demo("tom", 20)
print(demo1 == demo2)
demo_set = set()
demo_set.add(demo1)
>>> True
增加__hash__
之後,就可以將對象保存到集合中。
使用dataclass
實現對象去重
dataclass 是3.7之後的一個內置模塊,主要用於是利用類型註解的語法快速定義類似上面Demo的數據類。所謂數據類就是隻用於保存數據不做方法處理的類。
通過dataclass定義的一個數據類,可以自動實現__eq__
和 __hash__
方法,不需要手動實現。
說要說明的是要增加參數(frozen=True),默認創建的數據類可以修改參數不支持hash操作,使用fronzen=True顯示的將當前類變成不可變類型,能正常計算hash值。
from dataclasses import dataclass
@dataclass(frozen=True)
class Demo:
name: str
age: int
demo1 = Demo("tom", 20)
demo2 = Demo("tom", 20)
print(demo1 == demo2)
demo_set = set()
demo_set.add(demo1)
dataclass 補充
dataclass 是一個用來保存數據的類,不需要__init__方法就可以使用的類
from dataclasses import dataclass
@dataclass
class Demo:
name: str = ""
age: int = 0
demo = Demo(name="張三", age=200)
>>> demo
Demo(name='張三', age=200)
>>> print(demo)
Demo(name='張三', age=200)
dataclass的特點:
- 相比普通class,dataclass通常不包含私有屬性,數據可以直接訪問
- dataclass的repr方法通常有固定格式,會打印出類型名以及屬性名和它的值
- dataclass擁有
__eq__
和__hash__
魔法方法 - dataclass有着模式單一固定的構造方式,或是需要重載運算符,而普通class通常無需這些工作
關於dataclass擁有__eq__
和__hash__
魔法方法這一點做出說明,__eq__
魔法方法作用是讓兩個對象可以比較,__hash__
魔法方法作用是讓對象可以hash。通常來說重寫__eq__
方法就需要重寫__hash__
方法。
tom = Demo(name="tom", age=19)
jack = Demo(name="jack", age=20)
>>> tom == jack
False
dataclass 會自動實現__eq__
和__hash__
方法,__eq__
方法的比較規則是將所有屬性組合成元組,比較元祖。
tom == jack 等價與 ("tom", 19) == ("jack", 20), __hash__
方法也類似,用屬性組成的元組作爲hash的數據,然後比較。
dataclass 的原型
dataclass(init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False)
- init:默認將生成__init__方法。如果傳入False,那麼該類將不會有__init__方法。
- repr:__repr__方法默認生成。如果傳入False,那麼該類將不會有__repr__方法。
- eq:默認將生成__eq__方法。如果傳入False,那麼__eq__方法將不會被dataclass添加,但默認爲object.eq。
- order:默認將生成
__gt_
、__ge__
、__lt_
、__le__
方法。如果傳入False,則省略它們。 - frozen:設置爲True,那麼實例在初始化後無法修改其屬性
post_init:
使用dataclass裝飾的類一個主要優勢就是不用手動去實現__init__方法,但我們經常需要在對象初始化的時候對一些數據進行校驗或者額外操作,此時一個選擇是手動實現__init__方法,其中會有大段的初始化代碼,例如self.a=a;self.b=b。另一個選擇是定義__post_init__方法來進行初始化操作,優點是可以不需要完成屬性賦值而直接使用。
from dataclasses import dataclass
@dataclass
class Demo:
name: str = ""
age: int = 0
def __post_init__(self):
if self.age < 18:
raise Exception("age lower than 18")
tom = Demo(name="tom", age=10)
>>>
Traceback (most recent call last):
File "dataclass_deo.py", line 14, in <module>
tom = Demo(name="tom", age=10)
File "<string>", line 4, in __init__
File "dataclass_deo.py", line 10, in __post_init__
raise Exception("age lower than 18")
Exception: age lower than 18
__post__init__
在所有屬性初始化之後,被__init__
調用。通常,dataclass對象的創建以__init__
開始,以__post__init__
結束。
也就是說__post_init__
是在__init__
中被調用,如果沒有__init__
則會自動調用。如果__init__
中沒有調用__post_init__
,那麼即使定義了__post_init__
也不會被調用。
數據模型的使用
class Events:
def __init__(self, events):
self.events = events
def is_empty(self):
return not bool(self.events)
def list_events_by_range(self, start, end):
return self.events[start: end]
events = Events(["compute_start", "os lanuched", "docker startd"])
if not events.is_empty():
print(events.list_events_by_range(1,3))
['os lanuched', 'docker startd']
上面代碼時散發着濃濃的傳統面對對象的氣味,如果用數據模型的知識,可以將其改造的更加符合python的風格。
class Events:
def __init__(self, events):
self.events = events
def __len__(self):
return len(self.events)
def __getitem__(self, index):
return self.events[index]
events = Events(["compute_start", "os lanuched", "docker startd"])
if events:
print(events[1:3])
>>>
['os lanuched', 'docker startd']
不要依賴__del__
方法
class Foo:
def __del__(self):
print(f"cleaning up {self}....")
foo = Foo()
del foo
>>> cleaning up <__main__.Foo object at 0x7fdc738b2e80>....
一個對象的__del__
方法,並非在使用del語句時被觸發,而是在他被作爲垃圾回收時觸發。del語句無法直接回收任務東西,他只能簡單的刪除指向當前對象的一個引用而已。
依賴__del__
方法做一些清理資源、釋放鎖、關閉連接池之類的關鍵工作是非常危險的。因爲創建的對象完全有可能因爲某些原因一直都不被當做垃圾回收,這樣網絡連接會不斷增長,鎖也一直無法釋放。
十三、開發大型項目
常用工具介紹
flake8
: 檢查代碼是否遵循了PEP8規範
功能:
- 代碼風格
- 語法錯誤
- 函數複雜度
isort
:導入模塊規範化
導入模塊規則:
- 第一部分:標準庫包
- 第二部分:第三方包
- 第三部分:本地包
black
:更加嚴格的代碼檢查,只支持少量的參數
pre-commit
:
pre-commit預提交,是git hooks中的一個鉤子,由git commit命令調用,通常用於在提交代碼前,進行代碼規範檢查.
mypy
:
mypy 是 Python 中的靜態類型檢查器。可以在Python程序中添加類型提示(PEP 484),並使用mypy進行靜態類型檢查。查找程序中的錯誤。
單元測試
:
unittest、pytest
單元測試建議:
- 寫單元測試不是浪費時間
- 不要總想着補交單元測試
- 難測試的代碼就是爛代碼
- 想對待應用一樣對待測試代碼
- 避免教條主義