DRY 原則:當程式碼重複成為技術債

  1. 理解 DRY 原則
  2. 明顯的重複:複製貼上程式設計
  3. 微妙的重複:分散的業務邏輯
  4. 何時重複是可接受的
  5. 過早抽象的危險
  6. 真實世界的重構故事
  7. 應用 DRY:實用指南
  8. 結論

DRY(Don’t Repeat Yourself,不要重複自己)原則是軟體開發中最常被引用的準則之一。由 Andy Hunt 和 Dave Thomas 在《程式設計師修煉之道》中提出,它承諾更乾淨的程式碼、更容易的維護和更少的錯誤。然而這個看似簡單的原則——避免重複程式碼——在實踐中變得出乎意料地複雜。開發者掙扎於這些問題:何時重複是可接受的?多少抽象才算太多?遵循 DRY 真的會讓程式碼變得更糟嗎?

本文透過真實場景探討 DRY 原則,從明顯的複製貼上違規到微妙的知識重複。我們將剖析何時應該消除重複、何時暫時容忍它,以及何時過早抽象會造成更多問題。從生產環境程式碼庫和重構經驗中,我們揭示為什麼 DRY 既是必要的又是危險的。

理解 DRY 原則

在深入探討何時以及如何應用 DRY 之前,理解這個原則實際上的意義是必要的。DRY 不僅僅是避免複製貼上——它關乎知識的表示。

核心概念

DRY 原則指出:「每一項知識在系統中都必須有單一、明確、權威的表示。」這超越了單純的程式碼重複:

📚 DRY 範圍

程式碼重複

  • 相同或相似的程式碼區塊重複出現
  • 相同邏輯被實作多次
  • 複製貼上的程式設計模式
  • 最明顯的 DRY 違規形式

知識重複

  • 業務規則在多處編碼
  • 驗證邏輯分散在各層
  • 常數和配置重複
  • 資料庫結構在程式碼中鏡像

文件重複

  • 註解重複程式碼的功能
  • API 文件重複實作內容
  • 相同資訊有多個真相來源
  • 文件來源之間的不一致

這個原則強調「知識」而非「程式碼」,因為真正的問題不是文字相似性——而是在多個地方維護相同的概念。當業務規則改變時,你不應該需要在十個不同的位置更新程式碼。

為什麼 DRY 很重要

重複會造成維護負擔並引入錯誤:

⚠️ 重複的成本

維護負擔

  • 變更需要在多個位置更新
  • 更新時容易遺漏某個實例
  • 增加開發者的認知負荷
  • 使程式碼庫更難理解

錯誤倍增

  • 錯誤在所有副本中重複
  • 修復必須套用到所有地方
  • 不一致的修復造成微妙的錯誤
  • 測試負擔倍增

不一致風險

  • 副本隨時間分歧
  • 不同情境中的不同行為
  • 難以確定正確版本
  • 造成混淆和錯誤

這些成本隨時間累積。今天的小重複會隨著程式碼庫演進成為維護噩夢。

明顯的重複:複製貼上程式設計

最明目張膽的 DRY 違規來自複製貼上程式設計——複製整個程式碼區塊並稍作修改。

經典的複製貼上違規

考慮這個網頁應用程式中的常見模式:

# 使用者註冊端點
@app.route('/register', methods=['POST'])
def register():
    username = request.form.get('username')
    email = request.form.get('email')
    password = request.form.get('password')
    
    # 驗證
    if not username or len(username) < 3:
        return jsonify({'error': 'Username must be at least 3 characters'}), 400
    if not email or '@' not in email:
        return jsonify({'error': 'Invalid email address'}), 400
    if not password or len(password) < 8:
        return jsonify({'error': 'Password must be at least 8 characters'}), 400
    
    # 建立使用者
    user = User(username=username, email=email, password=hash_password(password))
    db.session.add(user)
    db.session.commit()
    
    return jsonify({'message': 'User created successfully'}), 201

