想像一個場景:你去便利商店買咖啡,結帳時店員卻直接伸手進你的皮包拿錢找零。這顯然非常失禮—正確的方式是你遞出錢,讓店員處理,而不是讓他直接翻你的口袋。
物件之間的溝通也有類似的禮儀。在物件導向程式設計中,迪米特法則(Law of Demeter,簡稱 LoD) 正是在告訴我們這件事:一個物件只應該跟它的「直接朋友」溝通,而不要跨越多層去存取「朋友的朋友」的內部資料。
根據 Wikipedia 的說法,迪米特法則由 Ian Holland 在 1987 年於美國東北大學提出,核心精神是:一個方法只能呼叫自己的方法、方法傳入的參數、自己建立的物件,以及自己直接擁有的成員物件的方法。
換句話說,如果你在程式碼中看到 a.b().c().d() 這種串鏈呼叫,就是一個警訊—這叫做火車事故(Train Wreck),因為每多加一節車廂,整列火車就更難操控、更容易出軌。
與迪米特法則緊密相連的,是 Martin Fowler 在他的 bliki 中提倡的另一個原則—Tell, Don't Ask(命令,別問)。
這個原則告訴我們:與其先「詢問」一個物件的內部狀態、再根據查詢結果自己動手,不如直接「告訴」物件要做什麼,讓物件自己決定如何處理。一旦你開始在外部讀取物件的資料然後做判斷,就代表這段邏輯本來應該屬於那個物件—你只是把它放錯地方了。
接下來我們透過兩個常見的業務場景,分別看看這兩個原則被違反時,程式碼會長什麼樣子。
// 不好的範例:電商訂單系統的呼叫鏈問題
interface Address {
city: string;
street: string;
zipCode: string;
}
interface Customer {
name: string;
address: Address; // 問題:address 直接對外暴露,任何人都能穿透進去
}
interface OrderItem {
productName: string;
quantity: number;
price: number;
}
interface Order {
id: string;
customer: Customer; // 問題:customer 直接對外暴露,連帶 address 也一起暴露
items: OrderItem[];
}
// 物流服務
class ShippingService {
printShippingLabel(order: Order): void {
// 問題:穿越 order → customer → address → city,共三層!
// ShippingService 被迫知道 Customer 和 Address 的內部結構
const city = order.customer.address.city;
const name = order.customer.name;
console.log(`收件人:${name}`);
console.log(`寄送城市:${city}`);
}
calculateShippingFee(order: Order): number {
// 問題:又一次穿越 order → customer → address → zipCode
// 只要 Customer 或 Address 的結構稍有異動,這裡就立刻爆炸
const zipCode = order.customer.address.zipCode;
if (zipCode.startsWith('10') || zipCode.startsWith('11')) {
return 60; // 台北市
}
return 120;
}
}
這段程式碼的問題在於:ShippingService 需要知道 Order 裡面有 Customer,Customer 裡面有 Address,Address 裡面有 city 和 zipCode—整整三層的內部結構全都暴露在外部。
日後只要 Customer 決定把 address 改成 addresses(支援多組地址),或是 Address 把 zipCode 改名為 postalCode,ShippingService 就得跟著修改。這正是迪米特法則想阻止的:不應該讓遠端物件的變動波及到與它八竿子打不著的類別。
// 不好的範例:銀行帳戶系統的邏輯外洩問題
class Account {
balance: number; // 問題:餘額直接對外公開,任何人都能讀取甚至修改
constructor(initialBalance: number) {
this.balance = initialBalance;
}
}
// 提款服務
class BankService {
withdraw(account: Account, amount: number): boolean {
// 問題一:先「詢問」account 的 balance(讀取內部狀態)
if (account.balance >= amount) {
// 問題二:確認後再自己動手修改 account 的 balance(操作內部狀態)
account.balance -= amount;
return true;
}
return false;
}
// 問題三:轉帳功能又複製了一份完全相同的判斷邏輯
// 若日後 ATMService、MobileAppService 也這樣寫,就會出現第三份、第四份
transferFunds(
fromAccount: Account,
toAccount: Account,
amount: number
): boolean {
if (fromAccount.balance >= amount) { // 判斷邏輯重複出現!
fromAccount.balance -= amount;
toAccount.balance += amount;
return true;
}
return false;
}
}
這段程式碼違反了「Tell, Don't Ask」的精神:BankService 先詢問 account.balance,確認餘額之後再自己動手修改 account.balance。「帳戶餘額是否足夠、如何扣款」這個業務邏輯,本來應該屬於 Account 自己,卻被搬到了外部。
更嚴重的是,這種外部操作的邏輯很容易散落到多個地方,每個地方都重複一次 balance >= amount 的判斷。日後若規則改變(例如允許帳戶小額透支),就必須找遍所有呼叫點一一修改—這正是 Bug 最容易滋生的溫床。
// 修正範例:透過封裝消除火車事故呼叫鏈
interface Address {
city: string;
street: string;
zipCode: string;
}
interface OrderItem {
productName: string;
quantity: number;
price: number;
}
// 改動重點 1:Customer 改為 class,把 address 設為私有屬性
class Customer {
private readonly address: Address;
constructor(
public readonly name: string,
address: Address
) {
this.address = address;
}
// 改動重點 2:只暴露「需要對外說的資訊」,不讓外部直接穿透到 address 內部
getCity(): string {
return this.address.city;
}
getZipCode(): string {
return this.address.zipCode;
}
}
// 改動重點 3:Order 也把 customer 設為私有,並提供語意清晰的封裝方法
class Order {
constructor(
public readonly id: string,
private readonly customer: Customer,
public readonly items: OrderItem[]
) {}
// 改動重點 4:封裝「取得運送資訊」的操作,ShippingService 只需問 Order 即可
getShippingCity(): string {
return this.customer.getCity();
}
getRecipientName(): string {
return this.customer.name;
}
getShippingZipCode(): string {
return this.customer.getZipCode();
}
}
// 改動重點 5:ShippingService 只跟直接朋友(Order)溝通
// 完全不需要知道 Customer 和 Address 的存在
class ShippingService {
printShippingLabel(order: Order): void {
// 清晰的語意呼叫,沒有火車事故
const city = order.getShippingCity();
const name = order.getRecipientName();
console.log(`收件人:${name}`);
console.log(`寄送城市:${city}`);
}
calculateShippingFee(order: Order): number {
// 若 Customer 或 Address 的內部結構改變,ShippingService 完全不受影響
const zipCode = order.getShippingZipCode();
if (zipCode.startsWith('10') || zipCode.startsWith('11')) {
return 60;
}
return 120;
}
}
// 修正範例:讓 Account 自己管理帳戶邏輯
class Account {
private balance: number; // 改動重點 1:餘額設為私有,外部無法直接存取或修改
constructor(initialBalance: number) {
this.balance = initialBalance;
}
// 改動重點 2:若有查詢需求,提供唯讀的方法
getBalance(): number {
return this.balance;
}
// 改動重點 3:把「判斷餘額 + 扣款」的邏輯收回 Account 內部
// 外部只需「告訴」Account 要提款,不需要自己讀取餘額再計算
withdraw(amount: number): boolean {
if (amount <= 0) {
throw new Error('提款金額必須大於零');
}
if (this.balance < amount) {
return false; // 餘額不足,提款失敗
}
this.balance -= amount;
return true;
}
// 改動重點 4:存款邏輯也收回 Account 內部,職責明確
deposit(amount: number): void {
if (amount <= 0) {
throw new Error('存款金額必須大於零');
}
this.balance += amount;
}
}
// 改動重點 5:BankService 改為只「告訴」Account 要做什麼,不再「詢問後自己動手」
class BankService {
withdraw(account: Account, amount: number): boolean {
return account.withdraw(amount); // 委託給 Account 自己處理,業務邏輯只存在一份
}
transferFunds(
fromAccount: Account,
toAccount: Account,
amount: number
): boolean {
// 各自告訴各自的帳戶要做什麼,BankService 不再直接碰任何 balance
if (fromAccount.withdraw(amount)) {
toAccount.deposit(amount);
return true;
}
return false;
}
}
// 使用範例—語意清晰,告訴帳戶要做什麼,而不是自己翻帳戶的口袋
const myAccount = new Account(1000);
const savings = new Account(500);
const service = new BankService();
console.log(service.withdraw(myAccount, 200)); // true(提款成功,餘額剩 800)
console.log(service.transferFunds(myAccount, savings, 300)); // true(轉帳成功,餘額剩 500)
console.log(service.withdraw(myAccount, 1000)); // false(餘額不足)
Customer 將 address 設為私有,Order 將 customer 設為私有,各自只暴露需要對外說的方法,外部不再能穿透多層存取ShippingService 只認識 Order,完全不知道 Customer 和 Address 的存在;日後 Customer 或 Address 的內部結構改變,ShippingService 完全不受影響Account 內部,判斷規則只存在一份,未來規則變更(例如允許透支上限)只需修改 Account 一個地方account.withdraw(amount) 就是在「告訴」帳戶要提款;相較之下,先讀取 account.balance 再自己扣款,是在「詢問後自己動手」—前者把職責留在物件內部,後者把職責偷偷搬走了這時候我們可以發現,兩個原則的核心其實是同一件事:讓資料與操作它的邏輯待在一起,而不是把邏輯散落到每一個使用這份資料的地方。
綜合以上所述,迪米特法則與 Tell, Don't Ask 從不同角度呼應了同一個設計直覺:
值得一提的是,當你看到一段程式碼「對著另一個物件的資料反覆存取、操作」—例如 BankService 一直盯著 account.balance 做判斷—這在 Martin Fowler 的《Refactoring》一書中有個名字,叫做依戀情結(Feature Envy)。這個異味的意思是:某個方法對別的類別的資料太過著迷,彷彿那份資料才是它真正的家。修正的方式,正是把這個方法移回資料所在的類別—這和 Tell, Don't Ask 的解法完全一致。
換句話說,這也正是我們在 Day 6 討論單一功能原則(SRP)時反覆強調的職責歸屬問題:帳戶餘額的驗證與扣款,理應是「帳戶」這個類別的職責,若把它放在 BankService 裡,就是讓同一個職責分散在多個地方,違反了 SRP 的精神。迪米特法則與 Tell, Don't Ask,正是幫助我們在實作層面識別出這類職責錯位的工具。
需要注意的是,迪米特法則有時會讓人走向另一個極端—為了避免呼叫鏈,在中間層加入大量的轉發方法,導致類別介面膨脹。這在實務上叫做過度委派(over-delegation)。若呼叫鏈跨越的都是穩定的值物件(Value Object,如座標、貨幣金額),偶爾打破法則是可以接受的。原則是工具,不是枷鎖。
下次當你看到 a.b.c.d 這種呼叫鏈,或是一段程式碼先讀取物件資料、再根據結果自己操作,不妨停下來問一句:「我是在跟陌生人說話嗎?我是在翻別人的口袋嗎?」