什麼是軟體開發的成本?

當我們談論軟體開發的「成本」,大多數人想到的是開發時間:「這個功能需要多少工時?」

但這只是冰山一角。

顯性成本 vs 隱性成本

成本類型例子容易被看見?
開發時間寫程式碼、除錯
維護成本修改 11 處重複程式碼
修復成本自訂實作引入 bug 後的 hotfix
失敗成本任務失敗後的重試和浪費
基礎設施債務缺乏可觀測性導致的除錯時間
設計決策的長期代價選擇了不適當的清理頻率

隱性成本的特點是:決策當下看不見,但會在未來反覆出現。

成本思維的核心問題

每次做技術決策時,問自己:

這個決策的「總成本」是多少?不只是現在的開發成本,還包括未來的維護、修復、擴展成本。

這就是成本思維的本質:把時間軸拉長來評估決策。

重新造輪子的真實成本

一個看似合理的決策

假設你需要一個「延遲建立檔案」的日誌 Handler – 只有在真正寫入日誌時才建立檔案,避免產生空的日誌檔。

你可能會這樣想:「標準庫的 FileHandler 不支援延遲建立,我自己寫一個。」

 1# 自訂實作(看似合理,實則隱藏成本)
 2class LazyFileHandler(logging.FileHandler):
 3    """延遲建立檔案的 Handler"""
 4    def __init__(self, filename, mode='a', encoding=None):
 5        self.filename = filename
 6        self.mode = mode
 7        self._file_created = False
 8        # 不呼叫 super().__init__() 以避免建立檔案
 9        logging.Handler.__init__(self)
10
11    def emit(self, record):
12        if not self._file_created:
13            os.makedirs(os.path.dirname(self.filename), exist_ok=True)
14            self._file_created = True
15        super().emit(record)
16        # AttributeError: 'LazyFileHandler' has no attribute 'stream'

隱藏的成本鏈

這段程式碼引發了一連串的成本:

11. 開發成本:寫自訂類別          ~30 分鐘
22. 除錯成本:追蹤 AttributeError  ~1 小時
33. 修復成本:派發 hotfix 任務      ~2 小時
44. 驗證成本:確認修復後無迴歸      ~30 分鐘
5─────────────────────────────────
6   總成本:~4 小時

標準庫方案

1# 一行解決
2handler = logging.FileHandler(filename, delay=True)
3# delay=True:延遲到第一次 emit 時才建立檔案
4# Python 3.0 就已存在,經過 15+ 年的穩定性驗證

開發成本:約 1 分鐘。維護成本:零。修復成本:零。

成本對比

維度自訂 LazyFileHandler標準庫 delay=True
開發時間30 分鐘1 分鐘
程式碼行數20+ 行1 行
測試需求需要自行測試標準庫已驗證
Bug 風險高(跳過 super 初始化)極低
維護成本需要持續維護
總成本~4 小時~1 分鐘

教訓:在寫任何自訂實作之前,先花 5 分鐘搜尋標準庫。這 5 分鐘的投資,可能節省數小時的維護和除錯成本。

重複程式碼的累積成本

從 1 處到 11 處

一個簡單的函式,從 stdin 讀取 JSON:

1# 這段程式碼出現在 11 個 Hook 檔案中
2def read_json_from_stdin():
3    import sys, json
4    try:
5        return json.loads(sys.stdin.read())
6    except Exception:
7        return {}

當它只出現在 1 個檔案中時,問題不大。但隨著 Hook 數量增加,這段程式碼被複製到了 11 個檔案。

累積成本的計算

假設有一天你需要修改這個函式的行為(例如加入錯誤日誌記錄):

 1# 修改後的版本
 2def read_json_from_stdin():
 3    import sys, json, logging
 4    logger = logging.getLogger(__name__)
 5    try:
 6        data = sys.stdin.read()
 7        return json.loads(data)
 8    except json.JSONDecodeError as e:
 9        logger.warning("stdin JSON 解析失敗: %s", e)
10        return {}
11    except Exception as e:
12        logger.error("stdin 讀取異常: %s", e)
13        return {}
維度1 份程式碼11 份重複
修改次數111
測試次數111
遺漏風險0%~20%(經驗值)
行為不一致風險
程式碼審查成本

指數增長的維護成本

重複程式碼的成本隨著時間呈指數增長:

1第 1 次修改:11 處 x 5 分鐘 = 55 分鐘
2第 2 次修改:11 處 x 5 分鐘 + 排查第 1 次遺漏的 bug = 75 分鐘
3第 3 次修改:11 處 x 5 分鐘 + 排查前兩次的行為不一致 = 120 分鐘
4...

每次遺漏一處修改,就會引入一個「行為不一致」的隱性 bug。這些 bug 不會立即爆發,而是在某個不相關的除錯過程中突然出現,讓你花數小時追蹤一個「不應該存在」的問題。

正確做法:提前提取

 1# lib/hook_io.py(共用模組)
 2def read_json_from_stdin() -> dict:
 3    """
 4    從 stdin 讀取 JSON 資料。
 5
 6    Returns:
 7        解析後的字典,失敗時返回空字典
 8    """
 9    import sys, json, logging
