iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Software Development

AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護系列 第 7

Day 7|AI 把函式拆得越小,就越符合 Clean Code 嗎?三種拆分方式實測

  • 分享至 

  • xImage
  •  

安安~我是ChiYu~

昨天,我們替 Work Item 逾期流程保留兩則有測試依據的警告註解,也確認 Formatter 只能整理已設定的格式規則。

註解沒有逐行翻譯 Code,排版也整理好了。可是打開 ProcessOverdue,查詢、迴圈、三種統計、狀態修改、通知、儲存與 HTTP Response,仍然擠在同一條流程裡。

這時,很容易直接丟給 AI 一句:

幫我把這個函式拆小一點,每個函式只做一件事。

這句 Prompt 對準了「小函式」與「只做一件事」兩個常見印象,Agent 也確實能很快縮短入口方法。

結果是,兩份拆分候選的入口同樣只有 15 行,Cyclomatic Complexity 與 Cognitive Complexity 也都是 0;其中一份卻把逾期流程拆成 12 個相關方法,另一份只保留 4 個。靜態數字完全一樣,閱讀路徑卻差了一大截。

大前天比較的是「這次重構應該整理多大的範圍」;今天把範圍固定在同一段逾期流程,繼續追問「這個範圍到底應該拆多細」。我要檢查的不是方法數量,而是 AI 把複雜度移到其他方法後,每次跳轉是否進入一項完整責任,還是只把原本的流程切成更多碎片。

Clean Code 用單一意圖與抽象層次判斷函式大小

〈整潔的函式〉與〈只做一件事〉都沒有提供固定行數門檻。只看長度時,Extract Method 很快就能讓入口縮短,卻不一定產生新的責任。Extract Method(擷取方法重構) 是把一段 Code 搬進另一個有名稱的方法,再由原本位置呼叫它。

例如:

private DateTimeOffset GetCurrentUtc() => timeProvider.GetUtcNow();

private static int Increment(int value) => value + 1;

private async Task SaveAsync(CancellationToken cancellationToken) =>
    await database.SaveChangesAsync(cancellationToken);

這三個方法都很短,也都有合理名稱;多一個名稱,不等於多一個值得獨立理解的概念。

GetCurrentUtc 只把原本一眼看得懂的呼叫搬到別處,Increment 則藏起最基本的加一運算。SaveAsync 的呼叫位置仍能看出儲存順序,但讀者必須跳進方法,才能確認裡面只有 SaveChangesAsync,還是另外做了其他事情。

如果一個 Helper 沒有形成獨立責任,這次跳轉就只增加查證成本。

我不會用固定行數判斷函式是否只做一件事。實際審查時,我會檢查下面四件事:

  1. 這段 Code 是否位於同一個抽象層次?
  2. 抽出來之後,是否形成一項可以獨立命名、具有共同變更理由的責任?
  3. 讀者進入新方法後,能不能一次看完那項責任?
  4. 這次跳轉是否排除了目前不必理解的細節,而不只是把一行語法搬走?

「找出到期且未完成的工作項目」是一項完整查詢責任;呼叫端不需要立刻知道 LINQ 條件的每個細節。「把通知嘗試次數加一」則只是批次處理裡的一個簡單步驟,單獨抽出去沒有形成新的業務概念。

函式長度仍是檢查線索,但不能替每一次抽取作決定。

Stepdown Rule 讓主要流程先說完,再逐層展開細節

Stepdown Rule(由上而下展開規則) 要求入口先交代主要流程,再把每個步驟的實作細節放到下一層。讀者可以先讀完整體,需要時才往下追其中一項責任。

用這次的逾期流程來說,入口應該先讓人讀出四件事:

  1. 找出哪些工作項目需要處理。
  2. 處理這批項目並取得摘要。
  3. 儲存狀態。
  4. 回傳 HTTP 結果。

下一層才分別回答:「哪些資料符合逾期資格?」「每一筆如何修改狀態、嘗試通知並累計結果?」

所以,「只做一件事」要連同呼叫關係一起看。入口負責說完高階意圖,被呼叫的方法承接完整細節;若每一層只包住一行語法,Stepdown 就只剩下外觀。

只要求行數與 Complexity,Agent 會優先完成可量化的目標

