Python: 愛它沒有如果 (Part 1)

Dboy Liao
7 min readSep 17, 2022

--

前幾天忘了在哪裡看到,應該是 FB 上的朋友或粉絲頁文章傳來傳去吧,看到了這一篇文章:

老實說工作幾年下來,看到很多人在說 design pattern 之類的,但更常碰到的狀況卻是,legacy code 慘不忍睹,在套用 pattern 之前必須清理一堆東西。

所以這邊主要是想聊聊,我自己工作上碰到醜醜 python code ,具體的說就是上面看到的長長一串 if-else 的程式,是怎麼一步步慢慢改到類似 Registry pattern 的樣子,跟相關效能的討論。

說 if 是最空虛的痛

相信不少人有看過類似這樣的程式:

if case == "case1":
...
elif case == "case2":
...
elif case == "case3":
...
elif case == "case4":
(下略 100 行)
else:
raise ValueError(f"unknown case: {case}")

剛入行的時候看到這種 code,起初不以為意,但等到程式碰到一些不符預期,需要去修改或調整它的時候,就會覺得這一長串 if-else 真的很頭痛,每次光是要找要修改這些 if-else 哪裏的 code 就眼花,更別提還要看修改後的區塊在離開這一串 if-else 時,有沒有造成其他影響,導致工程師常常覺得需要很大很長的螢幕。

我覺得會有這樣的困擾,在 Python 裡主要是兩個原因:

  1. 過長的程式碼,不相關的程式被放在同一區塊造成閱讀上的困難。具體來說,上面 case4 的區塊可能跟 case1 沒有相關,但你要看到 case4 之前,難免還是會看到 case1 那邊的 code,閱讀上屬於一種干擾。
  2. if-else 並不會產生新的 scope/namespace。簡單的來說,就是 if-else 裡做的事情可能會影響到 if-else 之外的程式。譬如我在 case3 的區塊裡,因為程式需要,寫了一個 x = do_stuff_in_case3() 之類的,這時候你就要小心如果在進入這個 if-else 之前已經有個 x 存在,而且 x 在這個 if-else 之後也有被使用,可能就會導致預期外的錯誤。這在 if-else 還很短的時候或許還容易察覺,但我自己碰過的案例是以前離職的同事把這邊寫到 300 多行,要察覺這種錯誤就不是太容易。

題外說一下, Python 裡製造新 scope 的方式就是寫一個 function,其他諸如 if-else、for-loop 跟 while-loop 都不會產生新的 scope,這跟其他語言例如 C/C++ 或 Java 很不一樣:

for x in range(10):
...
print(x) # 9
while True:
y = 3
break
print(y) # 3
if True:
z = "this is z"
print(z) # "this is z"
def foo():
bar = 3
return str(bar)
bar = 5
print(foo()) # "3"

所以處理這種很長很長的 if-else 就必須注意這些問題,也讓修改這樣的程式變得容易出錯,在前人沒有寫 test 去保護的情況下,這種修改就讓人感到如履薄冰且備感壓力,Developer 何苦為難 Developer 呢?

使用 dict 代替 if-else

如同前言裡附的連結內容類似,我自己碰到這種長長的 if-else 的時候會嘗試用一個 dict 處理它。

這邊稍微說一下,一個 Junior Python developer 可能不會知道的事。

dict.get 這個 method 可以查找一個 key 並在 key 不存在的時候回傳一個預設值 (通常是 None)。所以說上面的案例可以修改成這樣:

HANDLERS_MAP = {
"case1": handle_case1,
"case2": handle_case2,
"case3": handle_case3,
"case4": handle_case4,
....
}
handler = HANDLERS_MAP.get(case, None)
if handler is None:
raise ValueError(f"unknown case: {case}")
else:
handler()

這樣一來,上面說的兩個問題就被解決了:

  1. 不同的區塊程式閱讀上不會互相干擾。
  2. 每個區塊的程式都有各自的 scope,相對好管理。

通常我會把 handle_case1handle_case2 等 function 寫在不同的 .py 檔案,然後再透過 module 的方式 import 進來,最後更新到 HANDLERS_MAP 裡面。這樣一來,你在處理各自 handler 的程式時,可以避免閱讀到不相干的程式碼,而且可以很確定每個 handler 有各自的 scope,這在修改程式時會讓你比較有把握不會不小心製造沒有控制到的 side effect。

但這時候有個麻煩,如果之後要加新的 case,例如說 caseX 來說,身為公司老鳥,覺得這件事很適合讓公司新人學習 (a.k.a 你覺得很煩不想做),就交代給新人了。新人寫完 handle_caseX 之後,不小心忘了把 handle_caseX 加到 HANDLERS_MAP 裡面,尤其是這時候 HANDLERS_MAP 可能也藏在茫茫程式海之中,新人百思不得其解,又回頭請 (ㄉㄚˇ) 教 (ㄖㄠˇ) 你。

很討厭吧?至少我很討厭。

那有沒有辦法讓上面的 HANDLERS_MAP 自動長大呢?

Simple Registry via Decorator

我最終的解法是利用人寫程式的慣性,尤其新手最愛的就是 copy&paste 別人的 code,所以我只要讓他/她 copy&paste 的過程中自動幫他/她更新 HANDLERS_MAP 就好啦!那我常是透過 decorator 去幫我自動 book-keeping 一些變數狀態:

HANDLERS_MAP = {}
def register(case_name):
def register_func(handler):
if case_name in HANDLERS_MAP:
raise ValueError(f"duplicate case name: {case_name}")
HANDLERS_MAP[case_name] = handler
return handler
return register_func
@register("case1")
def handle_case1():
...
@register("case2")
def handle_case2():
...
...
handler = HANDLERS_MAP.get(case, None)
if handler is None:
raise ValueError(f"unknown case: {case}")
else:
handler()

這樣一來,當手下 Junior 要實作 caseX,就算他/她不懂 decorator 是在做什麼,心急手快的他/她就無腦 copy&paste 上面 handle_case1 的 code,我的目的就達成了。更棒的事,因為我在 decorator 裡有檢查這個 Junior 提供的 case_name,如果有重複的值出現,我就吐一個 ValueError 出來,也就是說要確定新提交的程式有沒有不小心覆寫掉舊有 case ,只要簡單 import 這個 module 看看就好,超級容易寫 test ,只要 import 出錯我就可以準備去好好指 (ㄐㄧㄠˋ) 導 (ㄒㄩㄣˋ) 新人了 😎

End of the Story (?

可能這個時候你會覺得,這種用 registry pattern 的方式管理這種程式很棒,想要到處用來增加程式可讀性與可維護性。如果你會這麼想,那我只好說你真是好傻好可愛好天真 😏

其實寫程式常常是這樣,有一好就沒有兩好。上面這些修改其實是伴隨著不同的成本,在增加可讀性與可維護性的情況下,可能對效能付出了一些些成本。我們身為軟體工程師,有時就是在不同的成本下作出妥協,寫出適合當下的應用所需要的程式,而不是盲目相信這世上有所謂 “最佳” 寫法的程式,但要說明清楚,就等等我的下一篇吧。

雖然以我寫 blog 的速度來說,可能很久以後就是了 XD

--

--

Dboy Liao

Code Writer, Math Enthusiast and Data Scientist, yet.