快轉到主要內容

程式菜鳥重構紀錄和心得

--
目錄

最近看到黑暗執行緒關於重構的文章很感興趣,存了一陣子終於有機會搬出來稍微整理一下,本文不會有真正的討論,只是個人紀錄和閱讀心得而已。

總共有以下幾篇文章:

以及幾篇相關文章:

重構

研究所雖然把 Numpy/Numba 摸到估計全台灣也不會有幾個人比我還熟,但是在數值模擬以外完全就是門外漢。退伍後完成了三個 Python 小專案,從能動進化到掌握各個程式碼品質工具已經進步很多,分別進行以下任務

  1. PostProcessor: 檔案分類、整理、爬蟲搜尋遺失檔案、可視化
  2. V2PH-Downloader: 就是個爬蟲專案,不過搞了多線程、抽象模式、策略模式、工廠模式、密碼學套件應用等等程式實作
  3. baha-blacklist: 網頁自動化專案

第一項沒什麼重構可言,因為各項任務間不相關程式難度低很多;相較之下第二項就複雜多了,修改途中甚至有一段時間覺得自己整天在重構沒有實質的功能優化。

V2PH-Downloader

重構前的第一個版本最大的問題是可讀性低,具體原因是程式耦合度高,呈現在「違反 SRP 的函式設計、沒有經過設計的變數傳遞、不知該如何下手的例外處理」三項,這些問題造就第一次重構 (v0.0.4)。第一次重構是摸著石頭過河走一步算一步,主要是把輸入打包成 dataclass 傳遞,雖然方便很多,但是將 RuntimeConfig 也放進 dataclass 傳遞反而造成未來的修改困難:因為要嘛一次傳整個大 Config,要嘛把靜態 Config 和 RuntimeConfig 分開傳,前者會因為動態設定比靜態設定晚生成造成初始化麻煩,後者都是設定卻要分成兩種參數,兩種方法都不太爽。

第一次重構還用了從很多語言模型學來的程式碼,例如 getattr __enter__ __exit__ 等等,不是說這些方法沒用,問題是我用不到這些功能,而且對於一個技術能力不夠的人這些簡直是幫倒忙,每次看到都要懷疑一下自己。除此之外那時候還看了码农高天的 type hint 影片,迫不及待的用最嚴格的 type hint 放在程式上,結果要用 @overload 和泛型才能解決 type hint 問題,這東西在 Python 上根本沒幾個人討論,浪費很長時間在解決這個問題。途中也有有趣的事,那時候寫完會叫語言模型幫我 code review,因為 Prompt 裡面有 SOLID 原則所以語言模型永遠都跟我說違反 SRP,然後就每天都在反駁他傻逼根本沒問題。在這個階段有稍微抓到 SRP 的感覺,知道要在 spaghetti code 和 ravioli code 之間找到平衡,也學到 type hint 不是越多越好。

接下來又經歷了數次重構,重構了整個入口函式(劃分職責)、重構下載器(封裝成類別)、再度重構下載器(新增非同步方式)、重構加密腳本(雖然是重構但是改動行數多到幾乎等同重寫)、重構整個專案資料夾架構。現在回頭檢討這些問題的原因,扣掉無可避免新手入門和早期專案會有的大量改變以外,「沒有明確的目標編寫邊想功能」是主要原因,導致東西加了要遇到問題才會發現,以及重構時最大的問題「感覺程式好像怪怪的,但是問題在哪裡?」,沒有搞清楚問題本質盲目重構反而造成更多的冤枉路,當然這是我個人練習才會出現的問題,有團隊 Code Review 應該不會發生冤枉路的問題吧。

到目前為止的重構經驗我知道要平衡 SRP、要清楚告訴自己問題出在哪才開始作業,還有把設計模式當作唯一準則會搞自己。關於可讀性方面,函式命名是很重要的部分,有時候會覺得函式很難命名可能有兩個原因,第一個是可能自己都沒設定好命名規範當然亂糟糟,第二個是過多的職責所以取什麼名字都怪。

baha-blacklist

經過前一個專案的磨練之後,寫這個我基本上已經知道架構要怎麼設計了,使用前一套的架構:

  1. 最外層控制初始化和捕捉錯誤
  2. 因為是簡單腳本所以不需要中間控制層
  3. 真正被調用的類別做出外部接口方便調用

這個專案完成速度應該有前一個的十倍以上。

效能

先說我只是掃過「重構」的電子檔第一章節,自己也沒寫過 JS,只是一個小觀點。

看到黑暗執行緒說成這樣我也很感興趣就去網路上找 PDF 讀了第一章。單純看黑暗執行緒的描述,如果是我寫八成也會想辦法合併迴圈,這裡就要提醒自己「相同等級的時間複雜度沒必要特別優化」,以及「編譯器比自己還聰明」。拿古老的 duff's device 為例,這種神奇的方式現代編譯器開 -O3就沒了沒必要搞這些,最後效能提升可能都 negligible。

效能優化問題就像我自己寫的效能測試一樣,在優化效能之前先搞清楚瓶頸和優化平台、語言等,而不是被假議題騙了。以 Python 科學運算為例,想都不用想就是改用 Numba 或 pybind11,其他都是徒勞,除此之外還要對現代硬體和編譯器有正確認知,例如 unconditional writes 這種略為 tricky 的方式就是很好的實現。這裡也順便推廣自己的文章,包含各種加速方式的文章蒐集。

總結就是搞清楚任務瓶頸、程式語言、硬體平台和編譯器。

重構:摘要和我的粗淺看法

