第46讲:设计原则的权衡——过度设计的陷阱,YAGNI原则,KISS原则与设计原则的平衡

说实话,我见过太多团队把设计原则当成了“圣旨”。

一上来就想着“我要高内聚低耦合”、“我要开闭原则”、“我要依赖倒置”。结果呢?代码写了一堆抽象层,接口套接口,最后连自己都绕晕了。项目延期,需求变更,那套“完美”的设计反而成了最大的累赘。

这一讲,我们就来聊聊设计原则的“度”。

1. 过度设计的陷阱:你是在设计,还是在炫技?

先讲个我自己的经历。几年前我接手过一个中间件项目,前任架构师把整个系统分成了7层抽象。每一层都有接口、虚基类、工厂模式、策略模式……代码读起来像在读《设计模式大全》。

但问题是,这个系统只处理三种消息类型。三种!

我花了三天时间,才搞清楚一条消息从收到处理,到底经过了哪些对象。后来我重构了,直接砍掉4层,用了一个简单的分发器加三个处理函数。代码量减少60%,性能提升30%。

这就是典型的过度设计

过度设计的典型症状:

  • 为未来可能出现的需求,提前预留了大量扩展点
  • 抽象层次过多,调用链路过长
  • 大量使用设计模式,但实际场景根本不需要
  • 代码难以理解,新人上手成本极高

为什么会这样?说白了,很多开发者把“设计”和“写复杂代码”划了等号。觉得不用几个设计模式,就不够“专业”。

但真正的专业,是用最简单的方式解决问题。

2. YAGNI原则:你不会需要它

YAGNI,全称是“You Aren't Gonna Need It”。翻译过来就是:你根本用不着它

这个原则来自极限编程(XP),核心思想很简单:只实现当前明确需要的功能,不要为未来可能的需求提前写代码。

我的习惯:每次想加一个抽象层或接口时,先问自己三个问题:

  1. 这个功能现在就需要吗?
  2. 如果现在不做,以后加的成本会很高吗?
  3. 这个抽象真的能应对未来的变化吗?

如果前两个答案是否定的,第三个不确定,那就别做。

举个例子。假设你正在写一个日志模块,目前只输出到文件。你心想:“以后可能要输出到网络、数据库,我得先抽象一个LogSink接口。”

于是你写了:

class LogSink {
public:
    virtual void write(const std::string& msg) = 0;
    virtual ~LogSink() = default;
};

class FileSink : public LogSink {
    // 实现...
};

class Logger {
    std::unique_ptr<LogSink> sink_;
public:
    Logger(std::unique_ptr<LogSink> sink) : sink_(std::move(sink)) {}
    void log(const std::string& msg) { sink_->write(msg); }
};

看起来挺“优雅”对吧?但问题是,项目跑了一年,需求从来没变过。你那个LogSink接口,唯一的实现就是FileSink。多出来的抽象层,除了增加代码量和理解成本,没有任何价值。

注意:YAGNI不是让你不做设计。而是让你延迟决策。当真正需要多态时,再引入接口也不迟。重构的成本,往往远低于过度设计的成本。

3. KISS原则:保持简单和直接

KISS,Keep It Simple, Stupid。别笑,这个“Stupid”不是骂人,而是提醒你:把代码写得简单到“傻瓜”都能看懂

我个人认为,KISS是比所有设计原则都更重要的原则。因为简单意味着:

  • 容易理解
  • 容易修改
  • 容易测试
  • 不容易出错

来看一个对比。假设你要实现一个功能:根据用户角色返回不同的菜单列表。

复杂版本(过度设计):

class MenuProvider {
public:
    virtual std::vector<MenuItem> getMenu(const User& user) = 0;
};

class AdminMenuProvider : public MenuProvider {
    std::vector<MenuItem> getMenu(const User& user) override {
        return { /* 管理员菜单 */ };
    }
};

class NormalMenuProvider : public MenuProvider {
    std::vector<MenuItem> getMenu(const User& user) override {
        return { /* 普通用户菜单 */ };
    }
};

class MenuService {
    std::map<UserRole, std::unique_ptr<MenuProvider>> providers_;
public:
    MenuService() {
        providers_[UserRole::Admin] = std::make_unique<AdminMenuProvider>();
        providers_[UserRole::Normal] = std::make_unique<NormalMenuProvider>();
    }
    std::vector<MenuItem> getMenu(const User& user) {
        return providers_[user.getRole()]->getMenu(user);
    }
};

简单版本(KISS):

std::vector<MenuItem> getMenuForUser(const User& user) {
    switch (user.getRole()) {
        case UserRole::Admin: return { /* 管理员菜单 */ };
        case UserRole::Normal: return { /* 普通用户菜单 */ };
        default: return {};
    }
}

哪个好?我选第二个。三个角色?加个case。五个角色?还是加case。直到switch变得臃肿,再考虑重构。别提前“优化”。

4. 设计原则的平衡:什么时候该用,什么时候该收?

你可能会问:“那设计原则到底还要不要学?”

当然要学。但你要明白:设计原则是工具,不是目的

我总结了一个简单的判断框架:

场景 建议 理由
代码会被多次复用 适当抽象,遵循开闭原则 减少未来修改的波及范围
需求明确且稳定 KISS,直接实现 避免过度设计
需求不确定,变化频繁 YAGNI + 简单设计 等需求明确了再重构
团队规模大,多人协作 适当增加接口和文档 降低沟通成本
核心模块,性能敏感 优先考虑性能,减少抽象 避免虚函数等开销

嗯,这里要注意:没有银弹。每个项目、每个模块的平衡点都不一样。我个人的经验是:先写一个能跑的版本,然后根据实际需求逐步重构。别一开始就想着“完美”。

5. 避坑指南:我曾经踩过的坑

我曾经在一个支付系统中,为了“优雅”地处理多种支付方式,设计了一个复杂的策略模式+工厂模式+注册表。结果呢?

  • 新加一种支付方式,要改三个文件
  • 调试时,调用链路过长,根本不知道哪里出了问题
  • 后来需求变了,支付方式从5种减到2种,那套“优雅”的设计瞬间变成了累赘

最后我全删了,换成简单的if-else。代码量从800行降到150行。测试覆盖率从60%升到95%。

记住:最好的设计,是当你半年后回头看自己的代码时,能在一分钟内看懂它在干什么。而不是感叹“我当时真牛逼,写了这么复杂的架构”。

6. 知识体系:一张图看懂权衡

下面这张图,是我对设计原则权衡的理解。你可以把它当作一个决策参考:

设计原则权衡决策图 当前需求 需求明确且稳定 需求不确定/变化快 核心/性能敏感 → KISS原则 直接实现,避免抽象 → YAGNI原则 延迟决策,等需求明确 → 性能优先 减少抽象,避免虚函数 核心:先实现,再重构。不要为未来过度设计。

7. 总结

设计原则是经验,不是教条。YAGNI告诉你别做多余的事,KISS告诉你要简单,而其他设计原则告诉你怎么在需要的时候组织代码。

这三者并不矛盾。它们的关系是:YAGNI和KISS是“刹车”,其他设计原则是“油门”。只会踩油门,车会翻;只会踩刹车,车不走。

我建议你:

  • 写代码前,先问自己“这个抽象现在真的需要吗?”
  • 写完后,看看能不能删掉一半的代码还能正常工作
  • 如果半年后你还能看懂自己的代码,那就是好设计

最后一句:别让设计原则成为你偷懒的借口。真正的架构师,懂得在合适的时候说“不”,也懂得在合适的时候说“够了”。


公众号:蓝海资料掘金营,微信deep3321