Python 工匠:在邊界處思考

這是 “Python 工匠”系列的第 15 篇文章。[點擊原文鏈接查看所有]

作者:piglei  | 來源:piglei

劇照 | 《三國機密之潛龍在淵》

2016 年,Linux 操作系統的創造者 Linus Torvalds 參加了一場 TED 訪談節目[1]。整個節目的前半部分,主要是他在講如何在家光着膀子寫出 Linux 的故事,沒有涉及太多編程相關的事情。

不過在訪談快結束時,突然出現了一個有趣的環節。主持人向 Linus 提問道:“你曾說過更願意和那些有着好的 代碼品味 的人共事,那在你眼裏,什麼纔是好的代碼品味?”

爲了解釋這個問題,Linus 在大屏幕上展示了一份代碼。我把其摘抄如下。

remove_list_entry(entry) {
    prev = NULL;
    walk = head;


    // 遍歷鏈表
    while (walk != entry) {
        prev = walk;
        walk = walk->next;
    }


    // 關鍵:當要刪除時,判斷當前位置是否在鏈表頭部進行不同的動作
    if (!prev)
        head = entry->next;
    else
        prev->next = entry->next;
}

函數 remove_list_entry 的主要功能是通過遍歷鏈表,刪除裏面的某個成員。但在這份代碼中,存在一個 邊界情況(Edge Case)[2]

在編程時,“邊界情況”是指那些只在極端情景下出現的情況。比如在上面的代碼裏,當我們要找的元素剛好處於鏈表頭部時,就是一個邊界情況。爲了處理它,函數在刪除前進行了一次 if / else 判斷。

Linus 認爲這條 if 語句是整段代碼的“壞味道”來源,寫出它的人代碼品味不夠好 ☹️。那麼,一個品味更好的人應該怎麼寫呢?很快,屏幕上出現了第二份代碼。

remove_list_entry(entry) {
    indirect = &head


    // 遍歷鏈表過程代碼已省略


    // 當要刪除時,直接進行指針操作刪除
    *indirect = entry->next
}

在新代碼中,remove_list_entry 函數利用了 C 語言裏的指針特性,把之前的 if / else 完全消除了。無論待刪除的目標是在鏈表頭部還是中間,函數都能一視同仁的完成刪除操作。之前的邊界情況消失了。

看到這你是不是在犯嘀咕:Python 又沒有指針,你跟我說這麼多指針不指針的幹啥?雖然 Python 沒有指針,但我覺得這個例子爲我們提供了一個很有趣的主題。那就是 如何充分利用語言特性,更好的處理編碼時的邊界情況。

我認爲,好代碼在處理邊界情況時應該是簡潔的、“潤物細無聲”的。就像上面的例子一樣,可以做到讓邊界情況消融在代碼主流程中。在寫 Python 時,有不少編碼技巧和慣例可以幫我們做到這一點,一塊來看看吧。

第一課:使用分支還是異常?

今天週末,你計劃參加朋友組織的聚餐,臨出門時突然想起來最近是雨季。於是你掏出手機打開天氣 App,看看今天是不是會下雨。如果下雨,就帶上一把傘再出門。

假如把“今天下雨”類比成編程時的 邊界情況,那“看天氣預報 + 帶傘”就是我們的邊界處理代碼。這種 if 下雨 then 帶傘 的分支式判斷,基本是一種來自直覺的思考本能。所以,當我們在編程時發現邊界情況時,第一反應往往就是:“弄個 if 分支把它包起來吧!”

比如下面這段代碼:

def counter_ap(l):
    """計算列表裏面每個元素出現的數量"""
    result = {}
    for key in l:
        # 主流程:累加計數器
        if key in result:
            result[key] += 1
        # **邊界情況:當元素第一次出現時,先初始化值爲 1**
        else:
            result[key] = 1
    return result


# 執行結果:
print(counter_ap(['apple', 'banana', 'apple']))
{'apple': 2, 'banana': 1}

在上面的循環裏,代碼的主流程是“對每個 key 的計數器加 1”。但是,當 result 字典裏還沒有 key 元素時,是不能直接進行累加操作的(會拋出 KeyError)。

>>> result = {}
>>> result['foo'] += 1
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
KeyError: 'foo'