團隊沒有寫下來的拆分習慣,Agent 無法直接取得。它能依據的,是 Prompt、Repository 規範、附近 Code 與測試。

如果 User 只交代「函式要小」「每個函式只做一件事」,驗收時最容易看到的是三項數字:入口從 40 多行降到 15 行、Complexity 變成 0,以及每個步驟都有名稱。這些結果都很好回報,卻沒有回答每次抽取是否值得。

今天會搭配兩項靜態指標觀察方法結構。Cyclomatic Complexity(循環複雜度) 依條件與控制流程估算獨立執行路徑;Cognitive Complexity 則關心巢狀判斷與流程切換增加多少閱讀負擔。

這兩個數字只能當成檢查訊號。入口得到 0,通常只表示條件與迴圈已經移到其他方法;整條流程的複雜度仍然存在,還可能被分散到更多符號裡。

本次過度拆分候選優先完成了可量化目標,並不是 Agent 無故犯錯。User 要求方法變短,卻沒有定義什麼樣的抽取值得保留,它自然先完成最容易驗收的答案。

Agent 可以承受更高複雜度,不代表函式可以任意變長

Uncle Bob 在近期訪談裡提到,他正在重新校準 Agent 適用的函式門檻。他使用的 CRAP,全名是 Change Risk Anti-Patterns,會把 Cyclomatic Complexity 與 Code Coverage 放在一起,觀察一個方法的改動風險。他原本替人類採用較嚴格的上限,面對 Agent 時則從 4 放寬到 6,並考慮測試 8。

這個說法很有意思,卻不能簡化成「AI 記得比較多,所以長函式已經沒關係」。他調整的是結合複雜度與測試覆蓋的風險門檻,沒有取消單一意圖、抽象層次、內聚與可驗證性,也沒有把行數當成判斷標準。這些數字還是他當時專案裡的校準值,不是所有 Repository 都該照抄的通則。

換回今天的三種情境,兩份拆分候選的入口 Complexity 都是 0,正好顯示單看入口分數會漏掉呼叫鏈與資料搬運。Agent 或許能追蹤比人類更多符號,下一次修改仍要找到正確責任、確認副作用順序,並判斷哪些細節可以安全忽略。

因此我會放寬「形式」,繼續守住「意圖與責任」。門檻若要調整,也要連同 Coverage、測試辨錯能力與後續修改結果一起驗證。

CLEAN 原則

E — Explicit Intent and Boundaries 意圖明確:先定義抽取條件與停止線

E — Explicit Intent and Boundaries 意圖明確 在這篇負責定義抽取條件與停止線。只說「把函式拆小」時,什麼內容值得抽、哪些簡單語法應留在原地,以及何時停止,全都留給 Agent 猜。

這次我不逐一指定 Helper 名稱,而是提供四項可驗收規則:

  • 一組 Code 形成可獨立命名、具有共同變更理由的責任時,才考慮抽取。
  • 同一個方法維持相近的抽象層次。
  • 抽取若只搬移語法、增加參數傳遞或閱讀跳轉,就停止。
  • HTTP 契約、狀態、通知次數與副作用順序不在本次授權範圍內。

這些規則不會替 User 決定固定的方法數量,而是讓每次跳轉都必須說明自己換來了什麼。

為什麼要設計三種拆分情境?

三種拆分情境刻意把成本拉開:單一 Action 暴露 Context 過寬;每個步驟都拆小,暴露跳轉與薄 Helper;Stepdown 則檢查能否在兩者之間保留完整責任。

實驗情境 交給 Agent 的主要方向 想觀察的問題
所有流程留在單一 Action 不建立逾期流程內部 Helper 沒有 Controller 內部方法跳轉時,不同抽象層次會不會擠在一起?
每個可命名步驟都拆小 盡量讓入口只保留高階呼叫 行數與 Complexity 降低後,是否出現大量薄包裝?
依 Stepdown 與共同變更理由拆分 只有形成完整責任才抽取,否則停止 每次跳轉能不能換到一項值得獨立理解的責任?

三次執行固定使用 Codex GPT-5.6-SOL-HIGH、相同起點、行為邊界與修改範圍。唯一用來比較的差異,是 User 提供的拆分方向。

