本章导读

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 接口,EmailNotifierSmsNotifier 各自实现它。加新渠道就新增一个实现类,已有代码一行都不用动。

// 符合开闭原则:新增渠道只需新增一个类
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 图和角色名称,理解应用场景和代码实现就够了。

最后提醒一点:设计模式不是越多越好。大多数简单场景根本不需要模式,只有当代码复杂度增长、需求频繁变化时,模式才该登场。不要为了用模式而用模式,它是工具,不是目的。