# 個人資料更新端點 - 重複的驗證
@app.route('/profile/update', methods=['POST'])
def update_profile():
    user_id = get_current_user_id()
    username = request.form.get('username')
    email = request.form.get('email')
    
    # 相同的驗證邏輯重複了
    if not username or len(username) < 3:
        return jsonify({'error': 'Username must be at least 3 characters'}), 400
    if not email or '@' not in email:
        return jsonify({'error': 'Invalid email address'}), 400
    
    # 更新使用者
    user = User.query.get(user_id)
    user.username = username
    user.email = email
    db.session.commit()
    
    return jsonify({'message': 'Profile updated successfully'}), 200

驗證邏輯被重複了。當需求改變時——比如說,使用者名稱最小長度增加到 5 個字元——你必須更新兩個位置。遺漏一個,你就會有不一致的行為。

這是複製貼上程式設計反模式——複製程式碼而不是提取可重用元件,當邏輯需要改變時就會造成維護噩夢。

重構為 DRY

將重複的驗證提取為可重用的函式:

# 驗證函式 - 單一真相來源
def validate_username(username):
    if not username or len(username) < 3:
        raise ValueError('Username must be at least 3 characters')
    return username

def validate_email(email):
    if not email or '@' not in email:
        raise ValueError('Invalid email address')
    return email

def validate_password(password):
    if not password or len(password) < 8:
        raise ValueError('Password must be at least 8 characters')
    return password

# 註冊端點 - 使用驗證函式
@app.route('/register', methods=['POST'])
def register():
    try:
        username = validate_username(request.form.get('username'))
        email = validate_email(request.form.get('email'))
        password = validate_password(request.form.get('password'))
    except ValueError as e:
        return jsonify({'error': str(e)}), 400
    
    user = User(username=username, email=email, password=hash_password(password))
    db.session.add(user)
    db.session.commit()
    
    return jsonify({'message': 'User created successfully'}), 201

# 個人資料更新端點 - 重用相同的驗證
@app.route('/profile/update', methods=['POST'])
def update_profile():
    user_id = get_current_user_id()
    
    try:
        username = validate_username(request.form.get('username'))
        email = validate_email(request.form.get('email'))
    except ValueError as e:
        return jsonify({'error': str(e)}), 400
    
    user = User.query.get(user_id)
    user.username = username
    user.email = email
    db.session.commit()
    
    return jsonify({'message': 'Profile updated successfully'}), 200

現在驗證規則只存在於一個地方。變更會自動傳播到所有使用點。

維護的勝利

重構後的版本展示了 DRY 的價值:

✅ DRY 的好處

單一真相來源

  • 驗證規則只定義一次
  • 變更自動更新所有端點
  • 沒有不一致驗證的風險

更容易測試

  • 獨立測試驗證函式
  • 端點測試專注於業務邏輯
  • 減少測試重複

更好的可讀性

  • 端點程式碼專注於工作流程
  • 驗證細節被抽象化
  • 沒有重複程式碼,意圖更清晰

這是 DRY 最好的狀態:消除沒有任何目的的明顯重複。

微妙的重複:分散的業務邏輯

比複製貼上重複更隱蔽的是分散在程式碼庫中的業務邏輯——相同的概念在多個地方以不同方式實作。

分散的計算問題

考慮一個電子商務系統計算訂單總額:

// 在購物車元件中
function calculateCartTotal(items) {
    let total = 0;
    for (const item of items) {
        total += item.price * item.quantity;
    }
    // 對超過 $100 的訂單套用 10% 折扣
    if (total > 100) {
        total = total * 0.9;
    }
    return total;
}

// 在訂單確認元件中 - 重複的邏輯
function calculateOrderTotal(order) {
    let subtotal = 0;
    for (const item of order.items) {
        subtotal += item.price * item.quantity;
    }
    // 相同的折扣邏輯重複了
    if (subtotal > 100) {
        subtotal = subtotal * 0.9;
    }
    return subtotal;
}