除了比較眼前結構,我還加入一條共同的未來變更壓力:

未來可能需要在通知失敗時,保存失敗的 WorkItemId 供後續處理。

我把這條未來需求當成「可維護性探針」,只檢查合理的第一個修改位置,以及讀者必須沿哪條呼叫路徑,才能同時找到 workItem.Id 與通知失敗結果。本次不實作,也不預先決定 ID 最後要保存在哪裡。

三次正式執行都遵守以下固定條件:

  • 共用起點:Commit 1583af33b5e517530871ff3ee724cdcad4e4c5c0/Tag day-06-comments-formatting
  • 模型:Codex GPT-5.6-SOL-HIGH。
  • 唯一允許修改:src/WorkItems.Api/WorkItemsController.cs
  • 必須保留:HTTP 契約、統計語意、通知次數、重複通知、通知早於儲存,以及通知失敗仍要儲存。
  • 禁止修改:測試、DTO、JSON、Schema、套件、錯誤訊息,或新增 Service、Repository、Interface 與架構層。

每種 Prompt 各執行一次,因此後面的結論只適用這三份候選,不代表模型在其他 Repository 也會產生相同結構。

情境一:完全不拆 Helper,零內部跳轉換來多少混合責任?

第一種情境故意取消逾期流程的內部 Helper,讓查詢、狀態、通知、統計、儲存與回應全部留在 ProcessOverdue

你正在新版 Day 7 函式層級實驗的隔離 Worktree。請先完整閱讀 AGENTS.md、WorkItemsController.ProcessOverdue、ApplyOverdueStatusAndAttemptNotificationAsync、ProcessOverdueBehaviorTests 與 scripts/run-series-baseline-smoke.ps1。

任務:把 WorkItemsController.ProcessOverdue 整理成「單一 Action 直接閱讀」候選。逾期資格查詢、狀態轉換、通知、統計、儲存與回應建立都留在 ProcessOverdue 內,移除並行內化目前的 ApplyOverdueStatusAndAttemptNotificationAsync。不要為逾期流程新增其他私有 Helper、區域函式、結果型別、類別或檔案。這份候選用來觀察零內部跳轉的閱讀成本,不代表預設接受方案。

共同變更壓力:未來可能新增「通知失敗時,保存失敗的 WorkItemId 供後續處理」規則。本次只能評估預期閱讀與修改落點,禁止實作這項規則。

只允許修改 src/WorkItems.Api/WorkItemsController.cs。必須保留既有兩則警告註解並讓它們靠近受約束的 Code。不得修改測試、AGENTS.md、指令碼、套件、Schema、公開 DTO、JSON、HTTP Route、Status Code、錯誤訊息、產品行為、通知次數、統計語意或通知與儲存順序。不得新增 Service、Repository、Interface、Pattern 或架構層。若需要超出範圍,停止並說明。

完成後執行 Repository 規定的 Locked Restore、Release Build、Test、Format、NuGet Vulnerability Audit、HTTP Smoke 與 git diff --check。回報實際 Diff、驗證結果、未來規則的預期修改落點與剩餘風險。不要 Commit。

Agent 移除原本的 ApplyOverdueStatusAndAttemptNotificationAsync,把內容放回 foreach

foreach (var workItem in dueIncompleteWorkItems)
{
    var statusChangedToOverdue = workItem.Status != "Overdue";

    if (statusChangedToOverdue)
    {
        workItem.Status = "Overdue";
    }

    var notificationSucceeded = await notificationGateway.SendOverdueAsync(
        workItem,
        cancellationToken);

    if (statusChangedToOverdue)
    {
        overdueStatusChangeCount++;
    }

    notificationAttemptCount++;

    if (!notificationSucceeded)
    {
        notificationFailureCount++;
    }
}

它的優點很直接:讀者不必離開 ProcessOverdue,就能看到狀態怎麼改、通知何時送出、失敗如何累計。

這裡的 49 行包含方法宣告與大括號,不等於 49 行可執行 Code,數字本身也不是淘汰理由。真正的成本是:時間來源、EF Core 查詢、狀態轉換、外部通知、三項統計、資料儲存與 HTTP Response 都在同一個 Context。