我也叫語言模型摘要了「重構」此書的幾項重點:

  1. 提升可讀性與可理解性
    不良的程式碼結構會使新成員無法迅速理解系統邏輯,甚至讓經驗豐富的開發者無法有效修改。

  2. 減少技術債
    隨著專案演進,程式碼往往會積累不必要的複雜性或重複邏輯,這些技術債將大幅增加維護成本。

  3. 改善軟體穩定性與性能
    壞程式碼結構可能導致更多的 Bug,甚至在修改時引入新的問題,進而損害產品的穩定性。

  4. 促進擴展性
    良好的程式碼結構能夠輕鬆應對新功能的引入,而壞的結構則可能造成系統崩潰或功能衝突。

除此之外,我不認同 bryanyu 這篇文章所說的「改進軟體設計:一個主要的方向就是消除重複的程式碼」,過度的抽象會導致改一個東西會需要動到其他現有的程式碼,文章後半段會更詳細說明這個問題。在經歷過三個專案後,也認知到重構應該先預估預期結果和未來的擴展,不過我目前能力還做不到的預估,現在我則是會考慮

  1. 問題的核心是什麼?
  2. 可讀性、可維護性、可擴展性
  3. 效能

也就是在搞清楚自己的問題後,針對「可讀、可維護、可擴展性」進行修改,並且在修改時提醒自己最開始分析的核心避免改到昏頭轉向,不過對於未來的可維護性和可擴展性方面,目前自身能力不足還沒辦法看到未來情況,也想過可能是因為我自己寫爽的想加啥都是臨時想到根本沒有計畫,沒計畫哪知道未來長怎樣,還有新增的所有功能對我來說都是新工具所以不好預估。

可讀性

提升可讀性聽起來簡單但實際上也是有的搞,從基礎的命名規範和一致性,到 SRP 職責劃分、Keep It Simple, Stupid (KISS)、上下文相關性、命名藝術(真的是藝術)、要不要抽象重複程式碼、專案生命週期…都有得考量。

基礎

  • 命名、日誌訊息和錯誤訊息一致性
  • 避免魔術數字
  • 避免過度封裝
  • 清晰易懂的變數命名
  • 善用 early return
  • 自定的錯誤處理方便定位問題
  • 務必使用 linter 和 formatter 協助排版
  • 單一職責 SRP: 每個模組、函式或類別只負責一個任務
  • 有意義的註釋: 不要寫廢話、盡量寫為什麼而不寫是什麼
  • 避免過度嵌套: 根據 Linux 風格指南,超過三層的嵌套代表 doomed,請見如何優雅地避免程式碼巢狀 | 程式碼嵌套 | 狀態模式 | 表驅動法 |
  • 內聚性: 模組內的成員都在為達成一個清晰、單一的目的或功能而合作。例如負責處理用戶資料的模組,裡面的方法只與用戶資料處理有關,沒有做任何不相關的工作(例如生成報告或處理訂單)
  • 對修改封閉: 設計要確保當功能需求變動時可以避免修改現有的程式,而是通過擴展現有系統完成,簡單的例子是使用條件判斷更新就更動到原有程式碼

避免奇妙語法

這裡需要提到前言的文章「能抓耗子的就是好貓?閒談程式碼 Anti-Pattern」,由於沒寫過 JS 所以請出 GPT:

簡而言之,作者反對以下兩種寫法:

  1. 不當使用 jQuery.map() 取代 jQuery.each() 迭代: map() 的目的是產生 新陣列,而非單純迭代。即使 map() 可用於迭代,但這 違反其原意,造成誤解。僅需迭代時應使用 each() 或原生迴圈。
  2. 不當使用 Select(o => ...).Count() 驗證或修改資料: Select() 的目的是產生 新序列Count()計數。使用它們驗證或在 Select()修改原始資料不當,嚴重違反其原意,導致後續維護困難。驗證或修改資料應使用 ForEach()foreach()

奇妙用法除非有明顯的效能優勢,不然省了行數看起來很爽,結果別人讀要多花一分鐘,更不要說自己以後回來看可能也不記得也需要多讀一分鐘,出 bug 還不知道到底要不要改這裡,簡而言之就是不炫技,不搞怪。

码农高天

關於可讀性的另一點是「顯式優於隱式」「語法約束優於邏輯約束」,這兩句話從码农高天偷來的,簡單的範例大概是顯式的寫出 else 比起每次看到還要判斷懷疑一下會不會進入下一行更好。這兩部影片比較推薦觀看,新手中手都適合:

不乾淨的程式碼

可維護性是重構的最高原則。

Write code that’s easy to delete, and easy to debug too. 這篇文章的標題很清楚就說明「好的程式碼不一定是乾淨的程式碼,而是容易除錯、容易理解其行為和缺陷的程式碼」,程式碼看起來很乾淨不代表他沒有問題,問題反而可能是被隱藏到別的地方,同時也不代表可讀性高,行為應直觀,讓任何開發者都能想出多種變更方式。寫程式的同時要釐清的模糊問題,現在不釐清就是以後除自己的錯,撰寫易於除錯的程式碼,從意識到未來會忘記這些程式碼開始

Do Repeat yourself

抽象在幹嘛?以 Python 為例,最直觀的抽象是 abstractmethod,就是定義一個模板,子類按規範實作,讓外部只看外觀不用管內部實作。甚至只把重複邏輯包成函式也屬於抽象,總之就是外部只需知道輸入與回傳值。

