Assertion 焦慮

大多數 production codebase 可以分成兩大陣營。A 陣營把 assert 當成裝飾調味料,每隔幾行就撒上一點,直到 function 讀起來像偏執律師寫的法律合約。B 陣營把 assertion 當成只在開發階段用的輔助輪,在 build 時全部拿掉,然後祈禱程式在 production 能正常運作,只因為測試曾經通過過一次。

兩個陣營都錯了。問題不是該不該寫 assertion。問題是 assertion 到底代表什麼。

Assertion 不是錯誤處理。它不是輸入驗證。它不是一個委婉的建議。Assertion 是在主張某件事不可能發生。 如果 assertion 觸發了,代表你對這支程式的心智模型已經崩壞。正是這個區別,決定了 assertion 該放在哪裡、以及你該寫多少個。

Assertion 是為了 Invariant,不是為了 Error

當使用者傳了一個負數的年齡給你的 API,那是一個 error。Error 是可預期的。Error 值得被正式處理、記錄下來,並給使用者一個可讀的訊息。當你的內部計算在一次看似成功的查詢後,得出負數的資料庫列數,那就是 invariant violation。那件事絕對不該發生。那才是 assertion 存在的意義。

這聽起來很顯而易見,直到你讀到 production code。我曾看過 function 先 assert 字串非空,三行後又檢查 if (!str) 並拋出一個格式化過的例外。開發者對同一個條件同時用了兩種工具,只因為他從未決定哪一個才是真正的 contract。

這裡有一條規則。如果這個條件可以被外部輸入觸發,它就不是 assertion。 如果它只能被你自己程式裡的 bug 觸發,那它就是。

def process_payment(user_id: str, amount_cents: int) -> Receipt:
    # NOT an assertion. Users or upstream services can send bad data.
    if amount_cents <= 0:
        raise ValueError("amount_cents must be positive")

    # NOT an assertion. The user_id comes from the outside world.
    if not user_id:
        raise ValueError("user_id is required")

    receipt = _charge_card(user_id, amount_cents)

    # THIS is an assertion. If charge_card returned None after
    # succeeding, our understanding of the universe is wrong.
    assert receipt is not None, "charge_card succeeded but returned None"

    # THIS is an assertion. A receipt with zero items after a
    # successful charge means our internal logic is broken.
    assert len(receipt.items) > 0, "receipt has no items after successful charge"

    return receipt

前兩個檢查守的是 boundary。後兩個守的是系統的內部一致性。混用這兩者會讓人搞不清楚誰該負責什麼。

三個 Assertion 的上限

如果你發現自己在單一 function 裡寫了超過三個 assertion,那你只有兩種問題。要不是這個 function 做了太多事,就是你的 invariant 太模糊,根本無法強制執行。

一個有十二個 assertion 的 function 不是防禦性強。它是沒有把握。作者不信任呼叫它的程式碼、不信任它所呼叫的程式碼、也不信任它們之間流動的資料。這種不確定性應該透過 refactor 來解決,而不是繼續加更多 assert

實際的限制來自開發者腦袋能裝多少東西。一個 function 應該只有一個清楚的 contract。這個 contract 暗示了一小組 invariant。如果你需要一打 assertion 才能感到安全,那你的 function 大概已經吸收了本該屬於其他地方的責任。

拆分這個 function。把負責資料轉換的部分抽出來。把呼叫外部服務的部分抽出來。讓每個抽出的 function 都擁有自己一小組 invariant。一個 function 裡有三個 assertion 是警示燈。五個就是爆胎。

生產環境中的 Assertion:開或關?

不同語言做了不同選擇。Python 在你使用 -O 旗標執行時會把 assert 語句移除。C 和 C++ 編譯器也經常在 release build 中移除 assertion。JavaScript 根本沒有內建的 assert。你要麼自己 polyfill,要麼使用在 production 中仍保持運作的函式庫。

這帶來了一個真正的兩難。如果你拿掉 assertion,你會在最需要安全網的時候失去它。那些只在 production 出現的 bug 會默默地腐蝕資料,而不是快速失敗。如果你保留它們,你冒著讓 production 程序因為一個理論上不可能、但實際上並非致命的情境而崩潰的風險。

