设计模式是语言缺陷的补丁 —— 从工厂、单例、策略三个模式说起

那天读到一篇文章,标题只有三个词:Design Patterns Suck。

作为一个从 C++ 起步、曾经把《设计模式》那本书当成技术品味象征的人,我点进去的时候是带着火气的。读完火气没了,剩下的是"他说的好像对"。

那篇文章的核心论点很硬:

设计模式被高估了、滥用了,而且常常是完全不必要的。

设计模式不过是丑陋的变通方案 ------ 因为我们的编程语言不够强大、不够灵活,无法表达我们真正想要的东西。

我不打算做"设计模式有用 / 没用"的站队。我只想把自己这些年踩过的例子摆出来,用代码验证一件事:很多设计模式之所以存在,是因为当时的语言表达力不够。

结论先行

如果只想要结论,三条:

  1. 设计模式是语言缺陷的补丁。 这不是比喻。翻开每一个模式,背后都对应一个"这门语言没法直接表达"的东西。语言补上了,模式就退化成噪音。
  2. 模式是词汇表,不是解题起点。 它的价值在"讨论已经写好的代码",不在"决定该怎么写代码"。反过来用,必然滑向过度设计。
  3. C++ 的演进史就是这场辩论的实证。 从 C++03 到 C++11/14/17/20,语言每补一个洞,就有一批模式从"必须掌握"降级为"知道就行"。

下面全是代码。每组都是同一个需求的两种写法,都能编译运行。

一、工厂模式:从四个类到一个模板函数

大学做学生管理系统的时候,我写过这样的代码。系统里总共只有两种学生类型,我搞出了一个完整的工厂继承体系:

arduino 复制代码
#include <iostream>
#include <memory>
#include <string>
​
class Student {
public:
    virtual ~Student() = default;
    virtual std::string getName() const = 0;
};
​
class Undergraduate : public Student {
    std::string name_;
    int age_;
public:
    Undergraduate(const std::string& name, int age) : name_(name), age_(age) {}
    std::string getName() const override { return name_; }
};
​
class Graduate : public Student {
    std::string name_;
    int age_;
public:
    Graduate(const std::string& name, int age) : name_(name), age_(age) {}
    std::string getName() const override { return name_; }
};
​
class StudentFactory {
public:
    virtual ~StudentFactory() = default;
    virtual std::unique_ptr<Student> createStudent(const std::string& name, int age) = 0;
};
​
class UndergraduateFactory : public StudentFactory {
public:
    std::unique_ptr<Student> createStudent(const std::string& name, int age) override {
        return std::make_unique<Undergraduate>(name, age);
    }
};
​
class GraduateFactory : public StudentFactory {
public:
    std::unique_ptr<Student> createStudent(const std::string& name, int age) override {
        return std::make_unique<Graduate>(name, age);
    }
};

用起来是这样:

c 复制代码
std::unique_ptr<StudentFactory> factory = std::make_unique<UndergraduateFactory>();
auto student = factory->createStudent("张三", 20);
std::cout << student->getName() << std::endl;

输出:

复制代码
张三

六个类,一层虚函数接口,一层具体实现,两个工厂。功能是什么?按名字造一个对象。

同样的事,用模板函数:

c 复制代码
class Undergraduate {
    std::string name_;
    int age_;
public:
    Undergraduate(const std::string& name, int age) : name_(name), age_(age) {}
    std::string getName() const { return name_; }
};
​
class Graduate {
    std::string name_;
    int age_;
public:
    Graduate(const std::string& name, int age) : name_(name), age_(age) {}
    std::string getName() const { return name_; }
};
​
template<typename T>
auto create_student(const std::string& name, int age) {
    return T(name, age);
}
c 复制代码
auto student = create_student<Undergraduate>("张三", 20);
std::cout << student.getName() << std::endl;
复制代码
张三

没有虚函数,没有工厂类,没有继承体系。 而且这个版本还快一点 ------ 返回的是具体类型不是 unique_ptr<Student>,编译器能内联,不用走 vtable。

那当时为什么写工厂?因为"用了设计模式,代码才算专业"。这句话本身,就是被滥用的起点。

工厂模式的补丁性质

看 GoF 书里对工厂方法的定义: "定义一个用于创建对象的接口,让子类决定实例化哪一个类。"

翻译一下:因为 C++ 早期没法把"构造函数"当参数传,所以只好用虚函数绕一圈。