10    logger = logging.getLogger(__name__)
11    try:
12        data = sys.stdin.read()
13        return json.loads(data)
14    except json.JSONDecodeError as e:
15        logger.warning("stdin JSON 解析失敗: %s", e)
16        return {}
17    except Exception as e:
18        logger.error("stdin 讀取異常: %s", e)
19        return {}
1# 每個 Hook 檔案中
2from lib.hook_io import read_json_from_stdin
3
4input_data = read_json_from_stdin()

修改 1 處,所有 11 個 Hook 自動生效。

教訓:DRY 不只是「不要重複自己」的美學追求,而是一個成本控制策略。重複程式碼的維護成本會隨時間加速增長。

可觀測性:看不見的基礎設施

一個真實的場景

想像一個有 20 個 Hook 的系統,某天你發現有 7 個 Hook 靜默失敗了 – 沒有錯誤訊息,沒有日誌,就是安靜地不做事。而且這個情況已經持續了至少 2 個 session(數小時)。

你怎麼發現的?靠偶然的手動檢查,監控系統沒有抓到。

為什麼會靜默失敗?

1# 「安全」的錯誤處理(實際上是最危險的)
2def run_hook_safely(hook_func):
3    try:
4        hook_func()
5    except Exception as e:
6        # 只寫入檔案日誌,不通知任何人
7        log_to_file(f"Hook 失敗: {e}")

這段程式碼的意圖是「不要讓 Hook 失敗影響主流程」。但它的副作用是:你完全不知道 Hook 有沒有在正常運作。

沒有可觀測性的除錯成本

當問題最終被發現時,除錯過程是這樣的:

11. 發現問題            0 分鐘(偶然發現,否則可能更久)
22. 確認哪些 Hook 失敗    30 分鐘(需要手動逐一檢查)
33. 找到失敗原因          2 小時(沒有日誌可看,只能猜測)
44. 修復失敗的 Hook       1 小時
55. 驗證修復效果          30 分鐘
66. 確認沒有其他受影響的部分  1 小時
7─────────────────────────
8   總成本:~5 小時(且可能仍有遺漏)

有可觀測性的除錯成本

如果一開始就投資可觀測性基礎設施:

1def run_hook_safely(hook_func, hook_name: str):
2    try:
3        hook_func()
4    except Exception as e:
5        # 寫入檔案日誌(完整追蹤)
6        log_to_file(f"Hook 失敗: {e}", traceback=True)
7        # 輸出到 stderr(確保使用者可見)
8        print(f"[Hook Error] {hook_name}: {e}", file=sys.stderr)

除錯過程變成:

11. 發現問題           0 分鐘(stderr 立即可見)
22. 確認失敗原因        5 分鐘(日誌有完整的 traceback)
33. 修復失敗的 Hook     30 分鐘
44. 驗證修復效果        10 分鐘
5─────────────────────────
6   總成本:~45 分鐘

投資回報分析

維度無可觀測性有可觀測性
前期投資0 小時~8 小時(建設日誌架構)
每次除錯~5 小時~45 分鐘
3 次事故後總成本15 小時8 + 2.25 = 10.25 小時
5 次事故後總成本25 小時8 + 3.75 = 11.75 小時
問題發現延遲數小時到數天即時

只要遇到 3 次以上的事故,可觀測性投資就開始回本。而在任何有一定規模的系統中,問題出現 3 次幾乎是必然的。

教訓:可觀測性是「看不見的基礎設施」。它的缺失不會直接造成 bug,但會讓每個 bug 的修復成本倍增。

系統設計中的頻率取捨

問題背景

一個 Hook 系統每次執行都會產生日誌檔案。隨著時間累積,過期的日誌需要被清理。問題是:多久清理一次?

三種方案的成本比較

 1# 方案 A:每次都清理
 2def run_hook():
 3    execute_hook_logic()
 4    cleanup_old_logs()  # 每次 Hook 執行後都清理
 5
 6# 方案 B:每 N 次清理一次
 7LOG_CLEANUP_TRIGGER_FREQUENCY = 10
 8
 9def run_hook():
10    execute_hook_logic()
11    state["execution_count"] += 1
12    if state["execution_count"] % LOG_CLEANUP_TRIGGER_FREQUENCY == 0:
13        cleanup_old_logs()
14
15# 方案 C:外部排程清理
16# 由 cron job 或系統排程器負責
17# Hook 本身不做任何清理
維度方案 A:每次清理方案 B:每 N 次方案 C:外部排程
I/O 成本高(每次都掃描目錄)低(每 10 次一次)零(Hook 無關)
精確度高(即時清理)中(最多延遲 10 次)高(可設定精確排程)
複雜度中(需要計數器)高(需要外部依賴)
對 Hook 效能影響有(每次增加 I/O)
維護成本中(需維護排程設定)

決策依據:找到平衡點