// 在發票產生器中 - 再次重複
function generateInvoice(order) {
    let amount = 0;
    order.items.forEach(item => {
        amount += item.price * item.quantity;
    });
    // 折扣邏輯第三次重複
    if (amount > 100) {
        amount = amount - (amount * 0.1);
    }
    return {
        orderId: order.id,
        total: amount,
        // ... 其他欄位
    };
}

相同業務規則的三種不同實作。當折扣改為超過 $150 的訂單 15% 折扣時,你必須找到並更新所有三個位置。遺漏一個,客戶就會在應用程式的不同部分看到不同的總額。

集中化業務邏輯

將業務規則提取為單一、權威的實作:

// 業務邏輯層 - 單一真相來源
class OrderCalculator {
    static DISCOUNT_THRESHOLD = 100;
    static DISCOUNT_RATE = 0.1;
    
    static calculateSubtotal(items) {
        return items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
    }
    
    static calculateDiscount(subtotal) {
        if (subtotal > this.DISCOUNT_THRESHOLD) {
            return subtotal * this.DISCOUNT_RATE;
        }
        return 0;
    }
    
    static calculateTotal(items) {
        const subtotal = this.calculateSubtotal(items);
        const discount = this.calculateDiscount(subtotal);
        return subtotal - discount;
    }
}

// 購物車 - 使用集中化的邏輯
function calculateCartTotal(items) {
    return OrderCalculator.calculateTotal(items);
}

// 訂單確認 - 使用相同的邏輯
function calculateOrderTotal(order) {
    return OrderCalculator.calculateTotal(order.items);
}

// 發票產生器 - 使用相同的邏輯
function generateInvoice(order) {
    return {
        orderId: order.id,
        subtotal: OrderCalculator.calculateSubtotal(order.items),
        discount: OrderCalculator.calculateDiscount(
            OrderCalculator.calculateSubtotal(order.items)
        ),
        total: OrderCalculator.calculateTotal(order.items),
    };
}

業務規則現在只存在於一個地方。折扣門檻和比率是可配置的常數。所有元件使用相同的計算邏輯,保證一致性。

🎯 業務邏輯集中化

識別業務規則

  • 實作業務需求的計算
  • 強制業務約束的驗證規則
  • 代表業務流程的工作流程
  • 任何可能基於業務決策而改變的邏輯

建立領域層

  • 將業務邏輯與展示和基礎設施分離
  • 使業務規則明確且可測試
  • 在程式碼中使用領域特定語言
  • 記錄業務規則來源(需求、法規)

強制單一來源

  • 所有元件使用集中化的業務邏輯
  • 不重新實作業務規則
  • 配置優於重複
  • 程式碼審查捕捉分散的邏輯

何時重複是可接受的

並非所有重複都是有害的。有時重複是正確的選擇,至少暫時如此。

巧合的重複

看起來相似但代表不同概念的程式碼不應該去重複化:

# 使用者身份驗證
def validate_user_password(password):
    if len(password) < 8:
        raise ValueError('Password too short')
    return True

# WiFi 密碼配置
def validate_wifi_password(password):
    if len(password) < 8:
        raise ValueError('Password too short')
    return True

這些函式看起來相同,但它們驗證的是不同的東西。使用者密碼可能很快就需要特殊字元,而 WiFi 密碼可能需要不同的規則。將它們合併會在不相關的概念之間建立耦合:

# 不好:過早抽象
def validate_password(password, password_type):
    if password_type == 'user':
        if len(password) < 8:
            raise ValueError('Password too short')
        # 未來:檢查特殊字元
    elif password_type == 'wifi':
        if len(password) < 8:
            raise ValueError('Password too short')
        # 未來:不同的規則
    return True

這個抽象比重複更糟。它耦合了不相關的概念,使未來的變更更困難。

🔍 巧合 vs. 真實重複

巧合的重複(保持分離)

  • 程式碼現在碰巧看起來相似
  • 代表不同的領域概念
  • 未來可能會分歧
  • 因不同原因而改變

