Logo

新人日誌

首頁關於我部落格

新人日誌

Logo

網站會不定期發佈技術筆記、職場心得相關的內容,歡迎關注本站!

網站
首頁關於我部落格
部落格
分類系列文

© 新人日誌. All rights reserved. 2020-present.

軟體工程的耦合:Loose Coupling 與 Tight Coupling 差在哪?

最後更新:2026年5月5日基礎概念

在寫程式的時候,你可能聽過有人說「這段程式碼耦合太緊了」或「應該要更鬆散一點」。

但「耦合」到底是什麼意思?

什麼時候該讓程式碼鬆散耦合?又有什麼代價?

這篇文章會用一個簡單的例子,帶你搞懂 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 套件,只要輸出的資料格式一樣,其他模組完全不需要知道這件事。

這就是鬆耦合帶來的自由度:每個模組都是可以獨立替換的零件,而不是焊死在一起的電路板。

什麼時候該做鬆耦合?實務上的取捨

鬆耦合聽起來很美好,但它不是免費的。

為了讓兩個模組獨立,你需要額外的抽象層和間接層——像前面例子中的介面和膠水程式碼。

有一句名言說「過早的優化是萬惡的根源」,但過早的抽象同樣會帶來很多痛苦。

在決定要不要解耦的時候,可以問自己幾個問題:

  • 解耦能帶來什麼具體好處? 這個好處是真實的,還是只是理論上的?
  • 解耦的成本有多高? 需要加多少抽象層和間接層?
  • 我現在有足夠的知識來做出好的抽象嗎? 如果還不夠了解系統的使用方式,硬做抽象反而可能越做越糟。這時候等一等、多看幾個實際案例再決定,可能是更好的選擇。
  • 這兩個部分天生就是緊密相關的嗎? 如果兩個概念天生就很交織,硬要拆開的成本會很高。

重點整理:耦合的設計原則

在其他條件相同的情況下,鬆耦合的設計通常是更好的選擇,因為它帶來的彈性和可維護性,大多數時候都值回票價。

但這不是一條絕對的規則。

有些時候,留著緊耦合的程式碼也完全沒問題——只要你是有意識地做了這個取捨,而不是因為沒想過。

工程的本質不是追求完美的抽象,而是在各種方案之間做出合理的權衡。

目前還沒有留言,成為第一個留言的人吧!

發表留言

留言將在審核後顯示。

基礎概念

目錄

  • 什麼是耦合(Coupling)?
  • 緊耦合:改一邊,壞另一邊
  • 鬆耦合:各自獨立,互不干擾
  • 耦合的好朋友:內聚(Cohesion)
  • 高內聚:每個模組各司其職
  • 低內聚:什麼都做,什麼都不精
  • 耦合和內聚是連動的
  • 用一個實際案例來理解
  • 緊耦合的寫法
  • 鬆耦合的寫法
  • 用 Callback 解除耦合
  • 緊耦合:解析和報表邏輯混在一起
  • 鬆耦合:用 Callback 讓 calculate() 不依賴特定的資料來源
  • 這個模式在 JavaScript 裡隨處可見
  • 鬆耦合的程式碼好在哪?
  • 更容易維護
  • 更有彈性
  • 什麼時候該做鬆耦合?實務上的取捨
  • 重點整理:耦合的設計原則