方案 B 被選中,原因是:

  1. I/O 成本可控 – 每 10 次才觸發一次,對效能影響極小
  2. 精確度可接受 – 日誌多存留幾次不是關鍵問題
  3. 零外部依賴 – 不需要額外的 cron 配置
  4. 實作簡單 – 一個計數器加一個 if 判斷
 1LOG_CLEANUP_TRIGGER_FREQUENCY = 10
 2
 3def maybe_cleanup_logs(execution_count: int, log_dir: Path) -> None:
 4    """
 5    根據執行次數決定是否清理舊日誌。
 6
 7    每 LOG_CLEANUP_TRIGGER_FREQUENCY 次觸發一次清理,
 8    在精確度和 I/O 成本之間取得平衡。
 9    """
10    if execution_count % LOG_CLEANUP_TRIGGER_FREQUENCY != 0:
11        return
12    cleanup_old_logs(log_dir)

教訓:「最佳方案」不存在,只有「在當前限制條件下成本最低的方案」。頻率問題的本質是精確度和成本之間的取捨。

失敗的成本

預驗證 vs 失敗重試

在派發任務之前,有一個關鍵的成本決策:是否先驗證任務的可行性?

 1# 方案 A:直接執行,失敗再處理
 2def dispatch_task(task):
 3    try:
 4        result = execute(task)  # 消耗資源
 5    except PermissionError:
 6        # 失敗了,資源已經浪費
 7        log("任務失敗:權限不足")
 8        return None
 9
10# 方案 B:預先驗證
11def dispatch_task(task):
12    if not has_required_permissions(task):
13        log("跳過:權限不足")
14        return None
15    result = execute(task)  # 確認可行才消耗資源

真實場景

兩個探索任務被派發去存取跨專案的資源,但都因為權限限制而失敗。每個任務各消耗了大量運算資源,但結果為零 – 完全浪費。

如果在派發前花 1 分鐘確認權限,就能避免這些浪費。

預驗證的成本公式

1預驗證成本 = 驗證時間 x 每次派發
2失敗成本 = 任務執行時間 x 失敗機率
3
4當 失敗成本 > 預驗證成本 時,預驗證是值得的
場景預驗證成本失敗成本建議
快速本地操作高(相對於操作本身)不需預驗證
耗時遠端操作低(相對於操作本身)必須預驗證
高失敗率操作必須預驗證
低失敗率操作視情況而定

教訓:失敗不是免費的。每次失敗都消耗了資源、時間和注意力。預驗證是一種「用小成本避免大浪費」的投資。

歸納:成本思維的核心原則

原則一:計算總成本,不只是開發成本

1總成本 = 開發成本 + 維護成本 + 修復成本 + 機會成本

一個「快速完成」的方案,如果未來每次修改都要花 3 倍時間,那它其實是最昂貴的方案。

原則二:重複的成本會指數增長

每一份重複的程式碼都是一顆定時炸彈。它的爆炸威力隨著修改次數和時間而增長。

原則三:先搜尋再建造

在寫任何自訂實作之前,先花 5 分鐘搜尋:

  • 標準庫有沒有這個功能?
  • 專案中有沒有類似的實作?
  • 有沒有經過驗證的第三方方案?

這 5 分鐘的搜尋成本,遠低於自訂實作可能帶來的維護成本。

原則四:可觀測性是必要投資

看不見的問題成本最高。因為:

  • 你不知道它存在(發現成本高)
  • 你不知道它影響多大(評估成本高)
  • 你不知道它什麼時候開始的(追溯成本高)

原則五:找到取捨的平衡點

很少有決策是「A 絕對比 B 好」。更多的情況是:

A 在維度 X 上更好,B 在維度 Y 上更好。

成本思維是在限制條件下找到總成本最低的方案

原則六:失敗有成本,預防是投資

每次失敗都消耗資源。適當的預驗證和防護措施是一種投資 – 用確定的小成本,避免不確定的大損失。

自我檢查清單

做技術決策時,問自己這些問題:

  • 這個方案的維護成本是多少?(不只是開發成本)
  • 標準庫或現有程式碼中有沒有類似的解決方案?
  • 這段程式碼會被複製到其他地方嗎?(DRY 風險)
  • 如果這裡出了問題,我能多快發現?(可觀測性)
  • 這個任務失敗的成本是多少?需要預驗證嗎?
  • 頻率設計是否在精確度和成本之間取得平衡?

小結

成本思維是把時間軸拉長來做決策。

很多「快速」的決策,在長期看來是最昂貴的。而很多看似「多餘」的投資(可觀測性、共用模組、預驗證),在長期看來反而是成本最低的選擇。

軟體開發不只是寫程式碼 – 它是在有限資源下做出無數個取捨決策。理解每個決策的隱性成本,才能做出真正「划算」的選擇。

最便宜的 bug 是那個從未被寫出來的 bug。


延伸閱讀


參考資料

  • McConnell, S. (2004). “Code Complete: A Practical Handbook of Software Construction”
  • Forsgren, N., Humble, J., & Kim, G. (2018). “Accelerate: The Science of Lean Software and DevOps”