Python 工匠:做一個(gè)精通規(guī)則的玩家(Python是什么意思)
今天還是給大家推薦一篇 Python 優(yōu)質(zhì)文章,主要講解 Python? 中我們應(yīng)該注意的一些規(guī)則。熟悉規(guī)則,并讓自己的代碼適應(yīng)這些規(guī)則,可以幫助我們寫(xiě)出更地道的代碼,事半功倍地完成工作。
轉(zhuǎn)載來(lái)源
閱讀本文大概需要 9 分鐘。
前言
編程,其實(shí)和玩電子游戲有一些相似之處。你在玩不同游戲前,需要先學(xué)習(xí)每個(gè)游戲的不同規(guī)則,只有熟悉和靈活運(yùn)用游戲規(guī)則,才更有可能在游戲中獲勝。
而編程也是一樣,不同編程語(yǔ)言同樣有著不一樣的“規(guī)則”。大到是否支持面向?qū)ο螅〉绞欠窨梢远x常量,編程語(yǔ)言的規(guī)則比絕大多數(shù)電子游戲要復(fù)雜的多。
當(dāng)我們編程時(shí),如果直接拿一種語(yǔ)言的經(jīng)驗(yàn)套用到另外一種語(yǔ)言上,很多時(shí)候并不能取得最佳結(jié)果。這就好像一個(gè) CS(反恐精英) 高手在不了解規(guī)則的情況下去玩 PUBG(絕地求生),雖然他的槍法可能萬(wàn)中無(wú)一,但是極有可能在發(fā)現(xiàn)第一個(gè)敵人前,他就會(huì)倒在某個(gè)窩在草叢里的敵人的伏擊下。
Python 里的規(guī)則
Python 是一門(mén)初見(jiàn)簡(jiǎn)單、深入后愈覺(jué)復(fù)雜的語(yǔ)言。拿 Python 里最重要的“對(duì)象”概念來(lái)說(shuō),Python 為其定義了多到讓你記不全的規(guī)則,比如:
定義了?__str__?方法的對(duì)象,就可以使用?str()?函數(shù)來(lái)返回可讀名稱
定義了?__next__?和?__iter__?方法的對(duì)象,就可以被循環(huán)迭代
定義了?__bool__?方法的對(duì)象,在進(jìn)行布爾判斷時(shí)就會(huì)使用自定義的邏輯
... ...
熟悉規(guī)則,并讓自己的代碼適應(yīng)這些規(guī)則,可以幫助我們寫(xiě)出更地道的代碼,事半功倍的完成工作。下面,讓我們來(lái)看一個(gè)有關(guān)適應(yīng)規(guī)則的故事。
案例:從兩份旅游數(shù)據(jù)中獲取人員名單
某日,在一個(gè)主打新西蘭出境游的旅游公司里,商務(wù)同事突然興沖沖的跑過(guò)來(lái)找到我,說(shuō)他從某合作伙伴那里,要到了兩份重要的數(shù)據(jù):
所有去過(guò)“泰國(guó)普吉島”的人員及聯(lián)系方式
所有去過(guò)“新西蘭”的人員及聯(lián)系方式
數(shù)據(jù)采用了 JSON 格式,如下所示:
# 去過(guò)普吉島的人員數(shù)據(jù)
users_visited_phuket = [
{"first_name": "Sirena", "last_name": "Gross", "phone_number": "650-568-0388", "date_visited": "2018-03-14"},
{"first_name": "James", "last_name": "Ashcraft", "phone_number": "412-334-4380", "date_visited": "2014-09-16"},
... ...
]
# 去過(guò)新西蘭的人員數(shù)據(jù)
users_visited_nz = [
{"first_name": "Justin", "last_name": "Malcom", "phone_number": "267-282-1964", "date_visited": "2011-03-13"},
{"first_name": "Albert", "last_name": "Potter", "phone_number": "702-249-3714", "date_visited": "2013-09-11"},
... ...
]
每份數(shù)據(jù)里面都有著 姓、 名、 手機(jī)號(hào)碼、 旅游時(shí)間 四個(gè)字段。基于這份數(shù)據(jù),商務(wù)同學(xué)提出了一個(gè)(聽(tīng)上去毫無(wú)道理)的假設(shè):“去過(guò)普吉島的人,應(yīng)該對(duì)去新西蘭旅游也很有興趣。我們需要從這份數(shù)據(jù)里,找出那些去過(guò)普吉島但沒(méi)有去過(guò)新西蘭的人,針對(duì)性的賣產(chǎn)品給他們。
第一次蠻力嘗試
有了原始數(shù)據(jù)和明確的需求,接下來(lái)的問(wèn)題就是如何寫(xiě)代碼了。依靠蠻力,我很快就寫(xiě)出了第一個(gè)方案:
def find_potential_customers_v1():
"""找到去過(guò)普吉島但是沒(méi)去過(guò)新西蘭的人
"""
for phuket_record in users_visited_phuket:
is_potential = True
for nz_record in users_visited_nz:
if phuket_record['first_name'] == nz_record['first_name'] and
phuket_record['last_name'] == nz_record['last_name'] and
phuket_record['phone_number'] == nz_record['phone_number']:
is_potential = False
break
if is_potential:
yield phuket_record
因?yàn)樵紨?shù)據(jù)里沒(méi)有“用戶 ID”之類的唯一標(biāo)示,所以我們只能把“姓名和電話號(hào)碼完全相同”作為判斷是不是同一個(gè)人的標(biāo)準(zhǔn)。
find_potential_customers_v1 函數(shù)通過(guò)循環(huán)的方式,先遍歷所有去過(guò)普吉島的人,然后再遍歷新西蘭的人,如果在新西蘭的記錄中找不到完全匹配的記錄,就把它當(dāng)做“潛在客戶”返回。
這個(gè)函數(shù)雖然可以完成任務(wù),但是相信不用我說(shuō)你也能發(fā)現(xiàn)。它有著非常嚴(yán)重的性能問(wèn)題。對(duì)于每一條去過(guò)普吉島的記錄,我們都需要遍歷所有新西蘭訪問(wèn)記錄,嘗試找到匹配。整個(gè)算法的時(shí)間復(fù)雜度是可怕的 O(n*m),如果新西蘭的訪問(wèn)條目數(shù)很多的話,那么執(zhí)行它將耗費(fèi)非常長(zhǎng)的時(shí)間。
為了優(yōu)化內(nèi)層循環(huán)性能,我們需要減少線性查找匹配部分的開(kāi)銷。
嘗試使用集合優(yōu)化函數(shù)
如果你對(duì) Python 有所了解的話,那么你肯定知道,Python 里的字典和集合對(duì)象都是基于 哈希表(Hash Table) 實(shí)現(xiàn)的。判斷一個(gè)東西是不是在集合里的平均時(shí)間復(fù)雜度是 O(1),非常快。
所以,對(duì)于上面的函數(shù),我們可以先嘗試針對(duì)新西蘭訪問(wèn)記錄初始化一個(gè)集合,之后的查找匹配部分就可以變得很快,函數(shù)整體時(shí)間復(fù)雜度就能變?yōu)?O(n+m)。
讓我們看看新的函數(shù):
def find_potential_customers_v2():
"""找到去過(guò)普吉島但是沒(méi)去過(guò)新西蘭的人,性能改進(jìn)版
"""
# 首先,遍歷所有新西蘭訪問(wèn)記錄,創(chuàng)建查找索引
nz_records_idx = {
(rec['first_name'], rec['last_name'], rec['phone_number'])
for rec in users_visited_nz
}
for rec in users_visited_phuket:
key = (rec['first_name'], rec['last_name'], rec['phone_number'])
if key not in nz_records_idx:
yield rec
使用了集合對(duì)象后,新函數(shù)在速度上相比舊版本有了飛躍性的突破。但是,對(duì)這個(gè)問(wèn)題的優(yōu)化并不是到此為止,不然文章標(biāo)題就應(yīng)該改成:“如何使用集合提高程序性能” 了。
對(duì)問(wèn)題的重新思考
讓我們來(lái)嘗試重新抽象思考一下問(wèn)題的本質(zhì)。首先,我們有一份裝了很多東西的容器 A(普吉島訪問(wèn)記錄),然后給我們另一個(gè)裝了很多東西的容器 B(新西蘭訪問(wèn)記錄),之后定義相等規(guī)則:“姓名與電話一致”。最后基于這個(gè)相等規(guī)則,求 A 和 B 之間的“差集”。
如果你對(duì) Python 里的集合不是特別熟悉,我就稍微多介紹一點(diǎn)。假如我們擁有兩個(gè)集合 A 和 B,那么我們可以直接使用 A-B 這樣的數(shù)學(xué)運(yùn)算表達(dá)式來(lái)計(jì)算二者之間的 差集。
>>>?a?=?{1,?3,?5,?7}>>>?b?=?{3,?5,?8}#?產(chǎn)生新集合:所有在?a?但是不在?b?里的元素>>>?a?-?b{1,?7}
所以,計(jì)算“所有去過(guò)普吉島但沒(méi)去過(guò)新西蘭的人”,其實(shí)就是一次集合的求差值操作。那么要怎么做,才能把我們的問(wèn)題套入到集合的游戲規(guī)則里去呢?
利用集合的游戲規(guī)則
在 Python 中,如果要把某個(gè)東西裝到集合或字典里,一定要滿足一個(gè)基本條件:“這個(gè)東西必須是可以被哈希(Hashable)的” 。什么是 “Hashable”?
舉個(gè)例子,Python 里面的所有可變對(duì)象,比如字典,就 不是 Hashable 的。當(dāng)你嘗試把字典放入集合中時(shí),會(huì)發(fā)生這樣的錯(cuò)誤:
>>>?s?=?set()>>>?s.add({'foo':?'bar'})Traceback?(most?recent?call?last):??File?"
所以,如果要利用集合解決我們的問(wèn)題,就首先得定義我們自己的 “Hashable” 對(duì)象:VisitRecord。而要讓一個(gè)自定義對(duì)象變得 Hashable,唯一要做的事情就是定義對(duì)象的 __hash__ 方法。
class?VisitRecord:????"""旅游記錄????"""????def?__init__(self,?first_name,?last_name,?phone_number,?date_visited):????????self.first_name?=?first_name????????self.last_name?=?last_name????????self.phone_number?=?phone_number????????self.date_visited?=?date_visited
一個(gè)好的哈希算法,應(yīng)該讓不同對(duì)象之間的值盡可能的唯一,這樣可以最大程度減少“哈希碰撞”發(fā)生的概率,默認(rèn)情況下,所有 Python 對(duì)象的哈希值來(lái)自它的內(nèi)存地址。
在這個(gè)問(wèn)題里,我們需要自定義對(duì)象的 __hash__ 方法,讓它利用 (姓,名,電話)元組作為 VisitRecord 類的哈希值來(lái)源。
def?__hash__(self):????return?hash(????????(self.first_name,?self.last_name,?self.phone_number)????)
自定義完 __hash__ 方法后, VisitRecord 實(shí)例就可以正常的被放入集合中了。但這還不夠,為了讓前面提到的求差值算法正常工作,我們還需要實(shí)現(xiàn) __eq__ 特殊方法。
__eq__ 是 Python 在判斷兩個(gè)對(duì)象是否相等時(shí)調(diào)用的特殊方法。默認(rèn)情況下,它只有在自己和另一個(gè)對(duì)象的內(nèi)存地址完全一致時(shí),才會(huì)返回 True。但是在這里,我們復(fù)用了 VisitRecord 對(duì)象的哈希值,當(dāng)二者相等時(shí),就認(rèn)為它們一樣。
def?__eq__(self,?other):????#?當(dāng)兩條訪問(wèn)記錄的名字與電話號(hào)相等時(shí),判定二者相等。????if?isinstance(other,?VisitRecord)?and?hash(other)?==?hash(self):????????return?True????return?False
完成了恰當(dāng)?shù)臄?shù)據(jù)建模后,之后的求差值運(yùn)算便算是水到渠成了。新版本的函數(shù)只需要一行代碼就能完成操作:
def?find_potential_customers_v3():????return?set(VisitRecord(**r)?for?r?in?users_visited_phuket)?-?????????set(VisitRecord(**r)?for?r?in?users_visited_nz)
Hint:如果你使用的是 Python 2,那么除了 __eq__ 方法外,你還需要自定義類的 __ne__(判斷不相等時(shí)使用) 方法。
使用 dataclass 簡(jiǎn)化代碼
故事到這里并沒(méi)有結(jié)束。在上面的代碼里,我們手動(dòng)定義了自己的 數(shù)據(jù)類 VisitRecord,實(shí)現(xiàn)了 __init__、 __eq__ 等初始化方法。但其實(shí)還有更簡(jiǎn)單的做法。
因?yàn)槎x數(shù)據(jù)類這種需求在 Python 中實(shí)在太常見(jiàn)了,所以在 3.7 版本中,標(biāo)準(zhǔn)庫(kù)中新增了 dataclasses 模塊,專門(mén)幫你簡(jiǎn)化這類工作。
如果使用 dataclasses 提供的特性,我們的代碼可以最終簡(jiǎn)化成下面這樣:
@dataclass(unsafe_hash=True)
class VisitRecordDC:
first_name: str
last_name: str
phone_number: str
# 跳過(guò)“訪問(wèn)時(shí)間”字段,不作為任何對(duì)比條件
date_visited: str = field(hash=False, compare=False)
def find_potential_customers_v4():
return set(VisitRecordDC(**r) for r in users_visited_phuket) -
set(VisitRecordDC(**r) for r in users_visited_nz)
不用干任何臟活累活,只要不到十行代碼就完成了工作。
案例總結(jié)
問(wèn)題解決以后,讓我們?cè)僮鲆稽c(diǎn)小小的總結(jié)。在處理這個(gè)問(wèn)題時(shí),我們一共使用了三種方案:
使用普通的兩層循環(huán)篩選符合規(guī)則的結(jié)果集
利用哈希表結(jié)構(gòu)(set 對(duì)象)創(chuàng)建索引,提升處理效率
將數(shù)據(jù)轉(zhuǎn)換為自定義對(duì)象,利用規(guī)則,直接使用集合運(yùn)算
為什么第三種方式會(huì)比前面兩種好呢?
首先,第一個(gè)方案的性能問(wèn)題過(guò)于明顯,所以很快就會(huì)被放棄。那么第二個(gè)方案呢?仔細(xì)想想看,方案二其實(shí)并沒(méi)有什么明顯的缺點(diǎn)。甚至和第三個(gè)方案相比,因?yàn)樯倭俗远x對(duì)象的過(guò)程,它在性能與內(nèi)存占用上,甚至有可能會(huì)微微強(qiáng)于后者。
但請(qǐng)?jiān)偎伎家幌拢绻惆逊桨付拇a換成另外一種語(yǔ)言,比如 Java,它是不是基本可以做到 1:1 的完全翻譯?換句話說(shuō),它雖然效率高、代碼直接,但是它沒(méi)有完全利用好 Python 世界提供的規(guī)則,最大化的從中受益。
如果要具體化這個(gè)問(wèn)題里的“規(guī)則”,那就是 “Python 擁有內(nèi)置結(jié)構(gòu)集合,集合之間可以進(jìn)行差值等四則運(yùn)算” 這個(gè)事實(shí)本身。匹配規(guī)則后編寫(xiě)的方案三代碼擁有下面這些優(yōu)勢(shì):
為數(shù)據(jù)建模后,可以更方便的定義其他方法
如果需求變更,做反向差值運(yùn)算、求交集運(yùn)算都很簡(jiǎn)單
理解集合與 dataclasses 邏輯后,代碼遠(yuǎn)比其他版本更簡(jiǎn)潔清晰
如果要修改相等規(guī)則,比如“只擁有相同姓的記錄就算作一樣”,只需要繼承?VisitRecord?覆蓋?__eq__?方法即可
其他規(guī)則如何影響我們
在前面,我們花了很大的篇幅講了如何利用“集合的規(guī)則”來(lái)編寫(xiě)事半功倍的代碼。除此之外,Python 世界中還有著很多其他規(guī)則。如果能熟練掌握這些規(guī)則,就可以設(shè)計(jì)出符合 Python 慣例的 API,讓代碼更簡(jiǎn)潔精煉。
下面是兩個(gè)具體的例子。
使用?__format__?做對(duì)象字符串格式化
如果你的自定義對(duì)象需要定義多種字符串表示方式,就像下面這樣:
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
def get_simple_display(self):
return f'{self.name}({self.age})'
def get_long_display(self):
return f'{self.name} is {self.age} years old.'
piglei = Student('piglei', '18')
# OUTPUT: piglei(18)
print(piglei.get_simple_display())
# OUTPUT: piglei is 18 years old.
print(piglei.get_long_display())
那么除了增加這種 get_xxx_display() 額外方法外,你還可以嘗試自定義 Student 類的 __format__ 方法,因?yàn)槟遣攀菍?duì)象變?yōu)樽址臉?biāo)準(zhǔn)規(guī)則。
class Student:
def __init__(self, name, age):
self.name = name
self.age = age
def __format__(self, format_spec):
if format_spec == 'long':
return f'{self.name} is {self.age} years old.'
elif format_spec == 'simple':
return f'{self.name}({self.age})'
raise ValueError('invalid format spec')
piglei = Student('piglei', '18')
print('{0:simple}'.format(piglei))
print('{0:long}'.format(piglei))
使用?__getitem__?定義對(duì)象切片操作
如果你要設(shè)計(jì)某個(gè)可以裝東西的容器類型,那么你很可能會(huì)為它定義“是否為空”、“獲取第 N 個(gè)對(duì)象”等方法:
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([
'computer started',
'os launched',
'docker started',
'os stopped',
])
# 判斷是否有內(nèi)容,打印第二個(gè)和第三個(gè)對(duì)象
if not events.is_empty():
print(events.list_events_by_range(1, 3))
但是,這樣并非最好的做法。因?yàn)?Python 已經(jīng)為我們提供了一套對(duì)象規(guī)則,所以我們不需要像寫(xiě)其他語(yǔ)言的 OO(面向?qū)ο螅?代碼那樣去自己定義額外方法。我們有更好的選擇:
class Events:
def __init__(self, events):
self.events = events
def __len__(self):
"""自定義長(zhǎng)度,將會(huì)被用來(lái)做布爾判斷"""
return len(self.events)
def __getitem__(self, index):
"""自定義切片方法"""
# 直接將 slice 切片對(duì)象透?jìng)鹘o events 處理
return self.events[index]
# 判斷是否有內(nèi)容,打印第二個(gè)和第三個(gè)對(duì)象
if events:
print(events[1:3])
新的寫(xiě)法相比舊代碼,更能適配進(jìn) Python 世界的規(guī)則,API 也更為簡(jiǎn)潔。
關(guān)于如何適配規(guī)則、寫(xiě)出更好的 Python 代碼。Raymond Hettinger 在 PyCon 2015 上有過(guò)一次非常精彩的演講 “Beyond PEP8 - Best practices for beautiful intelligible code”。這次演講長(zhǎng)期排在我個(gè)人的 “PyCon 視頻 TOP5” 名單上,如果你還沒(méi)有看過(guò),我強(qiáng)烈建議你現(xiàn)在就去看一遍 :)
Hint:更全面的 Python 對(duì)象模型規(guī)則可以在 官方文檔 找到,有點(diǎn)難讀,但值得一讀。
總結(jié)
Python 世界有著一套非常復(fù)雜的規(guī)則,這些規(guī)則的涵蓋范圍包括“對(duì)象與對(duì)象是否相等“、”對(duì)象與對(duì)象誰(shuí)大誰(shuí)小”等等。它們大部分都需要通過(guò)重新定義“雙下劃線方法 __xxx__” 去實(shí)現(xiàn)。
如果熟悉這些規(guī)則,并在日常編碼中活用它們,有助于我們更高效的解決問(wèn)題、設(shè)計(jì)出更符合 Python 哲學(xué)的 API。下面是本文的一些要點(diǎn)總結(jié):
永遠(yuǎn)記得對(duì)原始需求做抽象分析,比如問(wèn)題是否能用集合求差集解決
如果要把對(duì)象放入集合,需要自定義對(duì)象的?__hash__?與?__eq__?方法
__hash__?方法決定性能(碰撞出現(xiàn)概率),?__eq__?決定對(duì)象間相等邏輯
使用 dataclasses 模塊可以讓你少寫(xiě)很多代碼
使用?__format__?方法替代自己定義的字符串格式化方法
在容器類對(duì)象上使用?__len__、?__getitem__?方法,而不是自己實(shí)現(xiàn)
看完文章的你,有沒(méi)有什么想吐槽的?請(qǐng)留言或者在 項(xiàng)目 Github Issues 告訴我吧。
Python 存儲(chǔ)
版權(quán)聲明:本文內(nèi)容由網(wǎng)絡(luò)用戶投稿,版權(quán)歸原作者所有,本站不擁有其著作權(quán),亦不承擔(dān)相應(yīng)法律責(zé)任。如果您發(fā)現(xiàn)本站中有涉嫌抄襲或描述失實(shí)的內(nèi)容,請(qǐng)聯(lián)系我們jiasou666@gmail.com 處理,核實(shí)后本網(wǎng)站將在24小時(shí)內(nèi)刪除侵權(quán)內(nèi)容。
版權(quán)聲明:本文內(nèi)容由網(wǎng)絡(luò)用戶投稿,版權(quán)歸原作者所有,本站不擁有其著作權(quán),亦不承擔(dān)相應(yīng)法律責(zé)任。如果您發(fā)現(xiàn)本站中有涉嫌抄襲或描述失實(shí)的內(nèi)容,請(qǐng)聯(lián)系我們jiasou666@gmail.com 處理,核實(shí)后本網(wǎng)站將在24小時(shí)內(nèi)刪除侵權(quán)內(nèi)容。