於是一個邊界情況出現了:當元素第一次出現時,我們需要對值進行初始化。

所以,我專門寫了一條 if 語句去處理這個邊界情況。代碼簡單,無需過多解釋。但你可能不知道的是,其實有一個術語來專門描述這種編程風格:“(LBYL)Look Before You Leap”

“LBYL” 這縮寫不太好翻譯。用大白話講,就是在進行操作前,先對可能的邊界情況進行條件判斷。根據結果不同,確定是處理邊界情況,還是執行主流程。

如之前所說,使用 “LBYL” 來處理邊界情況,幾乎是一種直覺式的行爲。“有邊界情況,就加上 if 分支”“如果天氣預報說下雨,我就帶傘出門”一樣,是一種基本不需要過腦子的操作。

而在 LBYL 之外,有着與其形成鮮明對比的另外一種風格:“EAFP(Easier to Ask for Forgiveness than Permission)”

獲取原諒比許可簡單(EAFP)

“EAFP” 通常被翻譯成“獲取原諒比許可簡單”。如果還用下雨舉例,那 EAFP 的做法就類似於 “出門前不看任何天氣預報,如果淋雨了,就回家後洗澡喫感冒藥 ????”

使用 EAFP 風格的代碼是這樣的:

def counter_af(l):
    result = {}
    for key in l:
        try:
            # 總是直接執行主流程:累加計數器
            result[key] += 1
        except KeyError:
            # 邊界情況:當元素第一次出現時會報錯 KeyError,此時進行初始化
            result[key] = 1
    return result

和 LBYL 相比,EAFP 編程風格更爲簡單粗暴。它總是直奔主流程而去,把邊界情況都放在異常處理 try except 塊內消化掉。

如果你問我:“這兩種編程風格哪個更好?”,我只能說整個 Python 社區對基於異常捕獲的“請求原諒(EAFP)”型編程風格有着明顯的偏愛。其中的原因有很多。

首先,和許多其他編程語言不同,在 Python 裏拋出異常是一個很輕量的操作,即使程序會大量拋出、捕獲異常,使用 EAFP 也不會給程序帶來額外的負擔。

其次,“請求原諒”在性能上通常也更有優勢,因爲程序總是直奔主流程而去,只有極少數情況下才需要處理邊界情況。拿上面的例子來說,第二段代碼通常會比第一段更快,因爲它不用在每次循環時都做一次額外的成員檢查。

Hint:如果你想了解更多這方面的知識,建議閱讀: Write Cleaner Python: Use Exceptions[3]

所以,每當你想憑直覺寫下 if else 來處理邊界情況時,先考慮下使用 try 來捕獲異常是不是更合適。畢竟,Pythonista 們總是喜歡“喫感冒藥 ????”勝過“看天氣預報”。????

當容器內容不存在時

Python 裏有很多內建的容器類型,比如字典、列表、集合等等。在進行容器操作時,經常會出現一些邊界情況。其中“要訪問的內容不存在”,是最爲常見的一類:

操作字典時,訪問的鍵 key 不存在,會拋出 KeyError 異常操作列表、元組時,訪問的下標 index 不存在,會拋出 IndexError 異常

對於這類邊界情況,除了針對性的捕獲對應異常外,還有許多其他處理方式。

使用 defaultdict 改寫示例

在前面的例子裏,我們使用了 try except 語句處理了“key 第一次出現”這個邊界情況。雖然我說過,使用 try 的代碼比 if 更好,但這不代表它就是一份地道的 Python 代碼。

爲什麼?因爲如果你想統計列表元素的話,直接用 collections.defaultdict 就可以了:

from collections import defaultdict




def counter_by_collections(l):
    result = defaultdict(int)
    for key in l:
        result[key] += 1
    return result

這樣的代碼既不用“獲取許可”,也無需“請求原諒”。整個函數只有一個主流程,代碼更清晰、更自然。

爲什麼 defaultdict 可以讓邊界情況消失?因爲究其根本,之前的代碼就是少了針對 “鍵不存在” 時的默認處理邏輯。所以,當我們用 defaultdict 聲明瞭如何處理這個邊界情況時,原本需要手動判斷的部分就消失了。

Hint:就上面的例子來說,使用 collections.Counter[4] 也能達到同樣的目的。

使用 setdefault 取值並修改