抽象也可以達到程式設計準則中的 don't repeat yourself (DRY)。那為何標題是 Do repeat 呢?因為錯誤的抽象比重複還難改,如果寫了一個糟糕、不實際的抽象,或者是沒考慮到未來、對未來支援差、太久以前寫的抽象,要改就不是確認有沒有完整的複製貼上而已。關於這裡因為我還很菜所以各位就請看大老們的文章吧,這裡節錄 The Wrong Abstraction 裡面提到的情境:

  1. 工程師 A 觀察到程式碼中存在重複。
  2. 工程師 A 將這些重複提取出來並賦予它一個名稱,形成新的抽象化,這可能是一個方法,也可能是一個類別。
  3. 工程師 A 將重複的程式碼替換為新的抽象化,感覺程式碼變得完美無缺後心滿意足地離開。

時間過去……

  1. 新的需求出現,現有的抽象化幾乎能滿足,但仍需進行少許改動。
  2. 工程師 B 被指派來實現這項需求,他們希望能保留現有的抽象化,於是通過增加參數和條件邏輯來適應新的需求。

這樣一來,曾經的通用抽象化開始因應不同情況而表現出不同行為。

隨著更多需求的出現,這個過程持續重複:

  1. 又來一個工程師 X。
  2. 又增加一個參數。
  3. 又新增一個條件判斷。

最終,程式碼變得難以理解且錯誤頻出。而此時,你正好加入了這個項目,並開始陷入混亂。

Goodbye, Clean Code 裡面提到的「即使程式碼看起來很亂,但是要在裡面加東西比抽象方法簡單多了,正好呼應了 Write code that’s easy to delete, and easy to debug too. 裡面的「有時,程式碼本身非常混亂,任何企圖“清理”它的行為反而會帶來更大的問題。在未理解其行為前試圖撰寫乾淨程式碼,結果可能適得其反,無異於召喚出一個難以維護的系統。」

Law of Demeter 不是「法律」

Law of Demeter (LoD) 指的是不經過多層次的調用,例如 person.address.country.code 經過三層的調用取得該人的國籍碼就被視為違反 LoD 原則,目的是避免程式耦合問題。

我的程式並沒有遇到太多這種鏈式調用問題,但是發現反對程式設計原則的文章後覺得很有趣,也去查了有沒有關於反對 LoD 的文章,果然被我找到 The Law of Demeter Creates More Problems Than It Solves,於是放上來作為分享。文章指出以下幾點關鍵問題:

  1. Demeter 法則過於簡單化,且被誤解為「避免多於一個點」
    很多人把 Demeter 法則簡化為「程式碼行不能有多於一個點(如 person.address.country.code)」。錯誤的簡化概念反而讓人忽略了法則的真正目的:降低耦合性,結果導致過於複雜的封裝與過度抽象,反而降低程式的可讀性與效率。

  2. 「法律」命名過於嚴肅
    作者表示把它稱為「法律」會造成問題,因為它並非基於實證,而僅僅是建議,這在英文好的人比較有影響。裡面表示多數程式設計師對其理解過於片面,沒有深入研究原始文獻。盲目遵循 Demeter 法則會導致低效代碼,例如強行使用 Demeter 法則會增加不必要的抽象層,例如添加大量的「代理方法(proxy methods)」來封裝訪問,最終導致程式碼膨脹和難以維護。

  3. 解耦與高內聚需要上下文判斷
    應該根據應用領域的上下文來決定解耦程度。文章表示領域核心概念 (core domain concepts) 的穩定性往往比遵循 Demeter 法則更重要。例如對應用程式中的核心結構(如 Person、Address 和 Country)進行合理的耦合通常是可接受的。

  4. Demeter 法則忽略了實用性的權衡
    強行消除耦合往往會增加開發成本,例如:需要更多測試、增加程式碼的複雜性,這些額外的抽象帶來的效益可能大於成本。

流失率 Code Churn Rate

流失率表示事後回來修改現有程式的比率,沒看到正式的定義,只有看到簡單的定義是

Code Churn Rate = (新增或修改的程式碼行數 + 刪除的程式碼行數) / 總程式碼行數

這是用程式檢查工具才看到的名詞,不知為何幾乎沒什麼人談論到他。根據 Code Churn Rate: Challenges, Solutions, and Tools for Calculation 的說明,一般來說流失率 25% 以下算正常,15% 就屬於高效的運作了。

文章中有列出幾個會出現流失的情況,包含原型設計階段、完美主義、遇到難題、模糊的要求、優柔寡斷的利害關係人合作,五個我中了四個,那流失率高果然也是跑不掉,不過在最後一個專案流失率問題就好很多了。

程式碼檢測工具

重構筆記 - 壞味道 (Bad Smell) 提到的問題,使用現代檢查工具可以輕易的避免,目前我主要使用的有幾個:

  1. ruff linter: 程式碼品質檢查、確保一致性、可讀性、自動修復、支援 pep8/flake8/Pylint/Pyflakes 等多種規則設定,還會告訴你新語法跟 why better
  2. ruff-format: 格式化程式碼,支援 black 格式、內嵌 isort
  3. mypy: 靜態型別檢查,確認參數是否符合 type hint,可以減少很多 typo 問題,也可以檢查到某些位置的 code never reach
  4. bandit: 安全漏洞檢查
  5. pyupgrade: 檢查有沒有用新版 Python 語法
  6. pytest/pytest-cov: 單元測試和覆蓋率
  7. pre-commit: 預提交自動執行上述指令
  8. viztracer: 我老大码农高天開發的 profiler,好用
  9. codeclimate: 吃飽太閒的時候會上去看自己的 Issues/Churn/Maintainability 等,流失率就是在這裡學到的,裡面也同樣用 smells 表示有問題的程式碼

