iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Software Development

AI時代下的軟體工程系列 第 8 篇

Day8: Linter:程式能跑,不代表它沒問題

  • 分享至 

  • xImage
  •  

What is Linter

lint 的本意是棉絮、纖維屑。名字來自 1978 年 Bell Labs 的 C 語言工具 lint,取的是「把程式裡的毛屑挑掉」這個比喻——那些不至於讓編譯失敗、但看起來不太對勁的細碎問題。

關鍵在「不至於讓編譯失敗」。看一段 Python:

def get_config(overrides={}):
    overrides["loaded_at"] = time.time()
    return overrides

語法完全合法,第一次呼叫也會得到預期的結果。但 Python 的 default argument 只在函式定義時求值一次,這個 dict 會在每次呼叫之間共用——第二次呼叫時,裡面還留著上一次寫進去的東西。

這是一個能跑、能通過編譯、甚至可能通過測試的 bug。

但讓 linter 抓到它的理由其實很笨:這個陷阱有固定的形狀。太多人踩過「default argument 寫成 {} 或 []」這個坑,於是它被登記成一條規則——看到 default argument 是可變的 literal,就報一次。工具並不知道這個 dict 之後會被共用,它只是在比對程式碼長什麼樣子。

linter 的本質就是這件事:把前人踩過的坑、團隊談定的約定,寫成可以自動比對的形狀。

Why we need linter

要講清楚 linter 的價值,最快的方式是看它跟其他工具的分工:

  • compiler / 直譯器:這段程式能不能跑。
  • test:這段程式跑起來對不對。
  • linter:這段程式有沒有違反我們事先訂好的規則。

前兩項回答的是關於這段程式的事實,第三項不是——linter 做的事是拿程式碼去比對一份規則清單。換句話說,linter 能檢查的上限,就是有沒有人把那條規則寫出來。 它不是一個更聰明的 compiler,而是一本查得很快的前人經驗表。

這也正是它跟 static analysis 的差別。linter 看的是程式碼長什麼樣子,形狀對上某條規則就報;static analysis 則會去推論程式跑起來會怎樣——這個變數在某條分支上可能是 None、這塊記憶體可能沒被釋放、這段程式碼永遠不會被執行到。前者只要掃過語法樹就能給答案,所以快到可以掛在存檔時跑;後者要沿著所有可能路徑推,通常只放在 CI。型別檢查也不歸 linter 管,那是 mypy、tsc 的工作。

這個定位跟昨天的 formatter 是同一件事的兩面。formatter 處理排版上的約定,linter 處理寫法上的約定——「不要用 except: 接所有例外」、「這個 subprocess 不要開 shell=True」這種在 code review 裡會一講再講的意見。差別在於 formatter 可以直接把答案改好,linter 多數時候只能指出位置,改法還是要人判斷。

常見 Linter 檢查的方向

現在的 linter 包山包海,這是有原因的。以 ruff 為例,它不是從零設計一套規則,而是把社群裡幾十個既有工具的規則——flake8、isort、pylint、bandit——全部收進同一個執行檔,每條規則保留原本出處的代號前綴。

它們被同一個工具執行,但來自不同的問題意識:

方向 在檢查什麼 ruff 規則範例
Correctness 合法、但幾乎必然是 bug 的寫法 B006 mutable default argument
Security 可能造成安全風險的用法 S602 subprocess 使用 shell=True
Performance 有明顯更好寫法的效能問題 PERF401 迴圈 append 可改成 comprehension
Maintainability 能跑,但之後會難改 PLR0913 參數過多、C901 分支複雜度過高
Convention 純粹的約定,跟對錯無關 I001 import 未排序、N802 命名不符 snake_case

這張表由上往下,確定性在遞減。B006 指出的幾乎一定是問題;但 PLR0913 說你的函式有 6 個參數,可能代表設計有問題,也可能這個函式本來就需要 6 個參數;到了 I001,就完全只是團隊選了哪一種排序而已。

