iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
自我挑戰組

程式碼門診:診斷壞味道、開出重構處方系列 第 20 篇

一個函式不該做一百件事 - 過長函式 (Long Method)

  • 分享至 

  • xImage
  •  

簡單介紹

想像你在讀一份食譜,結果發現整份食譜只有一個段落:採買清單、備料步驟、火候控制、擺盤技巧全部擠在一起,中間只用「接下來」隔開。讀到擺盤的時候,你已經忘記一開始要買幾顆蛋了——不是因為你記性差,而是因為人類的工作記憶本來就只能同時記住少數幾件事。

程式碼也是一樣的道理。一個函式越長,讀者需要同時記在腦中的變數、條件與中間狀態就越多;當函式長到一個程度,讀程式的人就得像玩雜耍一樣,一邊往下讀、一邊努力不讓前面的細節從腦中掉出來。這就是今天的主角——過長函式(Long Method),它是 Martin Fowler 與 Kent Beck 在《Refactoring》一書中列出的最經典、也最常見的**程式碼異味(Code Smell)**之一。

根據 Refactoring Guru 的說法,一個函式一旦超過大約十行,我們就應該開始問自己:「它是不是做了太多事?」而過長函式的成因也非常寫實:

「東西總是不斷被加進函式裡,卻從來沒有東西被拿出來。」

因為每次需求來的時候,「在現有函式裡多塞兩行」永遠比「另外開一個新函式」來得省力——「才兩行而已,何必為它開一個函式?」於是兩行變四行、四行變十行,最後長成一隻誰都不敢碰的巨獸。

那麼 Fowler 的立場是什麼?答案非常明確:小函式萬歲。他在書中提到,活得最久、最健康的程式,往往都是由許多短小的函式組成的。重點從來不是行數本身,而是意圖(intention)與實作(implementation)之間的距離——函式的名字說出「要做什麼」,函式的內容才是「怎麼做」;當讀者只想知道「做什麼」時,他不應該被迫讀完所有「怎麼做」的細節。

值得一提的是,Fowler 還給了一個非常好用的判斷訊號:當你覺得需要寫一段註解來說明「下面這段程式碼在做什麼」時,就代表這段程式碼應該被抽成一個函式,而且函式的名字就叫做那段註解想說的事。換句話說,那些用來分隔段落的「// 驗證」「// 計算」區段註解,其實就是程式碼在對我們大喊:「我這裡藏了另一個函式!」

而對付過長函式的第一武器,就是 Fowler 重構目錄中最常用的手法——提取函式(Extract Function,舊稱 Extract Method):把一段程式碼搬進一個新函式,並用「這段程式碼的意圖」為它命名。

接下來我們透過一個電商結帳的場景,看看過長函式長什麼樣子,再示範如何用提取函式把它拆解乾淨。

TypeScript 不好的範例

下面這個 handleCheckout() 是許多專案裡真實存在的樣貌:驗證、計算、組裝回應全部塞在同一個函式裡,將近六十行的流水帳,只能靠區段註解勉強分段。

// 不好的範例:一個函式包辦結帳流程的所有細節

interface CartItem {
  productName: string;
  unitPrice: number;
  quantity: number;
}

interface CheckoutRequest {
  orderId: string;
  customerName: string;
  items: CartItem[];
  couponCode?: string;
  isVip: boolean;
}

interface Receipt {
  orderId: string;
  customerName: string;
  lines: string[];
  subtotal: number;
  discount: number;
  shippingFee: number;
  total: number;
  issuedAt: string;
}

class CheckoutService {
  handleCheckout(request: CheckoutRequest): Receipt {
    // ===== 驗證 =====
    // 問題一:需要用區段註解分段,正是「這裡其實是另一個函式」的訊號
    if (request.items.length === 0) {
      throw new Error(`訂單 ${request.orderId} 的購物車是空的,無法結帳`);
    }
    for (const item of request.items) {
      if (item.quantity <= 0) {
        throw new Error(`商品「${item.productName}」的數量必須大於 0`);
      }
      if (item.unitPrice < 0) {
        throw new Error(`商品「${item.productName}」的單價不可為負數`);
      }
    }
    if (
      request.couponCode !== undefined &&
      !/^[A-Z0-9]{6}$/.test(request.couponCode)
    ) {
      throw new Error(`折扣碼 ${request.couponCode} 格式不正確`);
    }

    // ===== 計算 =====
    // 問題二:讀到這裡時,上面十幾行驗證細節還佔據著讀者的工作記憶
    // 而 subtotal、discount 這些變數又要一路活到函式結尾
    let subtotal = 0;
    for (const item of request.items) {
      subtotal += item.unitPrice * item.quantity;
    }
    let discount = 0;
    if (request.couponCode !== undefined) {
      discount += Math.round(subtotal * 0.1); // 問題三:0.1 是魔術數字,意圖不明
    }
    if (request.isVip) {
      discount += Math.round(subtotal * 0.05);
    }
    const discountedTotal = subtotal - discount;
    let shippingFee = 0;
    if (discountedTotal < 1000) {
      shippingFee = 80; // 又是魔術數字:1000 和 80 分別代表什麼?
    }
    const total = discountedTotal + shippingFee;

    // ===== 組裝回應 =====
    // 問題四:第三種性質完全不同的工作(格式化輸出)也擠進同一個函式
    // 想單獨測試「收據格式」,就必須先讓前面的驗證和計算全部跑一遍
    const lines: string[] = [];
    for (const item of request.items) {
      lines.push(
        `${item.productName} x ${item.quantity} = ${item.unitPrice * item.quantity} 元`
      );
    }
    if (discount > 0) {
      lines.push(`折扣:-${discount} 元`);
    }
    if (shippingFee > 0) {
      lines.push(`運費:${shippingFee} 元`);
    }

    return {
      orderId: request.orderId,
      customerName: request.customerName,
      lines,
      subtotal,
      discount,
      shippingFee,
      total,
      issuedAt: new Date().toISOString(),
    };
  }
}

問題分析

這個函式最直觀的問題就是「長」,但長只是表象,我們把它背後的代價一條一條攤開來看:

  1. 工作記憶超載:讀到「組裝回應」段落時,讀者必須同時記得 subtotal、discount、shippingFee 是在哪裡、用什麼規則算出來的。函式裡的區域變數活得越久、跨越的段落越多,讀者腦中要維護的狀態就越多——這正是 luzkan 的 Code Smells 目錄中對過長函式的描述:行數越多,開發者理解程式碼所需的心智負擔就越重。

  2. 區段註解是最誠實的告密者:// ===== 驗證 =====、// ===== 計算 ===== 這些註解,本質上是作者自己也意識到「這個函式有三個段落」,卻選擇用註解貼標籤,而不是把段落拆成函式。需要注意的是,註解不會被編譯器檢查——日後有人在「驗證」區段裡偷偷加了一段計算邏輯,註解也不會報錯,只會慢慢變成謊言。

  3. 無法單獨測試與重用:想測試「滿千免運」的規則,就得建構一個完整合法的 CheckoutRequest,讓驗證先通過;想在別的地方(例如購物車頁面的預估金額)重用計算邏輯,卻發現它和驗證、組裝黏在一起,根本抽不出來。做太多事的函式,每一件事都很難被單獨對待。

  4. 修改的爆炸半徑很大:不管是「折扣碼規則改了」「運費門檻調整」還是「收據要多印一行」,通通都要打開同一個六十行的函式。每次修改都得重新讀懂整段流程,改壞其中一段就會波及全部——這也讓程式碼審查(Code Review)變得非常痛苦。

這時候我們可以發現,handleCheckout() 的名字說它「處理結帳」,但它實際上做了驗證、算錢、印收據三件事——名字與內容之間的落差,就是過長函式最典型的病徵。

修正後範例

治療的方法就是提取函式(Extract Function):把每一個「需要註解才能說明的段落」抽成獨立函式,並用那句註解為函式命名。

// 修正範例:用 Extract Function 把每個段落拆成有名字的函式

interface CartItem {
  productName: string;
  unitPrice: number;
  quantity: number;
}

interface CheckoutRequest {
  orderId: string;
  customerName: string;
  items: CartItem[];
  couponCode?: string;
  isVip: boolean;
}

// 改動重點 1:計算結果獨立成型別,函式之間用「資料」而不是「共用變數」溝通
interface PricingSummary {
  subtotal: number;
  discount: number;
  shippingFee: number;
  total: number;
}

interface Receipt {
  orderId: string;
  customerName: string;
  lines: string[];
  subtotal: number;
  discount: number;
  shippingFee: number;
  total: number;
  issuedAt: string;
}

// 改動重點 2:魔術數字升級為有名字的常數,業務規則一目了然
const COUPON_DISCOUNT_RATE = 0.1;   // 折扣碼折抵 10%
const VIP_DISCOUNT_RATE = 0.05;     // VIP 加碼折抵 5%
const FREE_SHIPPING_THRESHOLD = 1000; // 滿千免運
const SHIPPING_FEE = 80;            // 未達門檻的運費

class CheckoutService {
  // 改動重點 3:主函式變成「可以朗讀的目錄」——
  // 驗證購物車、計算金額、產生收據,三行讀完就知道整個流程
  handleCheckout(request: CheckoutRequest): Receipt {
    this.validateCart(request);
    const pricing = this.calculateTotal(request);
    return this.buildReceipt(request, pricing);
  }

  // 改動重點 4:原本的「// ===== 驗證 =====」註解,變成了函式的名字
  private validateCart(request: CheckoutRequest): void {
    if (request.items.length === 0) {
      throw new Error(`訂單 ${request.orderId} 的購物車是空的,無法結帳`);
    }
    for (const item of request.items) {
      if (item.quantity <= 0) {
        throw new Error(`商品「${item.productName}」的數量必須大於 0`);
      }
      if (item.unitPrice < 0) {
        throw new Error(`商品「${item.productName}」的單價不可為負數`);
      }
    }
    if (
      request.couponCode !== undefined &&
      !/^[A-Z0-9]{6}$/.test(request.couponCode)
    ) {
      throw new Error(`折扣碼 ${request.couponCode} 格式不正確`);
    }
  }

  // 改動重點 5:計算邏輯自成一格,回傳明確的 PricingSummary
  // 區域變數的生命週期被鎖在這個小函式裡,不再一路活到流程結尾
  private calculateTotal(request: CheckoutRequest): PricingSummary {
    const subtotal = request.items.reduce(
      (sum, item) => sum + item.unitPrice * item.quantity,
      0
    );

    let discount = 0;
    if (request.couponCode !== undefined) {
      discount += Math.round(subtotal * COUPON_DISCOUNT_RATE);
    }
    if (request.isVip) {
      discount += Math.round(subtotal * VIP_DISCOUNT_RATE);
    }

    const discountedTotal = subtotal - discount;
    const shippingFee =
      discountedTotal < FREE_SHIPPING_THRESHOLD ? SHIPPING_FEE : 0;

    return {
      subtotal,
      discount,
      shippingFee,
      total: discountedTotal + shippingFee,
    };
  }

  // 改動重點 6:組裝收據只依賴「輸入的資料」,
  // 想測試收據格式,直接餵一個 PricingSummary 即可,不必先跑驗證與計算
  private buildReceipt(
    request: CheckoutRequest,
    pricing: PricingSummary
  ): Receipt {
    const lines = request.items.map(
      (item) =>
        `${item.productName} x ${item.quantity} = ${item.unitPrice * item.quantity} 元`
    );
    if (pricing.discount > 0) {
      lines.push(`折扣:-${pricing.discount} 元`);
    }
    if (pricing.shippingFee > 0) {
      lines.push(`運費:${pricing.shippingFee} 元`);
    }

    return {
      orderId: request.orderId,
      customerName: request.customerName,
      lines,
      ...pricing,
      issuedAt: new Date().toISOString(),
    };
  }
}

// 使用範例——行為與拆解前完全相同,但每一段都有了自己的名字
const service = new CheckoutService();

const receipt = service.handleCheckout({
  orderId: 'ORD-2001',
  customerName: '小明',
  items: [
    { productName: '機械鍵盤', unitPrice: 2990, quantity: 1 },
    { productName: '滑鼠墊', unitPrice: 250, quantity: 2 },
  ],
  couponCode: 'SAVE10',
  isVip: true,
});

console.log(receipt.total);
// 3490 - 349(折扣碼 10%)- 175(VIP 5%)= 2966,已達免運門檻
console.log(receipt.lines);
// [ '機械鍵盤 x 1 = 2990 元', '滑鼠墊 x 2 = 500 元', '折扣:-524 元' ]

改進重點說明

  1. 主函式變成可朗讀的目錄:handleCheckout() 現在只剩三行——驗證購物車、計算金額、產生收據。任何人打開這個函式,三秒鐘就能掌握整個結帳流程;想深入某個步驟的細節,再點進對應的函式即可。這正是 Fowler 說的意圖與實作分離:主函式負責說「做什麼」,子函式負責說「怎麼做」。

  2. 註解被函式名字取代:原本的 // ===== 驗證 ===== 消失了,取而代之的是 validateCart() 這個名字。關鍵差異在於:註解可能過期、可能說謊,但函式名字是程式結構的一部分——呼叫端每一次呼叫都在複誦它的意圖,一旦名不符實,重構工具與程式碼審查都更容易抓到。

  3. 區域變數的生命週期被切短:拆解前,subtotal、discount 從「計算」段落一路活到函式結尾;拆解後,它們被鎖在 calculateTotal() 這個小小的作用域裡,函式之間改用 PricingSummary 這個明確的型別傳遞資料。讀者的工作記憶負擔,從「整個函式」縮小到「眼前這一小段」。

  4. 每一段都能被單獨測試與重用:calculateTotal() 可以直接拿去購物車頁面計算預估金額;buildReceipt() 餵一個假的 PricingSummary 就能測試收據格式。原本黏成一團的三件事,現在各自都是可以獨立對待的積木。

  5. 魔術數字升級為常數:0.1、1000、80 變成了 COUPON_DISCOUNT_RATE、FREE_SHIPPING_THRESHOLD、SHIPPING_FEE。日後「免運門檻調成 1500」這種需求,只需要改一行常數,而且用搜尋就能直接定位。

需要注意的是,提取函式並不是「行數警察」——目標從來不是把每個函式都壓到十行以下,而是讓每個函式只在一個抽象層次上說話。如果拆出來的函式取不出一個好名字(例如只能叫 doStuff() 或 processPart2()),那通常代表切割的位置不對,或是這段邏輯本來就不該被硬拆。反過來說,只要能為一段程式碼取出一個準確的名字,即使它只有一行,抽出來也是值得的。

總結

綜合以上所述,我們成功把一個六十行的流水帳函式,重構成一個三行的「目錄」加上三個名字清晰的小函式。過長函式的重點可以整理成以下幾點:

  1. 函式越長,工作記憶負擔越重:讀者必須同時記住的變數與條件越多,理解與修改的成本就越高;根據 Refactoring Guru 的說法,超過十行的函式就值得開始檢視
  2. 區段註解是拆函式的訊號:當你需要用「// 驗證」「// 計算」來分段時,程式碼已經在告訴你「這裡藏著另一個函式」——把註解變成函式名字,讓結構自己說話
  3. 提取函式是第一武器:Extract Function 是 Fowler 重構目錄中最常用的手法,它把「怎麼做」的細節收進子函式,讓主函式只保留「做什麼」的意圖
  4. 好函式的名字就是它的註解:註解會過期、會說謊,但名字是結構的一部分;當每個函式的名字都準確描述它的行為,程式碼本身就成了最好的文件

值得一提的是,如果回頭看我們在 Day 6 談過的單一功能原則(SRP),會發現過長函式其實就是 SRP 在「函式層級」被違反的樣子:handleCheckout() 同時肩負驗證、計算、組裝三個職責,也就同時有三個改變的理由——折扣規則變動、驗證規則變動、收據格式變動,都會逼我們打開同一個函式。而提取函式正是把 SRP 落實到日常編碼的具體工具:Day 6 告訴我們「每個函式應該只做一件事」,今天的 Extract Function 則告訴我們當函式已經做了一百件事時,該怎麼一步一步把它拆回來。

下次當你想在既有函式裡「順手多加兩行」,或是正準備寫下一行 // ===== 某某處理 ===== 的區段註解時,不妨停下來問一句:「這段程式碼,是不是其實想要一個自己的名字?」

參考資料

上一篇
埋在程式庫裡的殭屍 - 死程式碼 (Dead Code)
系列文
程式碼門診:診斷壞味道、開出重構處方 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言