在我看过的项目里,烂代码很少是“写”出来的,大多数是“改”出来的。一个新系统立项时大家都挺兴奋,架构也说得过去;真正开始难受,是在第三个版本之后——产品要加一个会员等级,运营要改计费规则,客户要求对接新渠道,于是你发现每加一个需求,都要在老代码里翻出三四个文件来动,改完A功能,B功能又莫名其妙挂了。软件设计7大原则,说白了就是七种应对这种“改代码之痛”的姿势,目标只有一个:让程序的修改成本可控,让它真正可维护。这篇文章我会把每个原则都配一组反例和正例代码,从“为什么非要这样”的角度拆开来讲,适合刚工作不久、想提升代码质量的开发,也适合正在准备软考中级软件设计师的朋友拿来对着学。
1. 先想清楚:软件设计七原则到底在解决什么问题
1.1 代码的“可维护”到底是什么
一提可维护性,很多人第一反应是“代码写得清楚、注释写得多”。但干过几年的人都知道,注释清楚只是表面功夫。真正的可维护,指的是当你面对一个需求变更时,改动能不能被限制在一个很小的范围内。
我拿我自己踩过的坑举个例子。之前维护过一个订单导出功能,最初需求很简单:从库里查订单,生成Excel,存到指定目录。第一版代码确实“能跑”,一个类里塞了三个方法——查数据、转格式、写文件。第三个月需求来了:Excel要换成PDF,同时文件要传到OSS而不是本地目录。我改的时候发现,调格式的地方牵扯到查询逻辑,写文件的代码又和格式化的代码混在一起。改了两天,还改出一个空指针。这就是没有设计原则的代价:需求每变一次,代码的脆弱性就指数级上升。
所以我把“可维护”拆成四条标准:
- 加一个新功能,尽量只新增代码,少改老代码;
- 改一个具体需求,能精准定位到一个类或一个方法,不波及其他功能;
- 类的职责清晰,看类名就能猜到里面大概有什么;
- 即使换掉底层实现,上层业务不用跟着动。
七条设计原则,全部是在为这四条标准服务。
1.2 七原则的一张速查表
先给出一张总览表,后面每一节再展开。这张表建议收藏,写代码前扫一眼,基本能规避大部分设计坏味道。
| 原则 | 一句话核心 | 典型应用场景 |
|---|---|---|
| 单一职责原则 | 一个类只该有一个修改理由 | 把报表的取数、格式化、输出拆成不同类 |
| 开闭原则 | 对扩展开放,对修改关闭 | 新增会员等级时只加策略类,不改原方法 |
| 里氏替换原则 | 子类继承父类后不能破坏父类的行为契约 | 正方形不要继承长方形 |
| 接口隔离原则 | 客户端不需要的接口方法就不该出现 | 机器人不必实现 eat() 方法 |
| 依赖倒置原则 | 高层不依赖低层,两者都依赖抽象 | Service 依赖 Repository 接口而非 MySQL 实现 |
| 迪米特法则 | 一个对象尽量少知道其他对象的内部结构 | 收银台不要直接操作钱包的内部卡 |
| 合成复用原则 | 优先使用组合而非继承来复用行为 | DAO 持有 DatabaseHelper 而不是继承它 |
这七个原则从来不是孤立的。开闭是目标,抽象和多态是手段;里氏替换是继承体系的安全阀;依赖倒置和合成复用其实都在说同一件事——面向抽象编程,少用继承传家宝。接下来我按“职责边界、继承体系、依赖管理”三条线来逐个拆解,这也是我认为最好记、最好对照代码自查的顺序。
2. 职责边界三原则:单一职责、接口隔离、最少知道
2.1 单一职责原则:一个类只该有一个修改理由
先看一段我经常在业务系统里见到的代码:
// 反例:一个类承担三种职责,任意一个改动都会波及另外两个 public class ReportService { public List<Order> queryOrders(Date start, Date end) { // 从数据库查询订单 } public String toHtml(List<Order> orders) { // 把订单列表格式化为 HTML } public void writeHtmlToFile(String html, String path) { // 把 HTML 写入文件 } }这三个方法各自负责一件事:查数据、拼格式、写文件。表面上看每个方法都挺单一,但这个类作为整体,它有三个不同的修改理由:数据库表结构变了要改它,页面样式变了要改它,存储路径规范变了还要改它。三个改动原因搅在同一个类里,就会出现我前面说的连锁反应——改样式的时候不小心动了查询逻辑,改存储的时候又影响了格式化。
正例是拆开,再用一个组装者去编排:
// 正例:每个类只有一个修改理由 public class OrderQuery { public List<Order> queryByDate(Date start, Date end) { ... } } public class HtmlReportFormatter { public String format(List<Order> orders) { ... } } public class ReportFileWriter { public void write(String content, String path) { ... } } // 组装者只需要编排流程,不关心细节 public class ReportService { private final OrderQuery query; private final HtmlReportFormatter formatter; private final ReportFileWriter writer; public ReportService(OrderQuery query, HtmlReportFormatter formatter, ReportFileWriter writer) { this.query = query; this.formatter = formatter; this.writer = writer; } public void generateReport(Date start, Date end, String path) { List<Order> orders = query.queryByDate(start, end); String html = formatter.format(orders); writer.write(html, path); } }拆分之后,每个类的改动原因都只有一个,改动范围被锁死在单个类里。判断一个类是否违反单一职责,有个很实用的土办法:把这个类的方法名列出来,看它们是不是服务于同一类需求提出者。比如“运营要改报表样式”和“开发要改数据库字段”,这两个角色就是不同的修改来源,那么对应的代码就不该长在同一个类里。
要注意,单一职责不是“一个类只写十行代码”。如果拆得过于零碎,会出现满天飞的工具类,反而增加理解成本。核心是看变更方向:职责划分的粒度,应当以“需求变更通常落在哪个边界上”为准。一个经常一起变化的职责,就应该放在一起;两个几乎不会同时变化、却会被不同角色不同节奏触发的职责,就该分开。
2.2 接口隔离原则:不要强迫实现类干它不干的事
接口隔离和单一职责是亲兄弟。单一职责管类,接口隔离管接口的拆分。
看典型反例,很多人定义接口时喜欢“一把梭”:
// 反例:一个接口塞入所有员工能力 public interface Worker { void work(); void eat(); } // 机器人被迫实现一个根本不存在的行为 public class Robot implements Worker { @Override public void work() { ... } @Override public void eat() { throw new UnsupportedOperationException("机器人不需要吃饭"); } }代码里出现空方法体、直接抛 UnsupportedOperationException,基本就是在交接口隔离的罚款。接口的本质是契约,实现类就是签合同的乙方。你让乙方签一份包含它做不到条款的合同,乙方就只能撒谎——这会造成两个问题:调用方以为机器人也会吃饭,于是写出需要所有 Worker 都实现 eat 的逻辑;实现类又被迫吞掉异常,运行时才炸出来。
正例是把接口拆成粒度更小的能力声明:
// 正例:接口按能力拆分,客户端按需依赖 public interface Workable { void work(); } public interface Eatable { void eat(); } public class Robot implements Workable { @Override public void work() { ... } } public class Human implements Workable, Eatable { @Override public void work() { ... } @Override public void eat() { ... } }这样写,调度系统只需要依赖 Workable,食堂系统只需要依赖 Eatable,谁都不会被迫关心跟自己无关的能力。判断接口胖不胖,可以看两件事:实现类里有没有用不到的方法,调用方有没有因为注入某个类型而带上了它根本不需要的依赖。有,就说明接口该拆了。
2.3 迪米特法则:别隔着中间人拿东西
迪米特法则还有个更直白的名字——最少知道原则。意思是一个对象对其他对象的结构知道得越少越好。
反例代码我经常在业务里看到,通常长这样:
// 反例:收银逻辑穿透了 Customer 和 Wallet 两个中间对象 public class Cashier { public void charge(Customer customer, double amount) { Wallet wallet = customer.getWallet(); Card card = wallet.getMainCard(); if (card.getBalance() >= amount) { card.deduct(amount); } } }这个方法为了一次收银,摸清了顾客有钱包、钱包里有主卡、卡里有余额三件事。一旦顾客以后改用脸部识别支付、或者钱包里没有主卡而是多张卡按策略选卡,这里全部要改,而且调用它的所有地方的代码全部要跟着改。更麻烦的是这条访问链路上任何一环的内部结构变化,都会像地震一样传导到每一处“穿透式”调用。
正例是让 Cashier 只和最直接的协作对象打交道:
// 正例:顾客自己负责处理支付细节,收银员只发起请求 public class Customer { private final Wallet wallet; public void pay(double amount) { wallet.deduct(amount); } } public class Cashier { public void charge(Customer customer, double amount) { customer.pay(amount); } }收银员依然收银,但他不再关心顾客的钱放在哪张卡里。Wallet 怎么换实现,都不影响 Cashier。迪米特法则不是在教你禁止使用 getter,而是在提醒你:当一个方法里出现连续两个以上 getXxx().getYyy().getZzz() 的调用链时,就该考虑把业务行为上移到真正拥有数据的对象里,让它自己开口说话。
3. 继承体系的两个闸门:开闭原则与里氏替换原则
3.1 开闭原则:加功能不动老代码
开闭原则的表述很优雅:对扩展开放,对修改关闭。落到代码层面,就是当你需要新增能力时,优先写一个新类,而不是去改一个已经验证过的老方法。
来看最经典的折扣计算反例:
// 反例:每来一个会员等级,就要往这个方法里塞一个 if public double getDiscount(String userType, double price) { if ("vip".equals(userType)) { return price * 0.8; } else if ("superVip".equals(userType)) { return price * 0.6; } else { return price; } }这个方法本身短小精悍,问题在于它的变化方向。下周产品说“加一个企业会员打七折”,你就得打开这个被十个地方调用的方法,往里面再插一个 else if。每多插一次,就等于在一个稳定的算法里增加一个分支,而分支是 bug 最容易藏身的地方——少一个括号、判断顺序错了、老用户命中了新分支,这些全是实打实的线上事故。
正例是把用户类型变成一类扩展点,用接口和多态把“变化”挡在外面:
// 正例:策略接口 + 独立实现类 public interface DiscountStrategy { double calculate(double price); } public class VipDiscountStrategy implements DiscountStrategy { @Override public double calculate(double price) { return price * 0.8; } } public class SuperVipDiscountStrategy implements DiscountStrategy { @Override public double calculate(double price) { return price * 0.6; } } // 以后新增企业会员,只需再写一个实现类,原代码零改动 public class EnterpriseDiscountStrategy implements DiscountStrategy { @Override public double calculate(double price) { return price * 0.7; } }把这段代码放到上下文里,调用方依赖的是 DiscountStrategy 接口,运行时传入不同的实现即可。这就是“开闭”的含义:新增一种折扣,老代码一行不改,只加一个新类。开闭原则是所有设计模式的终极目标,你在学策略模式、模板方法、装饰器模式的时候会发现,它们内部藏着的都是这条原则。
这里要澄清一个常见误解:开闭原则不是说你写出来的代码永远不能改。如果一个类本身就还在高频变化、需求还没定型,强行抽象只会拖慢节奏。开闭的适用前提是“这块逻辑已经稳定,但未来可能出现新变体”。
3.2 里氏替换原则:子类别拆父类的台
里氏替换原则是继承体系里最容易背会、也最容易写错的一条。它说:所有能使用父类对象的地方,都应该能无缝替换成子类对象。
教科书级的反例就是正方形继承长方形:
// 反例:正方形通过重写方法维持“长宽相等”,却破坏了父类行为契约 public class Rectangle { private int width; private int height; public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int getArea() { return width * height; } } public class Square extends Rectangle { @Override public void setWidth(int width) { super.setWidth(width); super.setHeight(width); // 强行让长宽相等 } @Override public void setHeight(int height) { super.setWidth(height); super.setHeight(height); } }看似精妙,一替换就出事:
public void resize(Rectangle rectangle) { rectangle.setWidth(5); rectangle.setHeight(4); // 调用者按长方形逻辑推断面积是 20 } Rectangle rectangle = new Square(); resize(rectangle); // 实际面积变成了 16Rectangle 这个父类隐含了一个契约:setWidth 只修改宽,setHeight 只修改高,二者互不影响。Square 为了维持自身约束,悄悄把这个契约改掉了。调用方基于父类契约写下的业务逻辑,在子类身上全线崩溃,于是你只能被迫去写 if (obj instanceof Square) 这样的分叉判断。这一写,开闭原则也被顺带破坏了——每次新增子类,调用方都要加一个判断分支。
正例是抽象一个真正的“几何形状”接口,让每个形状自己负责自己的面积计算:
// 正例:继承关系只在真正存在"is-a"语义时使用 public interface Shape { double getArea(); } public class Rectangle implements Shape { private double width; private double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } @Override public double getArea() { return width * height; } } public class Square implements Shape { private double side; public Square(double side) { this.side = side; } @Override public double getArea() { return side * side; } }Square 和 Rectangle 确实都有“形状”的能力,但它们没有谁该继承谁的关系。在设计继承结构时,我问自己最多的一句话是:这个子类替换掉父类之后,调用方已有的行为假设还会成立吗?如果答案是“不会”,那它就不是继承关系,最多是一个相似实现。很多时候你发现自己想从父类那里复用的只是几个方法,这种情况我不建议用继承,这也正好引出后面要讲的合成复用原则。
4. 依赖管理两原则:面向抽象编程,组合优于继承
4.1 依赖倒置原则:高层不依赖低层,都依赖抽象
依赖倒置原则听起来有点绕,但它解决的是我见过最多的架构病:业务层代码里直接 new 了一个具体实现,然后整条业务被这一个实现绑架。
经典反例:
// 反例:业务类直接依赖 MySQL 的具体 DAO public class OrderService { private MySQLOrderDao dao = new MySQLOrderDao(); public void saveOrder(Order order) { dao.save(order); } }这个写法在项目初期没问题,但你要换数据库、要引入缓存、要做读写分离的时候就会很痛苦。OrderService 构造时直接 new 死了 MySQLOrderDao,换 MongoDB 就要改 OrderService 的代码,再加一层 Redis 缓存还要再改一次。问题不在于换数据库本身,而在于“高层的订单流程”不该依赖“低层的存储细节”。订单流程是稳定业务,存储方式是可替换细节,稳定的一方被不稳定的一方绑架,这就是依赖倒置违反的典型症状。
正例是让上层依赖抽象接口,具体实现在运行时注入:
// 正例:定义仓储接口,让业务依赖抽象 public interface OrderRepository { void save(Order order); } public class MySQLOrderRepository implements OrderRepository { @Override public void save(Order order) { ... } } public class MongoDBOrderRepository implements OrderRepository { @Override public void save(Order order) { ... } } public class OrderService { private final OrderRepository repository; // 构造器注入,运行时决定具体实现 public OrderService(OrderRepository repository) { this.repository = repository; } public void saveOrder(Order order) { repository.save(order); } }以后加缓存实现也好,换数据库也好,OrderService 一行不用动,只需要在启动配置层面把新的实现类传进来。这正是依赖倒置的核心含义:高层策略和低层细节都要面向抽象编程,而抽象不依赖细节。
顺便说明,很多人学 Spring 时候把 @Autowired 当成“省去 new 的语法糖”,其实它服务的正是依赖倒置原则——让对象在运行时获得抽象实现,而不是在编译期写死具体类。判断自己有没有违反 DIP,有个成本很低的检查方式:看你的 Service 类里 import 语句中,依赖的是业务包的接口,还是具体框架的类。
4.2 合成复用原则:能组合就不继承
合成复用原则是我在代码评审里提醒最多的原则:“你这个继承是为了复用方法,还是为了表达 is-a 关系?多半是前者,那就改成组合。”
反例很常见。项目里有个 DatabaseHelper 负责拿连接,有人希望 DAO 能用上,图省事直接继承:
// 反例:为了复用连接逻辑而继承 Helper 类 public class DatabaseHelper { public Connection getConnection() { ... } } public class UserDao extends DatabaseHelper { public void saveUser(User user) { Connection conn = getConnection(); ... } }继承的方式先把 UserDao 和 DatabaseHelper 牢牢焊死。今天 UserDao 想换个连接池实现,没门,因为继承关系在编译期就固定了。更麻烦的是 Java 只能单继承,UserDao 有了这个爹,就不能再从其他基类拿能力。等 OrderDao、ProductDao 一个个复制这种继承写法时,DatabaseHelper 的任何改动都会沿着继承树传播到所有 DAO,风险完全不可控。
正例是把 DatabaseHelper 变成 UserDao 的一个成员,用组合的方式复用能力:
// 正例:把依赖作为成员变量持有,按需调用 public class UserDao { private final DatabaseHelper helper; public UserDao(DatabaseHelper helper) { this.helper = helper; } public void saveUser(User user) { Connection conn = helper.getConnection(); ... } }组合同样也能复用方法,但耦合比继承低得多:DatabaseHelper 换实现,不影响 UserDao 的类层次;UserDao 需要其它能力,随时可以再注入其他组件,不受单继承限制。“组合优先于继承”不是一条禁止继承的禁令,继承仍然适合真正的 is-a 建模,比如鸭子继承鸟类、正方形实现形状接口。但凡是你能说清“不是亲父子关系,只是想让这个类蹭点现成方法”,请首选组合。
依赖倒置和合成复用放在一起看,会发现它们方向一致——都在逼你把代码从“面向具体实现”扭转到“面向接口、按需组装”。这也是为什么优秀的框架代码里,很少有深不见底的继承链,倒是一堆接口配合构造器注入在互相协作。
5. 把七个原则放进同一个真实项目里
5.1 一个订单系统的多原则混用示例
学设计原则最怕的事,就是背得滚瓜烂熟,真写代码时一条都用不上。我拿一个最小可运行的订单结账场景,把七个原则同时塞进去,让你感受一下它们是怎么合体的。
public class OrderService { private final OrderRepository repository; private final DiscountStrategy discountStrategy; private final Notifier notifier; public OrderService(OrderRepository repository, DiscountStrategy discountStrategy, Notifier notifier) { this.repository = repository; this.discountStrategy = discountStrategy; this.notifier = notifier; } public void checkout(Cart cart) { // 只做流程编排,不掺和计算和存储细节 Order order = new Order(cart.getItems()); order.applyDiscount(discountStrategy.calculate(order.getAmount())); repository.save(order); notifier.send("订单已创建", order); } }就这么一个小方法,里面同时体现了至少六条原则:
- OrderService 只负责编排流程,不碰折扣算法细节、不碰存储细节、不碰通知细节——单一职责;
- repository、discountStrategy、notifier 都是接口类型,具体实现通过构造器注入——依赖倒置;
- 以后新增“满减”“限时折扣”,只需要新增 DiscountStrategy 实现类,OrderService 和已有策略一行不改——开闭;
- 只要策略实现遵守“输入金额、返回优惠后金额”的契约,替换任意策略都不会破坏订单流程——里氏替换;
- OrderService 不需要知道订单最终是存进 MySQL 还是 Kafka、也不需要知道通知走短信还是邮件,它只调用接口方法——接口隔离和迪米特法则;
- repository 是注入进来的组件而非继承父类拿来的能力——合成复用。
这套设计带来的实际收益是:订单流程稳定后,之后三个月里几乎没再动过 OrderService 本体,所有新需求都在新增策略和新增实现类里完成。
这个思路不只适用后端。就算写前端组件,比如 tablist 这类标签页组件,同样的原则照样管用。反例是渲染函数里写一堆 switch(tab.type) 来分发不同 tab 内容,每新增一种 tab 类型就要改渲染函数;正例是让每个 tab 实现同一个渲染接口,外层循环调用,新增 tab 就是新增一个实现组件。设计原则从来不绑定技术栈,它绑定的是变化。
5.2 原则“打架”时的判断标准
七个原则在理想情况下配合默契,但落到真实项目里,经常会出现两条原则互相拉扯的局面。这里列几个我实际遇过的抉择点,供你参考。
单一职责和接口隔离拆得太细,会让类数量暴涨。我见过一个项目把一个支付流程拆成了二十多个类和接口,结果是没人看得懂整体链路。我的判断标准是看“变更频率是否一致”:一组方法在可预见的三个月内总是因为同一个原因一起改动,就不要拆;如果它们各自由不同角色、不同节奏触发变更,就必须拆。
迪米特法则要求少知道别人的内部结构,但为了少知道而加一层门面对象,可能会带来额外的方法调用损耗。放在高性能场景里,分布式框架底层的字节码和热路径代码不可能全部拿设计原则套一遍。我的取舍依据是:那一层的对象结构是否稳定。Customer 的钱包结构大概率会变,值得隔离;底层 IO 结构相对稳定,那就不必为了“最少知道”硬包一层。
开闭原则和写抽象的成本天然冲突。每个接口、每个抽象类都是现在多写一层,而它的收益只有未来某个需求来临时才会兑现。过度设计项目的通病,就是为永远不会来的变化预埋抽象。我在判断“要不要抽象”时,看重需求方向是否明确:已经确认“未来确实要扩展”时才动手;仅仅“觉得以后可能变”,我建议先按最简单的方式写,等第二个真实需求出现时再重构。
5.3 软考中级视角:高频考点与易混淆点
如果你在准备软考中级软件设计师,设计原则这部分是上午选择题的常客,下午案例分析里也经常让你指出某段设计违反了哪条原则。考点本身不难,难在四个字:措辞严谨。
我从历年的易错选项里挑了这几组常见混淆,列成表格给你:
| 常见错误说法 | 正确理解 |
|---|---|
| 开闭原则就是软件发布后不能修改 | 开闭原则是“对扩展开放、对修改关闭”,指的是已有的稳定逻辑尽量通过扩展实现新需求,不是禁止修 bug |
| 依赖倒置就是依赖接口,所以 dao 改成接口就够了 | 依赖倒置还要求细节同样依赖抽象,且业务层不直接创建具体实现,运行时注入才算完整落地 |
| 接口隔离原则和单一职责原则完全一样 | 单一职责针对类和模块,接口隔离针对接口的方法集合大小,两者是不同层面的约束 |
| 里氏替换原则要求子类方法必须和父类实现完全相同 | 子类可以有自己的实现细节,但必须保持父类的行为契约不破裂 |
| 合成复用原则就是尽量用继承来复用代码 | 正相反,优先使用组合,只有真正符合 is-a 关系才用继承 |
备考时建议把每个原则的“定义关键词”标出来,比如开闭里的“扩展/修改”、里氏里的“替换/契约”、依赖倒置里的“抽象/细节”,选择题里很多陷阱就是把关键词换掉、或者反过来表述。理解设计原则不是为了答题,但答好这类题的前提,是你确实能在代码里识别出这些坏味道。
最后说一点我自己的落地习惯。我不太建议在项目里搞“设计原则大扫除”,一次性把所有代码推倒重写,那样风险极高。更稳妥的做法是:把七条原则变成代码审查时的一个检查清单,每次提交前问自己三个问题——这次修改会不会扩散到不止一个类?新需求到来时,是老代码里加分支实现还是新增一个实现类?调用方对内部结构的了解是不是太多了?只要这三个问题的答案出现“扩散”“分支”“穿透”,就说明这里该停下来做一次小重构。我这些年的体会是,设计原则不是背出来的信仰,而是在一次次“改代码太疼了”的教训里,慢慢长进手感的。