答案取決於繼續執行的代價。如果違反 invariant 代表下一個操作會腐蝕資料庫或洩漏敏感資料,那這個 assertion 就應該讓程序崩潰。 強制停止總比默默被攻破來得好。如果違反 invariant 只代表一筆稍微錯誤的日誌紀錄或一個小 UI 瑕疵,那就記錄下來然後繼續跑。

// This should probably crash. Continuing with a null user
// after auth succeeded is a security hole waiting to happen.
assert(user !== null, "auth middleware returned null user after success");

// This should probably not crash. A stale cache timestamp
// is annoying but not dangerous.
if (cache.timestamp > Date.now()) {
  logger.warn("cache timestamp is in the future, ignoring");
}

不是每個 invariant 都值得用同樣的姿態對待。學會區分「這件事必須停下來」和「這件事很詭異但還撐得過去」。

我們試過但沒成功的方法

在某個專案的早期,我們嘗試對每個 function 的前置條件都寫 assertion。每個引數都檢查 null、型別、範圍和格式。結果可想而知。測試漂亮地通過。Production 在第三方 API 把某個欄位從數字變成字串時第一次崩潰。

問題不在 assertion 本身。問題在於我們對不在控制範圍內的資料寫了 assertion,然後在 production 啟用了 assertion 來編譯。一個格式錯誤的外部回應殺死了我們的程序,而不是被清理並妥善處理。我們打造了一個內部一致、但外部脆弱的系統。

我們學會了把 boundary 和內部區分開來。在 boundary 上,積極地 parse 和 validate。把外部的混亂轉換成內部的確定性。在 boundary 內部,assert 那些定義這份確定性的 invariant。Assertion 留了下來。輸入驗證則搬到明確的 parsing function,那些 function 回傳 Result 型別,而不是直接拋出例外。

實用檢查清單

在你加入一個 assertion 之前,先跑過這份清單:

  1. 這個條件可以被外部輸入觸發嗎? 如果是,請用 validation,而不是 assertion。
  2. 如果這個 assertion 在 production 觸發了,程序應該停止嗎? 如果不該,請改記錄警告。
  3. 這個 function 裡是否已經有三個或更多 assertion? 如果是,請在加入下一個之前先考慮 refactor。
  4. 六個月後有人讀到這段程式碼時,這個 assertion 仍然說得通嗎? 模糊的 assertion 會在 refactor 時被刪掉。清楚的才能留下來。

Assertion 既是溝通工具,也是安全工具。它告訴下一位開發者:「這個條件在設計上不可能發生。」如果這個條件實際上在設計上並非不可能,那這個 assertion 就是在說謊。而 production code 裡的謊言,代價是很高昂的。

FAQ

我該對 function 的引數寫 assertion 嗎?

只有當呼叫者也是你的程式碼,而且引數是內部邏輯的產物、不是外部輸入時。Public API function 應該做 validate。Private helper function 可以對它收到的數值 assert invariant。

那 TypeScript 呢?它已經在編譯時就抓得到 null 了。

TypeScript 的型別系統是一層強大的 assertion layer,但它在執行時就消失了。請把它用在編譯器能證明的所有地方。對那些缺口加入執行時 assertion:API 回應、反序列化後的資料、以及任何繞過型別檢查器的 as cast。

Assertion 會影響效能嗎?

在大多數語言中,一個擺對地方的 assertion 只耗費幾微秒。如果你在處理數百萬筆資料的 tight loop 裡寫 assertion,請把 assertion 移到迴圈外面。檢查batch 資料上的 invariant,而不是每一個元素。

我該寫自訂的 assert function 嗎?

只有當內建的 assertion 訊息幫不上忙的時候。一個會印出實際陣列長度的自訂 assertNonEmpty,會比一個只會當機、沒有任何脈絡的泛用 assert len(items) > 0 更有用。保持它們的簡潔。不要打造一個 assertion framework。