真實的重複(消除)

  • 相同概念實作多次
  • 因相同原因一起改變
  • 代表單一知識片段
  • 分歧表示錯誤

「三次法則」有幫助:容忍重複直到你有三個實例,然後考慮抽象。這可以防止基於巧合相似性的過早抽象。

跨邊界的重複

跨架構邊界的重複通常是可接受的:

# 資料庫模型
class User(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(50), nullable=False)
    email = db.Column(db.String(100), nullable=False)

# API 回應模型
class UserResponse:
    def __init__(self, id, username, email):
        self.id = id
        self.username = username
        self.email = email

# 前端 TypeScript 介面
interface User {
    id: number;
    username: string;
    email: string;
}

User 結構在資料庫、後端和前端之間重複。這種重複是有意的——它解耦了各層。資料庫模型可以改變而不影響 API 契約。API 可以演進而不強制前端改變。

🏗️ 架構邊界

何時重複解耦

  • 層之間(資料庫、業務邏輯、展示)
  • 微服務架構中的服務之間
  • 內部和外部 API 之間
  • 具有不同生命週期的模組之間

邊界重複的好處

  • 各層可以獨立演進
  • 變更不會跨邊界級聯
  • 元件之間的清晰契約
  • 更容易獨立測試

過早抽象的危險

過度熱衷地應用 DRY 會導致過早抽象——在充分理解問題之前就建立抽象。

過度抽象的混亂

開發者看到兩個相似的函式就立即抽象:

// 原始函式
function sendWelcomeEmail(user) {
    const subject = 'Welcome to Our Service!';
    const body = `Hello ${user.name}, welcome aboard!`;
    sendEmail(user.email, subject, body);
}

function sendPasswordResetEmail(user, resetLink) {
    const subject = 'Password Reset Request';
    const body = `Hello ${user.name}, click here to reset: ${resetLink}`;
    sendEmail(user.email, subject, body);
}

// 過早抽象
function sendUserEmail(user, emailType, extraData = {}) {
    let subject, body;
    
    if (emailType === 'welcome') {
        subject = 'Welcome to Our Service!';
        body = `Hello ${user.name}, welcome aboard!`;
    } else if (emailType === 'password_reset') {
        subject = 'Password Reset Request';
        body = `Hello ${user.name}, click here to reset: ${extraData.resetLink}`;
    } else if (emailType === 'order_confirmation') {
        subject = 'Order Confirmation';
        body = `Hello ${user.name}, your order ${extraData.orderId} is confirmed!`;
    } else if (emailType === 'shipping_notification') {
        subject = 'Your Order Has Shipped';
        body = `Hello ${user.name}, order ${extraData.orderId} shipped via ${extraData.carrier}!`;
    }
    // ... 更多電子郵件類型
    
    sendEmail(user.email, subject, body);
}

這個抽象比原始的重複更糟:

🚫 過早抽象的問題

增加的複雜性

  • 單一函式處理多個不相關的情況
  • 條件邏輯隨每個電子郵件類型增長
  • 難以理解每個電子郵件類型的作用
  • 難以測試所有分支

脆弱的設計

  • 新增電子郵件類型需要修改中央函式
  • 變更有破壞現有電子郵件類型的風險
  • extraData 參數變成欄位的大雜燴
  • 失去類型安全(extraData 需要哪些欄位?)

更難改變

  • 無法修改一個電子郵件類型而不影響其他類型
  • 重構需要理解所有電子郵件類型
  • 害怕破壞現有功能
  • 諷刺的是比重複更難維護

更好的方法

與其過早抽象,不如使用組合和清晰的介面:

// 電子郵件範本介面
class EmailTemplate {
    constructor(user) {
        this.user = user;
    }
    
    getSubject() {
        throw new Error('Must implement getSubject');
    }
    
    getBody() {
        throw new Error('Must implement getBody');
    }
    
    send() {
        sendEmail(this.user.email, this.getSubject(), this.getBody());
    }
}