未來若要收集通知失敗 ID,修改位置雖然就在眼前,讀者仍得先看完整條混合流程。如果流程很短、規則穩定,而且只有一個改變理由,單一函式仍可能是最簡單的答案。這份候選在目前 Demo 不採用,不代表所有單一 Action 都不乾淨。

單一 Action 的閱讀路徑

圖:單一 Action 沒有方法跳轉,但查詢、狀態、通知、統計與回應共享同一個閱讀 Context。

情境二:每個步驟都拆成小函式,入口變短卻更難追

第二種情境把「函式要小、每個函式只做一件事」照字面交給 Agent:只要一個步驟可以命名,就允許抽成方法。

你正在新版 Day 7 函式層級實驗的隔離 Worktree。請先完整閱讀 AGENTS.md、WorkItemsController.ProcessOverdue、ApplyOverdueStatusAndAttemptNotificationAsync、ProcessOverdueBehaviorTests 與 scripts/run-series-baseline-smoke.ps1。

任務:依「函式要小、每個函式只做一件事」整理 WorkItemsController.ProcessOverdue。把每個可以獨立命名的步驟抽成私有 Helper,讓 ProcessOverdue 盡量只保留高階呼叫。時間取得、候選查詢、單筆狀態處理、通知、計數、儲存與回應建立都可以拆開;新增內容仍必須留在 WorkItemsController.cs,不得新增其他類別或檔案。這份候選用來觀察持續抽取小函式後的閱讀路徑,不代表預設接受方案。

共同變更壓力:未來可能新增「通知失敗時,保存失敗的 WorkItemId 供後續處理」規則。本次只能評估預期閱讀與修改落點,禁止實作這項規則。

只允許修改 src/WorkItems.Api/WorkItemsController.cs。必須保留既有兩則警告註解並讓它們靠近受約束的 Code。不得修改測試、AGENTS.md、指令碼、套件、Schema、公開 DTO、JSON、HTTP Route、Status Code、錯誤訊息、產品行為、通知次數、統計語意或通知與儲存順序。不得新增 Service、Repository、Interface、Pattern 或架構層。若需要超出範圍,停止並說明。

完成後執行 Repository 規定的 Locked Restore、Release Build、Test、Format、NuGet Vulnerability Audit、HTTP Smoke 與 git diff --check。回報實際 Diff、驗證結果、未來規則的預期修改落點與剩餘風險。不要 Commit。

Agent 把 ProcessOverdue 縮成五個高階呼叫:

public async Task<ActionResult<ProcessOverdueResponse>> ProcessOverdue(
    CancellationToken cancellationToken)
{
    var currentUtc = GetCurrentUtc();
    var dueIncompleteWorkItems = await FindDueIncompleteWorkItemsAsync(currentUtc, cancellationToken);
    var overdueProcessingSummary = await ProcessDueIncompleteWorkItemsAsync(
        dueIncompleteWorkItems,
        cancellationToken);

    await SaveOverdueStatusesAsync(cancellationToken);

    return CreateProcessOverdueResponse(overdueProcessingSummary);
}

入口只有 15 行,Cyclomatic Complexity 與 Cognitive Complexity 都是 0。若驗收只看長度與入口指標,這份候選已經完成任務。

往下追之後,逾期流程擴張成 12 個相關方法,其中幾個薄 Helper 如下:

private DateTimeOffset GetCurrentUtc() => timeProvider.GetUtcNow();

private async Task SaveOverdueStatusesAsync(CancellationToken cancellationToken)
{
    // 通知必須先於狀態儲存;即使通知回報失敗,仍須儲存 Overdue 狀態。
    await database.SaveChangesAsync(cancellationToken);
}

private static int CountOverdueStatusChange(int currentCount, bool statusChangedToOverdue) =>
    statusChangedToOverdue ? currentCount + 1 : currentCount;

private static int CountNotificationAttempt(int currentCount) => currentCount + 1;

private static int CountNotificationFailure(int currentCount, bool notificationSucceeded) =>
    notificationSucceeded ? currentCount : currentCount + 1;

GetCurrentUtc 只把 timeProvider.GetUtcNow() 搬到別處;三個 Count Helper 把原本跟著條件式就能看懂的加一運算切開。SaveOverdueStatusesAsync 的呼叫位置仍能看出儲存發生在批次處理之後,但實際的 SaveChangesAsync 與警告註解被移進另一個方法。

