別寫乾淨的程式
最近看到黑暗執行緒關於重構的文章很感興趣,存了一陣子終於有機會搬出來稍微整理一下,本文是個人紀錄和閱讀心得。程式新手上網查怎麼寫好程式就會看到如何撰寫乾淨的程式寫的那些原則,然而很多文章把「必要原則」和「建議原則」寫在一起導致很多誤會,以 DRY 為例:
Repeat Yourself
- 工程師 A 觀察到程式碼中存在重複。
- 工程師 A 將這些重複提取出來並賦予它一個名稱,形成新的抽象化,這可能是一個方法,也可能是一個類別。
- 工程師 A 將重複的程式碼替換為新的抽象化,感覺程式碼變得完美無缺後心滿意足地離開。
時間過去……
- 新的需求出現,現有的抽象化幾乎能滿足,但仍需進行少許改動。
- 工程師 B 被指派來實現這項需求,他們希望能保留現有的抽象化,於是通過增加參數和條件邏輯來適應新的需求。
這樣一來,曾經的通用抽象化開始因應不同情況而表現出不同行為。
隨著更多需求的出現,這個過程持續重複:
- 又來一個工程師 X。
- 又增加一個參數。
- 又新增一個條件判斷。
最終,程式碼變得難以理解且錯誤頻出。而此時,你正好加入了這個項目,並開始陷 入混亂。
於是之後就開始找相關資源,除了黑暗執行緒以外還有幾篇文章:
- 重構筆記 - 壞味道 (Bad Smell)
- 重構筆記 - .NET 壞味道補充包
- 能抓耗子的就是好貓?閒談程式碼 Anti-Pattern
- Goodbye, Clean Code
- The Wrong Abstraction
- Write code that’s easy to delete, and easy to debug too.
- Repeat yourself, do more than one thing, and rewrite everything
- ...還有很多其他的
重構
研究所雖然把 Numpy/Numba 摸到估計全台灣也不會有幾個人比我還熟,但是在數值模擬以外完全就是門外漢,練習了兩個小專案,從能動進化到掌握各個程式碼品質工具已經進步很多:
- V2PH-Downloader: 就是個爬蟲專案,不過搞了多線程、抽象模式、策略模式、工廠模式、密 碼學套件應用等等程式實作
- baha-blacklist: 網頁自動化
雖然只是一個簡單的爬蟲專案,但是寫的時候是想要比照最成功的圖片爬蟲工具 gallery-dl 做到和他一樣的功能,所以開發過程改了滿多東西,甚至有一段時間覺得整天在修以前的程式碼沒有實質的功能優化。
V2DL
重構前的第一個版本最大的問題是可讀性低,具體原因是程式耦合度高,呈現在「違反 SRP 的函式設計、沒有經過設計的變數傳遞、不知該如何下手的例外處理」三項,這些問題造就第一次重構。初版程式碼還是菜到不行的階段,傳參沒有任何包裝就是直接傳參數本身,第一次重構主是要解決這個問題,過程也是走一步算一步,把輸入打包成 dataclass 傳遞,雖然方便很多但是也多了一個新問題,就是把 RuntimeConfig
放進 dataclass 傳遞造成修改困難:因為要嘛一次傳整個大 Config,要嘛把 StaticConfig
和 RuntimeConfig
分開傳,前者會因為動態設定比靜態設定還晚實例化造成初始化麻煩不易理解,並且在單元測試中非常麻煩;後者都是設定卻要分開傳,兩種方法都不太爽。
第一次重構還用了從很多語言模型學來的程式碼,例如 getattr
__enter__
__exit__
等等,不是說這些方法沒用,問題是我用不到這些功能,而且對於一個技術能力不夠的人這些就是在幫倒忙,每次看到都要懷疑一下自己。除此之外那時候還看了码农高天的 type hint 影片,迫不及待的把最嚴格的 type hint 放在程式上,結果要用 @overload
和泛型才能解決 type hint 問題,這東西在 Python 上根本沒幾個人討論,浪費很長時間在解決這個問題。在這個階段有稍微抓到 SRP 的感覺,知道要在 spaghetti code 和 ravioli code 之間找到平衡,也學到 type hint 不是越多越好。
後來又經歷了數次重構,重寫了整個入口函式(劃分職責)、重構下載器(封裝成類別)、再度重構下載器(新增非同步方式)、重寫加密功能、重構整個專案資料夾架構。現在回頭檢討這些問題的原因,扣掉無可避免新手入門和早期專案會有的大量改變以外,沒有明確的目標編寫邊想功能是主要原因,導致東西加了要遇到問題才會發現,以及重構時最大的問題感覺程式好像怪怪的,但是問題在哪裡?沒有搞清楚問題本質盲目重構反而造成更多的冤枉路,當然這是我個人練習才會出現的問題,有團隊 Code Review 應該不會發生這種問題。
到目前為止的重構經驗我知道要平衡 SRP、要清楚告訴自己問題出在哪才開始作業,還有把設計模式當作唯一準則會搞自己。關於可讀性方面,函式命名是很重要的部分,以我個人來說,會覺得函式很難命名可能有兩個原因,第一個是自己都沒設定命名規範當然亂糟糟,第二個是函式違反 SRP 有多個功能所以取什麼名字都怪。
baha-blacklist
經過前一個專案後,寫這個我基本上已經知道架構要怎麼設計了,使用前一套的架構:
- 最外層控制初始化和捕捉錯誤
- 因為是簡單腳本所以不需要中間控制層
- 真正被調用的類別做出外部接口方便調用
這個專案完成速度應該有前一個的十倍以上。
效能問題
看黑暗執行緒說成這樣我也很感興趣去讀了第一章,單純看他的描述如果是我寫八成也會想辦法合併迴圈,這裡就要提醒自己「相同等級的時間複雜度沒必要特別優化」,以及「編譯器比自己還聰明」。拿古老的 duff's device 為例,這種神奇的方式現代編譯器開 -O3就沒了沒必要搞這些,最後效能提升可能都 negligible。
效能優化問題就像我自己寫的效能測試一樣,在優化效能之前先搞清楚瓶頸和優化平台、語言等,而不是被假議題騙了。以 Python 科學運算為例,想都不用想就是改用 Numba 或 pybind11,其他都是徒勞,除此之外還要對現代硬體和編譯器有正確認知,例如 unconditional writes 這種略為 tricky 但是還算好理解的方式就是很好的實現。
總結就是搞清楚任務瓶頸、程式語言、硬體平台和編譯器。
我的看法
在經歷過三個專案後,也認知到重構應該先預估預期結果和未來的擴展,現在我會考慮
- 問題的核心是什麼?
- 可讀性、可維護性、可擴展性
- 效能
也就是在搞清楚自己的問題後,針對「可讀、可維護、可擴展性」進行修改,修改時也要提醒自己一開始分析的核心,避免改到昏頭轉向,不過對於未來的擴展性方面,目前自身能力不足還沒辦法看到未來情況,也想過可能是因為我自己寫爽的想加啥都是臨時想到覺得很棒就加了根本沒有計畫,沒計畫哪知道未來長怎樣,還有新增的所有功能對我來說都是新工具所以不好預估。
可讀性
提升可讀性聽起來簡單但實際上也是有的搞,從基礎的命名規範和一致性,到 SRP 職責劃分、Keep It Simple, Stupid (KISS)、上下文相關性、命名藝術(真的是藝術)、要不要抽象重複程式碼、專案生命週期...都有得考量。
基礎
- 命名、日誌訊息和錯誤訊息一致性
- 避免魔術數字
- 避免過度封裝
- 清晰易懂的變數命名
- 善用 early return
- 自定的錯誤處理方便定位問題
- 務必使用 linter 和 formatter 協助排版
- 單一職責 SRP: 每個模組、函式或類別只負責一個任務
- 有意義的註釋: 不要寫廢話、盡量寫為什麼而不寫是什麼
- 避免過度嵌套: 根據 Linux 風格指南,不要超過三層的嵌套
- 還有其他我自己寫的整理