// 特定的電子郵件類型
class WelcomeEmail extends EmailTemplate {
    getSubject() {
        return 'Welcome to Our Service!';
    }
    
    getBody() {
        return `Hello ${this.user.name}, welcome aboard!`;
    }
}

class PasswordResetEmail extends EmailTemplate {
    constructor(user, resetLink) {
        super(user);
        this.resetLink = resetLink;
    }
    
    getSubject() {
        return 'Password Reset Request';
    }
    
    getBody() {
        return `Hello ${this.user.name}, click here to reset: ${this.resetLink}`;
    }
}

// 使用方式
new WelcomeEmail(user).send();
new PasswordResetEmail(user, resetLink).send();

這個設計消除了重複(電子郵件發送邏輯),同時保持電子郵件類型獨立且易於修改。

✅ 良好抽象原則

等待模式浮現

  • 不要在第一次重複時就抽象
  • 等到你有 3 個以上的實例
  • 在抽象之前理解程式碼如何變化

偏好組合而非條件

  • 使用繼承或組合
  • 避免大型條件區塊
  • 每個變體都是獨立的

保持抽象簡單

  • 單一職責原則
  • 清晰、專注的介面
  • 易於理解和測試

真實世界的重構故事

我曾經接手一個有嚴重重複問題的程式碼庫。應用程式有機地成長,開發者為了趕上截止日期而複製貼上程式碼。結果:相同的業務邏輯在數十個檔案中以不同方式實作。

發現問題

在一次例行的錯誤修復中,我發現折扣計算會根據應用程式中呼叫的位置產生不同的結果。購物車顯示一個總額,結帳頁面顯示另一個,發票又顯示第三個。全都略有不同。

🔍 重複災難

我發現的問題

  • 折扣邏輯在 12 個不同檔案中重複
  • 每個實作略有不同
  • 有些包含稅金,有些沒有
  • 不同的四捨五入策略
  • 邊緣情況處理不一致

影響

  • 客戶抱怨總額不斷變化
  • 支援團隊無法解釋差異
  • 會計對帳噩夢
  • 計算錯誤導致收入損失
  • 損害客戶信任

重構過程

我花了兩週時間提取和集中化業務邏輯:

# 之前:分散在 12 個檔案中,有各種變化
# 檔案 1:
total = sum(item.price * item.qty for item in items)
if total > 100:
    total = total * 0.9

# 檔案 2:
subtotal = 0
for item in items:
    subtotal += item.price * item.qty
discount = subtotal * 0.1 if subtotal > 100 else 0
total = subtotal - discount

# 檔案 3:
amount = sum([i.price * i.qty for i in items])
if amount >= 100:
    amount = amount - (amount * 0.1)
# ... 還有 9 個變化

# 之後:單一真相來源
class PricingEngine:
    DISCOUNT_THRESHOLD = Decimal('100.00')
    DISCOUNT_RATE = Decimal('0.10')
    
    @classmethod
    def calculate_subtotal(cls, items):
        return sum(
            Decimal(str(item.price)) * item.quantity 
            for item in items
        )
    
    @classmethod
    def calculate_discount(cls, subtotal):
        if subtotal >= cls.DISCOUNT_THRESHOLD:
            return (subtotal * cls.DISCOUNT_RATE).quantize(
                Decimal('0.01'), rounding=ROUND_HALF_UP
            )
        return Decimal('0.00')
    
    @classmethod
    def calculate_total(cls, items):
        subtotal = cls.calculate_subtotal(items)
        discount = cls.calculate_discount(subtotal)
        return subtotal - discount

重構揭示了 12 個實作中有 8 個有錯誤。有些使用浮點運算(造成四捨五入錯誤),其他有門檻檢查的差一錯誤,還有幾個忘記處理空購物車。

結果

部署集中化的定價引擎後:

✅ 重構成果

立即改善

  • 整個應用程式的總額一致
  • 客戶投訴降至零
  • 會計對帳簡化
  • 收入增加(錯誤正在損失金錢)