這些 Helper 都有名稱,卻沒有替讀者排除真正複雜的細節。

讀者若要確認通知失敗結果從哪裡來,路徑會變成:

ProcessOverdue
└─ ProcessDueIncompleteWorkItemsAsync
   └─ ProcessDueIncompleteWorkItemAsync
      └─ AttemptOverdueNotificationAsync
         └─ notificationGateway.SendOverdueAsync

本文把讀者從一個方法追到下一個方法的動作稱為一次 method hop(方法跳轉)。這份候選從入口到 Notification Gateway 最深要走 4 次跳轉;找到通知結果後,還得回到批次方法,重新拼出三個 Count Helper 如何累計統計。

這種高階流程、單行語法與批次處理來回切換的閱讀路徑,稱為 abstraction roller coaster(抽象層次雲霄飛車)。Agent 確實完成了 User 提供的目標:方法變短,每個步驟都有名稱。問題出在驗收條件沒有區分完整責任與薄包裝。

微小函式造成的閱讀跳轉

圖:入口很短,確認通知結果卻要連續跨越四層方法;薄 Helper 讓閱讀路徑拉長。

情境三:加入抽取條件與停止線,只保留完整責任

第三種情境同樣要求小函式與 Stepdown Rule,並補上停止條件:抽取若只搬移語法、增加跳轉,沒有形成新概念,就停下來。

你正在新版 Day 7 函式層級實驗的隔離 Worktree。請先完整閱讀 AGENTS.md、WorkItemsController.ProcessOverdue、ApplyOverdueStatusAndAttemptNotificationAsync、ProcessOverdueBehaviorTests 與 scripts/run-series-baseline-smoke.ps1。

任務:依同一抽象層次與 Stepdown Rule 整理 WorkItemsController.ProcessOverdue,讓入口能由上而下讀出逾期流程。只有一組程式碼形成可獨立命名、具有共同變更理由的責任時才抽成私有 Helper;抽取若只搬移語法、增加跳轉,沒有形成新概念,就停止。每個 Helper 也要維持單一抽象層次。所有修改留在 WorkItemsController.cs,不得新增其他類別或檔案。

共同變更壓力:未來可能新增「通知失敗時,保存失敗的 WorkItemId 供後續處理」規則。本次只能評估預期閱讀與修改落點,禁止實作這項規則。

只允許修改 src/WorkItems.Api/WorkItemsController.cs。必須保留既有兩則警告註解並讓它們靠近受約束的 Code。不得修改測試、AGENTS.md、指令碼、套件、Schema、公開 DTO、JSON、HTTP Route、Status Code、錯誤訊息、產品行為、通知次數、統計語意或通知與儲存順序。不得新增 Service、Repository、Interface、Pattern 或架構層。若需要超出範圍,停止並說明。

完成後執行 Repository 規定的 Locked Restore、Release Build、Test、Format、NuGet Vulnerability Audit、HTTP Smoke 與 git diff --check。回報實際 Diff、驗證結果、未來規則的預期修改落點與剩餘風險。不要 Commit。

第三次 Run 沿用相同模型、起點與行為邊界,拆分指令則加入「共同變更理由」與「只搬移語法就停止」。這次產生的入口同樣是 15 行,只新增兩個 Helper:

public async Task<ActionResult<ProcessOverdueResponse>> ProcessOverdue(
    CancellationToken cancellationToken)
{
    var dueIncompleteWorkItems = await FindDueIncompleteWorkItemsAsync(cancellationToken);
    var processingSummary = await ProcessDueIncompleteWorkItemsAsync(
        dueIncompleteWorkItems,
        cancellationToken);

    // 通知必須先於狀態儲存;即使通知回報失敗,仍須儲存 Overdue 狀態。
    await database.SaveChangesAsync(cancellationToken);

    return Ok(processingSummary);
}

入口可以由上而下讀成四步:找候選、處理候選、儲存、回傳。

FindDueIncompleteWorkItemsAsync 把時間來源與 EF Core 條件留在「找出符合資格的工作項目」這項查詢責任裡:

private async Task<List<WorkItem>> FindDueIncompleteWorkItemsAsync(
    CancellationToken cancellationToken)
{
    var currentUtc = timeProvider.GetUtcNow();

    // 不可排除已是 Overdue 的項目;重複執行仍須再次嘗試通知。
    return await database.WorkItems
        .Where(workItem => workItem.Status != "Completed" && workItem.DueAtUtc <= currentUtc)
        .ToListAsync(cancellationToken);
}

ProcessDueIncompleteWorkItemsAsync 則保留迴圈、單筆處理結果與三項統計。未來真的要收集通知失敗 ID,讀者進到這裡就能同時取得 workItem.IdNotificationSucceeded

private async Task<ProcessOverdueResponse> ProcessDueIncompleteWorkItemsAsync(
    IReadOnlyCollection<WorkItem> dueIncompleteWorkItems,
    CancellationToken cancellationToken)
{
    var overdueStatusChangeCount = 0;
    var notificationAttemptCount = 0;
    var notificationFailureCount = 0;

    foreach (var workItem in dueIncompleteWorkItems)
    {
        var overdueProcessingResult = await ApplyOverdueStatusAndAttemptNotificationAsync(
            workItem,
            cancellationToken);

        if (overdueProcessingResult.StatusChangedToOverdue)
        {
            overdueStatusChangeCount++;
        }

        notificationAttemptCount++;

        if (!overdueProcessingResult.NotificationSucceeded)
        {
            notificationFailureCount++;
        }
    }

    return new ProcessOverdueResponse(
        overdueStatusChangeCount,
        notificationAttemptCount,
        notificationFailureCount);
}

Agent 停在這裡,是因為 GetCurrentUtc、Count Helper、Save Helper 與 Response Helper 都只會搬移一行語法,沒有形成值得獨立維護的責任。

Stepdown 只保留完整責任

圖:Stepdown 只抽出具有完整責任的查詢與批次處理,保留高階順序,也避免單行包裝。

三份候選都保留既有行為,結構差異要從閱讀與修改成本判斷

三份候選都通過相同的 Build、Test、格式、套件弱點檢查與 HTTP Smoke。受保護的 HTTP 契約、統計語意、重複通知與副作用順序都沒有漂移。

這個結果只證明三份候選都守住目前已知行為,不能替我們選出最適合的結構。

判斷問題 單一 Action 每個步驟都拆小 Stepdown
入口讀到什麼 查詢、狀態、通知、統計、儲存與回應混在一起 五個高階呼叫,細節分散到多個薄 Helper 找候選、處理候選、儲存、回傳
逾期流程相關方法數,包含入口 1 12 4
入口到通知 Gateway 的最深方法跳轉 1 4 3
未來失敗 ID 規則的第一個合理落點 49 行 Action 裡的失敗分支 批次方法,但還要追查單筆通知與多個結果搬運方法 同時握有 ID 與通知結果的批次責任
主要結構成本 Context 太寬 跳轉與碎片太多 仍有跳轉,但每次都進入完整責任
較適合的情境 短小、穩定、只有一個改變理由的局部流程 這次沒有找到足以支持薄 Helper 的情境 目前這種具有查詢、批次處理與副作用順序的應用流程

兩份拆分候選的入口都是 15 行,兩項入口 Complexity 也都是 0。完整呼叫路徑攤開後,一份有 12 個相關方法,另一份只有 4 個。

這次真正拉開差異的,不是短了幾行,而是每次跳轉後看見什麼。Stepdown 仍然需要跳轉,但讀者每次進去,都能取得一項完整責任;過度拆分版本則常常只看到另一個轉呼叫或一次加一。

主要交由 AI Agent 修改時,三種拆法會增加不同的理解成本

如果後續需求大多交給 AI Agent,函式結構仍然會影響它怎麼找程式碼、重建資料流,以及判斷修改何時可以停止。三份候選都能執行,卻會把下一個 Agent 帶進三條不同的閱讀路徑:

