關於「認知契合」
認知不契合的結構、語意、行為,往往是臭蟲的根源。
曾經有一回,客戶請我幫一個 Http API 加上 rate limit。那次我很快找到三個差異不大的選項,都可以透過 Ring middleware 加上 rate limit:
然而,無論用哪一個,結果都是:「一加上 rate limit,整個 web application 就不能使用,不停地出錯。」經過一段時間的除錯後,我發現問題出在 Compojure。Compojure 比對 route 的方式,是一個接一個地嘗試比對,所以一旦使用的是 stateful middleware,就很容易發生這種異常情況:「本來不該被加上 rate limit 的 API,卻因為拿去比對 route 而被錯誤地加上了。」
一旦理解 bug 的本質,剩下就簡單了:使用 wrap-routes 這個函式,因為它可以在 routes 被比對之後才套用 middleware。
我認為這種 bug 可以歸因於一種「認知不契合」的現象。在這裡,「認知契合」指的是系統的結構與語意,是否能自然地對齊開發者的心智模型。Ring、Compojure、middleware 都是處理 HTTP request 的解法,多數時候,開發者只需要大致了解它們的語意就能順利開發。但在 rate limiting 的案例裡,開發者忽略了 Compojure 的 route 實作方式與 stateful middleware 之間的衝突,這種語意上的錯認就會導致 bug。
自從那次之後,我一方面會特別留意 middleware 是否為 stateful,另一方面,我會優先選擇那些路由比對與 handler 綁定清楚分離的設計,例如 Reitit,還有總是優先使用 interceptor 式而非 decorator 式的 middleware。
這邊提出「認知契合」的一個定義:
認知契合是指系統的結構、語意與行為,與開發者自然的心智模型高度對齊,從而降低理解與推理的認知負擔,並有效避免錯誤發生。
依照這個定義,認知契合帶來的好處主要有兩個:
減少認知負擔
預防錯誤
這邊特別談一下「預防錯誤」。認知契合可以視為一種對抗錯誤的設計方式。但它和可檢視性有本質上的不同:可檢視性是錯誤已經發生後的「應變性設計」,而認知契合則是一種在錯誤發生前就避免錯誤的「預防性設計」。
接下來,我們來看幾個 Clojure 語言中的例子,實際感受認知契合的威力。
並行原語(Concurrency primitive)
Clojure 的並行原語是一種認知契合的典範。
pmap 就是 parallel map,語意與命名高度一致,幾乎看名稱就知道怎麼使用。
future 也是一個極好的例子:
當我們寫下 (def result (future (do-something))),這句話自然表達了開發者的意圖:「請立即啟動 do-something,但我現在不需要結果,等需要時再來取。」
這種語意上的認知契合,具體表現在幾個層面:
值語意的結果:
future回傳的是一個可以deref的值,開發者不需理解 thread、task、promise 的底層細節,只要知道「這是個未來會有值的東西」,這與心智模型完美對齊。清晰的取值語意:寫下
@result,語意非常明確:「我要取得未來的值,如果還沒算完,就等它算完。」
在許多傳統語言中,例如 Java,開發者得自己創建執行單元、管理 thread lifecycle、處理結果傳遞與共享,這對心智模型造成干擾。明明只是想「在背景做某件事,之後再拿結果」,卻得額外處理 thread pool、race condition 等問題。
with- 開頭的 macro
除了語言的語法設計外,Clojure 生態系常見的一種 macro pattern,也充分展現了認知契合。
像是 with-open, with-out-str, with-redefs 等以 with-開頭的 macro,它們的共通點是:包住一段程式碼,並在進入與離開該區塊時執行特定操作(如資源開關、IO 綁定、設定變動與還原)。
對開發者而言,「我想在這段程式碼前做些設定,結束後自動還原」是非常自然的心智模型。而 with- macro 的命名與語意,恰好與這個模型完全對齊。這不僅減少了需要記住的東西(with- 就是一個清楚提示),也降低了錯誤機率(例如忘記在 finally 裡釋放資源)。
更進一步地,開發者甚至可以根據需求撰寫新的 with- macro,例如 with-temp-file, with-db-transaction 等,延續這種認知模式,讓整體系統設計風格更一致、可維護性與正確性更高。
Java interop
有時候我們需要使用 Java 的大型函式庫,例如要讀寫 Excel 檔案,這時候通常會有好幾個 Java interop 的選項。
多數時候我會遇到選擇困難:「該怎麼選?選擇的標準是什麼?」
但從認知契合的角度來看,這些 library 通常可以分成兩類:
與底層 Java library 設計對齊
與某種 Clojure coding style 對齊
在 Gaiwan,我們常選擇第一類的 library,例如 logback + pedestal.log,或是 Docjure。雖然這類選擇有時需要使用者多花時間學習 Java library 的運作方式,但它們的實作通常較單純,並能提供一致且容易理解的心智模型。
生產力的矛盾
曾經有段時間我懷疑:真的有必要使用 Reitit 與 interceptor 式 middleware 嗎?這些東西的學習成本值得嗎?
經過一些痛苦的除錯經驗,我現在認為——很值得。因為認知契合最大的好處是預防錯誤,只是這件事不容易量化。畢竟,你要怎麼量化「你沒犯的錯」?
總結
當初那個難以察覺的 rate limiting bug,正是因為開發者的心智模型與工具的語意沒有對齊。這類錯誤往往不易發現,也因此,主動追求認知契合,成為我現在設計系統與選擇函式庫的首要原則之一。
當我們在新專案評估 framework、library 或設計模式時,不妨問問自己:
這個介面或語意是否吻合我的直覺?
在什麼情況下可能會被誤用?
同時,我們也可以將認知契合納入開發準則中:
挑選 library:新的 library 是否提供單一、一致的心智模型?
開發模組:模組除了要有深度(deep)之外,更要提供與開發者自然心智模型對齊的 API 語意。
寫文件:在文件中強調心智模型與實作的對應,幫助新成員快速上手。
雖然引入高認知契合的工具或模式需要一些學習成本,但與因語意誤解或隱晦實作所產生的大量除錯時間相比,這筆「預防性成本」通常是值得的。軟體系統的心智契合度,雖然是一種隱性品質,卻是降低錯誤、提升生產力的關鍵。



我記得stateful middleware是anti-pattern