那天读到一篇文章,标题只有三个词:Design Patterns Suck。
作为一个从 C++ 起步、曾经把《设计模式》那本书当成技术品味象征的人,我点进去的时候是带着火气的。读完火气没了,剩下的是"他说的好像对"。
那篇文章的核心论点很硬:
设计模式被高估了、滥用了,而且常常是完全不必要的。
设计模式不过是丑陋的变通方案 ------ 因为我们的编程语言不够强大、不够灵活,无法表达我们真正想要的东西。
我不打算做"设计模式有用 / 没用"的站队。我只想把自己这些年踩过的例子摆出来,用代码验证一件事:很多设计模式之所以存在,是因为当时的语言表达力不够。
结论先行
如果只想要结论,三条:
- 设计模式是语言缺陷的补丁。 这不是比喻。翻开每一个模式,背后都对应一个"这门语言没法直接表达"的东西。语言补上了,模式就退化成噪音。
- 模式是词汇表,不是解题起点。 它的价值在"讨论已经写好的代码",不在"决定该怎么写代码"。反过来用,必然滑向过度设计。
- 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。文中所有代码均为可编译的完整示例,输出为实际运行结果。)