有時候,我們需要操作字典裏的某個值,但它又可能並不存在。比如下面這個例子:

# 往字典的 values 鍵追加新值,假如不存在,先以列表初始化
try:
    d['values'].append(value)
except KeyError:
    d['values'] = [value]

針對這種情況,我們可以使用 d.setdefault(key, default=None) 方法來簡化邊界處理邏輯,直接替換上面的異常捕獲語句:

# 如果 setdefault 指定的 key(此處爲 "values")不存在,以 [] 初始化,否則返回已存在
# 的值。
d.setdefault('values', []).append(value)

Hint:使用 defaultdict(list) 同樣可以利索的解決這個問題。

使用 dict.pop 刪除不存在的鍵

如果我們要刪除字典的某個 key,一般會使用 del 關鍵字。但當 key 不存在時,刪除操作就會拋出 KeyError 異常。

所以,想要安全刪除某個 key,還得加上一段異常捕獲邏輯。

try:
    del d[key]
except KeyError:
    # 忽略 key 不存在的情況
    pass

但假設只是單純的想刪除某個 key,並不關心它是否存在、有沒有刪成功。使用 dict.pop(key, default) 方法就夠了。

只要在調用 dict.pop 方法時傳入默認值,key 不存在時就不會拋出異常了。

# 使用 pop 方法,指定 default 值爲 None,當 key 不存在時,不會報錯
d.pop(key, None)

Hint:嚴格來說,pop 方法的主要用途並不是去刪除某個 key,而是 取出 某個 key 對應的值。不過我覺得偶爾用它來做刪除也無傷大雅。

當列表切片越界時

所有人都知道,當你的列表(或元組)只有 3 個元素,而你想要訪問第 4 個時,解釋器會報出 IndexError 錯誤。我們通常稱這類錯誤爲“數組越界”

>>> l = [1, 2, 3]
>>> l[2]
3
>>> l[3]
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
IndexError: list index out of range

但你可能不知道的是,假如你請求的不是某一個元素,而是一段範圍的切片。那麼無論你指定的範圍是否有效,程序都只會返回一個空列表 [],而不會拋出任何錯誤:

>>> l = []
>>> l[1000:1001]
[]

瞭解了這點後,你會發現像下面這種邊界處理代碼根本沒有必要:

def sum_list(l, limit):
    """對列表的前 limit 個元素求和
    """
    # 如果 limit 過大,設置爲數組長度避免越界
    if limit > len(l):
        limit = len(l)
    return sum(l[:limit])

因爲做切片不會拋出任何錯誤,所以不需要判斷 limit 是否超出範圍,直接做 sum 操作即可:

def sum_list(l, limit):
    return sum(l[:limit])

利用這個特點,我們還可以簡化一些特定的邊界處理邏輯。比如安全刪除列表的某個元素:

# 使用異常捕獲安全刪除列表的第 5 個元素
try:
    l.pop(5)
except IndexError:
    pass


# 刪除從 5 開始的長度爲 1 的切片,不需要捕獲任何異常
del l[5:6]

好用又危險的 “or” 操作符

or 是一個幾乎在所有編程語言裏都有的操作符,它在 Python 裏通常被用來和 and 一起做布爾值邏輯運算。比如:

>>> False or True
True

但 or 還有一個有趣的特點是短路求值,比如在下面的例子裏,1 / 0 永遠不會被執行(也就意味着不會拋出 ZeroDivisionError)

>>> True or (1 / 0)
True

在很多場景下,我們可以利用 or 的特點來簡化一些邊界處理邏輯。看看下面這個例子:

context = {}
# 僅當 extra_context 不爲 None 時,將其追加進 context 中
if extra_context:
    context.update(extra_context)

在這段代碼裏,extra_context 的值一般情況下會是一個字典,但有時也可能是 None。所以我加了一個條件判斷語句,當它的值不爲 None 時才做 .update 操作。

如果使用 or 操作符,我們可以讓上面的語句更簡練:

context.update(extra_context or {})

因爲 a or b or c or ... 這樣的表達式,會返回這些變量裏第一個布爾值爲真的值,直到最後一個爲止。所以 None or {} 其實就等於 {},於是當 extra_context 值爲 None 時,我們的 or 表達式會將它變成一個空字典。之前的條件判斷就可以被簡化成一個 or 表達式了。