其他

不在本文標題中,心得也沒有多到可以寫成文章的地步,流水帳描述目前的狀況

  1. 版本管理:心情好就打版本標籤,沒規律
  2. 錯誤處理:外層攔截特定例外,底層攔截特定例外,其餘沒想法
  3. 一致性:包含日誌和錯誤訊息,要怎麼處理一致性還沒想法,想過用裝飾器但感覺不是最佳解
  4. 測試策略:沒策略,感覺重要的就單元測試,主功能有整合測試,但是有感受到 CLI 專案整合測試比起單元測試更重要,尤其是我菜鳥階段視野不夠廣的情況下,整合測試能保證至少錯完還是可以動,而單元測試無法保證
  5. CI/CD:白嫖 Github 免費流量
  6. scope creep(範圍蔓延):與現在的我無關但我就想放在這
  7. 敏捷開發:與現在的我無關但我就想放在這

結尾

本篇就是流水帳紀錄過程和看到的文章,大致上可以用「房間稍微有點亂至少行動方便,乾乾淨淨但是反而會造成作什麼都麻煩」概括,再加上「避免過早抽象」這個結論。

Reference

附錄

由語言模型翻譯文章,如有侵權請來信告知。

Translated by a language model. If there are any copyright issues, please contact us.

Repeat yourself

https://programmingisterrible.com/post/176657481103/repeat-yourself-do-more-than-one-thing-and

重複自己,做超過一件事,並且重寫所有內容

如果你向工程師尋求建議——這是個糟糕的主意——他們可能會告訴你類似這樣的話:不要重複自己;程式應該只做好一件事;永遠不要從頭重寫你的程式碼。

遵循「不要重複自己」可能會導致你寫出一個帶有四個布林值標記的函數,並且在修改程式碼時需要小心地處理各種行為的組合。將事情分割成簡單的單元可能會導致尷尬的組合,並且難以協調交叉性的更改。避免重寫意味著它們經常被拖延到很晚,以至於沒有成功的機會。

這些建議本身並不壞——儘管出發點是好的,但完全照字面意思執行可能會造成比它承諾解決的更多問題。

有時候遵循一個格言的最佳方式是做完全相反的事:擁抱功能開關並持續重寫你的程式碼,將事物整合在一起使它們之間的協調更容易管理,並且重複自己以避免在一個函數中實現所有功能。

不幸的是,這個建議更難遵循。

重複自己以發現抽象化

「不要重複自己」幾乎是一個自明之理——如果說有什麼的話,程式設計的重點就是避免重複工作。

沒有人喜歡寫樣板程式碼。寫起來越直接,在文字編輯器中敲打出來就越無聊。人們在還沒開始寫之前就已經厭倦了寫出完全相同程式碼的八個副本。你不需要說服工程師不要重複自己,但你確實需要教導他們如何以及何時避免它。

「不要重複自己」經常被解釋為「不要複製貼上」或避免在程式碼庫中重複程式碼,但避免重複的最佳形式是避免重新實現已經存在於其他地方的東西——而且幸運的是,我們大多數人已經在這樣做了!

幾乎每個網路應用程式都heavily依賴作業系統、資料庫和各種其他程式碼來完成工作。現代網站在不經意間就重複使用了數百萬行程式碼。不幸的是,工程師喜歡避免重複,而「不要重複自己」變成了「總是使用抽象化」。

說到抽象化,我指的是兩個相互關聯的事物:一個我們可以思考和推理的概念,以及我們在程式語言中對它建模的方式。抽象化是一種重複自己的方式,這樣你就可以在一個地方更改程式的多個部分。抽象化允許你管理系統中的交叉性更改,或在其中共享行為。

總是使用抽象化的問題在於,你在預先猜測程式碼庫的哪些部分需要一起更改。「不要重複自己」將導致一個僵硬的、緊密耦合的程式碼混亂。重複自己是發現你實際需要哪些抽象化(如果有的話)的最佳方式。

正如Sandi Metz所說:「重複遠比錯誤的抽象化便宜」。

你實際上無法預先寫出一個可重用的抽象化。大多數成功的函式庫或框架都是從一個更大的運作中的系統中提取出來的,而不是從頭開始創建的。如果你還沒有用你的函式庫構建出有用的東西,其他人也不太可能會用它。程式碼重用不是避免重複程式碼的好藉口,而且在你的專案中寫可重用的程式碼通常是一種過早優化的形式。

當涉及到在你自己的專案中重複自己時,重點不是能夠重用程式碼,而是進行協調性的更改。在你確定要將事物耦合在一起時使用抽象化,而不是為了機會主義或意外的程式碼重用——重複自己以找出何時需要這樣做是可以的。

重複自己,但不要重複其他人的辛勤工作。重複自己:首先重複以找到正確的抽象化,然後去除重複以實現它。

關於「不要重複自己」,有些人堅持認為這不是關於避免程式碼重複,而是關於避免功能重複或責任重複。這更普遍地被稱為「單一責任原則」,而且同樣容易被誤解。

匯聚責任以簡化其之間的互動

當談到將大型服務拆分成小塊時,一種想法是系統中的每個部分應該只做一件事——做好一件事——希望通過遵循這個規則,變更和維護會變得更容易。

這在小範圍內運作良好:將變數用於不同目的是一個永恆存在的錯誤來源。在其他地方就不那麼成功了:儘管一個類可能以相當糟糕的方式做兩件事,但當你最終得到兩個糟糕的類,它們之間有著更複雜的連接混亂時,解開它並沒有多大好處。

