依賴反轉原則(Dependency Inversion Principle, DIP)

Robert C. Martin 為這個原則提出兩條互相呼應的規則: High-level modules should not import anything from low-level modules. Both should depend on abstractions(高階模組不應依賴低階模組,兩者都應依賴抽象)。 Abstractions should not depend on details. Details should depend on abstractions(抽象不應依賴細節,細節應依賴抽象)。 這兩條規則合在一起,描述的是系統中依賴關係的方向性應該如何安排,要理解它們需要先理解「高階模組」和「低階模組」的差別。 高階模組與低階模組 高階模組(High-level modules):包含應用程式核心業務邏輯的模組,它們描述的是「這個系統要做什麼」:訂單需要被驗證、付款需要被處理、報表需要被產生;這些邏輯代表了系統存在的根本原因,是具有業務價值的部分。 低階模組(Low-level modules):提供具體技術實作的模組,它們描述的是「這件事具體怎麼做」:資料存到 SQL Server、Email 用 SMTP 發送、圖片存到 S3;這些模組是高階模組的工具,本身通常不含業務價值,可以被替換。 在傳統的依賴方向中,高階模組直接使用(依賴)低階模組: OrderService(高階)→ SqlServerRepository(低階) 這意味著業務邏輯層與具體的資料庫技術「綁死」在一起;若今天想把 SQL Server 換成 PostgreSQL,就必須修改 OrderService 這個本來應該只關心業務規則的類別,DIP 要「反轉」的正是這個依賴方向。 與 OOP 的關係:多型(Polymorphism)與抽象(Abstraction) DIP 的實現依賴抽象與多型,具體的做法是在高階模組與低階模組之間插入一層抽象(介面),讓兩者都依賴這個介面,而非彼此直接依賴。 傳統方向(高耦合): OrderService ─────────────────→ SqlServerRepository (高階) (低階,具體實作) DIP 方向(低耦合): OrderService ──→ IOrderRepository ←── SqlServerRepository (高階) (抽象介面) (低階,具體實作) 在 DIP 的架構下,依賴箭頭的方向發生了「反轉」: ...

2026年6月3日

介面隔離原則(Interface Segregation Principle, ISP)