拆分方式 Agent 接到下一次修改時要處理什麼 AI Coding 下的採用條件
單一 Action 在同一段 Code 裡同時辨認查詢、狀態、通知、統計、儲存與回應 流程短、規則穩定,而且只有一個變更理由時保留
每個步驟都拆小 反覆搜尋 Helper、追參數與回傳值,再把被切碎的資料流重新拼回來 只有每個 Helper 都形成完整責任時才成立;本次這種單行包裝不採用
Stepdown 先從入口讀懂流程,再跳進查詢或批次處理等完整責任 多步驟應用流程的目前預設;每次跳轉都必須帶來新的意圖或邊界

因此,AI Coding 下的預設不該是「函式越短越好」,也不能只追求最少跳轉。我的選擇是先讓 Agent 看見高階流程,再用少量、能獨立命名的責任收起細節。這能提供明確的搜尋入口與停止點,又不必為了外觀整齊,把一段流程拆成十幾個薄 Helper。

如果 Repository 已有穩定的 Controller、Use Case、Domain、Gateway 與 Infrastructure 邊界,更多跳轉反而可能幫 Agent 縮小單次任務範圍;前提是每一層真的隔離不同依賴、變更理由或測試方式。沒有這些證據時,新增層次只會增加要讀的檔案。

未來需求的修改落點,比今天的函式行數更接近維護成本

現在回到共同的未來變更壓力:通知失敗時,保存失敗的 WorkItemId 供後續處理。

Stepdown 適合目前 Demo,因為第一個合理修改位置和現有責任一致:批次處理方法同時擁有工作項目與通知結果。

ID 最後要進資料庫、背景佇列、可靠訊息暫存機制或 Response,仍需要新的需求證據;Agent 找到判斷位置後就應停止。

單一 Action 的修改位置也很好找,只是周圍 Context 較大;過度拆分候選則要追過更多方法與資料搬運路徑。

這個比較也再次提醒我:方法長度描述的是今天的外觀,下一項需求落在哪裡,才更接近未來真正要支付的修改成本。

目前 Demo 接受 Stepdown,不代表所有專案都該選它

這次我用五項條件接受 Stepdown 候選:

  • 入口先交代完整流程,不混入查詢條件與計數細節。
  • 每個 Helper 都對應一項可以說清楚的責任。
  • 通知與儲存順序仍能在入口附近看見。
  • 下一項已知變更能先落在持有必要資訊的批次方法。
  • Agent 碰到未知的持久化目的地時會停止,不會提前發明架構。

這五項條件都來自目前的 Work Item API Demo;換成其他流程,結論可能不同。

流程只有幾個簡單步驟、規則長期穩定時,留在單一函式通常更直接。正式專案若已有 Controller、Use Case、Domain、Gateway 與 Infrastructure 等穩定邊界,可以接受更多有意義的跳轉;前提是每一層都隔離不同的變更理由、依賴或測試方式。

這次找不到證據支持的,是為時間取得、計數加一、單行儲存與 Response 包裝各建立一個薄 Helper。這些方法有名字,卻沒有替讀者排除需要暫時忽略的細節。

所以這次 Demo 選擇 Stepdown。專案規模、領域規則或既有邊界改變後,就要重新跑一次相同判斷。

把三種適用情境寫進 Function Structure Policy

Function Structure Policy 是跟著 Repository 保存、讓 Agent 判斷函式要不要拆,以及何時應該停止的共同規則。

我把這次校準結果整理進 Function Structure Policy,讓後續 Agent 能依流程情境判斷每個 Helper 是否值得抽取,不必等待人類逐一指定。現在先在 AGENTS.md 保留 Repository 特有的語言與邊界;等規則穩定後,再把通用的檢查流程整理成 Skill。

## Function Structure Policy