長期好處

  • 新的定價規則在一個地方實作
  • A/B 測試定價策略變得可能
  • 定價邏輯的全面測試套件
  • 對進行定價變更有信心

學到的教訓

  • 重複隱藏錯誤
  • 不一致損害使用者信任
  • 重構很快就能回本
  • DRY 關乎正確性,不只是可維護性

這次經驗強化了 DRY 不僅僅是減少程式碼——它是透過單一真相來源確保正確性。

應用 DRY:實用指南

知道何時以及如何應用 DRY 需要判斷力。這些指南有助於導航決策:

🎯 DRY 決策框架

何時消除重複:

  • 多個地方有相同的業務邏輯
  • 變更需要在多個位置更新
  • 不一致造成錯誤或混淆
  • 重複沒有架構目的

何時容忍重複:

  • 程式碼巧合相似
  • 重複解耦架構層
  • 抽象會是過早的
  • 你有少於 3 個實例

謹慎重構:

  • 在抽象之前理解問題
  • 偏好簡單的抽象而非複雜的
  • 使用組合而非條件邏輯
  • 重構後徹底測試
  • 記錄抽象的目的

結論

DRY 原則——Don’t Repeat Yourself(不要重複自己)——是軟體品質的基石,但其應用需要細膩和判斷力。其核心在於,DRY 不是要消除每一個看起來相似的程式碼實例;而是確保每一項知識在系統中都有單一、權威的表示。

透過複製貼上程式設計的明顯重複會造成立即的維護負擔。當驗證邏輯、計算或業務規則分散在多個檔案中時,變更變得容易出錯,不一致不可避免。將這種重複提取為可重用的函式或類別提供了明確的好處:單一真相來源、更容易測試,以及減少錯誤倍增。

微妙的重複——分散在元件中的業務邏輯——構成更大的危險,因為它更難檢測。當相同的概念在多個地方以不同方式實作時,程式碼庫就成為不一致的雷區。將業務邏輯集中到領域層確保一致性,並使業務規則明確且可測試。

然而,並非所有重複都是有害的。巧合的重複——碰巧看起來相似但代表不同概念的程式碼——應該保持分離。基於表面相似性的過早抽象會在不相關的概念之間建立耦合,使未來的變更更困難。三次法則提供了指導:容忍重複直到你有三個實例,然後考慮抽象是否合理。

跨架構邊界的重複通常有其目的。在資料庫模型、API 契約和前端介面之間重複資料結構可以解耦各層,並允許獨立演進。這種有意的重複提供了靈活性和元件之間的清晰契約。

過早抽象的危險不容小覷。過度熱衷地應用 DRY 會導致複雜的、充滿條件的函式,比原始的重複更難理解和維護。良好的抽象來自理解多個實例的模式,而不是消除你看到的第一個重複。偏好組合和清晰的介面,而非條件邏輯和參數驅動的行為。

真實世界的經驗表明,重複隱藏錯誤並造成損害使用者信任的不一致。將重複的業務邏輯重構為單一真相來源不僅改善可維護性,還經常揭示並修復存在於分散實作中的錯誤。重構的投資透過增加的正確性和進行變更的信心而回本。

有效應用 DRY 的關鍵在於區分有害的重複和可接受的相似性。問問自己:這個重複代表相同的知識嗎?這些片段會因相同原因一起改變嗎?消除這個重複會在不相關的概念之間建立耦合嗎?答案會指導你是否應該重構或容忍重複。

DRY 最終關乎可維護性和正確性。當業務規則存在於多個地方時,變更是有風險的,不一致是不可避免的。當知識有單一權威的表示時,變更會自動傳播,正確性更容易驗證。但實現這一點需要判斷力——知道何時抽象、何時等待,以及何時重複有其目的。

在反射性地消除每一個相似程式碼的實例之前,考慮你是在移除有害的重複還是在建立過早的抽象。目標不是零重複——而是一個知識被清晰、權威地表示一次,同時保持演進的靈活性的程式碼庫。

分享到