iT邦幫忙

2026 iThome 鐵人賽

DAY 2
0
Software Development

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

Day 2|叫 AI 清理程式碼,為什麼常只清到表面?

  • 分享至 

  • xImage
  •  

安安~我是ChiYu~

昨天,我真的把這句話交給了 Codex:

改善逾期處理,讓它符合 Clean Code。

Agent 最後交出一份看起來很成功的候選:能 Build、測試全綠,名稱更清楚,主要流程更短,Diff 也集中在逾期處理。

如果只看完成報告,這份候選看起來已經足以進入 Review。

但我腦中還有 Route、通知順序、相容性限制與後續需求;Agent 能取得的只有 Prompt、程式碼、測試,以及 Repository Instruction,也就是放在專案裡、供 Agent 長期遵循的規則。沒有寫下來的判斷,它只能自己猜。

所以在正式談命名、函式與類別以前,我想先追問:當 User 只給 AI 一個聽起來正確的方向,它究竟會把什麼當成 Clean Code?

〈清理你的程式碼〉提醒我們:能跑只是起點,清理還要守住原有行為

《無瑕的程式碼 第二版》的〈清理你的程式碼〉展示了一段程式碼如何從「可以執行」,逐步整理成更容易閱讀與維護的版本。

這個清理流程有一個重要前提:先讓程式正確運作,再逐步整理,而且每次整理都要用測試守住原有行為。「可以執行」只代表第一步完成。

章末的 Grok 3 實作裡,Uncle Bob 先提出清理目標並準備測試,再讓 AI 修改程式碼,最後由他判斷結果是否符合需求。AI 可以交出候選;驗收標準與接受決定仍在人身上。

髒亂程式碼不只拖慢人,也會讓 Agent 反覆修東壞西

Uncle Bob 後來在一場 AI 時代軟體基本功的訪談中,分享了更直接的實作感受:早期 Agent 雖然產碼很快,每次修改後卻會留下需要清理的混亂。這些問題持續累積後,Agent 也開始修好一邊又弄壞另一邊,反覆打轉,最後甚至無法繼續完成任務。

這段經驗讓「Clean Code 是寫給誰看的」多了一個答案。下一位讀者可能是維護者,也可能是另一個 Agent;責任、名稱、測試與邊界一旦混亂,後續每次修改都得重新支付搜尋、理解與返工成本。

他也試過把 Clean Code 與 TDD 規則寫成五到十頁的長篇指令,後來改成較短的初始要求,再用可以重複執行的工具檢查結果。文字規範仍有用途;只對 Agent 說「請寫出 Clean Code」,就算換成更長的形容詞清單,依然缺少能被驗收的問題、邊界與完成條件。

今天我刻意拿掉明確的清理目標與驗收條件,只留下同一句模糊 Prompt,看看 Agent 會如何自行定義 Clean Code。

後續實驗會沿用同一支 AI Clean Code API Demo。讀者不用先下載專案,也能從文章看懂每次實驗的設計與結果。

我故意只給 Agent 一句模糊需求

這次交給 Agent 的任務 Prompt 只有一句:

改善逾期處理,讓它符合 Clean Code。

我沒有指定要縮短方法、改善命名、拆出 Service,還是重新分配責任,也沒有附上任何 Clean Code 檢查表。

為了避免把模型與起點差異混進結果,我固定使用 Codex GPT-5.6 Sol(high),並從同一個「系列行為基準」開始,也就是後續實驗共同使用的未修改版本。Agent 仍可讀取 AGENTS.md、README、Production Code 與既有測試;我刻意拿掉的只有一件事:這次清理究竟要改善什麼。

我要觀察的是:當任務只剩一句話,Agent 會從 Repository 找到哪些線索,又會替 User 補上哪些決定。這次輸出甚至比我預期克制;也因為它不是明顯失敗,更能顯示「偶爾猜得合理」和「可以反覆依賴」是兩回事。

原始 Controller 把時間判斷、資料查詢、狀態更新、通知、統計與儲存塞進同一個 Action

我選這段 Action,是因為它很適合測試一句模糊的「幫我整理一下」會發生什麼。功能可以執行,每一行也不難看懂,但時間、查詢、狀態、通知、統計與儲存全都集中在同一個入口。