- 不使用固定行數作為函式是否需要拆分的唯一標準;行數與 Complexity 只能作為檢查訊號。
- 流程短、只有一個變更理由,而且狀態、副作用與回傳能在同一段讀完時,可以保留單一函式;不得為了符合 Stepdown 的外觀強迫抽取。
- 一般應用流程優先讓入口維持同一抽象層次,並依 Stepdown Rule 由上而下展開細節。
- 只有一組 Code 形成可獨立命名、具有共同變更理由的責任時,才抽成 Helper。
- Helper 的名稱必須說明呼叫端需要知道的意圖;跳入 Helper 後應能一次讀完該項責任。
- 不為單純的時間取得、欄位讀寫、計數加一、單行儲存或 Response 包裝建立 Helper,除非 Repository 已存在相同抽象或真實變更壓力。
- 關鍵副作用與順序若留在入口更容易閱讀,不得只為縮短方法而隱藏。
- Repository 已有 Controller、Use Case、Domain、Gateway 或 Infrastructure 等穩定架構邊界時,可以接受較多跳轉;每一次跳轉都要隔離不同變更理由、依賴或測試方式。
- 跳入 Helper 只看見欄位讀寫、計數加一、單行轉呼叫或另一個沒有新增語意的薄包裝方法時,視為過度拆分,應合併回最近的完整責任。
- 重構前先固定外部契約、狀態、通知、儲存、錯誤與重跑行為;不得修改測試期待來配合拆分。
- 完成後回報入口直接 Helper 數、最深呼叫路徑、每個新增 Helper 的責任,以及已知下一次變更的預期落點。
- 抽取若只搬移語法、增加參數傳遞或閱讀跳轉,沒有形成新概念,停止抽取並允許零 Diff。
- 若拆分必須新增架構層、公開契約、Schema 或新的領域概念,停止並交回 User 決定。

Function Structure Policy 同時保存三條判斷路線:簡單流程可以不拆,一般應用流程優先使用 Stepdown,既有架構邊界則能接受更多有意義的跳轉。Agent 先辨認目前情境,再選擇對應策略;遇到 Policy 沒有涵蓋的新領域責任或架構邊界,才交回 User。

實驗重現與完整證據

如果已經 Clone 公開的 AI Clean Code API Demo,可以先切到三次執行共用的起點:

git fetch origin --tags
git switch --detach day-06-comments-formatting
git rev-parse HEAD

最後一行應顯示:

1583af33b5e517530871ff3ee724cdcad4e4c5c0

三份候選分別固定在:

實驗情境 Commit Annotated Tag
單一 Action 80396f749017678b475f791ad46d5b83a53fd756 day-07-sol-high-single-action-run-01
每個步驟都拆小 e0295c5ef2cba454518f65ee9b87d339a7d78f31 day-07-sol-high-over-split-run-01
Stepdown 8fb0953b9e2034dfea57915d0644ef498ed7d1e0 day-07-sol-high-stepdown-run-01

本系列後續沿用 Stepdown 版本,導覽 Tag 為 day-07-function-levels

讀者可以直接比較單一 Action每個步驟都拆小Stepdown 的完整 Diff。

三份 Prompt、結構量測、驗證結果與人工決策則保存在 Day 7 公開 Evidence

這些證據只能比較本 Repository 的三種候選,不能推論某種函式風格永遠比較省 Token,也不能把單次輸出當成 Codex 的固定行為。

拆分函式前,先告訴 Agent 什麼責任值得獨立

回到開頭,兩份候選的入口同樣是 15 行,Complexity 也同樣是 0,真正的差異卻是 12 個相關方法與 4 個相關方法背後的閱讀路徑。

Clean Code 用單一意圖、抽象層次與閱讀順序判斷函式是否夠小。每次跳轉,都應該進入一項值得獨立理解的責任。

E — Explicit Intent and Boundaries 意圖明確 把這些觀念轉成 Agent 能執行的條件:什麼責任可以抽取、哪些行為禁止改動,以及什麼情況必須停止。

目前 Demo 接受 Stepdown,因為它保留完整責任,也替下一項變更留下明確落點。簡單、穩定的流程仍可保留單一 Action;既有架構已形成真實邊界時,也能接受更多有意義的跳轉。把每一個步驟都包成薄 Helper,則不會成為預設選項。

Stepdown 整理了閱讀層次,函式內部的責任衝突仍然存在:ProcessDueIncompleteWorkItemsAsync 會修改 Entity、呼叫通知並建立摘要,ApplyOverdueStatusAndAttemptNotificationAsync 也同時包含狀態變更與外部副作用。

明天要把參數、CQS、DRY、例外與副作用放回同一段 Code,看看 Agent 該如何在互相衝突的函式原則之間做取捨。

參考資料


上一篇
Day 6|註解與格式都漂亮,程式碼為什麼還是難讀?
下一篇
Day 8|同一項需求交給下一個 AI Agent,三種函式結構會走出哪條修改路徑?
系列文
AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護13
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言