Robert C. Martin 對這個原則的定義是:Clients should not be forced to depend upon interface methods that they do not use.(不應強迫客戶端依賴它不使用的方法)。 這裡的「客戶端」不是指使用者介面的終端用戶,而是指在程式碼中使用某個介面的類別或模組;當一個介面提供了十個方法,但某個實作類別只需要其中三個,ISP 說這樣的設計是有問題的。 與 OOP 的關係:抽象(Abstraction) 介面(Interface)是 OOP 抽象機制的體現,抽象的目的是「只暴露必要的能力,隱藏不必要的細節」;一個設計良好的介面應該像是一份精確的「能力聲明」:「持有這個介面的物件,保證能做 X、Y、Z。」 然而,若一個介面同時聲明了十幾個能力,而這些能力並非總是同時需要,這個介面就失去了精準性;實作者不得不提供所有能力的實作,即使其中有些對它毫無意義;呼叫端也無法從介面名稱判斷「這個物件究竟擅長做什麼」,因為它什麼都做。 ISP 的本質是對抽象品質的要求:介面應精簡、專一,只描述一個「角色(role)」。 「肥大介面」的危害 假設系統有一個 IWorker 介面: 1public interface IWorker 2{ 3 void Work(); 4 void Eat(); 5 void TakeBreak(); 6 void ReceiveSalary(); 7} 對於人類員工來說,這四個方法都有意義;但如果在系統中引入了「工業機器人」(自動化設備),它同樣需要實作 IWorker(因為它也需要執行工作),但機器人不吃飯、不休息、也不領薪水,這時機器人類別就被迫實作它根本不支援的方法: 1public class IndustrialRobot : IWorker 2{ 3 public void Work() { Console.WriteLine("機器人執行工序中..."); } 4 5 // 機器人不需要這些,但被強迫實作 6 public void Eat() => throw new NotSupportedException("機器人不需要進食"); 7 public void TakeBreak() => throw new NotSupportedException("機器人不需要休息"); 8 public void ReceiveSalary() => throw new NotSupportedException("機器人不領薪水"); 9} 這個設計同時違反了 ISP 和 LSP:IndustrialRobot 宣稱自己是一個 IWorker,但呼叫 Eat() 會在執行期間報錯,呼叫端如果不特別防範就會有潛在風險。 ...

2026年6月2日

里氏替換原則(Liskov Substitution Principle, LSP)

Barbara Liskov 提出的定義是: If S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program(若 S 是 T 的子型別,則程式中使用 T 物件的地方可以使用 S 物件來替換,而不改變程式的任何預期屬性)。 Barbara Liskov 是麻省理工學院(MIT)的電腦科學教授,這個原則由他在 1987 年的一篇論文中首次提出,後來被 Robert C. Martin 納入 SOLID 體系。 用更白話的方式說:凡是可以使用父類別物件的地方,換成任何子類別的物件,程式應該要能正確執行,結果也應該要符合預期;如果換了子類別之後行為變了,那就是違反了 LSP。 與 OOP 的關係:繼承(Inheritance) 繼承是 OOP 中強大但也容易被濫用的機制,把繼承當作「程式碼複用」的工具:「子類別可以繼承父類別的方法,這樣就不用重寫了。」這個想法本身沒有錯,但它遺漏了繼承更深層的語義。 在物件導向系統中,繼承不只是程式碼的共享機制,它同時建立了一種型別關係(is-a relationship);當寫了 class Dog : Animal 時,不只是讓 Dog 複用了 Animal 的程式碼,同時宣告了「Dog 是一種 Animal」;這個宣告有一個重要的隱含意義:在任何需要 Animal 的場合,都可以放入一隻 Dog,而程式的行為不應改變。 ...

2026年6月1日

開放封閉原則(Open–Closed Principle, OCP)

Bertrand Meyer 在 1988 年提出、後經 Robert C. Martin 推廣的定義是:Software entities should be open for extension, but closed for modification(軟體實體應該對擴展開放,對修改封閉)。 「對擴展開放」意味著當需求出現新的變化時,我們可以為系統加入新的行為;「對修改封閉」意味著加入新行為時,我們不需要改動已經存在且正常運作的程式碼;這兩個要求乍聽矛盾,既然不能改動既有程式碼,又怎麼增加新行為呢?OCP 的概念是:透過多型與抽象,讓新行為以「新增類別」的方式注入系統,而非以「修改現有邏輯」的方式侵入系統。 與 OOP 的關係:多型(Polymorphism)與抽象(Abstraction) OCP 的實現幾乎完全仰賴 OOP 的多型機制,想理解這個關係要先從多型的本質說起;多型讓我們可以寫出這樣的程式碼: 1// 呼叫端只知道 shape 是某種 Shape,不知道它具體是哪一種 2void DrawShape(IShape shape) 3{ 4 shape.Draw(); // 執行時才決定呼叫哪個 Draw() 5} DrawShape 這個方法依賴的是抽象(IShape 介面),而不是任何具體的形狀類別;未來若要新增「六邊形」,只需要新增一個實作 IShape 的 Hexagon 類別,不需要改動 DrawShape;這就是 OCP 的運作原理:抽象是穩定的,具體實作是可以擴展的。 換言之,OCP 是多型概念重要的應用場景:用「新增類別」取代「修改現有邏輯」,讓已通過測試的程式碼保持穩定。 「修改」的代價 為什麼要這麼費心避免修改既有程式碼?每次修改都有潛在的風險: ...

2026年5月31日

單一職責原則(Single-Responsibility Principle, SRP)

Robert C. Martin 對這個原則的定義是:A class should have only one reason to change(一個類別應該只有一個改變的理由)。 乍看之下這句話的意思相當直觀:一個類別只做一件事,但「改變的理由」這個措辭或許比表面上更深刻,也是 SRP 常被誤解的地方。 與 OOP 的關係:封裝(Encapsulation) 封裝是 OOP 的第一個基石,它的目的是將資料與操作資料的行為包裝在一起,並對外隱藏實作細節;這讓類別可以像一個「黑盒子」,使用者只需要知道「它能做什麼」,不需要知道「它是怎麼做的」。 然而,封裝只解決了「如何包裝」的問題,沒有回答「應該把哪些東西包在一起」;一個把所有功能都塞進同一個類別的設計,在語法上是合法的封裝,但在設計上卻是災難性的。 SRP 為封裝提供了邊界判斷的標準:同一個職責(responsibility)的程式碼才應該被封裝在同一個類別裡;換句話說,類別的邊界不應由技術上的便利性決定(這幾個 method 放在一起比較方便),而應由職責的邊界來決定(這幾個 method 服務於同一個目的、同一個改變來源)。 也就是說,SRP 是封裝的昇華:不只把資料藏起來,更要讓「職責」成為劃分類別邊界的判斷依據。 「改變的理由」的含義 Uncle Bob 在後來的詮釋中進一步說明:一個模組應該對一個且只對一個 actor(行為者) 負責。 這裡的 actor 不是指使用者介面上的角色,而是指會要求這個模組發生改變的利害關係人(stakeholder),可能是一個部門、一個業務單位、一個外部系統,或任何一個有能力提出需求變更的人或團隊。 用一個具體例子來說明:假設有一個 Employee 類別,它提供三個方法: CalculatePay():計算薪資,財務部門關心這個邏輯。 ReportHours():回報工時,HR 部門關心這個邏輯。 Save():儲存員工資料,DBA 或資料庫團隊關心這個邏輯。 三個方法,三個不同的 actor;財務部門可能要求調整薪資計算的演算法,HR 部門可能要求修改工時的統計方式,資料庫團隊可能要求更換 ORM 框架或調整儲存格式;這三類需求彼此獨立,但因為它們全都落在同一個 Employee 類別裡,任何一方的修改都有可能意外影響其他兩方,即使改動本身只涉及其中一個方法。 這就是「有多個改變的理由」的意涵:一個類別因為服務多個 actor,所以有多個獨立的、互不相關的原因可能促使它發生改變。 程式碼範例 違反 SRP:三種職責擠在一個類別 以下是一個很常見的反模式,在實際專案中幾乎隨處可見: 1public class ReportService 2{ 3 private readonly string _connectionString; 4 5 public ReportService(string connectionString) 6 { 7 _connectionString = connectionString; 8 } 9 10 // 職責一:取得業務資料(屬於業務邏輯層) 11 // actor:業務分析師、後端工程師 12 public List<Order> GetOrders() 13 { 14 using var conn = new SqlConnection(_connectionString); 15 // 查詢並回傳訂單清單... 16 return new List<Order>(); 17 } 18 19 // 職責二:格式化報表(屬於呈現層) 20 // actor:前端工程師、設計師 21 public string FormatAsHtml(List<Order> orders) 22 { 23 var sb = new StringBuilder(); 24 sb.Append("<table><thead><tr><th>ID</th><th>Total</th></tr></thead><tbody>"); 25 foreach (var o in orders) 26 sb.Append($"<tr><td>{o.Id}</td><td>{o.Total:C}</td></tr>"); 27 sb.Append("</tbody></table>"); 28 return sb.ToString(); 29 } 30 31 // 職責三:傳送通知(屬於基礎設施層) 32 // actor:維運團隊、產品經理 33 public void SendByEmail(string recipient, string content) 34 { 35 var client = new SmtpClient("smtp.example.com"); 36 client.Send("[email protected]", recipient, "Weekly Report", content); 37 } 38} 這個設計的問題不只是「感覺不太對」,而是會帶來非常具體的麻煩: ...

2026年5月30日

什麼是 SOLID?與 OOP 的根源

許多工程師都有過類似的經驗:接手一份「能跑就好」的舊專案,或只是想改一個小功能,卻發現牽一髮動全身,改了 A 壞了 B,修了 B 又壞了 C;幾輪下來,程式碼愈來愈難以理解,沒有人敢輕易動它。 這種現象有個名字,叫做程式碼腐化(Software Rot);它的成因不是因為工程師不夠努力,也不是因為程式語言本身的限制,而是因為程式碼在設計上缺乏清晰的邊界與結構,隨著時間累積,複雜度像滾雪球一樣不斷擴大。 程式碼腐化常見的三種症狀: Fragility(脆弱性):系統在意想不到的地方發生錯誤;改了一處邏輯之後,看似無關的另一個模組卻開始出錯,原因是它們之間存在隱藏的耦合。 Rigidity(僵化性):每一個改動都需要修改大量的地方,工程師必須追蹤一長串的連鎖反應,導致任何變更的成本都極高。 Immobility(不動性):某段邏輯明明可以在其他地方複用,卻因為它與太多其他元件糾纏在一起,根本無法單獨抽出來。 SOLID 就是為了系統性地對抗這三種症狀而誕生的。 SOLID 的由來 Robert C. Martin(人稱 Uncle Bob)是《Clean Code》與《Clean Architecture》的作者,也是敏捷宣言的共同簽署人之一,他在 2000 年代初期,將多年在軟體工程領域的觀察與實踐整理成文,提出了五個核心設計原則。 這五個原則各自在過去幾十年間分散出現於不同的學術論文與工程討論中,Uncle Bob 的貢獻在於將它們系統化,並賦予一致的框架;之後 Michael Feathers(《Working Effectively with Legacy Code》的作者)發現這五個原則的英文首字母恰好組成「SOLID」,這個縮寫從此沿用至今。 五個原則各自對應的英文全名與中文名稱: S — Single-Responsibility Principle:單一職責原則 O — Open-Closed Principle:開放封閉原則 L — Liskov Substitution Principle:里氏替換原則 I — Interface Segregation Principle:介面隔離原則 D — Dependency Inversion Principle:依賴反轉原則 OOP 的四個基石,以及它們的局限 物件導向程式設計(OOP)提供了四個強大的工具: 封裝(Encapsulation):將資料與操作資料的方法包裝在一起,隱藏實作細節,只對外暴露必要的介面;這讓模組之間的邊界更清楚,也保護了資料不被任意存取。 繼承(Inheritance):讓子類別繼承父類別的屬性與行為,實現程式碼的複用;它也建立了「is-a」的型別關係,讓一個子類別的物件可以被當作父類別使用。 多型(Polymorphism):同一個介面或方法名稱,可以在不同的類別中有不同的實作;呼叫端只需要知道「這個物件能做什麼」,不需要知道「它是怎麼做的」,讓程式碼更具彈性。 抽象(Abstraction):透過介面(Interface)或抽象類別(Abstract Class),只定義行為的「輪廓」,而不指定實作,這讓高階的業務邏輯可以與低階的實作細節解耦。 然而這四個工具只是手段,本身並不保證良好的設計;一個大量使用繼承、卻讓子類別行為不一致的系統,可能比不用繼承更糟糕;一個把所有邏輯封裝在同一個「萬能類別」裡的設計,只是用封装帶來更大的隱患;OOP 告訴你可以做什麼,SOLID 告訴你應該怎麼做。 ...

2026年5月29日

Cursor 可以怎麼改善效能?從自我參照到集合運算的幾種改善方式

上篇提到,一段看起來沒有問題的 Cursor,因為同時「從同一張表讀資料,又寫回同一張表」,最後讓資料量一路膨脹,效能也跟著快速惡化;理解原因之後,接下來要探討的是:如果接手這樣的問題,該怎麼調整或修改? 可以怎麼調整? 拆出獨立的「來源表」,把自我參照分開 修改幅度最小的做法;把母體先複製到另一張暫存表,Cursor 從來源表讀、寫入目標表,兩者不會混在一起: 1-- 先把「會員母體」獨立出來 2SELECT DISTINCT member_id, member_name, member_level 3INTO #member_source 4FROM #member_base; 5 6-- 清空目標表(如果需要的話) 7TRUNCATE TABLE #member_base; 8 9DECLARE month_cursor CURSOR FOR SELECT month_id FROM dim_month; 10OPEN month_cursor; 11FETCH NEXT FROM month_cursor INTO @month_id; 12 13WHILE @@FETCH_STATUS = 0 14BEGIN 15 INSERT INTO #member_base (member_id, member_name, member_level, month_id, amount) 16 SELECT member_id, member_name, member_level, @month_id, 0 17 FROM #member_source; -- 來源固定,不會膨脹 18 19 FETCH NEXT FROM month_cursor INTO @month_id; 20END 21 22CLOSE month_cursor; 23DEALLOCATE month_cursor; 24DROP TABLE #member_source; 這個改法只處理了「資料爆炸」的部分,Cursor 本身的效能成本還在;不過如果業務邏輯比較複雜、確實需要逐筆處理,這算是相對安全的最小改動。 ...

2026年5月25日

一段「看起來會跑」的 SQL,為什麼跑起來這麼慢?聊聊 Cursor 的自我參照

最近在看一段比較舊的 T-SQL 程式碼,遇到一個還算滿常見的效能議題:用 Cursor 逐筆寫入時,從同一張暫存表讀資料、又寫回去同一張表;乍看之下邏輯沒問題、結果也正確,但只要資料量稍微一多,整段 SQL 就會跑得非常慢;這篇筆記想整理一下背後的原理,以及為什麼這種寫法在 T-SQL 中通常不太建議。 簡單情境 用一個跟業務無關的例子來說明,假設有一個「會員 × 月份的消費紀錄初始化」的需求: 有一張 #member_base,裡面是會員基本資料(會員編號、姓名、等級)。 有 12 個月份,需要幫每個會員產生 12 筆「初始為 0」的消費紀錄。 預期結果:會員數 × 12 筆資料。 一個比較直覺、但其實會有問題的寫法可能會長這樣: 1DECLARE month_cursor CURSOR FOR 2 SELECT month_id FROM dim_month; -- 1 ~ 12 月 3 4OPEN month_cursor; 5FETCH NEXT FROM month_cursor INTO @month_id; 6 7WHILE @@FETCH_STATUS = 0 8BEGIN 9 INSERT INTO #member_base (member_id, member_name, member_level, month_id, amount) 10 SELECT DISTINCT member_id, member_name, member_level, @month_id, 0 11 FROM #member_base; -- 注意:從 #member_base 讀,又寫回 #member_base 12 13 FETCH NEXT FROM month_cursor INTO @month_id; 14END 直覺上看起來是:「就是跑 12 圈,每圈把所有會員複製一份、補上月份而已。」但實際跑起來,資料量會以指數的速度成長。 ...

2026年5月24日

Index 建立的時機:先建還是後建?

前陣子在跟同事討論一段 SP(Stored Procedure)的效能問題,話題漸漸轉到了 Table 建立時的設計策略。 同事的想法是這樣的:「建 Table 的時候就把欄位結構跟 Index 都設定好,之後 Insert 資料就一步到位,感覺比較有效率。」 而我的直覺剛好相反:「不對吧,Index 本身會影響 Insert 的速度,應該是先把資料塞完,最後再建 Index?」 兩個人都有點不確定,但認知方向剛好相反,就決定一起查清楚;結果,我的印象是對的,但要加一些但書。 為什麼 Index 會讓 Insert 變慢? 要解釋這件事,得先聊聊資料庫最常用的 Index 結構:B-Tree。 B-Tree 是什麼? B-Tree 是大多數關聯式資料庫(PostgreSQL、MySQL、SQL Server)預設使用的 Index 結構,它長這樣: [50] / \ [25] [75] / \ / \ [10] [40] [60] [90] 重點有三個: 樹的每一層都保持平衡:葉子節點都在同一深度。 資料有排序:從左到右值是遞增的。 查詢速度快:找一筆資料需要 O(log n) 次比較。 因為 B-Tree 要維持排序與平衡的特性,每次 Insert 一筆資料,都必須同時更新 B-Tree 的結構。 Insert 時到底發生什麼事? 每當 INSERT 一筆新資料,資料庫不只是把那筆資料寫進去而已,它還要作以下這些事: 找到這筆資料在 B-Tree 中對應的位置。 把新資料插入正確的節點。 如果節點滿了,觸發「節點分裂(node split)」,重新調整樹的結構。 必要時,更新上層節點的資訊。 如果你有 3 個 Index,就得重複這個過程 3 次;資料量越大,這個效能開銷就越明顯。 ...

2026年5月23日

SQL Server 效能調校之五:從發現問題到解決問題的步驟

經過前面四篇文章,能夠知道 Execution Plan 的四大核心:閱讀方向(粗細箭頭)、存取方式(Seek 與 Scan)、關聯策略(三大 Join),以及警告標誌(四大陷阱)。 SQL Server 效能調校之一:不迷路的導航,Execution Plan 的閱讀方向與指標 SQL Server 效能調校之二:看懂 Index Seek、Scan 與 Key Loop SQL Server 效能調校之三:三大 Join 運算子解密 SQL Server 效能調校之四:四個常見陷阱解析 然而,當正式面對一張包含幾十個、甚至上百個節點的巨大執行計畫時,很容易再度感到不知所措;這時候,需要的是一套系統化的除錯 SOP;效能調校的心法「一次只動一個地方,並且永遠以數據驗證差異。」以下是從一團亂麻中找出解方的步驟: 步驟一:鎖定「最貴」的目標 (Find the Most Expensive) 面對龐大的執行計畫,不要試圖由右至左把每個節點都看懂,需要「擒賊先擒王」。 尋找 Cost % 最高的節點:每個節點下方都會標示該步驟佔整句查詢成本的百分比;直接掃視全圖,把目光鎖定在那些標示 40%、60% 甚至 90% 的高成本節點上。 追蹤「最粗的箭頭」:尋找哪兩個節點之間傳遞了異常龐大的資料量,特別是「漏斗效應」;如果一個節點右邊進來很粗的箭頭,左邊出去卻變得很細,這代表它浪費了大量資源在過濾不必要的資料。 限縮範圍,挑出 1 到 2 個效能最差的局部節點作為第一階段的開刀對象,不要急著對整句 SQL 做全域(Global)的改寫。 步驟二:辨識症狀與警告 (Identify Symptoms) 鎖定可疑節點後,把滑鼠懸停(Hover)在該節點上,開始進行「健康檢查」: 有沒有黃色驚嘆號:如果是 CONVERT_IMPLICIT,就去檢查程式端參數型別是不是和資料庫不符(尤其是字串型別);如果是 Spill to TempDB,先去檢查統計資料是否過期。 它是怎麼存取資料的:如果是 Table Scan 或 Clustered Index Scan,檢查 WHERE 條件欄位是不是漏建了索引,或者條件寫法讓索引失效了(例如在欄位上套用函數);如果是 Index Seek,點開屬性檢查有沒有發生了「殘餘篩選條件(Residual Predicate)」,導致讀取了一堆資料卻被拋棄。 有沒有跟著 Key Lookup:如果高成本節點是 Key Lookup,去看看 SELECT 了哪些多餘的欄位,或者把它們加入現有索引的 INCLUDE 清單中。 步驟三:戳破 Optimizer 的幻覺 (Check Row Estimates) 很多時候,SQL Server 選了極差的執行計畫(例如:不該用 Hash Match 卻用了),是因為它「猜錯了資料量」: ...

2026年5月19日