AI 写代码越来越强了,实现一个函数、写一段算法,AI 比大多数人都快。
但你让 AI 帮你从头搭一个有一定复杂度的系统,它大概率给你糊出一坨面向过程的代码:一个函数从头干到尾,所有逻辑揉在一起。能跑,但不能改、不能扩展。
我自己用 AI 写代码的体会是:AI 写代码是很快,但随着业务逻辑的增加,你需要经常介入,指挥它去抽象、简化,甚至重构代码。如果你完全不管它,AI 遗留下来的冗余逻辑会越积越多,最后代码臃肿到没法维护。
所以设计模式在 AI 时代反而更重要了:你先有一个清晰的、可扩展的代码结构设计,让 AI 在你框定的框架内工作,能避免很多潜在的问题。
所以,编码能力可以交给 AI,但代码结构的设计能力得在你自己手里。
坏的设计长什么样?
我们来看一个具体的例子,假设你在开发一个 Web 应用,需要实现用户注册功能。你让 AI 写,它可能会生成这样的代码:
public void register(String username, String password) {
// 校验参数
if (username == null || password.length() < 6) {
throw new IllegalArgumentException("参数不合法");
}
// 密码加密
String hashed = BCrypt.hash(password);
// 插入数据库
db.execute("INSERT INTO users ...", username, hashed);
// 发欢迎邮件
EmailClient client = new EmailClient("smtp.xxx.com");
client.send(username, "欢迎注册!");
// 记录日志
logger.info("新用户注册: " + username);
}代码能跑,但问题马上就来了:
产品说要把欢迎邮件换成短信通知,你得翻到这个函数中间去改;运维说日志要接入新的日志平台,还是改这个函数;测试想单独测注册逻辑,做不到,因为一跑就会发邮件、写日志。
每一次需求变更都要在同一个函数里动刀,改着改着就出 bug 了,这就是不好的设计。
问题出在哪?关键在这个函数干了太多事情:注册、通知、日志全揉在一起,没有边界。
正确的思路是把它们拆开:
注册模块只管注册,通知模块只管发通知,日志模块只管记日志。注册逻辑不关心通知是邮件还是短信,它只需要「注册完了通知一声」。
这就是系统边界:每个模块只负责自己的事,通过约定好的接口和其他模块通信。
良好的系统边界能够有效 解耦 系统,让每个模块都保持独立,只关注自己的职责,不会互相影响。
这样做的好处很直观:要把邮件换成短信?只改通知模块,注册逻辑一行不动。要加一种新的通知渠道(比如 App 推送)?新写一个通知实现就行,完全不用碰注册相关的代码。
什么是设计模式
上面这种「拆模块、定边界」的思路,其实就是设计模式要解决的问题。
设计模式不是什么新技术,也不是什么高深理论。就像我们刷算法题有固定的套路一样,设计模式是编写工程代码时的套路,帮你在遇到特定的代码结构问题时快速找到一个经过验证的解法。
经典的设计模式有 23 种,来自 GoF(Gang of Four)在 1994 年出版的《设计模式》一书。这 23 种模式按关注点不同分为三大类:
创建型模式关注怎么创建对象,比如单例模式、工厂方法模式。
结构型模式关注怎么组合对象,比如适配器模式、装饰模式。
行为型模式关注对象之间怎么通信,比如观察者模式、策略模式。
本章会按照这个顺序,逐一讲解每种模式的原理和应用场景。
设计模式的核心原则
这 23 种模式背后有几条共同的原则在指导,理解了这些原则,学具体模式的时候就能举一反三。
单一职责原则
一个模块只干一件事。注册就只管注册,通知就只管通知,职责越单一,修改的理由就越少,代码就越稳定。上面那个 register 函数之所以难维护,就是因为它同时承担了三四个职责。
开闭原则
开闭原则要求对扩展开放,对修改关闭。
还是拿上面的通知功能举例:你写了个 notify 方法,最初只有邮件通知,后来要加短信,就得代码里加了个 if-else;再加 App 推送,又加一层 if-else。每次加渠道都要改已有代码,每次改都有引入 bug 的风险。
// 违反开闭原则:每加一种渠道,都要改这个函数
void notify(String channel, String msg) {
if (channel.equals("email")) {
// 发邮件...
} else if (channel.equals("sms")) {
// 发短信...
} else if (channel.equals("app")) {
// App 推送...
}
// 下次再加一种?继续加 else if...
}符合开闭原则的做法是:定义一个 Notifier 接口,EmailNotifier、SmsNotifier 各自实现它。加新渠道就新增一个实现类,已有代码一行都不用动。
// 符合开闭原则:新增渠道只需新增一个类
interface Notifier {
void send(String msg);
}
class EmailNotifier implements Notifier { ... }
class SmsNotifier implements Notifier { ... }
// 加 App 推送?新建一个类就行,上面的代码不用动
class AppNotifier implements Notifier { ... }这就叫对扩展开放(你可以基于 Notifier 接口新增实现类),对修改关闭(现有代码不用动)。
依赖倒置原则
依赖倒置原则要求高层模块不应该依赖底层实现的细节。
上述例子中,注册功能属于高层模块,通知功能属于底层模块,所以注册相关的代码不应该依赖具体的通知功能实现。
比如这就是反例:
// 注册功能直接依赖具体类
class RegisterService {
// 写死了 EmailNotifier,想换短信就得改这里
// 违反依赖倒置原则
Notifier notifier = new EmailNotifier();
void register(String username, String password) {
// ...注册逻辑...
notifier.send("欢迎注册!");
}
}依赖倒置的做法是,注册逻辑只认接口,具体用哪个实现由外部传入:
// 依赖接口:注册逻辑不关心具体实现
class RegisterService {
Notifier notifier;
// 从外部传入,用邮件还是短信,这里不管
// 符合依赖倒置原则
RegisterService(Notifier notifier) {
this.notifier = notifier;
}
void register(String username, String password) {
// ...注册逻辑...
notifier.send("欢迎注册!");
}
}
// 调用方决定用哪种通知
new RegisterService(new EmailNotifier());
new RegisterService(new SmsNotifier());register 方法里只调 notifier.send(),完全不知道也不关心底层是邮件还是短信。换通知方式只需要在创建 RegisterService 时传入不同的实现,注册逻辑的代码一行不动。
写测试的时候也方便,传一个 MockNotifier 进去就能单独测注册逻辑,不会真的发邮件。
组合优于继承
在设计模式中,能用组合解决的问题,就不要用继承。
举个直观的例子,如果用继承来设计电脑,你会按层级一级一级往下分:
Computer
├── IntelComputer(锁定 CPU 是 Intel)
│ ├── Intel16GBComputer(锁定内存 16GB)
│ │ └── Intel16GB_SSDComputer(锁定硬盘类型)
│ └── Intel32GBComputer ...
├── AMDComputer(锁定 CPU 是 AMD)
│ ├── AMD16GBComputer ...
│ └── ...每一层继承锁定一个选择,想覆盖所有组合,这棵继承树就会急剧膨胀。而且继承链是刚性的:如果你想改 Computer 基类的某个行为,所有子类都受影响。
用组合就简单了:
Computer
├── cpu: CPU(Intel i7 / AMD R9 / ...)
├── memory: Memory(16GB / 32GB / ...)
├── storage: Storage(SSD / HDD / ...)
└── gpu: GPU(RTX 4090 / RX 7900 / ...)Computer 持有四个独立组件,每个组件可以自由替换,互不影响。想升级内存?换一根内存条,CPU 和硬盘完全不用动。
继承是把每一层的选择锁死在类层级里,层层绑定,形成复杂的继承链;组合是把各个维度拆成独立部件,自由搭配。这是很多设计模式的共同思路,你在后面的文章中会反复看到这一点。
上面就是设计模式的几个核心元组,不过这些原则是经验法则,不是教条。不同语言、不同场景下完全可以灵活变通。
比如 Go 语言压根没有继承,天然就是组合优先;而有些简单的工具类,硬要拆出接口反而是过度设计。原则的价值在于给你一个思考方向,而不是一套必须遵守的规矩。
学习建议
本章每种模式都配有完整的多语言可运行代码(Java、Go、C++、Python、TypeScript),你可以直接在页面上运行看效果。
不用背 UML 图和角色名称,理解应用场景和代码实现就够了。
最后提醒一点:设计模式不是越多越好。大多数简单场景根本不需要模式,只有当代码复杂度增长、需求频繁变化时,模式才该登场。不要为了用模式而用模式,它是工具,不是目的。