设计模式的开门砖 - 六大设计原则 - 依赖倒置原则 - C#
在学习设计模式之前,我强烈建议你先搞懂设计原则。
前言:为什么要先学设计原则?
用最通俗的话来解释:
-
设计原则是"指导思想"——告诉你什么方向是对的
-
设计模式是"通用模板"——告诉你针对某个具体问题怎么写代码是对的
设计模式就是设计原则的"现成答案"。
如果你不了解原则,直接学模式,很容易出现几种情况:
-
为了用模式而用模式,写出又重又绕的代码
-
换个场景就不会用了,因为只记得"怎么写",不明白"为什么这么写"
当你先明白了依赖倒置原则,再去看工厂模式、抽象工厂模式、依赖注入,你就会恍然大悟:
"原来这些模式就是为了实现依赖倒置而总结出来的经典写法——让高层和低层都依赖同一个抽象,谁也别直接依赖谁。"
原则是道,模式是术。先悟道,再学术,事半功倍。
一、设计原则是什么?
词语定义:
设计原则是前辈们在长期的软件开发实践中总结出来的通用指导原则,用来帮助我们写出更容易维护、扩展、复用和理解的代码。
它不是具体的代码(不是库、不是框架),也不是外面的语法规则(不遵守代码也能跑),而是一种"最佳实践"或"编程好习惯"。
设计原则解决了什么问题?
无设计原则时常见现象:
-
改一个功能,拆东墙补西墙
-
加一个小需求,改十几种
-
一种几千行,谁也不敢动
设计原则的目标就是避免上面这些情况。
设计原则的本质
识别变化 + 隔离变化
好代码不是一成不变的,而是把容易变化的部分和稳定的部分分开,这样需求增加,你只需要改变"变化区",不碰"稳定区"。
六大设计原则草案速览
| 原则 | 核心一句 |
|---|---|
| 开闭原则 | 增加功能多加新类,少改旧类 |
| 里氏替换原则 | 子类要能替换父类不出错 |
| 依赖倒置原则 | 依赖接口,不依赖具体类 |
| 接口隔离原则 | 接口别太胖,深入定制 |
| 单一职责原则 | 一类只干一件事 |
| 迪米特原则 | 别和陌生人说话 |
二、依赖倒置原则(DIP)的概念
标准定义
High-level modules should not depend on low-level modules. Both should depend on abstractions.
Abstractions should not depend on details. Details should depend on abstractions.
—— 罗伯特·C·马丁(Robert C. Martin),1996年
中文翻译:
高层模块不应该依赖低层模块,两者都应该依赖抽象。
抽象不应该依赖细节,细节应该依赖抽象。
四个关键词拆解
| 关键词 | 含义 | 通俗解释 |
|---|---|---|
| 高层模块 | 调用者、业务逻辑、决策方 | 老板——负责"要做什么" |
| 低层模块 | 被调用者、工具、执行方 | 员工——负责"怎么做" |
| 抽象 | 接口或抽象类,定义"契约" | 合同——规定做什么,不规定怎么做 |
| 细节 | 具体的实现类 | 具体干活的人——可以换,合同不变 |
传统依赖 vs 依赖倒置
传统依赖(自上而下):
高层 ──依赖──→ 低层 老板直接点名:"张三,你去干这个。" 老板绑死在张三身上,张三请假老板就傻了。
依赖倒置(自下而上反转):
高层 ──依赖──→ 抽象 ←──依赖── 低层 老板说:"会干这个活的人,来一个。" 张三、李四、王五都可以来,因为都签了同一份合同。
通俗理解
依赖倒置原则通俗讲就是:不要让你的核心代码直接依赖那些容易变的东西。中间插一层接口/抽象,让两边都依赖这个接口,而不是互相依赖。
最形象的类比——电源插座:
-
插座(抽象/接口)定义了标准:220V、三孔
-
电视机、冰箱、微波炉(细节/实现)都按这个标准来做
-
插座不关心你插的是什么电器,只要符合标准就能工作
-
换一个电器,不用重新装修布线。这就是依赖倒置。
一句话记住
别让老板直接点名张三——让老板说"我需要一个会干活的人",符合了老板的标准就来。
三、违反 vs 符合依赖倒置原则(C#代码对比)
违反依赖倒置:高层直接依赖低层
// 低层模块:具体的通知方式 public class EmailSender { public void Send(string to, string message) { Console.WriteLine($"📧 发送邮件给 {to}:{message}"); } } public class SmsSender { public void Send(string to, string message) { Console.WriteLine($"📱 发送短信给 {to}:{message}"); } } // 高层模块:订单服务 —— 直接依赖了低层的 EmailSender public class OrderService { private readonly EmailSender _emailSender; // ❌ 依赖具体类 public OrderService() { _emailSender = new EmailSender(); // ❌ 自己 new 具体实现 } public void PlaceOrder(string orderId) { // 订单逻辑…… Console.WriteLine($"订单 {orderId} 创建成功"); // 发送通知 _emailSender.Send("user@example.com", $"您的订单 {orderId} 已确认"); } }问题分析:
现在只要发邮件,但明天老板说"再加个短信通知"——你要改
OrderService的代码后天老板说"换成微信公众号推送"——又要改
OrderService每次换通知方式,稳定运行的核心业务代码
OrderService都要跟着改单元测试时,你没办法把 EmailSender 换成 Mock,因为它被硬编码在 OrderService 里了
符合依赖倒置:面向接口编程
// 1. 定义抽象(接口):定义"能发通知"的契约 public interface INotificationSender { void Send(string to, string message); } // 2. 低层模块:各自实现接口 public class EmailSender : INotificationSender { public void Send(string to, string message) { Console.WriteLine($"📧 发送邮件给 {to}:{message}"); } } public class SmsSender : INotificationSender { public void Send(string to, string message) { Console.WriteLine($"📱 发送短信给 {to}:{message}"); } } public class WeChatSender : INotificationSender { public void Send(string to, string message) { Console.WriteLine($"💬 发送微信给 {to}:{message}"); } } // 3. 高层模块:依赖接口,不依赖具体实现 public class OrderService { private readonly INotificationSender _sender; // ✅ 依赖抽象接口 public OrderService(INotificationSender sender) // ✅ 通过构造函数注入 { _sender = sender; } public void PlaceOrder(string orderId) { // 订单逻辑…… Console.WriteLine($"订单 {orderId} 创建成功"); // 发送通知 —— 不关心用的是邮件还是短信 _sender.Send("user@example.com", $"您的订单 {orderId} 已确认"); } }优势分析:
换通知方式 → 只需改外部注入,
OrderService一行代码都不用动新增通知方式 → 加新类实现
INotificationSender,OrderService 无感知单元测试 → 轻松 Mock 一个
INotificationSender,不用真发邮件也能测试业务逻辑同时支持多种通知 → 想发邮件+短信?写一个组合实现
CompositeSender即可,OrderService 依然不变扩展示例:运行时切换通知方式
// 根据用户偏好动态选择通知方式——核心业务逻辑不变 INotificationSender sender; switch (userPreference) { case "sms": sender = new SmsSender(); break; case "wechat": sender = new WeChatSender(); break; default: sender = new EmailSender(); break; } var orderService = new OrderService(sender); orderService.PlaceOrder("ORD-20260624"); // OrderService 从来不知道、也不关心到底用的是哪个 Sender对比总结表
维度 违反 DIP 符合 DIP 依赖关系 高层 → 低层(OrderService → EmailSender) 高层 → 抽象 ← 低层 换通知方式 改 OrderService 源码 改外部注入,OrderService 不动 新增通知方式 改 OrderService,加 else if / switch 加新类实现接口即可 单元测试 困难,需真发邮件 轻松,注入 Mock 对象 代码耦合度 高(牵一发动全身) 低(核心逻辑与实现细节解耦)
四、如何实现依赖倒置原则?
| 具体手段 | 说明 | 代码示例 |
|---|---|---|
| 面向接口编程 | 变量类型声明为接口,而非具体类 | INotificationSender _sender; |
| 依赖注入(DI) | 通过构造函数、属性或方法参数传入依赖,而不是在类内部 new | new OrderService(sender) |
| 工厂模式 | 创建对象交给工厂,调用方不需要知道具体类型怎么创建 | SenderFactory.Create("email") |
| 控制反转(IoC)容器 | 把"谁创建谁、谁依赖谁"的决策权交给容器统一管理 | services.AddTransient<INotificationSender, EmailSender>() |
依赖注入的三种方式(C# 示例)
// 方式一:构造函数注入(最推荐——依赖明确、不可变)
public class OrderService
{
private readonly INotificationSender _sender;
public OrderService(INotificationSender sender)
{
_sender = sender;
}
}
// 方式二:属性注入(可选依赖时使用)
public class OrderService
{
public INotificationSender Sender { get; set; }
}
// 方式三:方法参数注入(方法级别需要时使用)
public class OrderService
{
public void PlaceOrder(string orderId, INotificationSender sender)
{
sender.Send(...);
}
}
五、常见误区
| 误区 | 正解 |
|---|---|
| "DIP 就是 依赖注入(DI)" | DIP 是原则(理念:要依赖抽象),DI 是实现手段之一(技术:怎么把依赖传进去)。你可以用 DI 实现 DIP,也可以用工厂模式、服务定位器等实现 DIP。 |
| "每个类都要搞一个接口" | 只有当这个类确实可能变化,或者你需要隔离测试时,才需要抽出接口。一个简单的值对象(DTO、VO)不需要接口。 |
| "接口里方法定了就不能改" | 接口确实比具体类更稳定,但它不是不能改。当需求确实变了,接口也可以演化。关键是——改接口比改散落在 50 个地方的具体依赖要容易得多。 |
| "用了接口就是符合 DIP" | 光有接口不够。如果高层模块自己 new 低层模块,即便低层有接口,高层仍然依赖了低层。真正的 DIP 要求高层不直接创建低层实例。 |
六、结语:把依赖关系"翻过来"
传统思维是这样的:
"我要发通知,所以我去找一个能发通知的东西。"
依赖倒置的思维是这样的:
"我定义一个'能发通知的东西'的标准,谁能满足这个标准,谁就来。"
说白了就是从"主动找人"变成"被动等人来应聘"。这会产生一个疑问:
这样写代码,文件更多了、接口更多了、还要配依赖注入容器,代码量不是变多了吗?
答案是:多出来的不是代码量,是灵活性。
短视角(只看今天)
| 违反 DIP | 符合 DIP | |
|---|---|---|
| 文件数 | 2 个(OrderService + EmailSender) | 5 个(OrderService + 接口 + 3 个 Sender) |
| 今天的感受 | ✅ 简单,直接 new 就行 | ❌ 绕,多了接口和注入 |
纵向视角(看三个月、一年后)
| 违反 DIP | 符合 DIP | |
|---|---|---|
| 第 3 次换通知方式 | OrderService 已经改了三遍,每次改都胆战心惊 | 只加了一个新 Sender 类,OrderService 纹丝不动 |
| 第 5 个业务模块也要发通知 | 每个模块都自己 new EmailSender,代码复制了 5 份 | DI 容器里配一次,所有模块共用 |
| 引入 Bug 的风险 | 高(改了核心业务代码) | 低(只新增了实现类) |
| 测试覆盖率 | 难以 Mock,测试覆盖率低 | 每个模块独立可测,覆盖率轻松提升 |
一张图说清楚
违反 DIP 的项目结构: OrderService ──→ EmailSender UserService ──→ EmailSender ← 三个模块各写各的 new PayService ──→ EmailSender ❌ 换 EmailSender → 三个模块全要改 符合 DIP 的项目结构: OrderService ──→ INotificationSender ←── EmailSender UserService ──→ INotificationSender ←── SmsSender PayService ──→ INotificationSender ←── WeChatSender ✅ 换什么 Sender → 三个模块全不动,只改注入配置
一句话破
依赖倒置不是在教你把代码写复杂。而是在教你:把"不变的核心逻辑"和"会变的实现细节"之间的绳子剪断,中间接一个接口插头。
以后只换插头那一头,核心逻辑这头永远不动。(如游戏开发中的逻辑层和视觉层分离)
真正的厉害
不是写出一个功能不报错。而是三个月后换需求,你能只改一行配置而不改任何逻辑代码。
依赖倒置,就是给了你这种底气。
依赖倒置原则给了你一个清晰的编码铁律:
-
所有变量类型声明为接口/抽象类(除非是值对象)
-
永远不要在业务类里 new 具体实现(交给构造函数/工厂/DI容器)
-
问自己:换掉这个实现,我要改几行代码? → 答案应该是 0
金句收尾
面向实现编程,今天开心,明天糟心。
面向接口编程,今天多写三行,明天少改三天。
依赖倒置——就是把"我找谁"变成"谁来找我"。主动权在你手里,别交给具体类。
下一篇预告
接口隔离原则 — 你的接口是不是太"胖"了?有些方法,调用者根本不需要,但被迫实现。是时候给接口减肥了。
如果觉得文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我更新的动力!
更多推荐
所有评论(0)