將事物推到一起和將事物分開的唯一真正區別是,某些更改變得比其他更改更容易執行。

整體架構和微服務之間的選擇是另一個例子——在開發和部署單一服務,或是由較小的、獨立開發的服務組成的選擇之間。

它們之間的最大區別是,在一個中進行交叉性更改更容易,而在另一個中進行局部更改更容易。哪一個最適合團隊通常更多地取決於環境因素,而不是正在進行的具體更改。

儘管當需要添加新功能時,整體架構可能會很痛苦,而當需要協調時,微服務可能會很痛苦,但整體架構可以通過功能標記和短期分支順利運行,而當部署容易且高度自動化時,微服務也能很好地工作。

即使是整體架構也可以在內部分解為微服務,儘管是在單一儲存庫中並作為一個整體部署。所有事物都可以被分解成更小的部分——訣竅是知道何時這樣做是有利的。

模組化不僅僅是將事物減少到最小的部分

援引「單一責任原則」,工程師已知會殘酷地將軟體分解成驚人數量的小型互鎖部件——這種工藝很少在昂貴的手錶或bash之外見到。

傳統的UNIX命令列展示了只執行一個功能的小型元件,要發現你需要哪一個以及如何使用它來完成工作可能是一個挑戰。將東西導入到 awk '{print $2}' 幾乎是一種成年禮。

git是單一責任原則的另一個例子。儘管你可以使用git checkout對儲存庫做六種不同的事情,但它們在內部都使用類似的操作。儘管具有單一功能,元件可以以非常不同的方式使用。

沒有共享功能的小型元件層創造了對上層的需求,這些功能在這裡重疊,如果缺乏,使用者將創建一個,通過bash別名、腳本,甚至是用於複製粘貼的電子表格。

即使添加這一層可能也幫不了你:git已經有了面向使用者和面向自動化的命令的概念,但UI仍然是一團糟。為現有命令添加新標記總是比複製它並平行維護更容易。

同樣,隨著程式碼庫需求的變化,函數獲得布林值標記,類獲得新方法。在試圖避免重複並將程式碼放在一起時,我們最終將事物糾纏在一起。

儘管元件可以用單一責任創建,但隨著時間推移,它們的責任將以新的和意想不到的方式改變和互動。模組目前在系統中負責的內容不一定與它將如何發展相關。

模組化是關於限制增長的選項

給定的模組經常被更改,因為它是最容易更改的模組,而不是進行更改的最佳位置。最終,定義模組的是系統中它永遠不會負責的部分,而不是它目前負責的部分。

當一個單元對不能包含什麼程式碼沒有規則時,它最終將包含越來越多的系統。這對於每個名為'util'的模組來說永遠是真實的,這也是為什麼在Model-View-Controller系統中幾乎所有東西最終都在控制器中。

理論上,Model-View-Controller是關於三個互鎖的程式碼單元。一個用於資料庫,另一個用於UI,還有一個用於它們之間的粘合劑。在實踐中,Model-View-Controller類似於具有兩個不同子系統的整體架構——一個用於資料庫程式碼,另一個用於UI,兩者都嵌套在控制器內。

MVC的目的不僅僅是將所有資料庫程式碼放在一個地方,還要將它遠離前端程式碼。我們擁有的資料以及我們想要查看它的方式將隨時間獨立於前端程式碼而改變。

儘管程式碼重用是好的,較小的元件也是好的,但它們應該是其他期望變更的結果。兩者都是權衡,通過缺乏冗餘引入耦合,或在事物如何組合方面引入複雜性。將事物分解成更小的部分或統一它們對程式碼庫來說既不是普遍好也不是普遍壞,很大程度上取決於之後會發生什麼變化。

就像抽象化不是關於程式碼重用,而是為了變更而耦合事物一樣,模組化不是關於按功能將相似的事物組合在一起,而是找出如何讓事物分開並限制整個程式碼庫的協調。

這意味著要認識哪些部分比其他部分稍微更糾纏,知道哪些部分需要相互溝通,哪些需要共享資源,什麼共享責任,最重要的是,存在什麼外部約束以及它們向哪個方向移動。

最終,這是關於為這些變更優化——這很少通過追求可重用的程式碼來實現,因為有時處理變更意味著重寫所有內容。

重寫一切

通常,重寫只有在成為唯一選擇時才是實際可行的選項。技術債務,或是我們不能無禮評論的資深工程師寫的程式碼,累積到所有更改都變得危險。只有當系統到達臨界點時,重寫才會被考慮為一個選項。

有時原因可能不那麼戲劇性:一個API被關閉了,一個新創公司完成了美好的旅程,或者城裡有了新的時尚潮流和上級追逐它的命令。重寫也可能發生在安撫工程師時——用一個單獨的專案來獎勵良好的團隊合作。

重寫在實踐中如此冒險的原因是,用另一個系統替換一個運作中的系統很少是一夜之間的改變。我們很少理解先前的系統做了什麼——它的許多特性本質上是偶然的。文檔稀少,測試是裝飾性的,介面本質上是有機的,頑固地將行為鎖定在適當的位置。

如果遷移到替代系統取決於一次性切換所有內容,請確保你提前預訂了過渡期間的假期。

成功的重寫會計劃新舊系統間的遷移,計劃逐步增加現有負載,並計劃處理事物同時存在於一個或兩個地方的情況。兩個系統都持續維護,直到其中一個可以被停用。在較大的系統上,緩慢、謹慎的遷移是唯一可靠的選擇。