/// <summary>
/// 提供工作項目的建立、指派、完成與逾期處理 API。
/// </summary>
[ApiController]
[Route("api/work-items")]
public sealed class WorkItemsController(
    WorkItemsDbContext database,
    INotificationGateway notificationGateway,
    TimeProvider timeProvider) : ControllerBase
{
    // 建立、指派與完成工作項目的 Action 省略。

    /// <summary>
    /// 處理目前已到期且尚未完成的工作項目。
    /// </summary>
    /// <param name="cancellationToken">取消權杖。</param>
    /// <returns>本次狀態轉換與通知嘗試摘要。</returns>
    [HttpPost("process-overdue")]
    [ProducesResponseType<ProcessOverdueResponse>(StatusCodes.Status200OK)]
    public async Task<ActionResult<ProcessOverdueResponse>> ProcessOverdue(
        CancellationToken cancellationToken)
    {
        var now = timeProvider.GetUtcNow();
        var items = await database.WorkItems
            .Where(item => item.Status != "Completed" && item.DueAtUtc <= now)
            .ToListAsync(cancellationToken);

        var processedCount = 0;
        var notificationAttemptCount = 0;
        var notificationFailureCount = 0;

        foreach (var item in items)
        {
            // 如果還不是逾期,就把狀態改成逾期。
            if (item.Status != "Overdue")
            {
                item.Status = "Overdue";
                processedCount++;
            }

            // 每一筆到期資料都傳送通知。
            notificationAttemptCount++;

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

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

        await database.SaveChangesAsync(cancellationToken);

        return Ok(new ProcessOverdueResponse(
            processedCount,
            notificationAttemptCount,
            notificationFailureCount));
    }
}

這段 Code 目前有三項不能在清理時偷偷改掉的行為:

  • 只處理已到期,而且尚未完成的工作項目。
  • 已經是 Overdue 的項目重跑時,不再增加 ProcessedCount,但仍會再次通知。
  • 每筆通知都發生在 SaveChangesAsync 之前。

這些設計未必理想,卻是 API 現在真正對外提供的行為。沒有取得修改授權,清理程式碼時就不能順手換掉。

Prompt 沒寫的五個決定,最後都由 Agent 補上

Codex 讀完 Repository 後,自行推導出這次清理應該做到哪裡:

  • 保留 Route、DTO、HTTP Status Code 與回應統計。
  • 保留重跑時再次通知的行為。
  • 保留每筆先在記憶體更新狀態、再呼叫通知,而且所有通知都早於 SaveChangesAsync 的順序。
  • 只改善逾期流程的局部可讀性與責任分離。
  • 不新增 Service、Repository,也不引入 Outbox,也就是先可靠保存待送通知、再交由另一段流程傳送的機制。

原始 Prompt 沒有交代其中任何一項。Agent 是從 Repository Instruction、README、Production Code 與既有測試推導出這些限制,所以這份候選並非毫無根據;問題在於,專案線索只能支持它的推論,不能取代 User 的授權與完成定義。

模糊 Prompt 更難察覺的風險,是 AI 可能交出一份合理、漂亮,看起來足以進入 Review 的程式碼,讓我們忘記追問:這份合理答案到底是誰定義的?

模糊的 Clean Code Prompt 讓 Agent 自行補完責任、邊界、錯誤、副作用與停止條件

圖:程式碼表面變乾淨,不代表 Agent 補出的五項決策就是 Repository 真正需要的答案。

候選真的更好讀,改善集中在局部可讀性

Agent 最後把原本完整塞在 Action 裡的流程整理成四個步驟:

public async Task<ActionResult<ProcessOverdueResponse>> ProcessOverdue(
    CancellationToken cancellationToken)
{
    var dueIncompleteWorkItems = await GetDueIncompleteWorkItemsAsync(cancellationToken);
    var response = await ProcessOverdueWorkItemsAsync(
        dueIncompleteWorkItems,
        cancellationToken);

    await database.SaveChangesAsync(cancellationToken);

    return Ok(response);
}

查詢被抽成具名方法,資料範圍直接寫進名稱:

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

    return await database.WorkItems
        .Where(workItem =>
            workItem.Status != CompletedStatus &&
            workItem.DueAtUtc <= currentUtc)
        .ToListAsync(cancellationToken);
}

這份候選確實改善了三件事:

  • items 改成 dueIncompleteWorkItems,資料範圍更清楚。
  • Action 先呈現「查詢、處理、儲存、回應」,閱讀順序更容易掌握。
  • 能由方法名稱表達的意圖,不再依賴只把程式碼逐句翻成中文、而且容易與實作脫節的註解。

