Python 工匠:作一個精通規則的玩家

前言

這是 「Python 工匠」系列的第 10 篇文章。[查看系列全部文章]html

編程,其實和玩電子遊戲有一些類似之處。你在玩不一樣遊戲前,須要先學習每一個遊戲的不一樣規則,只有熟悉和靈活運用遊戲規則,才更有可能在遊戲中獲勝。python

而編程也是同樣,不一樣編程語言一樣有着不同的「規則」。大到是否支持面向對象,小到是否能夠定義常量,編程語言的規則比絕大多數電子遊戲要複雜的多。git

當咱們編程時,若是直接拿一種語言的經驗套用到另一種語言上,不少時候並不能取得最佳結果。這就好像一個 CS(反恐精英) 高手在不瞭解規則的狀況下去玩 PUBG(絕地求生),雖然他的槍法可能萬中無一,可是極有可能在發現第一個敵人前,他就會倒在某個窩在草叢裏的敵人的伏擊下。github

Python 裏的規則

Python 是一門初見簡單、深刻後愈覺複雜的語言。拿 Python 裏最重要的「對象」概念來講,Python 爲其定義了多到讓你記不全的規則,好比:算法

  • 定義了 __str__ 方法的對象,就可使用 str() 函數來返回可讀名稱
  • 定義了 __next____iter__ 方法的對象,就能夠被循環迭代
  • 定義了 __bool__ 方法的對象,在進行布爾判斷時就會使用自定義的邏輯
  • ... ...

**熟悉規則,並讓本身的代碼適應這些規則,能夠幫助咱們寫出更地道的代碼,事半功倍的完成工做。**下面,讓咱們來看一個有關適應規則的故事。docker

案例:從兩份旅遊數據中獲取人員名單

某日,在一個主打新西蘭出境遊的旅遊公司裏,商務同事忽然興沖沖的跑過來找到我,說他從某合做夥伴那裏,要到了兩份重要的數據:編程

  1. 全部去過「泰國普吉島」的人員及聯繫方式
  2. 全部去過「新西蘭」的人員及聯繫方式

數據採用了 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__(判斷不相等時使用) 方法。

使用 dataclass 簡化代碼

故事到這裏並無結束。在上面的代碼裏,咱們手動定義了本身的 數據類 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)
複製代碼

不用幹任何髒活累活,只要不到十行代碼就完成了工做。

案例總結

問題解決之後,讓咱們再作一點小小的總結。在處理這個問題時,咱們一共使用了三種方案:

  1. 使用普通的兩層循環篩選符合規則的結果集
  2. 利用哈希表結構(set 對象)建立索引,提高處理效率
  3. 將數據轉換爲自定義對象,利用規則,直接使用集合運算

爲何第三種方式會比前面兩種好呢?

首先,第一個方案的性能問題過於明顯,因此很快就會被放棄。那麼第二個方案呢?仔細想一想看,方案二其實並無什麼明顯的缺點。甚至和第三個方案相比,由於少了自定義對象的過程,它在性能與內存佔用上,甚至有可能會微微強於後者。

但請再思考一下,若是你把方案二的代碼換成另一種語言,好比 Java,它是否是基本能夠作到 1:1 的徹底翻譯?換句話說,它雖然效率高、代碼直接,可是它沒有徹底利用好 Python 世界提供的規則,最大化的從中受益。

若是要具體化這個問題裏的「規則」,那就是 「Python 擁有內置結構集合,集合之間能夠進行差值等四則運算」 這個事實自己。匹配規則後編寫的方案三代碼擁有下面這些優點:

  • 爲數據建模後,能夠更方便的定義其餘方法
  • 若是需求變動,作反向差值運算、求交集運算都很簡單
  • 理解集合與 dataclasses 邏輯後,代碼遠比其餘版本更簡潔清晰
  • 若是要修改相等規則,好比「只擁有相同姓的記錄就算做同樣」,只須要繼承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__ 決定對象間相等邏輯
  • 使用 dataclasses 模塊可讓你少寫不少代碼
  • 使用 __format__ 方法替代本身定義的字符串格式化方法
  • 在容器類對象上使用 __len____getitem__ 方法,而不是本身實現

看完文章的你,有沒有什麼想吐槽的?請留言或者在 項目 Github Issues 告訴我吧。

附錄

系列其餘文章:

相關文章
相關標籤/搜索