现在能了 ------ 模板、std::variant、std::any、甚至简单的 lambda 都能做到。工厂模式在"需要延迟决定对象类型"的场景下还有位置,但"有两种类型"显然不在此列。

二、单例模式:语言已经替你做完了

传统 C++ 单例的标准写法:

c 复制代码
class Logger {
    static std::unique_ptr<Logger> instance_;
​
    Logger() = default;
public:
    Logger(const Logger&) = delete;
    Logger& operator=(const Logger&) = delete;
​
    static Logger& getInstance() {
        if (!instance_) {
            instance_ = std::make_unique<Logger>();
        }
        return *instance_;
    }
​
    void log(const std::string& message) {
        std::cout << "[LOG] " << message << std::endl;
    }
};
​
std::unique_ptr<Logger> Logger::instance_ = nullptr;
css 复制代码
Logger::getInstance().log("Hello World");
csharp 复制代码
[LOG] Hello World

这段代码有个经典 bug:它不是线程安全的 。两个线程同时第一次调用 getInstance(),instance_ 都为空,都去 new ------ 构造两次。要修就得加锁、写双重检查锁定、处理内存序,然后你就拥有了一个看起来绝不该这么复杂的东西。

C++11 之后:

c 复制代码
class Logger {
public:
    static Logger& getInstance() {
        static Logger instance;
        return instance;
    }

    void log(const std::string& message) {
        std::cout << "[LOG] " << message << std::endl;
    }
};
css 复制代码
Logger::getInstance().log("Hello World");
csharp 复制代码
[LOG] Hello World

线程安全是标准保证的(C++11 stmt.dcl/4:局部静态变量的初始化是线程安全的,且只发生一次)。不用锁,不用双重检查,不用手动管理生命周期。

甚至如果你的日志不需要状态,连类都不需要:

c 复制代码
namespace logger {
    void log(const std::string& message) {
        std::cout << "[LOG] " << message << std::endl;
    }
}
arduino 复制代码
logger::log("Hello World");
csharp 复制代码
[LOG] Hello World

单例模式的补丁性质

单例存在的理由是: "语言没法表达全局唯一实例。"

C++ 的答案:局部静态变量 (C++11 起线程安全)、namespace 、或者更强硬的 Meyers Singleton。当语言给了你"函数内静态变量的初始化只发生一次"这个保证,整个模式就塌缩成了三行。

顺便说一个更实际的问题:单例模式在工程上经常是负资产 ------ 它把依赖藏进了调用点,测试时没法替换。能用 namespace 或依赖注入解决的,别用单例。

三、策略模式:std::function 就是那个缺失的语言特性

传统写法,虚函数接口 + 两个实现 + 一个持有者:

arduino 复制代码
class PaymentStrategy {
public:
    virtual ~PaymentStrategy() = default;
    virtual void pay(double amount) = 0;
};

class CreditCardPayment : public PaymentStrategy {
public:
    void pay(double amount) override {
        std::cout << "Paying " << amount << " with credit card" << std::endl;
    }
};

class PayPalPayment : public PaymentStrategy {
public:
    void pay(double amount) override {
        std::cout << "Paying " << amount << " with PayPal" << std::endl;
    }
};

class ShoppingCart {
    std::unique_ptr<PaymentStrategy> paymentStrategy_;
public:
    ShoppingCart(std::unique_ptr<PaymentStrategy> strategy)
        : paymentStrategy_(std::move(strategy)) {}

    void checkout(double amount) {
        paymentStrategy_->pay(amount);
    }
};
scss 复制代码
ShoppingCart cart(std::make_unique<CreditCardPayment>());
cart.checkout(100.0);
csharp 复制代码
Paying 100 with credit card

四个类干一件事:把一段逻辑当参数传进去。

现代 C++:

c 复制代码
#include <functional>

void credit_card_pay(double amount) {
    std::cout << "Paying " << amount << " with credit card" << std::endl;
}

void paypal_pay(double amount) {
    std::cout << "Paying " << amount << " with PayPal" << std::endl;
}

void process_payment(std::function<void(double)> payment_method, double amount) {
    payment_method(amount);
}
scss 复制代码
process_payment(credit_card_pay, 100.0);
csharp 复制代码
Paying 100 with credit card

不想为一次性逻辑专门写个函数?lambda:

c 复制代码
process_payment([](double amount) {
    std::cout << "Paying " << amount << " with Apple Pay" << std::endl;
}, 100.0);
csharp 复制代码
Paying 100 with Apple Pay

