日常 Python:如何使用 assert
提高代碼品質,讓 Bug 無所遁形
在開始講如何使用 Assertion 之前,我們先來看看一段程式碼:
def apply_discount(product: dict, discount: float) -> int:
price = int(product["price"] * (1.0 - discount))
return price
這段程式碼的用途是計算商品折扣後的價格,它可以接收字典 product
跟浮點數 discount
兩個參數,所以假設我有一個商品是毛巾,售價為 2000 元,我想要打 75 折,我可以這樣寫:
product = {
"name": "towel",
"price": 2000,
}
discount = 0.25
apply_discount(product, discount) # 這條毛巾打完折的價格是 1500
那假設今天店員在上架 1000 元的帽子時,本來打算打八折(也就是 discount = 0.2
),但一個不小心手滑,將 0.2
按成了 2
,此時再看看程式碼:
product = {
"name": "hat",
"price": 1000,
}
discount = 2
apply_discount(product, discount) # 這條毛巾打完折的價格是 -1000
(買一頂賺一頂)
顯然,價格不可能為負數,否則就會導致商家虧損,就算打折打到老闆要跳樓,return
回傳折扣後的價格最少就是 0 元,於是身為 Python 初學者的我們,使用 if
/ else
將 apply_discount
做了一點優化:
def apply_discount(product: dict, discount: float) -> int:
price = int(product["price"] * (1.0 - discount))
if price >= 0:
return price
else:
raise ValueError("折扣後價格不能小於 0")
product = {
"name": "hat",
"price": 1000,
}
discount = 2
apply_discount(product, discount)
乍看之下好像沒什問題,但讓我們重新思考一下 if
/ else
所要表達的語意是什麼?
通常我們會用 if
/ else
表達各種狀況到流程控制,稍微翻譯一下大概是:「if
是 A 狀況,則是 A 結果,else
其他的狀況,通通都是 B 結果」,換句話說 if 跟 else 都是有可能發生的狀況,所以我們才需要思考對應的結果。
回到 apply_discount
商品折扣的範例,對我們而言,最後打折完的售價無論如何都要大於等於 0,也就是說如果發生小於 0 的狀況,程式應該立即中斷,並回報錯誤:「這邊錯了,不可能小於 0!」,此時的「小於 0」應該視為一個錯誤(bug)而不是一種狀況(condition),既然如此 if
/ else
可能就不是最好的選擇。
使用 assert
來解決這個問題
Assertion(斷言)聽翻譯就有種「鐵口直斷」的感覺,可以把它想像成「只有我說的這種可能,其他都是錯的」,如果是 apply_discount
這個例子,那就是「結果只可能大於等於 0,不然一定是錯的」,換成程式碼的話,可以這樣改寫 apply_discount
:
def apply_discount(product: dict, discount: float) -> int:
assert 0.0 <= discount <= 1.0
price = int(product["price"] * (1.0 - discount))
return price
所以當我們再次執行下面的程式碼時:
product = {
"name": "hat",
"price": 1000,
}
discount = 2
apply_discount(product, discount)
會發生這樣的 AssertionError
:
Traceback (most recent call last):
File "/Users/chienchuanw/Documents/django-template/tempCodeRunnerFile.python", line 14, in <module>
apply_discount(product, discount)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "/Users/chienchuanw/Documents/django-template/tempCodeRunnerFile.python", line 2, in apply_discount
assert 0.0 <= discount <= 1.0
^^^^^^^^^^^^^^^^^^^^^^
AssertionError
使用 Assertion 有什麼好處?
相較於先前的 if
/ else
不僅會完整地運行完程式碼,就算我們看到了 "出問題了,結果不應該小於 0"
,我們也只能知道有地方不對,但無法迅速找出錯誤發生的地方,但 Assertion 就不一樣了,終端機不僅噴錯,還很明確地說明:
- 在第十四行(line 14),使用
apply_discount(product, discount)
時出錯。 - 這個錯誤可以追溯到第二行(line 2) apply_discount 內部裡,不符合
0 <= price <= product["price"]
這個斷言。
所以我們可以很快地得知,是因為 price 計算出來的結果不符合規定,因此引發了錯誤。
這邊如果想讓使用者能更快的理解 0 <= price <= product["price"]
所代表的意涵,還可以更一步地善用 assert
的警示文字:
def apply_discount(product: dict, discount: float) -> int:
assert 0.0 <= discount <= 1.0, "折扣必須介於 0.0 和 1.0 之間"
price = int(product["price"] * (1.0 - discount))
return price
product = {
"name": "hat",
"price": 1000,
}
discount = 2
apply_discount(product, discount)
這樣錯誤訊息也會顯示原因,而不是單純拋出 AssertionError
Traceback (most recent call last):
File "/Users/chienchuanw/Documents/django-template/tempCodeRunnerFile.python", line 14, in <module>
apply_discount(product, discount)
~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^
File "/Users/chienchuanw/Documents/django-template/tempCodeRunnerFile.python", line 2, in apply_discount
assert 0.0 <= discount <= 1.0, "折扣必須介於 0.0 和 1.0 之間"
^^^^^^^^^^^^^^^^^^^^^^
AssertionError: 折扣必須介於 0.0 和 1.0 之間
所以我們可以將 assert
當作是開發階段的錯誤檢查工具,它的主要用途在於不變性檢查(invariant checking),也就是確保某些條件在程式執行期間始終成立。
Assertion 也有踩雷的時候
好的你現在學會了 Assertion,所以迫不及待地在程式碼上到處 assert
,但不要以為 assert
百利而無一害,錯誤地使用 assert 不僅沒能除錯,甚至還會程式產生安全漏洞,進一步地被惡意攻擊。
雷點一:不應該使用 assert
進行資料驗證
def delete_product(prod_id: int, user: dict) -> None:
assert user.is_admin(), "必須是 admin"
assert store.has_product(prod_id), "未知的 product"
store.get_product(prod_id).delete()
根據這段 delete_product
(刪除商品)想表達的意思,應該是:
- 斷言
user.is_admin()
必須為True
,也就是說user
必須是 admin。 - 斷言
store.has_product(prod_id)
必須為True
,也就是說 store 裡面必須有這個 prod_id。 - 通過前面兩個斷言後,store 會刪除這 prod_id。
乍看之下好像沒什麼問題,但如果我們在某些部署環境(如特定的生產環境),使用優化模式(optimize mode)執行 Python 程式碼時,此時所有的 assert
語句都會被忽略,也就是說當我們執行 delete_product
時,不管「user 是不是 admin」、「store 有沒有 prodid」,都會執行 `store.getproduct(prod_id).delete()`,這樣會發生兩個問題:
我們可以使用
$ python -O
進入 Optimize mode,或是使用$ python -OO
進入 Extra Optimize mode,兩種優化模式都會忽略assert
- 不是 admin 的 user 也可以任意刪除
store
裡的任意prod_id
商品。 - 有心人可以透過大量發送無效(不存在)的
prod_id
影響程式的效能,甚至可能導致伺服器崩潰,也就是所謂的 DDoS (Distributed Denial of Service) 攻擊。
所以我們可以這樣改寫程式碼,避免因為優化模式所帶來的副作用,針對每一個步驟的異常錯誤,拋出對應處理與訊息:
def delete_product(prod_id: int, user) -> None:
if not user.is_admin():
raise PermissionError("Must be admin")
if not store.has_product(prod_id):
raise ValueError("Unknown product")
product = store.get_product(prod_id)
if product is None:
raise ValueError("獲取 product 失敗,可能已被刪除")
try:
product.delete()
except Exception as e:
raise RuntimeError(f"刪除 product 失敗: {e}")
既然優化模式會忽略 assert
,那這時候我們再回去看稍早講的 apply_discount
程式碼,不禁會納悶優化模式下,assert 0 <= price <= product["price"]
不就也沒作用了嗎?price
最後仍舊有可能有可能會出現小於 0 的狀況。
def apply_discount(product: dict, discount: float) -> int:
assert 0.0 <= discount <= 1.0, "折扣必須介於 0.0 和 1.0 之間" # 這段在優化模式下,會被忽略
price = int(product["price"] * (1.0 - discount))
return price
假設 apply_discount
是重要的商業計算邏輯,那確實不應該使用 assert
,避免遇到負價格出現的可能,進而導致整個商店系統的崩壞,所以比較好的做法也是透過 if
檢查後,使用 raise
拋出錯誤異常:
def apply_discount(product: dict, discount: float) -> int:
assert 0.0 <= discount <= 1.0, "折扣必須介於 0.0 和 1.0 之間"
price = int(product["price"] * (1.0 - discount))
if price < 0:
raise ValueError("折扣後價格不可為負數")
return price
(開發的時候都沒問題,正式上線也不會有問題(吧?))
雷點二:不小心寫出永遠不會出錯的 assert
先看看這段程式碼:
a = 1
b = 2
assert (a >= b, "a 應該要大於 b")
print("通過測試")
單就這段程式碼,我們可以猜測其中想要表達的意思應該是:「使用 assert
斷言 a 要大於 b,如果不是的話,則會拋出 AssertionError
」,所以上面程式碼應該要跳出錯誤才對,但怎麼結果不僅沒跳出錯誤,還順順印出了"通過測試"
的字串了呢?
因為在 Python 中,assert
並不是一個函數(function),而是一個關鍵字(keyword)!
正確的 assert
語法是這樣:
assert 判斷的結果(boolean), 顯示的錯誤訊息
讓我們逐步拆解 assert (a >= b, "a 應該要大於 b")
所代表的意思:
assert (a >= b, "a 應該要大於 b")
這一段的括號()
實際上是把(a >= b, "a 應該要大於 b")
變成了一個元組(tuple),元組裡的第一個值(a >= b
)會得到一個布林值(boolean),第二個值("a 應該要大於 b"
)會得到字串(string)。a >= b
經過程式計算後會得到False
,所以程式碼會變成assert (False, "a 應該要大於 b")
。- 由於
assert
會針對判斷的結果(boolean)為False
,才會顯示錯誤訊息,所以此處 Python 會將元組(False, "a 應該要大於 b")
轉換為True
。 - 程式碼最後會變成
assert True
,而assert True
永遠不會觸發到AssertionError
。
因此如果要讓程式碼發生預期的檢查,應該要改為:
a = 1
b = 2
assert a >= b, "a 應該要大於 b"
print("通過測試")
(少一個括號
()
差很多啊!)
小結
Assertion 作為一個 debug 工具可以幫助我們快速找出程式碼問題所在之處,然而也不是任何狀況使用 assert
都是最好的選擇,通常會在這些情境下使用 assert
:
- 開發、測試環境
- 為了確保內部邏輯正確
- 單元測試
- 不影響正式環境
因此 assert
主要用於內部檢查與測試,協助工程師盡快找出問題之所在,而不能用來處理執行時所發生的錯誤,或是權限或商業邏輯檢查,如果會因為 assert
被忽略後而影響程式本來的行為,那麼就應該改用 if
/ raise Exception
,而攸關安全性、資料驗證、商業邏輯等關鍵代碼,更不應該使用 assert
,以防造成不可預想的後果。
如果本文章對你有幫助,歡迎按讚或者留言討論 ヽ(●´∀`●)ノ
本文同步發佈於作者的 Medium 網站。