使用 a or b 來表示“ a 爲空時用 b 代替”,這種寫法一點也不新鮮。你在各種編程語、各類框架源碼源碼裏都能發現它的影子。但在這個寫法下,其實也藏有一個陷阱。

因爲 or 操作計算的是變量的布爾真假值。所以,不光是 None,所有的 0、[]、{}、set() 以及其他所有會被判斷爲布爾假的東西,都會在 or 運算中被忽略。

# 所有的 0、空列表、空字符串等,都是布爾假值
>>> bool(None), bool(0), bool([]), bool({}), bool(''), bool(set())
(False, False, False, False, False, False)

如果忘記了 or 的這個特點,可能會碰到一些很奇怪的問題。比如這段代碼:

timeout = config.timeout or 60

雖然上面代碼的目的,是想要判斷當 config.timeout 爲 None 時使用 60 做默認值。但假如 config.timeout 的值被主動配置成了 0 秒,timeout 也會因爲上面的 0 or 60 = 60 運算被重新賦值爲 60。正確的配置因此被忽略掉了。

所以,有時使用 if 來進行精確的邊界處理會更穩妥一些:

if config.timeout is None:
    timeout = 60

不要手動去做數據校驗

無數前輩的經驗告訴我們:“不要信任任何用戶輸入”。這意味着所有存在用戶輸入的地方,都必須對其進行校驗。那些無效、危險的用戶輸入值,就是需要我們處理的邊界情況。

假如我在寫一個命令行小程序,需要讓用戶輸入一個 0-100 範圍的數字。要是用戶的輸入無效,就要求其重新輸入。

程序大概長這樣:

def input_a_number():
    """要求用戶輸入一個 0-100 的數字,如果無效則重新輸入
    """
    while True:
        number = input('Please input a number (0-100): ')


        #  此處往下的三條 if 語句都是輸入值的邊界校驗代碼
        if not number:
            print('Input can not be empty!')
            continue
        if not number.isdigit():
            print('Your input is not a valid number!')
            continue
        if not (0 <= int(number) <= 100):
            print('Please input a number between 0 and 100!')
            continue


        number = int(number)
        break


    print(f'Your number is {number}')

執行效果如下:

Please input a number (0-100):
Input can not be empty!
Please input a number (0-100): foo
Your input is not a valid number!
Please input a number (0-100): 65
Your number is 65

這個函數一共有 14 行有效代碼。其中有 3 段 if 共 9 行代碼,都是用於校驗的邊界值檢查代碼。也許你覺得這樣的檢查很正常,但請想象一下,假如需要校驗的輸入不止一個、校驗邏輯也比這個複雜怎麼辦?那樣的話,這些邊界值檢查代碼就會變得又臭又長。

如何改進這些代碼呢?把它們抽離出去,作爲一個校驗函數和核心邏輯隔離開是個不錯的辦法。但更重要的在於,要把“輸入數據校驗”作爲一個獨立的職責與領域,用更恰當的模塊來完成這項工作。

在數據校驗這塊,pydantic[5] 模塊是一個不錯的選擇。如果用它來做校驗,代碼可以被簡化成這樣:

from pydantic import BaseModel, conint, ValidationError




class NumberInput(BaseModel):
    # 使用類型註解 conint 定義 number 屬性的取值範圍
    number: conint(ge=0, le=100)




def input_a_number_with_pydantic():
    while True:
        number = input('Please input a number (0-100): ')


        # 實例化爲 pydantic 模型,捕獲校驗錯誤異常
        try:
            number_input = NumberInput(number=number)
        except ValidationError as e:
            print(e)
            continue


        number = number_input.number
        break


    print(f'Your number is {number}')

在日常編碼時,我們應該儘量避免去手動校驗數據。而是應該使用(或者自己實現)合適的第三方校驗模塊,把這部分邊界處理工作抽象出去,簡化主流程代碼。

Hint: 假如你在開發 Web 應用,那麼數據校驗部分通常來說都挺容易。比如 Django 框架有自己的 forms 模塊,Flask 也可以使用 WTForms 來進行數據校驗。

不要忘記做數學計算