要成功,你必須先從困難的問題開始——通常與性能相關——但也可能涉及處理最困難的客戶,或系統最大的客戶或使用者。重寫必須由分類驅動,將問題範圍縮小到可以有效改進的程度,同時由當前的更大問題引導。

如果替換系統在三個月後仍然沒有做出任何有用的事情,那麼它很可能永遠不會做出任何有用的事情。

在生產環境中運行替換系統所需的時間越長,找到錯誤所需的時間就越長。不幸的是,遷移因功能開發而被推遲。新專案有最多的功能膨脹空間——這被稱為第二系統效應。

第二系統效應是典型的注定失敗的重寫的名稱,其中計劃了大量功能,實現的不夠多,而且已經寫好的很少能可靠工作。這類似於在沒有遊戲來指導決策的情況下寫遊戲引擎,或在沒有內部產品的情況下寫框架。結果的程式碼是一個無約束的混亂,幾乎不適合其目的。

我們說「永遠不要重寫程式碼」的原因是我們把重寫留得太晚,要求太多,並期望它們立即工作。永遠不要倉促重寫比完全不重寫更重要。

空值為真,一切皆允許

完全按照建議行事的問題是它在實踐中很少有效。不惜一切代價遵循它的問題是,最終我們無法承擔這樣做的代價。

它不是「不要重複自己」,而是「一些冗餘是健康的,一些不是」,並且在你確定想要將事物耦合在一起時使用抽象化。

它不是「每個事物都有一個獨特的組件」,或單一責任原則的其他變體,而是「如果介面之間簡單,將部分解耦成更小的部分通常是值得的,並試圖讓快速變化和難以實現的部分彼此遠離」。

它永遠不是「不要重寫!」,而是「不要放棄正在工作的東西」。建立遷移計劃,並行維護,然後最終停用。在高速增長的情況下,你可能可以推遲停用,甚至可能推遲遷移。

當你聽到一個建議時,你需要理解使它成為真理的結構和環境,因為它們同樣經常使它成為假的。像「不要重複自己」這樣的事情是關於做出權衡,通常在小範圍內或對初學者來說一開始複製是好的,但在較大的系統上不加質疑地調用是危險的。

在較大的系統中,理解我們的設計選擇的後果要困難得多——在許多情況下,這些後果只有在過程中太晚太晚時才被發現,只有通過將更多的工程師投入坑中才有完成的希望。

最終,我們將我們的好決定稱為「乾淨的程式碼」,將壞決定稱為「技術債務」,儘管遵循相同的規則和實踐來達到那裡。

Easy Debug

https://programmingisterrible.com/post/173883533613/code-to-debug

撰寫程式碼時,應著重於簡單易刪與易除錯

可除錯的程式碼是那種不會讓你摸不著頭緒的程式。有些程式碼會比較難除錯,原因可能是隱含的行為、不良的錯誤處理、含糊不清的邏輯、結構過於鬆散或過於緊密,亦或是處於改動中的程式碼。在一個足夠大的專案中,你最終會遇到自己無法理解的程式碼。

在一個夠老舊的專案中,你甚至會發現連自己曾寫過的程式碼都不記得了——如果不是因為提交記錄的存在,你甚至會懷疑這些程式碼是別人寫的。隨著專案規模的增長,記住每段程式碼的功能會越來越困難,尤其當這些程式碼無法正常運作時更是如此。在無法理解的程式碼上進行修改,最終你只能透過除錯的方式去了解它。

撰寫易於除錯的程式碼,從意識到未來你將無法記住這些程式碼開始。

規則0:良好的程式碼應具備明顯的錯誤

有許多方法論的推銷者認為,撰寫可理解的程式碼應該著重於「乾淨的程式碼」。然而,問題在於「乾淨」的定義往往依情境而異。乾淨的程式碼可能是硬編碼進系統中的,某些骯髒的快速修正反而可以輕鬆移除或停用。有時候,程式碼之所以顯得乾淨,只是因為其他複雜的部分被移到別處。

程式碼的乾淨與否,更大程度上反映了開發者對自身工作的自豪或羞愧,而非它的易維護性或可變更性。因此,比起所謂的「乾淨」,我們更需要的是「無趣」的程式碼,這樣的程式碼使變更點顯而易見——我發現,當專案留下許多簡單問題等待他人解決時,更容易吸引其他人參與。

良好的程式碼應具備以下特徵:

  • 不試圖掩蓋醜陋的問題,亦不試圖讓無趣的問題看起來有趣。
  • 錯誤顯而易見,行為清晰可辨,而非隱晦不明。
  • 清楚記錄自身的不足,而非試圖追求完美。
  • 行為足夠直觀,以致於任何開發者都能想出無數種變更方式。
  • 即便是極其糟糕的程式碼,有時過於追求清潔反而會帶來更大的問題。

並非乾淨的程式碼不好,而是過度強調「乾淨」有時反而更像是將問題掃進地毯下。可除錯的程式碼不一定乾淨,而充滿檢查與錯誤處理的程式碼,也未必令人愉悅。

規則1:電腦永遠處於「起火」狀態

電腦「著火」,而且程式上次執行時已崩潰。

程式執行的第一件事,應是確保自己從一個已知的、良好的、安全的狀態啟動,而非盲目進行任務。有時,因為使用者刪除了狀態文件或升級了系統,無法獲取乾淨的狀態副本。儘管程式上次執行時崩潰了,但現在卻被視作第一次執行。

例如,在讀寫程式狀態至檔案時,可能會發生以下問題:

  • 檔案遺失
  • 檔案損毀
  • 檔案版本過舊或過新
  • 檔案最後的變更未完成
  • 檔案系統撒了謊

