這是 「Python 工匠」系列的第 10 篇文章。[查看系列全部文章]html
編程,其實和玩電子遊戲有一些類似之處。你在玩不一樣遊戲前,須要先學習每一個遊戲的不一樣規則,只有熟悉和靈活運用遊戲規則,才更有可能在遊戲中獲勝。python
而編程也是同樣,不一樣編程語言一樣有着不同的「規則」。大到是否支持面向對象,小到是否能夠定義常量,編程語言的規則比絕大多數電子遊戲要複雜的多。git
當咱們編程時,若是直接拿一種語言的經驗套用到另一種語言上,不少時候並不能取得最佳結果。這就好像一個 CS(反恐精英) 高手在不瞭解規則的狀況下去玩 PUBG(絕地求生),雖然他的槍法可能萬中無一,可是極有可能在發現第一個敵人前,他就會倒在某個窩在草叢裏的敵人的伏擊下。github
Python 是一門初見簡單、深刻後愈覺複雜的語言。拿 Python 裏最重要的「對象」概念來講,Python 爲其定義了多到讓你記不全的規則,好比:算法
__str__
方法的對象,就可使用 str()
函數來返回可讀名稱__next__
和 __iter__
方法的對象,就能夠被循環迭代__bool__
方法的對象,在進行布爾判斷時就會使用自定義的邏輯**熟悉規則,並讓本身的代碼適應這些規則,能夠幫助咱們寫出更地道的代碼,事半功倍的完成工做。**下面,讓咱們來看一個有關適應規則的故事。docker
某日,在一個主打新西蘭出境遊的旅遊公司裏,商務同事忽然興沖沖的跑過來找到我,說他從某合做夥伴那裏,要到了兩份重要的數據:編程
數據採用了 JSON 格式,以下所示:編程語言
# 去過普吉島的人員數據
users_visited_puket = [
{"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"},
... ...
]
# 去過新西蘭的人員數據
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"},
... ...
]
複製代碼
每份數據裏面都有着姓
、名
、手機號碼
、旅遊時間
四個字段。基於這份數據,商務同窗提出了一個*(聽上去毫無道理)*的假設:「去過普吉島的人,應該對去新西蘭旅遊也頗有興趣。咱們須要從這份數據裏,找出那些去過普吉島但沒有去過新西蘭的人,針對性的賣產品給他們。函數
有了原始數據和明確的需求,接下來的問題就是如何寫代碼了。依靠蠻力,我很快就寫出了第一個方案:oop
def find_potential_customers_v1():
"""找到去過普吉島可是沒去過新西蘭的人 """
for puket_record in users_visited_puket:
is_potential = True
for nz_record in users_visited_nz:
if puket_record['first_name'] == nz_record['first_name'] and \
puket_record['last_name'] == nz_record['last_name'] and \
puket_record['phone_number'] == nz_record['phone_number']:
is_potential = False
break
if is_potential:
yield puket_record
複製代碼
由於原始數據裏沒有*「用戶 ID」*之類的惟一標示,因此咱們只能把「姓名和電話號碼徹底相同」做爲判斷是否是同一我的的標準。
find_potential_customers_v1
函數經過循環的方式,先遍歷全部去過普吉島的人,而後再遍歷新西蘭的人,若是在新西蘭的記錄中找不到徹底匹配的記錄,就把它當作「潛在客戶」返回。
這個函數雖然能夠完成任務,可是相信不用我說你也能發現。**它有着很是嚴重的性能問題。**對於每一條去過普吉島的記錄,咱們都須要遍歷全部新西蘭訪問記錄,嘗試找到匹配。整個算法的時間複雜度是可怕的 O(n*m)
,若是新西蘭的訪問條目數不少的話,那麼執行它將耗費很是長的時間。
爲了優化內層循環性能,咱們須要減小線性查找匹配部分的開銷。
若是你對 Python 有所瞭解的話,那麼你確定知道,Python 裏的字典和集合對象都是基於 哈希表(Hash Table) 實現的。判斷一個東西是否是在集合裏的平均時間複雜度是 O(1)
,很是快。
因此,對於上面的函數,咱們能夠先嚐試針對新西蘭訪問記錄初始化一個集合,以後的查找匹配部分就能夠變得很快,函數總體時間複雜度就能變爲 O(n+m)
。
讓咱們看看新的函數:
def find_potential_customers_v2():
"""找到去過普吉島可是沒去過新西蘭的人,性能改進版 """
# 首先,遍歷全部新西蘭訪問記錄,建立查找索引
nz_records_idx = {
(rec['first_name'], rec['last_name'], rec['phone_number'])
for rec in users_visited_nz
}
for rec in users_visited_puket:
key = (rec['first_name'], rec['last_name'], rec['phone_number'])
if key not in nz_records_idx:
yield rec
複製代碼
使用了集合對象後,新函數在速度上相比舊版本有了飛躍性的突破。可是,對這個問題的優化並非到此爲止,否則文章標題就應該改爲:「如何使用集合提升程序性能」 了。
讓咱們來嘗試從新抽象思考一下問題的本質。首先,咱們有一份裝了不少東西的容器 A*(普吉島訪問記錄),而後給咱們另外一個裝了不少東西的容器 B(新西蘭訪問記錄)*,以後定義相等規則:「姓名與電話一致」。最後基於這個相等規則,求 A 和 B 之間的**「差集」**。
若是你對 Python 裏的集合不是特別熟悉,我就稍微多介紹一點。假如咱們擁有兩個集合 A 和 B,那麼咱們能夠直接使用 A - B
這樣的數學運算表達式來計算兩者之間的 差集。
>>> a = {1, 3, 5, 7}
>>> b = {3, 5, 8}
# 產生新集合:全部在 a 可是不在 b 裏的元素
>>> a - b
{1, 7}
複製代碼
因此,計算「全部去過普吉島但沒去過新西蘭的人」,其實就是一次集合的求差值操做。那麼要怎麼作,才能把咱們的問題套入到集合的遊戲規則裏去呢?
在 Python 中,若是要把某個東西裝到集合或字典裏,必定要知足一個基本條件:「這個東西必須是能夠被哈希(Hashable)的」 。什麼是 「Hashable」?
舉個例子,Python 裏面的全部可變對象,好比字典,就 不是 Hashable 的。當你嘗試把字典放入集合中時,會發生這樣的錯誤:
>>> s = set()
>>> s.add({'foo': 'bar'})
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unhashable type: 'dict'
複製代碼
因此,若是要利用集合解決咱們的問題,就首先得定義咱們本身的 「Hashable」 對象:VisitRecord
。而要讓一個自定義對象變得 Hashable,惟一要作的事情就是定義對象的 __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
複製代碼
一個好的哈希算法,應該讓不一樣對象之間的值儘量的惟一,這樣能夠最大程度減小「哈希碰撞」發生的機率,默認狀況下,全部 Python 對象的哈希值來自它的內存地址。
在這個問題裏,咱們須要自定義對象的 __hash__
方法,讓它利用 (姓,名,電話)
元組做爲 VisitRecord
類的哈希值來源。
def __hash__(self):
return hash(
(self.first_name, self.last_name, self.phone_number)
)
複製代碼
自定義完 __hash__
方法後,VisitRecord
實例就能夠正常的被放入集合中了。但這還不夠,爲了讓前面提到的求差值算法正常工做,咱們還須要實現 __eq__
特殊方法。
__eq__
是 Python 在判斷兩個對象是否相等時調用的特殊方法。默認狀況下,它只有在本身和另外一個對象的內存地址徹底一致時,纔會返回 True
。可是在這裏,咱們複用了 VisitRecord
對象的哈希值,當兩者相等時,就認爲它們同樣。
def __eq__(self, other):
# 當兩條訪問記錄的名字與電話號相等時,斷定兩者相等。
if isinstance(other, VisitRecord) and hash(other) == hash(self):
return True
return False
複製代碼
完成了恰當的數據建模後,以後的求差值運算便算是水到渠成了。新版本的函數只須要一行代碼就能完成操做:
def find_potential_customers_v3():
return set(VisitRecord(**r) for r in users_visited_puket) - \
set(VisitRecord(**r) for r in users_visited_nz)
複製代碼
Hint:若是你使用的是 Python 2,那麼除了
__eq__
方法外,你還須要自定義類的__ne__
(判斷不相等時使用) 方法。
故事到這裏並無結束。在上面的代碼裏,咱們手動定義了本身的 數據類 VisitRecord
,實現了 __init__
、__eq__
等初始化方法。但其實還有更簡單的作法。
由於定義數據類這種需求在 Python 中實在太常見了,因此在 3.7 版本中,標準庫中新增了 dataclasses 模塊,專門幫你簡化這類工做。
若是使用 dataclasses 提供的特性,咱們的代碼能夠最終簡化成下面這樣:
@dataclass(unsafe_hash=True)
class VisitRecordDC:
first_name: str
last_name: str
phone_number: str
# 跳過「訪問時間」字段,不做爲任何對比條件
date_visited: str = field(hash=False, compare=False)
def find_potential_customers_v4():
return set(VisitRecordDC(**r) for r in users_visited_puket) - \
set(VisitRecordDC(**r) for r in users_visited_nz)
複製代碼
不用幹任何髒活累活,只要不到十行代碼就完成了工做。
問題解決之後,讓咱們再作一點小小的總結。在處理這個問題時,咱們一共使用了三種方案:
爲何第三種方式會比前面兩種好呢?
首先,第一個方案的性能問題過於明顯,因此很快就會被放棄。那麼第二個方案呢?仔細想一想看,方案二其實並無什麼明顯的缺點。甚至和第三個方案相比,由於少了自定義對象的過程,它在性能與內存佔用上,甚至有可能會微微強於後者。
但請再思考一下,若是你把方案二的代碼換成另一種語言,好比 Java,它是否是基本能夠作到 1:1 的徹底翻譯?換句話說,它雖然效率高、代碼直接,可是它沒有徹底利用好 Python 世界提供的規則,最大化的從中受益。
若是要具體化這個問題裏的「規則」,那就是 「Python 擁有內置結構集合,集合之間能夠進行差值等四則運算」 這個事實自己。匹配規則後編寫的方案三代碼擁有下面這些優點:
VisitRecord
覆蓋 __eq__
方法便可在前面,咱們花了很大的篇幅講了如何利用「集合的規則」來編寫事半功倍的代碼。除此以外,Python 世界中還有着不少其餘規則。若是能熟練掌握這些規則,就能夠設計出符合 Python 慣例的 API,讓代碼更簡潔精煉。
下面是兩個具體的例子。
__format__
作對象字符串格式化若是你的自定義對象須要定義多種字符串表示方式,就像下面這樣:
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__
方法,由於那纔是將對象變爲字符串的標準規則。
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__
定義對象切片操做若是你要設計某個能夠裝東西的容器類型,那麼你極可能會爲它定義「是否爲空」、「獲取第 N 個對象」等方法:
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',
])
# 判斷是否有內容,打印第二個和第三個對象
if not events.is_empty():
print(events.list_events_by_range(1, 3))
複製代碼
可是,這樣並不是最好的作法。由於 Python 已經爲咱們提供了一套對象規則,因此咱們不須要像寫其餘語言的 OO*(面向對象)* 代碼那樣去本身定義額外方法。咱們有更好的選擇:
class Events:
def __init__(self, events):
self.events = events
def __len__(self):
"""自定義長度,將會被用來作布爾判斷"""
return len(self.events)
def __getitem__(self, index):
"""自定義切片方法"""
# 直接將 slice 切片對象透傳給 events 處理
return self.events[index]
# 判斷是否有內容,打印第二個和第三個對象
if events:
print(events[1:3])
複製代碼
新的寫法相比舊代碼,更能適配進 Python 世界的規則,API 也更爲簡潔。
關於如何適配規則、寫出更好的 Python 代碼。Raymond Hettinger 在 PyCon 2015 上有過一次很是精彩的演講 「Beyond PEP8 - Best practices for beautiful intelligible code」。此次演講長期排在我我的的 「PyCon 視頻 TOP5」 名單上,若是你尚未看過,我強烈建議你如今就去看一遍 :)
Hint:更全面的 Python 對象模型規則能夠在 官方文檔 找到,有點難讀,但值得一讀。
Python 世界有着一套很是複雜的規則,這些規則的涵蓋範圍包括「對象與對象是否相等「、」對象與對象誰大誰小」等等。它們大部分都須要經過從新定義「雙下劃線方法 __xxx__
」 去實現。
若是熟悉這些規則,並在平常編碼中活用它們,有助於咱們更高效的解決問題、設計出更符合 Python 哲學的 API。下面是本文的一些要點總結:
__hash__
與 __eq__
方法__hash__
方法決定性能(碰撞出現機率),__eq__
決定對象間相等邏輯__format__
方法替代本身定義的字符串格式化方法__len__
、__getitem__
方法,而不是本身實現看完文章的你,有沒有什麼想吐槽的?請留言或者在 項目 Github Issues 告訴我吧。
系列其餘文章: