Theme / v4.9.0

Ponytail

讓 AI 編碼代理像最懶的資深工程師

實戰範例

實戰範例 007:狀態回歸:以簡單 Enum 取代繁瑣的狀態模式

探討為何在簡單場景下,狀態模式 (State Pattern) 是一種過度設計,並示範如何用列舉 (Enum) 與 Switch 敘述寫出更易懂的狀態機。

實戰範例 007:狀態回歸:以簡單 Enum 取代繁瑣的狀態模式

簡介與核心精神

在物件導向設計 (Object-Oriented Design) 中,狀態模式 (State Pattern) 是大名鼎鼎的 GoF 23 種設計模式之一。它的初衷是為了解決當一個物件的行為取決於其狀態,且狀態切換邏輯過於複雜時,透過將每一種狀態封裝成獨立的類別 (Class) 來消除龐大的條件分支 (if-else 或 switch)。

然而,如同許多經典設計模式一樣,狀態模式在現代開發中經常被不加思索地濫用。Ponytail 的極簡主義哲學強調:「不要為了模式而模式。過早引入結構複雜的設計模式,只會讓簡單的問題變得難以理解。」

對於一個只有 3 到 4 種狀態,且狀態轉換規則固定的簡單業務邏輯(例如:訂單狀態、播放器狀態、文件審核狀態),強行套用狀態模式會導致「類別爆炸 (Class Explosion)」。開發者為了追蹤一個簡單的動作,不得不在十幾個檔案之間來回跳轉。本篇實戰範例將示範如何將這種過度設計的狀態模式,還原成清晰、易懂且集中管理的 Enumeration (列舉) 搭配 Switch 敘述。

實際場景描述

我們正在開發一個簡單的內容管理系統 (CMS)。其中的核心實體是一份「文章 (Document)」。文章的生命週期非常單純,只有三種狀態:

  1. Draft (草稿)
  2. Reviewing (審核中)
  3. Published (已發佈)

可以執行的動作也只有三個:submit()approve()reject()

一位剛讀完《設計模式》的熱血工程師,認為這是一個應用「狀態模式」的完美時機。他建立了一個抽象的 State 介面,然後為每個狀態實作了獨立的類別。結果,原本只需要幾行程式碼的邏輯,被拆分成了 5 個不同的檔案,充滿了樣板代碼 (Boilerplate) 與循環依賴。

原始的過度設計代碼

讓我們欣賞這段極度「優雅」但也極度難懂的狀態模式實作。

// 檔案:src/models/document/DocumentState.ts
export interface DocumentState {
  submit(document: DocumentContext): void;
  approve(document: DocumentContext): void;
  reject(document: DocumentContext): void;
}

// 檔案:src/models/document/states/DraftState.ts
import { DocumentState } from '../DocumentState';
import { ReviewingState } from './ReviewingState';
import { DocumentContext } from '../DocumentContext';

export class DraftState implements DocumentState {
  submit(document: DocumentContext): void {
    console.log("Submitting document for review...");
    document.setState(new ReviewingState());
  }

  approve(document: DocumentContext): void {
    throw new Error("Cannot approve a draft document.");
  }

  reject(document: DocumentContext): void {
    throw new Error("Cannot reject a draft document.");
  }
}

// 檔案:src/models/document/states/ReviewingState.ts
import { DocumentState } from '../DocumentState';
import { PublishedState } from './PublishedState';
import { DraftState } from './DraftState';
import { DocumentContext } from '../DocumentContext';

export class ReviewingState implements DocumentState {
  submit(document: DocumentContext): void {
    throw new Error("Document is already under review.");
  }

  approve(document: DocumentContext): void {
    console.log("Document approved. Publishing...");
    document.setState(new PublishedState());
  }

  reject(document: DocumentContext): void {
    console.log("Document rejected. Returning to draft.");
    document.setState(new DraftState());
  }
}

// 檔案:src/models/document/states/PublishedState.ts
import { DocumentState } from '../DocumentState';
import { DocumentContext } from '../DocumentContext';

export class PublishedState implements DocumentState {
  submit(document: DocumentContext): void {
    throw new Error("Document is already published.");
  }

  approve(document: DocumentContext): void {
    throw new Error("Document is already published.");
  }

  reject(document: DocumentContext): void {
    throw new Error("Cannot reject a published document.");
  }
}

// 檔案:src/models/document/DocumentContext.ts
import { DocumentState } from './DocumentState';
import { DraftState } from './states/DraftState';

export class DocumentContext {
  private state: DocumentState;

  constructor(public id: string, public content: string) {
    this.state = new DraftState(); // 初始狀態
  }

  setState(state: DocumentState): void {
    this.state = state;
  }

  // 將動作委託給內部狀態物件
  submit(): void {
    this.state.submit(this);
  }

  approve(): void {
    this.state.approve(this);
  }

  reject(): void {
    this.state.reject(this);
  }
}

這段程式碼雖然完全符合 GoF 狀態模式的定義,但它帶來了巨大的維護成本。如果我們只想知道「在 Reviewing 狀態下呼叫 approve 會發生什麼事」,我們必須打開三個以上的檔案追蹤邏輯。更糟糕的是,這種設計引入了嚴重的類別間循環依賴 (Circular Dependency) —— 狀態類別互相知道彼此的存在,破壞了模組的獨立性。

開發者與 AI 的對話記錄

當團隊試圖為這個文件系統增加一個新的「封存 (Archived)」狀態時,開發者崩潰了,轉而向 AI 求助。

開發者

「AI,我們需要為 Document 加上一個『Archived』狀態。我現在必須去修改 DocumentState 介面,然後在 DraftStateReviewingStatePublishedState 裡面全部加上新的方法實作,即便它們大部分都會拋出 Error。這改動範圍也太大了吧!狀態模式不是應該『符合開閉原則 (OCP)』嗎?」

AI (Ponytail 模式)

「這就是為什麼在簡單的狀態流轉中,狀態模式往往是一種反模式 (Anti-pattern)。

狀態模式在狀態極多、且每個狀態的行為邏輯異常複雜(例如:包含各種不同的演算法或策略)時才有用。但你們的文章狀態只有三四種,動作也只有三個。你為了封裝幾行簡單的條件判斷,製造了五個類別和一堆重複的例外拋出。這完全違背了極簡主義。」

開發者

「可是大學教授和設計模式的書上都說,用 Switch case 來寫狀態機是不優雅的,違反了物件導向的精神啊!」

AI (Ponytail 模式)

「這是一種教條主義。對於一個狀態轉換明確的有限狀態機 (FSM),一個集中管理的 Enum 加上 Switch 結構,反而能提供最完美的『全局視角 (Global View)』。

在單一檔案中,你可以一眼看完所有狀態的流轉規則。沒有類別爆炸,沒有循環依賴,增加新狀態只需要加一個 Enum 和幾個 case。讓我們把這些分散的碎片拼回去,回歸最簡單直白的設計。」

重構與優化:集中管理的簡單狀態機

我們將捨棄所有多餘的狀態類別與介面,將邏輯全部收攏回 Document 實體本身。利用 TypeScript 的 Enum 與 Switch,我們打造出一個防呆、清晰且集中管理的微型狀態機。

// 檔案:src/models/document/Document.ts

// 1. 定義簡單明確的列舉
export enum DocumentStatus {
  DRAFT = 'DRAFT',
  REVIEWING = 'REVIEWING',
  PUBLISHED = 'PUBLISHED',
  ARCHIVED = 'ARCHIVED' // 輕鬆新增的狀態
}

export class Document {
  // 將狀態以單一變數儲存
  public status: DocumentStatus;

  constructor(public id: string, public content: string) {
    this.status = DocumentStatus.DRAFT;
  }

  // 2. 集中管理動作與狀態轉換規則
  public submitForReview(): void {
    // 邊界與安全檢查 (Agent Reach 防呆精神)
    if (!this.content || this.content.trim() === '') {
      throw new Error("Cannot submit an empty document.");
    }

    switch (this.status) {
      case DocumentStatus.DRAFT:
        console.log("Submitting document for review...");
        this.status = DocumentStatus.REVIEWING;
        break;
      default:
        throw new Error(`Invalid action: Cannot submit document from status ${this.status}.`);
    }
  }

  public approve(): void {
    switch (this.status) {
      case DocumentStatus.REVIEWING:
        console.log("Document approved. Publishing...");
        this.status = DocumentStatus.PUBLISHED;
        break;
      default:
        throw new Error(`Invalid action: Cannot approve document from status ${this.status}.`);
    }
  }

  public reject(): void {
    switch (this.status) {
      case DocumentStatus.REVIEWING:
        console.log("Document rejected. Returning to draft.");
        this.status = DocumentStatus.DRAFT;
        break;
      default:
        throw new Error(`Invalid action: Cannot reject document from status ${this.status}.`);
    }
  }

  public archive(): void {
    // 新增動作變得極為簡單,邏輯集中
    switch (this.status) {
      case DocumentStatus.PUBLISHED:
      case DocumentStatus.DRAFT:
        console.log("Archiving document...");
        this.status = DocumentStatus.ARCHIVED;
        break;
      default:
        throw new Error(`Invalid action: Cannot archive document from status ${this.status}.`);
    }
  }
}

重構亮點分析

  1. 消滅類別爆炸:檔案數量從 5 個縮減為 1 個。沒有多餘的 State 介面,也沒有分散在各處的實作類別。
  2. 全局視角的掌控感:當開發者想了解「approve 這個動作在哪些狀態下合法」,他們只需要看 approve() 方法裡面的 5 行程式碼。在原來的設計中,他們必須打開每一個狀態類別去檢查是否拋出例外。
  3. 無痛擴充新狀態:加入 ARCHIVED 狀態變得極其輕鬆,只需要在 Enum 增加一個值,並在對應允許該動作的方法中加上一行 case。不需要去每個舊有的狀態類別中補寫無意義的樣板代碼。
  4. 完全消除循環依賴:原先各個狀態物件必須不斷 new 其他狀態物件來進行切換,產生了複雜的記憶體參照與耦合。現在狀態只是一個輕量的字串列舉。

效益分析表格與解讀

透過「反模式化」的重構,我們以極簡的思維大幅提升了程式碼的工程品質。

評估維度 過度設計 (State Pattern) 極簡設計 (Enum + Switch) 改善程度 / 解讀
檔案數量 5+ 個檔案 1 個檔案 降低了尋找邏輯時的目錄跳轉負擔。
程式碼行數 (LOC) 80+ 行 (充滿樣板代碼) 40+ 行 (純粹核心邏輯) 減少 50%。移除了無數的 implements DocumentState 與重複的 Error 拋出。
狀態流轉可視性 碎片化、難以追蹤全局 集中化、一目了然 將行為集中在對應的方法中,符合人類的直覺思維模型。
模組間依賴 嚴重的循環依賴 (Circular) 零依賴 Document 類別完全獨立自主,不依賴外部的狀態管理類別。
擴充成本 高 (需修改所有既有類別) 極低 (僅增加 Enum 項目與 Case) 實踐了真正的敏捷開發,能快速反應業務需求的變化。

結論總結

設計模式是前人智慧的結晶,但絕非放諸四海皆準的萬靈丹。Ponytail 哲學的核心在於「適才適所」。對於一個簡單的有限狀態機,最古老、最基礎的 Switch/Case 往往才是最合適的工具。它提供了無可比擬的直觀性與集中性。

下次當你想在簡單的 CRUD 應用中套用各種高大上的設計模式時,不妨停下來問問自己:「我真的需要這麼複雜嗎?還是我只是想展現我懂這個模式?」 回歸簡單,才是最高級的工程智慧。