這些並非新問題,資料庫從古至今(1970-01-01起)便一直在處理這些狀況。使用像 SQLite 這樣的工具,可以解決許多類似問題。然而,如果程式上次執行時崩潰,可能會導致後續以錯誤的資料或方式執行程式。

以排程程式為例,你可以確定以下事故將會發生:

  • 由於日光節約時間的變更,在同一小時內被執行兩次。
  • 因為操作人員遺忘已執行過,導致再次執行。
  • 因為磁碟空間不足或雲端網路異常,錯過一個小時的排程。
  • 執行時間超過一小時,延遲後續排程。
  • 在錯誤的時間點執行。
  • 接近午夜、月末或年底等邊界時間點執行時,因數學運算錯誤而失敗。

撰寫健壯的軟體,應假設程式上次執行時已崩潰,並在程式無法確定正確行為時選擇崩潰。與其留下「這不應該發生」的註解,直接拋出例外更有助於除錯,因為當問題發生時,你至少有除錯的起點。

你不必能夠處理所有這類問題——僅需讓程式停止並避免進一步惡化即可。小型檢查機制能夠大幅節省排查日誌的時間,而一個簡單的鎖檔機制則能避免耗費數小時進行備份還原。

易於除錯的程式碼應具備以下特性:

  • 執行前先檢查條件是否正確。
  • 使程式能夠輕鬆回到已知的良好狀態並重新嘗試。
  • 透過多層防禦機制盡早讓錯誤浮現。

規則 2:你的程式正在與自身對抗

Google 最嚴重的 DoS 攻擊往往來自自身系統,這是因為我們擁有非常龐大的系統。雖然偶爾會有外部人士嘗試挑戰我們的極限,但真正來說,沒有人比我們更擅長把自己「打趴」。

這對所有系統來說都是一樣的。
——Astrid Atkinson,《Engineering for the Long Game》

軟體總是會在上次執行時崩潰,現在它則總是耗盡 CPU、記憶體與磁碟資源。所有工作程序都在處理空佇列,所有人都在重試早已失效的請求,而所有伺服器同時因垃圾回收機制暫停執行。不僅系統故障,系統本身還在不斷地試圖破壞自己。

甚至要確認系統是否正在執行本身就相當困難。

檢查伺服器是否運作相對容易,但要檢查它是否正在處理請求則困難得多。如果你不檢查執行時間,程式可能會在每次檢查之間不斷崩潰。有時健康檢查甚至可能觸發程式錯誤:我曾經兩次在不同時間點設計出導致系統崩潰的健康檢查機制。

在軟體中,撰寫處理錯誤的程式碼最終會發現更多需要處理的錯誤,其中許多甚至是錯誤處理本身導致的。同樣地,效能優化往往成為系統瓶頸的來源——一個在單一標籤中使用流暢的應用程式,當同時開啟二十個標籤時可能會變得難以使用。

另一個例子是當管線中的工作程序執行速度過快,導致在下一個部分有機會跟上之前,耗盡了可用記憶體。用車流來比喻:交通阻塞通常是因為過快的車流造成的,阻塞會以回饋形式向後延伸。某些優化會在高載或重載時導致系統以難以預測的方式失效。

換句話說:系統越快,負載越高;若不設法讓系統適度回應,別驚訝它會崩潰。

回壓(Back-pressure)是一種系統內部的回饋機制,而易於除錯的程式碼,是能夠讓使用者參與回饋循環的程式碼,並能讓使用者了解系統的所有行為,包括意外、預期與非預期的行為。易於除錯的程式碼應該是容易檢查的,能讓你觀察並理解其內部變化。

規則 3:現在不消除歧義,以後就得花時間除錯

換句話說:看著程式中的變數,應該能夠輕鬆推斷發生了什麼事。除了一些令人恐懼的線性代數子程序外,你應該努力使程式的狀態表示得儘可能直觀。這意味著不要在程式中途改變變數的用途。若要指出一項明顯的嚴重錯誤,那就是用同一個變數表示兩種不同的目的。

這同樣意味著要謹慎避免「半謂詞問題」(Semi-predicate Problem),即不要用單一值(如計數器)來表示兩個值(布林值與計數器)。避免使用類似返回正數代表結果,返回 -1 代表無匹配結果的方式。原因在於你可能會遇到需要「0,但為真」這類情況(Perl 5 就有這種特性),或是產生難以與系統其他部分組合的程式碼(-1 在下一部分程式中可能是合法輸入,而非錯誤)。

除了單一變數用於兩個目的外,使用一對變數表示單一目的也同樣糟糕——尤其當這些變數是布林值時。我指的並不是用一對數字來表示範圍不好,而是用多個布林值來表示程式的狀態,通常暗示著隱藏的狀態機。

當狀態不從上到下順序流動時,最好為狀態建立單獨的變數並簡化邏輯。如果你在物件中有一組布林值,可以將其替換為名為 state 的變數,並使用列舉型別(enum)或字串(若需持久化)。這樣,if 語句會變成 if state == name,而非 if bad_name && !alternate_option

即使你明確表達了狀態機,也可能會出錯:有時候程式碼中隱藏了兩個狀態機。我在撰寫 HTTP Proxy 時就曾遇到極大的困難,直到我分別為連接狀態與解析狀態建立各自的狀態機,才順利解決問題。當你將兩個狀態機合併為一個時,很難新增新狀態或準確判斷當前狀態應該是什麼。

這更多是關於創造不需要除錯的東西,而非使其易於除錯。通過列出有效狀態,可以更輕鬆地直接拒絕無效狀態,而不是不小心讓一兩個無效狀態通過。