所以 linter 的嚴格程度不是工具的性質,是你的選擇——規則一條一條開關,寫在專案的設定檔裡,拿pyproject.toml為例子:

[tool.ruff.lint]
select = ["E", "F", "B", "S"]   # 開啟:基本錯誤、Pyflakes、bugbear、安全性
ignore = ["E501"]                # 關閉:行長度(交給 formatter 處理)

常見的策略是:Correctness 與 Security 幾乎不該關,往下則依團隊的容忍度遞減。 單一檔案裡真的有例外,可以用抑制註解跳過(# noqa: B006)。這是必要的逃生門,但也是最容易被濫用的東西。

最後補一條昨天留下的線:ruff、eslint、clang-tidy 都提供 autofix,但 linter 的 autofix 不保證維持程式行為。 formatter 只動空白與換行,結果必然等價;linter 動的是程式本身。ruff 因此把 fix 分成 safe 與 unsafe 兩級,unsafe 的預設不套用——因為有些「修正」會改變語義,工具自己也知道。跑完要看 diff。

linter 是如何幫助 AI 的

linter 給 coding agent 的東西,跟 formatter 完全相反。formatter 給的是不用看的東西,它把風格差異從 diff 裡抹掉;linter 給的則是可以讀的訊號:

src/config.py:12:20: B006 Do not use mutable data structures for argument defaults
help: Replace with `None`; initialize within function

位置、規則代號、問題描述、修改方向都在裡面。這本來是設計給人看的格式,但它剛好也是 agent 最好消化的形式——明確、可定位、每一條獨立。

把這件事跟 prompt 對照就清楚了。你可以在 AGENT.md 裡寫「不要使用 mutable default argument」,但那要靠 agent 每次生成時都記得並正確套用;linter 則是在它寫完之後,給出一個指到第 12 行第 20 個字元的確定訊號。前者是提醒,後者是驗證。

這讓 agent 有機會拿到一個它自己就能跑、自己就能讀的檢查迴圈,不需要你在中間轉述:

寫 code → 跑 linter 拿到feedback → 依 feedback 寫 code → 跑 linter 拿到feedback → ...

至於怎麼讓 agent 真的跑起這個迴圈,等 test、CI 這些工具都講完,再一次談。

有兩件事要先提醒:

  • 選 rule set 就是在選 agent 會收到的 feedback。規則開太多,agent 每輪都被 convention 類的警告淹沒,把回合數花在排序 import 上;開太少,真正的問題它也看不到。前面那張表的順序可以直接當成優先級。

  • 注意 agent 走捷徑。消掉一條 warning 最省事的方法不是修好它,而是加一行 # noqa。這對 agent 尤其有吸引力,因為抑制註解一定成功,修改邏輯則可能失敗。設定檔裡要明講:修問題本身;真的需要例外時,要在註解裡寫清楚理由。

最後是成本。既有專案第一次跑 linter,拿到幾百上千條 warning 很正常,而且每一條都需要判斷,沒辦法像 formatter 那樣整批接受。實務上是先只開最確定的那幾類,把數字降到可以處理的範圍再逐步往下開;有些工具也支援建立 baseline,只擋新增的部分。


到這裡,formatter 確保了風格一致,linter 指出了可疑的寫法。但這兩個工具有一個共同的限制:它們都只讀程式碼本身,不會真的執行你的程式。

get_config 那個 bug 之所以被抓到,是因為它剛好長成一個已知的、可以用規則描述的形狀。而絕大多數的邏輯錯誤沒有固定形狀——它算錯了一個數字、少處理了一個邊界條件,程式碼看起來完全正常。

這種問題只有一種方法能抓到:真的跑跑看。明天來談 test。


上一篇
Day7: Formatter:一鍵讓 codebase 變得整潔
下一篇
Day9: Type Checker:檢查是否有矛盾的地方
系列文
AI時代下的軟體工程 共 12 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言