在寫程式的時候,你可能聽過有人說「這段程式碼耦合太緊了」或「應該要更鬆散一點」。
但「耦合」到底是什麼意思?
什麼時候該讓程式碼鬆散耦合?又有什麼代價?
這篇文章會用一個簡單的例子,帶你搞懂 Tight Coupling 和 Loose Coupling 的差別,以及實務上怎麼做取捨。
什麼是耦合(Coupling)?
耦合指的是系統中兩個部分之間的「相依程度」——這兩個部分在多大程度上依賴彼此、互相牽扯。
你可以把它想像成兩塊程式碼之間連了幾條線。
每一條線都代表一個依賴關係:可能是 A 直接呼叫了 B 的某個函式,可能是 A 用了 B 內部的資料結構,也可能是 A 的行為會因為 B 的改動而改變。
線越多,代表這兩塊程式碼之間的牽扯越深,改動其中一邊的時候,另一邊就越容易跟著壞掉。
當兩段程式碼之間的連線很多、邏輯和實作細節混在一起、彼此高度相依,就叫做 Tight Coupling(緊耦合)。
緊耦合:改一邊,壞另一邊
在緊耦合的情況下,你幾乎不可能單獨修改其中一個模組。
改了 A,B 就壞了;改了 B,A 也得跟著調整。
兩邊的程式碼就像被膠水黏在一起一樣。
鬆耦合:各自獨立,互不干擾
反過來,如果兩段程式碼之間的連線很少,甚至只透過一個簡單的介面溝通,各自獨立、互不干擾,就叫做 Loose Coupling(鬆耦合)。
在鬆耦合的情況下,你可以放心地修改 A 的內部實作,只要對外的介面沒變,B 完全不會受到影響。
一個最直覺的判斷方式是:當你改了 A 模組的程式碼,B 模組會不會跟著壞掉?如果會,那它們就是緊耦合的。
耦合的好朋友:內聚(Cohesion)
提到耦合,就不能不提另一個密切相關的概念——內聚(Cohesion)。
耦合講的是「模組與模組之間」的相依程度,而內聚講的是「同一個模組裡面」的程式碼有多合拍。
你可以這樣想:把每個模組內部的函式、變數、邏輯想像成一群節點,節點之間的連線代表它們的關聯程度。
高內聚:每個模組各司其職
高內聚代表模組裡面的程式碼都在為同一個目標工作。
每個函式、每個變數都跟這個模組的核心職責有關,彼此之間有明確的關聯。
當你打開這個模組的程式碼,一眼就能看出「這個模組在做什麼」。
低內聚:什麼都做,什麼都不精
低內聚代表模組裡面的程式碼各做各的,彼此之間沒什麼關係。
你打開這個模組,會發現裡面什麼功能都有,但沒有一個明確的主題。
這些程式碼湊在一起,通常只是因為當初不知道該放哪,而不是因為它們真的有關聯。
耦合和內聚是連動的
有趣的是,耦合和內聚通常是連動的。
當兩個模組緊耦合的時候,它們的邏輯互相滲透,各自的模組裡面反而會混入不屬於自己的職責,導致低內聚。
反過來,當兩個模組鬆耦合的時候,各自的邊界清楚,每個模組只專注在自己的事情上,自然就有高內聚。
所以理想的設計目標是:低耦合、高內聚。
模組之間的線越少越好(低耦合),模組內部的程式碼越合拍越好(高內聚)。
用一個實際案例來理解
假設你正在開發一個簡單的系統:從硬碟讀取一個 CSV 檔案,然後對裡面的資料做一些計算,產出一份報表。
這個系統拆開來看,有兩大塊各自獨立的邏輯:
第一塊是讀取和解析 CSV 檔案,我們把它叫做 parseCsv()。
負責把硬碟上的原始檔案讀進來,把裡面用逗號分隔的文字拆成一筆一筆的資料。
第二塊是對資料做計算、產出報表,我們把它叫做 calculate()。
負責拿到資料之後做加總、統計、排序之類的運算,然後把結果整理成報表輸出。
另外還有一個程式的進入點 main(),負責啟動整個流程。
這三個函式的職責很清楚,理論上應該各做各的。
但實際上怎麼把它們組合在一起,就會決定你的程式碼是緊耦合還是鬆耦合。
緊耦合的寫法
在一種常見的寫法中,main() 會呼叫 calculate() 來產出報表。
而 calculate() 內部會直接呼叫 parseCsv() 來讀取和解析 CSV 檔案。
parseCsv() 回傳的是 CSV 專屬的 row 物件,calculate() 拿到這些物件之後,就直接用它們來跑計算,最後把結果和 CSV 資料綁在一起回傳。
整個呼叫流程是:main() → calculate() → parseCsv()。
看起來很直覺,但問題就藏在這個流程裡。
這樣寫有什麼問題?
calculate() 不只直接呼叫了 parseCsv(),還在內部直接操作它回傳的 CSV row 物件——這代表計算邏輯深度依賴了解析邏輯的內部資料結構。
你會發現,parseCsv() 的解析職責和 calculate() 的計算職責,這兩個本來不同的工作,已經開始攪在一起了。
這時候如果需求改了,比如說要改成讀 Excel 檔而不是 CSV 檔,你就得追蹤「CSV」這個概念滲透到 calculate() 的多深,改動範圍可能比你想像的大。
鬆耦合的寫法
如果要讓這兩塊邏輯鬆耦合,關鍵是讓 calculate() 和 parseCsv() 不再直接認識彼此。
做法是這樣的:
首先,parseCsv() 獨立存在,只負責解析 CSV 檔案,不和任何計算邏輯混在一起。
接著,calculate() 定義一個介面(interface),說明它期望接收什麼格式的資料。
它不在乎資料是從 CSV 來的、從 Excel 來的、還是從 API 來的,它只關心「給我符合這個格式的資料就好」。
最後,main() 扮演膠水程式碼(glue code)的角色。
它負責先呼叫 parseCsv() 拿到原始資料,把資料轉換成 calculate() 期望的格式,然後再把轉換後的資料傳給 calculate()。
整個流程變成:main() 先呼叫 parseCsv(),再呼叫 calculate(),兩邊各自獨立,透過 main() 串接。
注意跟緊耦合的差別:原本是 calculate() 自己去呼叫 parseCsv(),現在 calculate() 完全不知道 parseCsv() 的存在。
這樣一來,你可以隨時換掉檔案解析器(改成讀 Excel、讀 JSON 都行),也可以換掉計算邏輯,兩邊互不影響。
用 Callback 解除耦合
前面用概念說明了鬆耦合的做法,接下來用實際的程式碼來看怎麼實現。
一樣用同一個 CSV 報表的例子。
緊耦合:解析和報表邏輯混在一起
在緊耦合的寫法中,main() 把解析和計算全部做完,parseCsv() 和 calculate() 的邏輯全部混在同一個函式裡:
function main() {
// parseCsv 的邏輯
const rows = readFile('sales.csv').split('\n');
const data = rows.map(row => {
const cols = row.split(',');
return { name: cols[0], amount: Number(cols[1]) };
});
// calculate 的邏輯直接寫在這裡
const total = data.reduce((sum, item) => sum + item.amount, 0);
console.log(`總筆數:${data.length},總金額:${total}`);
}這個函式的問題跟前面講的一模一樣——parseCsv() 和 calculate() 的職責被綁死在同一個地方了。
如果今天想用同一份資料做不同的事,比如匯出成 JSON 檔而不是印報表,你沒辦法重用這個函式,只能再寫一個幾乎一樣的版本。
鬆耦合:用 Callback 讓 calculate() 不依賴特定的資料來源
前面講到,鬆耦合的關鍵是讓 calculate() 不依賴特定的資料來源。
用 Callback 可以這樣做:讓 calculate() 接收一個函式參數 fetchData,由外面告訴它「資料要怎麼拿」。
首先,parseCsv() 獨立出來,只負責解析:
function parseCsv(filePath) {
const rows = readFile(filePath).split('\n');
return rows.map(row => {
const cols = row.split(',');
return { name: cols[0], amount: Number(cols[1]) };
});
}接著,calculate() 接收一個 fetchData 函式,在內部呼叫它來取得資料。
它完全不知道資料是從 CSV、Excel 還是 JSON 來的:
function calculate(fetchData) {
const data = fetchData();
const total = data.reduce((sum, item) => sum + item.amount, 0);
console.log(`總筆數:${data.length},總金額:${total}`);
}最後,main() 負責把兩邊串起來。
這裡有一個細節要注意——我們要傳給 calculate() 的是一個函式,不是資料本身。
如果直接寫 calculate(parseCsv('sales.csv')),parseCsv('sales.csv') 會立即執行,calculate() 收到的就是資料(陣列),不是函式。
這樣 calculate() 內部呼叫 fetchData() 的時候就會壞掉,因為陣列不是函式、沒辦法被呼叫。
所以要用一個箭頭函式把它包起來:
function main() {
// ❌ parseCsv 立即執行,calculate 收到的是資料,不是函式
// calculate(parseCsv('sales.csv'));
// ✅ 包成箭頭函式,calculate 收到的是函式
// 等到 calculate 內部呼叫 fetchData() 時,才會執行 parseCsv
calculate(() => parseCsv('sales.csv'));
}() => parseCsv('sales.csv') 是一個匿名函式,它不會馬上執行 parseCsv。
要等到 calculate() 內部呼叫 fetchData() 的時候,才會真正去執行 parseCsv('sales.csv') 拿到資料。
如果需求改了,要改成讀 Excel 檔,main() 只要換一個 Callback 就好,calculate() 完全不用動:
function main() {
calculate(() => parseExcel('sales.xlsx'));
}同一個 calculate(),搭配不同的資料來源,不用改動計算邏輯的任何一行程式碼。
回想前面鬆耦合那張圖,Code A 和 Code B 之間有一個 interface 方塊。
在 JavaScript 裡,Callback 就是那個介面。
calculate(fetchData) 的 fetchData 參數就是介面——它定義了「給我一個函式,我呼叫它就能拿到資料」,至於這個函式背後是 parseCsv、parseExcel 還是其他東西,calculate 完全不在乎。
這個模式在 JavaScript 裡隨處可見
上面的例子用 Callback 實現了鬆耦合:calculate() 不依賴特定的資料來源,main() 透過 Callback 告訴它資料要從哪拿。
其實 JavaScript 裡很多內建 API 也是同一招——把「要做什麼」當作函式傳進去,讓 API 本身不依賴特定的邏輯。
Array.prototype.filter 本身只負責「遍歷陣列、決定哪些元素要留下」,但它不知道你的篩選條件是什麼。
你透過 Callback 告訴它:
const numbers = [1, 2, 3, 4, 5];
// filter 不知道條件是什麼,你透過 Callback 告訴它
numbers.filter((n) => n > 3);addEventListener 本身只負責「監聽事件」,但它不知道事件發生後要做什麼。
你透過 Callback 告訴它:
// addEventListener 不知道要做什麼,你透過 Callback 告訴它
button.addEventListener('click', () => {
console.log('被點了');
});setTimeout 本身只負責「等待一段時間」,但它不知道時間到了要執行什麼。
你透過 Callback 告訴它:
// setTimeout 不知道要執行什麼,你透過 Callback 告訴它
setTimeout(() => {
console.log('3 秒到了');
}, 3000);這些內建 API 之所以能在各種不同的情境下被使用,就是因為它們透過 Callback 把「核心邏輯」和「外部細節」解耦了。
跟前面的例子一模一樣——filter 不依賴特定的篩選條件,addEventListener 不依賴特定的處理邏輯,setTimeout 不依賴特定的執行內容。
鬆耦合的程式碼好在哪?
看完了前面的例子,來整理一下鬆耦合的設計主要帶來哪些好處:
更容易維護
因為同一個功能的程式碼都集中在一起,當你要修改某個功能的時候,不用滿世界找相關的程式碼。
舉個例子,假設你的 CSV 解析邏輯散落在報表模組、匯出模組、驗證模組裡面。當 CSV 的格式規格改了(比如分隔符號從逗號改成 tab),你得翻遍整個 codebase,把每一處解析邏輯都找出來改。漏改一個地方,就是一個 bug。
但如果解析邏輯集中在一個獨立的 CSVParser 模組裡,你只需要改那一個地方就好,其他模組完全不用動。
更有彈性
你可以自由修改某個模組的內部實作,而不會影響到系統的其他部分。
比如你原本的 CSVParser 是用 split(',') 來拆分欄位,後來發現欄位裡面可能包含逗號,需要改用更完整的解析演算法。只要對外提供的資料格式沒變,報表模組完全不需要知道你內部怎麼改的。
更極端的情況是——你甚至可以直接把某個模組整個換掉。
今天用自己寫的 CSV parser,明天換成第三方的 papaparse 套件,只要輸出的資料格式一樣,其他模組完全不需要知道這件事。
這就是鬆耦合帶來的自由度:每個模組都是可以獨立替換的零件,而不是焊死在一起的電路板。
什麼時候該做鬆耦合?實務上的取捨
鬆耦合聽起來很美好,但它不是免費的。
為了讓兩個模組獨立,你需要額外的抽象層和間接層——像前面例子中的介面和膠水程式碼。
有一句名言說「過早的優化是萬惡的根源」,但過早的抽象同樣會帶來很多痛苦。
在決定要不要解耦的時候,可以問自己幾個問題:
- 解耦能帶來什麼具體好處? 這個好處是真實的,還是只是理論上的?
- 解耦的成本有多高? 需要加多少抽象層和間接層?
- 我現在有足夠的知識來做出好的抽象嗎? 如果還不夠了解系統的使用方式,硬做抽象反而可能越做越糟。這時候等一等、多看幾個實際案例再決定,可能是更好的選擇。
- 這兩個部分天生就是緊密相關的嗎? 如果兩個概念天生就很交織,硬要拆開的成本會很高。
重點整理:耦合的設計原則
在其他條件相同的情況下,鬆耦合的設計通常是更好的選擇,因為它帶來的彈性和可維護性,大多數時候都值回票價。
但這不是一條絕對的規則。
有些時候,留著緊耦合的程式碼也完全沒問題——只要你是有意識地做了這個取捨,而不是因為沒想過。
工程的本質不是追求完美的抽象,而是在各種方案之間做出合理的權衡。