Python高手必读,做一个精通规则的玩家
編程,其實和玩電子游戲有一些相似之處。你在玩不同游戲前,需要先學習每個游戲的不同規則,只有熟悉和靈活運用游戲規則,才更有可能在游戲中獲勝。
而編程也是一樣,不同編程語言同樣有著不一樣的“規則”。大到是否支持面向對象,小到是否可以定義常量,編程語言的規則比絕大多數電子游戲要復雜的多。
當我們編程時,如果直接拿一種語言的經驗套用到另外一種語言上,很多時候并不能取得最佳結果。這就好像一個 CS(反恐精英) 高手在不了解規則的情況下去玩 PUBG(絕地求生),雖然他的槍法可能萬中無一,但是極有可能在發現第一個敵人前,他就會倒在某個窩在草叢里的敵人的伏擊下。
Python 里的規則
Python 是一門初見簡單、深入后愈覺復雜的語言。拿 Python 里最重要的“對象”概念來說,Python 為其定義了多到讓你記不全的規則,比如:
-
定義了 __ str__ 方法的對象,就可以使用 str() 函數來返回可讀名稱
-
定義了 __ next__ 和 __ iter__ 方法的對象,就可以被循環迭代
-
定義了 __ bool__ 方法的對象,在進行布爾判斷時就會使用自定義的邏輯
… …
熟悉規則,并讓自己的代碼適應這些規則,可以幫助我們寫出更地道的代碼,事半功倍的完成工作。下面,讓我們來看一個有關適應規則的故事。
案例:從兩份旅游數據中獲取人員名單
某日,在一個主打新西蘭出境游的旅游公司里,商務同事突然興沖沖的跑過來找到我,說他從某合作伙伴那里,要到了兩份重要的數據:
所有去過“泰國普吉島”的人員及聯系方式
所有去過“新西蘭”的人員及聯系方式
數據采用了 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"},... ... ]每份數據里面都有著 姓、 名、 手機號碼、 旅游時間 四個字段。基于這份數據,商務同學提出了一個(聽上去毫無道理)的假設:“去過普吉島的人,應該對去新西蘭旅游也很有興趣。我們需要從這份數據里,找出那些去過普吉島但沒有去過新西蘭的人,針對性的賣產品給他們。
第一次蠻力嘗試
有了原始數據和明確的需求,接下來的問題就是如何寫代碼了。依靠蠻力,我很快就寫出了第一個方案:
''' 遇到問題沒人解答?小編創建了一個Python學習交流QQ群:857662006 尋找有志同道合的小伙伴, 互幫互助,群里還有不錯的視頻學習教程和PDF電子書! ''' def find_potential_customers_v1():"""找到去過普吉島但是沒去過新西蘭的人"""for puket_record in users_visited_puket:is_potential = Truefor 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 = Falsebreakif 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_nameself.last_name = last_nameself.phone_number = phone_numberself.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 Truereturn 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__(判斷不相等時使用) 方法。
使用 dataclass 簡化代碼
故事到這里并沒有結束。在上面的代碼里,我們手動定義了自己的 數據類 VisitRecord,實現了 init、 eq 等初始化方法。但其實還有更簡單的做法。
因為定義數據類這種需求在 Python 中實在太常見了,所以在 3.7 版本中,標準庫中新增了 dataclasses 模塊,專門幫你簡化這類工作。
如果使用 dataclasses 提供的特性,我們的代碼可以最終簡化成下面這樣:
''' 遇到問題沒人解答?小編創建了一個Python學習交流QQ群:857662006 尋找有志同道合的小伙伴, 互幫互助,群里還有不錯的視頻學習教程和PDF電子書! ''' @dataclass(unsafe_hash=True) class VisitRecordDC:first_name: strlast_name: strphone_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)不用干任何臟活累活,只要不到十行代碼就完成了工作。
案例總結
問題解決以后,讓我們再做一點小小的總結。在處理這個問題時,我們一共使用了三種方案:
使用普通的兩層循環篩選符合規則的結果集
利用哈希表結構(set 對象)創建索引,提升處理效率
將數據轉換為自定義對象,利用規則,直接使用集合運算
為什么第三種方式會比前面兩種好呢?
首先,第一個方案的性能問題過于明顯,所以很快就會被放棄。那么第二個方案呢?仔細想想看,方案二其實并沒有什么明顯的缺點。甚至和第三個方案相比,因為少了自定義對象的過程,它在性能與內存占用上,甚至有可能會微微強于后者。
但請再思考一下,如果你把方案二的代碼換成另外一種語言,比如 Java,它是不是基本可以做到 1:1 的完全翻譯?換句話說,它雖然效率高、代碼直接,但是它沒有完全利用好 Python 世界提供的規則,最大化的從中受益。
如果要具體化這個問題里的“規則”,那就是 “Python 擁有內置結構集合,集合之間可以進行差值等四則運算” 這個事實本身。匹配規則后編寫的方案三代碼擁有下面這些優勢:
-
為數據建模后,可以更方便的定義其他方法
-
如果需求變更,做反向差值運算、求交集運算都很簡單
-
理解集合與 dataclasses 邏輯后,代碼遠比其他版本更簡潔清晰
-
如果要修改相等規則,比如“只擁有相同姓的記錄就算作一樣”,只需要繼承 VisitRecord 覆蓋 __ eq__ 方法即可
其他規則如何影響我們
在前面,我們花了很大的篇幅講了如何利用“集合的規則”來編寫事半功倍的代碼。除此之外,Python 世界中還有著很多其他規則。如果能熟練掌握這些規則,就可以設計出符合 Python 慣例的 API,讓代碼更簡潔精煉。
下面是兩個具體的例子。
使用 __ format__ 做對象字符串格式化
如果你的自定義對象需要定義多種字符串表示方式,就像下面這樣:
class Student:def __init__(self, name, age):self.name = nameself.age = agedef 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 = nameself.age = agedef __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 個對象”等方法:
''' 遇到問題沒人解答?小編創建了一個Python學習交流QQ群:857662006 尋找有志同道合的小伙伴, 互幫互助,群里還有不錯的視頻學習教程和PDF電子書! ''' class Events:def __init__(self, events):self.events = eventsdef 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 = eventsdef __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__ 決定對象間相等邏輯
-
使用 dataclasses 模塊可以讓你少寫很多代碼
-
使用 __ format__ 方法替代自己定義的字符串格式化方法
-
在容器類對象上使用 __ len__、 __ getitem__ 方法,而不是自己實現
總結
以上是生活随笔為你收集整理的Python高手必读,做一个精通规则的玩家的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Python老司机给上路新手的3点忠告
- 下一篇: Python 为了提升性能,竟运用了共享