規則 4:意外行為即為預期行為

當你對資料結構的功能不夠清晰時,使用者會自行填補空白——無論是預期中的行為或是意外行為,最終都會在其他地方被依賴。許多主流程式語言的雜湊表可以遍歷,這種行為大多數情況下會保留插入順序。

有些語言選擇讓雜湊表按照使用者的預期行為進行——依照添加順序遍歷鍵,而另一些語言則選擇每次遍歷時返回不同順序的鍵。在後者情況下,部分使用者便抱怨這樣的行為不夠隨機。

悲哀的是,程式中的任何隨機性最終會被用於統計模擬,或更糟的是,用於加密,而任何排序的來源則會被用於排序操作。

在資料庫中,有些識別碼包含的資訊比其他的更多。當創建資料表時,開發者可以選擇不同類型的主鍵。正確的選擇是 UUID 或與 UUID 相似的東西。其他選擇的問題在於,它們除了身份外,還暴露了排序資訊,即不僅能知道 a == b,還能知道 a <= b,而這些其他選擇指的就是自增主鍵。

自增主鍵是資料庫為每一行分配數字,在插入新行時會增加 1。這會產生一種模糊性:人們不知道哪部分資料是「權威的」。換句話說:你是按主鍵排序,還是按時間戳排序?正如之前對於雜湊表的情況,人們會自行決定正確答案。另一個問題是,使用者很容易猜出附近記錄的其他鍵值。

最終,任何試圖比 UUID 更聰明的做法都會適得其反:我們曾經嘗試過郵政編碼、電話號碼和 IP 位址,並且每次都以慘敗告終。UUID 也許不會讓你的程式碼更容易除錯,但較少的意外行為往往意味著較少的錯誤。

排序並不是唯一會從鍵中提取的資訊:如果你創建的是由其他欄位構成的資料庫鍵,使用者將會拋棄原始資料,僅從鍵中重建。現在你有兩個問題:當程式狀態被存儲在多個地方時,副本之間很容易產生不一致。如果你不確定該修改哪一個,或者不確定自己已經修改了哪一個,那麼保持同步會變得更加困難。

無論你允許使用者做什麼,他們都會去實現。撰寫易於除錯的程式碼是對它可能被濫用的方式進行前瞻性思考,並考慮其他人如何與其互動。

規則 5:除錯是社交的,先於技術的

當一個軟體專案分散於多個組件和系統時,發現錯誤會變得更加困難。一旦你理解了問題的發生原因,可能需要在多個部分協調更改才能修正行為。修復大型專案中的錯誤,更多的是關於說服其他人相信這些錯誤是實際存在的,甚至說服他們修復是可能的。

錯誤在軟體中持續存在,因為沒有人能完全確定誰對哪些部分負責。換句話說,當一切都沒有書面記錄、所有問題都必須在 Slack 上詢問,而且直到唯一知道答案的人登入之前,什麼問題都無法解決,那麼程式碼的除錯就變得更加困難。

計劃、工具、流程和文檔是解決這些問題的途徑。

計劃是如何移除待命的壓力,建立結構來處理事件。計劃是如何保持客戶通知,當人員待命過久時更換人手,並跟蹤問題以進行變更以降低未來風險。工具是將工作簡化並使其對他人可用的方法。流程是如何將控制從個人轉交給團隊的方式。

人員會變動,互動也會變,但隨著時間推移,團隊會繼續沿用這些流程和工具。問題不在於某一方是否比另一方更重要,而是如何構建一種支持彼此變化的方式。流程也可以將控制移出團隊,所以它並非總是好或壞,但總有某種流程在運作,即使它沒有書面記錄,記錄下來的行為是讓其他人改變它的第一步。

文檔不僅僅是文字檔:文檔是如何交接責任,如何讓新成員快速了解情況,如何將變更傳達給受影響的人。撰寫文檔需要比撰寫程式碼更多的同理心,並且需要更多的技能:沒有簡單的編譯器標誌或型別檢查器,寫下大量文字卻無實際內容也很容易。

如果沒有文檔,怎麼能指望使用者做出明智的決策,或同意使用軟體的後果呢?如果沒有文檔、工具或流程,你無法分擔維護負擔,甚至無法替換當前被賦予這項任務的人。

讓程式碼易於除錯同樣適用於圍繞程式碼的流程,讓程式碼變得清晰,知道為了修正程式碼你需要踩到哪些人的腳。

易於除錯的程式碼也容易解釋。
在除錯過程中,常見的情況是當你解釋問題給別人聽時,自己會意識到問題所在。對方甚至不必存在,但你確實需要強迫自己從頭開始解釋情況、問題及重現步驟,這樣的框架往往足以讓我們洞察答案。

如果可以的話。有時候,當我們尋求幫助時,我們並未詢問正確的問題,這也是我常犯的錯誤——這是如此常見的困擾,以至於有個名字:“X-Y 問題”:我該如何獲取檔案名稱的最後三個字母?哦?不,我是指檔案副檔名。

我們會根據自己理解的解決方案來談論問題,並根據已知的後果來談論解決方案。除錯是一種艱難的學習過程,學會面對意外後果和替代方案,並承認自己錯誤,這是工程師最難做到的事之一:承認自己搞錯了。

畢竟,這並不是編譯器的錯。

ZSL
作者
ZSL
正事不做。

相關文章

Ultimate Numba Guide Speed Up Python Numerical Computation
嘔心瀝血超過萬字的終極 Numba 教學指南,絕對是你在中文圈能找到的最好教學。