很多年前剛接觸 Web 開發時,我想學着用 JavaScript 來實現一個簡單的文字跑馬燈動畫。如果你不知道啥是“跑馬燈”,我可以稍微解釋一下。“跑馬燈”就是讓一段文字從頁面左邊往右邊不斷循環滾動,十幾年前的網站特別流行這個。????

我記得裏面有一段邏輯是這樣的:控制文字不斷往右邊移動,當橫座標超過頁面寬度時,重置座標後繼續。我當時寫出來的代碼,翻譯成 Python 大概是這樣:

while True:
    if element.position_x > page_width:
        # 邊界情況:當對象位置超過頁面寬度時,重置位置到最左邊
        element.position_x -= page_width


    # 元素向右邊滾動一個單位寬度
    element.position_x += width_unit

看上去還不錯對不對?我剛寫完它時也是這麼認爲的。但後來有一天,我重新看到它時,才發現其中的古怪之處。

在上面的代碼裏,我需要在主循環裏保證 “element.position_x 不會超過頁面寬度 page_width”。所以我寫了一個 if 來處理當 position_x 超過頁面寬度的情況。

但如果是要保證某個累加的數字(position_x)不超過另一個數字(page_width),直接用 % 做取模運算不就好了嗎?

while True:
    # 使用 % page_with 控制不要超過頁面寬度
    element.position_x = (element.position_x + width_unit) % page_with

這樣寫的話,代碼裏的邊界情況就連着那行 if 語句一起消失了。

和取模運算類似的操作還有很多,比如 abs()math.floor() 等等。我們應該記住,不要寫出 if value < 0: value = -value 這種“邊界判斷代碼”,直接使用 abs(value) 就好,不要重新發明絕對值運算。

總結

“邊界情況(Edge cases)”是我們在日常編碼時的老朋友。但它不怎麼招人喜歡,畢竟,我們都希望自己的代碼只有一條主流程貫穿始終,不需要太多的條件判斷、異常捕獲。

但邊界情況同時又是無法避免的,只要有代碼,邊界情況就會存在。所以,如果能更好的處理它們,我們的代碼就可以變得更清晰易讀。

除了上面介紹的這些思路外,還有很多東西都可以幫助我們處理邊界情況,比如利用面向對象的多態特性、使用 空對象模式[6] 等等。

最後再總結一下:

使用條件判斷和異常捕獲都可以用來處理邊界情況在 Python 裏,我們更傾向於使用基於異常捕獲的 EAFP 風格使用 defaultdict / setdefault / pop 可以巧妙的處理當鍵不存在時的邊界情況對列表進行不存在的範圍切片不會拋出異常使用 or 可以簡化默認值邊界處理邏輯,但也要注意不要掉入陷阱不要手動去做數據校驗,使用 pydantic 或其他的數據校驗模塊利用取模、絕對值計算等方式,可以簡化一些特定的邊界處理邏輯

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

附錄

更多系列文章地址:https://github.com/piglei/one-python-craftsman

系列其他文章:

Python 工匠:寫好面向對象代碼的原則(上)Python 工匠:讓函數返回結果的技巧

References

[1] TED 訪談節目: https://www.ted.com/talks/linus_torvalds_the_mind_behind_linux/transcript?language=en
[2] 邊界情況(Edge Case): https://en.wikipedia.org/wiki/Edge_case
[3] Write Cleaner Python: Use Exceptions: https://jeffknupp.com/blog/2013/02/06/write-cleaner-python-use-exceptions/
[4] collections.Counter: https://docs.python.org/3/library/collections.html#collections.Counter
[5] pydantic: https://pydantic-docs.helpmanual.io/
[6] 空對象模式: https://github.com/piglei/one-python-craftsman/blob/master/zh_CN/5-function-returning-tips.md#5-%E5%90%88%E7%90%86%E4%BD%BF%E7%94%A8%E7%A9%BA%E5%AF%B9%E8%B1%A1%E6%A8%A1%E5%BC%8F
[7] 項目 Github Issues: https://github.com/piglei/one-python-craftsman
[8] 所有文章索引 [Github]: https://github.com/piglei/one-python-craftsman

優質文章,推薦閱讀:

如何畫好一張架構圖?

Python 爲什麼推薦蛇形命名法?

爲什麼 C 語言仍然佔據統治地位?

介紹幾款 Python 類型檢查工具

感謝創作者的好文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章