Agent 一度把與這次需求無關的 Complete Action 也納入重構,後來才在自我 Review 時撤回,讓最終 Diff 回到逾期流程。這次它成功收斂了範圍;同時也證明,任務沒有寫清楚邊界時,是否越界仍取決於 Agent 當下的判斷。

這些都是 Clean Code 關心的改善。Controller 仍直接協調 EF Core、時間、狀態、通知與儲存,因此這次改善集中在局部可讀性,尚未重新分配責任與邊界。

短方法與好名稱只能改善局部,Clean Code 還要回答責任與變更位置

如果我們只要求短方法、漂亮名稱與一致格式,Agent 通常能從眼前的 Code 找到修改目標,也能很快交出合理結果。這些局部改善之外,更難的判斷還藏在專案情境裡。

Controller 為什麼同時碰資料庫與通知?Status 為什麼仍是任意字串?下一項逾期規則應該放在 Query、Entity、Policy,還是另一個 Use Case?現在值得增加新的抽象嗎?

這些問題都和可讀性、責任、可修改性有關,也都屬於 Clean Code 會持續追問的範圍。Agent 可以提出答案,卻無法從一句「更 Clean」知道 User 這次真正想解決哪一層。

AI Coding 讓產生候選的速度變快,沒有讓工程判斷一起自動完成。User 越少說清楚,Agent 就越需要依照常見做法與 Repository 線索代替我們做決定。

測試能確認既有行為沒有改變,不能決定新規則該放在哪裡

系列行為基準與候選都通過相同的 Build、測試與 HTTP Smoke Test。Smoke Test 會在 API 啟動後,用少量關鍵 HTTP Request 確認基本流程能否運作。

這些綠燈只能確認已寫進測試的 Route、統計語意、通知次數與副作用順序沒有發現漂移。Controller 的責任是否放對位置,以及下一項規則該落在哪一層,仍要由 User 判斷。

假設明天新增一條規則:「High Priority 的工作項目,到期兩小時後才轉成 Overdue。」規則應該直接放進 LINQ Query、收回 Work Item 的狀態轉換方法,還是抽成獨立的業務規則物件?

原始 Prompt 沒有定義,現有測試也不會替我們選答案。

測試守住原本行為;什麼結構值得留下,仍然需要工程判斷。

這份候選值得 Review,但這句 Prompt 不值得重複使用

這份候選本身不差。它讓閱讀路徑更清楚,修改範圍也集中在逾期流程,現有驗證沒有發現行為漂移。面對一般低風險任務,我會讓它進入正式 Review。

這份候選本身合格,但我沒有把它合併回 series-v2。後面的命名、函式、物件、測試、設計與架構實驗都要維持相同起點,避免前一篇的重構改變下一篇結果。候選仍完整保留,讀者可以直接檢查原始輸出與 Diff。

回到開頭的問題,Agent 會把什麼當成 Clean Code?這次它先改善了名稱、函式與閱讀順序,也從 Repository 推導出不能改動的行為。可是,一句「請符合 Clean Code」仍沒有告訴它:這次要解決哪一項問題、哪些行為不能動,以及做到什麼程度應該停止。

User 沒有說清楚的地方,最後仍會變成 Agent 的選擇。明天,我會把這些不該繼續交給模型猜的責任,整理成 CLEAN 五原則。

想重現這次實驗

閱讀本文不需要先下載 Demo。想檢查完整 Prompt、固定版本、候選 Diff 與原始驗證結果時,可以使用公開 Repository。

版本 Tag Commit
系列行為基準 series-behavior-baseline-v1 cdcd870635128f13d7a3fe0813768ce91ce8b46e
Sol/high 候選 day-02-sol-high-candidate-run-01 59a3ee9a1ff55e1aa18f4de3209c975b31410486
git clone --branch series-v2 https://github.com/eric861129/AI-CleanCode-API-Demo.git
Set-Location .\AI-CleanCode-API-Demo
git fetch origin --tags

git switch --detach series-behavior-baseline-v1
git rev-parse HEAD

git switch --detach day-02-sol-high-candidate-run-01
git rev-parse HEAD

完整資料:

參考資料


上一篇
Day 1|第三個參賽系列:我為什麼還想寫 AI 時代的 Clean Code?
下一篇
Day 3|CLEAN 五原則:如何掌握 AI Coding 的情境、範圍、意圖、證據與行為?
系列文
AI 時代的 Clean Code:30 天讓 AI 產出的程式碼可讀、可驗證、可維護3
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言