策略模式的补丁性质

策略模式解决的是: "这门语言不能把算法当参数传。"

在 C 里这个问题是函数指针解决的,在 C++ 里是 std::function 和 lambda 解决的,在 Java 里直到 Java 8 有了 lambda 才好一点(Java 7 时代给一个 Comparator 写匿名内部类的痛,写过的人都记得)。

策略模式和函数指针是同一个东西,只是穿了正装。 需要多方法接口(一个"策略"要暴露好几个操作)时它仍有价值;只需要传一个函数时,std::function 就够了。

四、过度设计:为不存在的需求付的税

上面是"语言能替你做",这一类更严重:语言替你做了,你也用不上,但你还是写了一个框架。

例子一:一个 DNS 解析器,配了一个"提供者工厂"

csharp 复制代码
class DnsResolver {
public:
    virtual ~DnsResolver() = default;
    virtual std::string resolve(const std::string& host) = 0;
};

class FileDnsResolver : public DnsResolver {
public:
    std::string resolve(const std::string& host) override {
        return "192.168.1.10";
    }
};

class RemoteDnsResolver : public DnsResolver {
public:
    std::string resolve(const std::string& host) override {
        return "10.0.0.1";
    }
};

class DnsResolverFactory {
public:
    static std::unique_ptr<DnsResolver> create() {
        return std::make_unique<FileDnsResolver>();
    }
};
c 复制代码
auto resolver = DnsResolverFactory::create();
std::cout << resolver->resolve("api.example.com") << std::endl;
复制代码
192.168.1.10

这段代码的实际情况是:项目从始至终只用本地文件解析,RemoteDnsResolver 一行都没被调用过。 它存在的唯一理由是"万一以后要接远程 DNS 呢"。

YAGNI 原则(You Aren't Gonna Need It)就是冲着这个来的。为一个假设中的未来写抽象层,代价是你现在就要维护两条代码路径。而那个未来如果不来 ------ 大概率不来 ------ 你付出的就是纯亏损。

真实需要的是一个查表:

c 复制代码
#include <map>
#include <string>

class DnsCache {
    std::map<std::string, std::string> entries_;
public:
    DnsCache() {
        entries_["api.example.com"] = "192.168.1.10";
    }

    std::string resolve(const std::string& host) const {
        auto it = entries_.find(host);
        return it != entries_.end() ? it->second : "";
    }
};
c 复制代码
DnsCache cache;
std::cout << cache.resolve("api.example.com") << std::endl;
复制代码
192.168.1.10

例子二:一个日志系统,包了接口 + 单例 + 管理器

c 复制代码
class Logger {
public:
    virtual ~Logger() = default;
    virtual void debug(const std::string& message) = 0;
    virtual void info(const std::string& message) = 0;
    virtual void error(const std::string& message) = 0;
};

class ConsoleLogger : public Logger {
public:
    void debug(const std::string& message) override {
        std::cout << "[DEBUG] " << message << std::endl;
    }
    void info(const std::string& message) override {
        std::cout << "[INFO] " << message << std::endl;
    }
    void error(const std::string& message) override {
        std::cerr << "[ERROR] " << message << std::endl;
    }
};

class LoggerManager {
    static std::unique_ptr<Logger> instance_;
public:
    static Logger& getInstance() {
        if (!instance_) {
            instance_ = std::make_unique<ConsoleLogger>();
        }
        return *instance_;
    }
};

std::unique_ptr<Logger> LoggerManager::instance_ = nullptr;
css 复制代码
LoggerManager::getInstance().info("Application started");
csharp 复制代码
[INFO] Application started

一个抽象基类、一个实现、一个单例管理器 ------ 就为了往控制台打印一行。而且这个单例还不是线程安全的(同第二节)。

如果一定要自己写(不用 spdlog 这类成熟库),现代 C++ 给了 std::source_location:

c 复制代码
#include <source_location>
#include <iostream>

void log_info(const std::string& message,
              const std::source_location& loc = std::source_location::current()) {
    std::cout << "[INFO] " << loc.file_name() << ":" << loc.line()
              << " " << message << std::endl;
}
scss 复制代码
log_info("Application started");
css 复制代码
[INFO] main.cpp:42 Application started

调用点不传任何额外参数,file_name() / line() 自动填上 ------ std::source_location::current() 作为默认实参时,会在调用点求值。这是 C++20 补的一个洞,过去这个需求靠宏(__FILE__ / __LINE__)绕,宏的代价是没法调试、没法组合。

那条最实用的判据

那篇文章里引了一句话,我很喜欢:

如果你的解决方案需要一张图来解释,那你已经走得太远了。

直觉上像鸡汤,但工程上很准。当你要画 UML 才能说清一个类的职责时,那通常不是"设计精妙",而是"职责没切开或者切碎了"。 好的抽象是自解释的,不需要图来背书。

五、两种语言哲学,两种模式地位

那篇文章里有个我很认同的观察:设计模式的地位,很大程度上由语言设计者对程序员的态度决定。

James Gosling(Java) 对程序员持悲观态度 ------ 认为普通开发者会滥用自由,所以提供沙盒、限制表达方式,强调规范与安全。

Guido van Rossum(Python) 对程序员持乐观态度 ------ 认为代码读得多、写得少,所以提供强大工具、信任开发者,强调可读性与表达力。

这个差异直接体现在模式的地位上:

方面 Java(Gosling 哲学) Python(Guido 哲学)
设计模式地位 核心工具,必须掌握 辅助工具,按需使用
代码风格 冗长、规范、模式化 简洁、灵活、直接
问题解决方式 通过模式间接解决 直接解决
学习曲线 陡峭,需要掌握大量模式 平缓,语言本身就是解决方案

最典型的例子:Java 在一个类里写两行逻辑,需要一个匿名内部类;Python 传一个函数就行。 前者催生了 Command、Strategy、Observer 一整片模式;后者一个 sorted(key=...) 就过去了。

这不是说 Java 差 ------ 它换来了另一种保证(强约束下的大型团队协作一致性)。但代价确实是由模式来支付的。

六、C++ 的位置,和它的发展史

C++ 处在很微妙的位置:比 Java 灵活,又不肯放弃静态类型和零开销抽象。

更重要的是,C++ 的版本演进恰好是"设计模式是语言补丁"这个论点的实证。

语言版本 补上的洞 降级的模式
C++03 (只有虚函数 + 模板) 工厂、策略、观察者全靠手写继承体系
C++11 lambda、auto、std::function、局部静态线程安全 策略 → 传函数;单例 → 三行;观察者 → vector<function>
C++11/14 移动语义、unique_ptr 工厂返回值不再需要裸指针 / 引用计数
C++17 std::variant、std::optional、结构化绑定 类型标签 + 联合体手写的"多态容器"
C++20 概念(concepts)、std::source_location、std::span SFINAE 套娃、"基类 + 纯虚"式的接口约束、宏式日志
C++20/23 协程、std::expected 回调地狱、错误码约定

每一行都是一次"模式退场"。对照表:

设计模式 C++ 替代方案 说明
单例模式 局部静态变量、namespace C++11 起线程安全
工厂模式 模板函数、std::variant、std::any 编译期多态、零运行时开销
策略模式 std::function、lambda 一等函数,灵活组合
观察者模式 std::vector<std::function> 直接存回调,无需继承
装饰器模式 模板、组合 编译期组合,零虚函数开销
适配器模式 模板、lambda 编译期适配

一个完整的例子:数据处理器

需求:处理不同格式的数据(JSON / CSV / XML)。

传统方式,策略模式 + 工厂:

c 复制代码
class DataProcessor {
public:
    virtual ~DataProcessor() = default;
    virtual void process(const std::string& data) = 0;
};

class JsonProcessor : public DataProcessor {
public:
    void process(const std::string& data) override {
        std::cout << "Processing JSON: " << data.substr(0, std::min(data.size(), size_t(50))) << std::endl;
    }
};

class CsvProcessor : public DataProcessor {
public:
    void process(const std::string& data) override {
        std::cout << "Processing CSV: " << data.substr(0, std::min(data.size(), size_t(50))) << std::endl;
    }
};

class XmlProcessor : public DataProcessor {
public:
    void process(const std::string& data) override {
        std::cout << "Processing XML: " << data.substr(0, std::min(data.size(), size_t(50))) << std::endl;
    }
};

class DataProcessorFactory {
public:
    static std::unique_ptr<DataProcessor> create(const std::string& format) {
        if (format == "json") return std::make_unique<JsonProcessor>();
        if (format == "csv") return std::make_unique<CsvProcessor>();
        if (format == "xml") return std::make_unique<XmlProcessor>();
        throw std::invalid_argument("Unknown format: " + format);
    }
};
rust 复制代码
auto processor = DataProcessorFactory::create("json");
processor->process("{"name": "test", "value": 123}");
css 复制代码
Processing JSON: {"name": "test", "value": 123}

现代方式,一张 map:

c 复制代码
#include <map>
#include <functional>

void process_json(const std::string& data) {
    std::cout << "Processing JSON: " << data.substr(0, std::min(data.size(), size_t(50))) << std::endl;
}

void process_csv(const std::string& data) {
    std::cout << "Processing CSV: " << data.substr(0, std::min(data.size(), size_t(50))) << std::endl;
}

void process_xml(const std::string& data) {
    std::cout << "Processing XML: " << data.substr(0, std::min(data.size(), size_t(50))) << std::endl;
}

std::map<std::string, std::function<void(const std::string&)>> processors = {
    {"json", process_json},
    {"csv", process_csv},
    {"xml", process_xml}
};

void process_data(const std::string& format, const std::string& data) {
    auto it = processors.find(format);
    if (it != processors.end()) {
        it->second(data);
    } else {
        throw std::invalid_argument("Unknown format: " + format);
    }
}
bash 复制代码
process_data("json", "{"name": "test", "value": 123}");
css 复制代码
Processing JSON: {"name": "test", "value": 123}

功能一样,代码少一半,而且新增格式不用改类 (策略模式那个版本,加一种格式要加一个类 + 改工厂的 if 链)。

七、所以设计模式真的烂吗

不烂。但它被误用的方式很烂。

设计模式不是坏东西,问题在于我们被教成"见到问题先想套哪个模式"。正确的顺序应该反过来:

先问语言能不能直接解决,再问模式能不能帮忙描述。

我的四条原则 说明
模式是词汇表,不是解题起点 说"这里是个门面"比解释"用类封装了三个子系统提供统一接口"高效得多 ------ 但这是描述已经写好的代码
优先用语言特性 C++ 的模板、lambda、std::function、RAII、source_location 往往能直接解决问题
避免为假设的需求抽象 YAGNI 永远适用。只被调用一次、"以后可能要"的抽象层,先别写
需要画图才能解释的,重新想 一张图说不清的抽象,通常是切错了

一个实际的观察:我见过一个项目,代码里塞满了"依赖注入""控制反转""中介者模式"的名词,团队讨论时张口就来。但那个项目的实际功能非常朴素,而代码极难读。那是把模式当成了目的。

八、小结

  • 设计模式是语言缺陷的补丁。 语言补一个洞,退场一批模式。C++11→20 的演进史就是证据。
  • 真正的价值是沟通词汇。 它是用来谈论已经写好的 代码的,不是用来决定该怎么写的。
  • 优先语言特性,其次模式。 单例 → 三行局部静态;策略 → std::function;工厂 → 模板或 map。
  • 过度设计的代价是真实的。 为"可能的需求"写抽象层,是在现在付两条代码路径的维护税。

代码是用来解决问题的,不是用来展示模式的。

(本文讨论的原始文章:Design Patterns Suck。文中所有代码均为可编译的完整示例,输出为实际运行结果。)

相关推荐
Lost of 程序猿2 小时前
命令模式实战:把“下单后的动作“打包成可执行的任务
后端·设计模式·c#·asp.net·命令模式
一只旭宝2 小时前
【C++复习】四种类型转换 + 常见设计模式 + Redis/MySQL(后端面试复习完整版)
c++·redis·笔记·mysql·设计模式
YYYing.4 小时前
【设计模式系列 (四) 】建造者模式
c++·后端·设计模式·建造者模式·c/c++
Zane19941 天前
同一段单例代码,为什么在高并发下偶尔会创建出两个实例?聊聊哪种单例实现才是真的安全
设计模式
Carl_奕然1 天前
【智能体】Loop 的四种设计模式之:Agent Loop(2026 最新版)
人工智能·设计模式·语言模型
小酒星小杜1 天前
画 AI 漫画,别只会写“日漫风”:10 种画风、适用故事和可复制提示词
人工智能·设计模式·程序员
sarasuki1 天前
为什么 Agent 工具需要一个「协议」而不是「框架」:MCP 成为标准的底层逻辑
设计模式·agent·mcp
vivo互联网技术1 天前
知识不是文件,也不是向量 | KDC 系列 02
人工智能·设计模式·架构
Zane19947 天前
一个 new 就能创建对象,为什么还要拆出单例、工厂、建造者、原型四种模式?
设计模式