设计模式篇
一句话概述:设计模式是针对特定问题的解决方案,简单设计是不要一开始就过度设计——二者并不矛盾,关键在于适度。
知识图谱
图注:设计模式篇涵盖设计模式总览、简单设计原则、语言发展脉络及函数式编程技巧四大主题。
6.1 设计模式:特定问题的解决方案
概述
设计模式是软件设计领域中参考资料最多、最容易学习的知识。随着工作经验的积累,开发者会逐渐认识到代码质量对系统的影响,而设计模式正是应对反复出现的设计问题的可复用方案。本节重点探讨如何理解和学习设计模式,帮助建立对设计模式的整体认知。
设计模式:一种特定的解决方案
所谓模式,就是针对一些普遍存在的问题给出的解决方案。模式这个说法起源于建筑领域,建筑师克里斯托佛·亚历山大曾把建筑中的一些模式汇集成册。然而,模式这个概念却在软件行业流行了起来。
最早是 Kent Beck 和 Ward Cunningham 探索将模式思想应用于软件开发领域,之后 Erich Gamma 把这一思想写入了其博士论文。真正让建筑上的模式思想成为设计模式、在软件行业得到广泛接受的,则是《设计模式》这本书的出版。
这本书扩展了 Erich Gamma 的论文,四位作者 Erich Gamma、Richard Helm、Ralph Johnson 和 John Vlissides 因此名声大噪,得到了 GoF(Gang of Four)的称呼。今天大部分人知道的 23 种设计模式就来自这本书,而困惑也由此开始。
这 23 种设计模式只是在这本书里写的,并不是天底下只有 23 种设计模式。随着人们越发认识到设计模式的重要性,越来越多的模式被发掘出来,各种模式相关的书先后问世。例如,Martin Fowler 写过《企业应用架构模式》,甚至还有人写了一套 5 卷本的《面向模式的软件架构》。
然而,很多人从开始学习设计模式,就对设计模式的认知产生了偏差——所谓的 23 个模式实际上就是 23 个例子。
如果用数学来比喻,设计原则就像公理,是讨论各种问题的基础;而设计模式则是定理,是在特定场景下,对于经常发生的问题给出的可复用解决方案。
因此,想把所有已知的模式统统学一遍,即便不是不可能,也会花费大量时间,更何况新的模式还在不断出现。而且,虽然《设计模式》书中提到的大部分设计模式都很流行,但有一些模式,如果不是编写特定的代码,很可能根本就用不上。
例如 Flyweight 模式,如果系统中没有那么多小对象,可能就根本用不到它;而 Visitor 模式,在设计自己的系统时也很少会用到,因为自己写的类常常都可以直接拿到信息,犯不上舍近求远。
因此,学习设计模式不要贪多求全,那注定会是一件费力不讨好的事。
想要有效地学习设计模式,首先要知道每一个模式都是一个特定的解决方案。关键点在于,要知道这个模式在解决什么问题。很多人强行应用设计模式会让代码不必要地复杂起来,原因就在于他解决的问题与设计模式本身要解决的问题并不匹配。学习设计模式不仅仅要学习代码怎么写,更重要的是要了解模式的应用场景。
设计模式分类与核心意图
图注:23 种 GoF 设计模式按目的分类——创建型关注"怎么创建"、结构型关注"怎么组合"、行为型关注"怎么协作"。
从原则到模式
设计模式之所以能成为一个特定的解决方案,很大程度上是因为它是一种好的做法,符合软件设计原则。因此,设计原则实际上是这些模式背后的东西。
前面花了大量篇幅讲各种编程范式、设计原则,因为它们是比设计模式更基础的东西。掌握这些内容,按照它们去写代码,可能并没有刻意使用某个设计模式,往往也能写出符合某个设计模式的代码。
以用户注册场景为例。用户注册完成后,相关信息需要发给后台的数据汇总模块,以便后续进行数据分析。代码如下:
interface UserSender {
void send(User user);
}
// 把用户信息发送给后台数据汇总模块
class UserCollectorSender implements UserSender {
private UserCollectorChannel channel;
public void send(final User user) {
channel.send(user);
}
}同时,还需要把用户注册成功的消息通过短信通知给用户,这里会用到第三方服务,因此需要 APP 的 key 和 secret:
// 通过短信发消息
class UserSMSSender implements UserSender {
private String appKey;
private String appSecret;
private UserSMSChannel channel;
public void send(final User user) {
channel.send(appKey, appSecret, user);
}
}现在,需要对用户的一些信息做处理,保证敏感信息不会泄漏(例如用户密码),同时希望在信息发送成功之后有一个统计,以便了解发出了多少信息。
如果不假思索地加上这段逻辑,那两个类里必然都会有相同的处理。本着单一职责原则,把这个处理放到一个父类里面,代码变成这样:
class BaseUserSender implements UserSender {
// 敏感信息过滤
protected User sanitize(final User user) {
...
}
// 收集消息发送信息
protected void collectMessageSent(final User user) {
...
}
}
class UserCollectorSender extends BaseUserSender {
...
public void send(final User user) {
User sanitizedUser = sanitize(user);
channel.send(sanitizedUser);
collectMessageSent(user);
}
}
class UserSMSSender extends BaseUserSender {
...
public void send(final User user) {
User sanitizedUser = sanitize(user);
channel.send(appKey, appSecret, user);
collectMessageSent(user);
}
}然而,这两段发送的代码除了发送的部分不一样,其他部分完全一样。因此,可以考虑把共性的东西提取出来,而差异的部分让子类各自实现:
class BaseUserSender implements UserSender {
// 发送用户信息
public void send(final User user) {
User sanitizedUser = sanitize(user);
doSend(user);
collectMessageSent(user);
}
// 敏感信息过滤
private User sanitize(final User user) {
...
}
// 收集消息发送信息
private void collectMessageSent(final User user) {
...
}
}
class UserCollectorSender extends BaseUserSender {
...
public void doSend(final User user) {
channel.send(sanitizedUser);
}
}
class UserSMSSender extends BaseUserSender {
...
public void doSend(final User user) {
channel.send(appKey, appSecret, user);
}
}这段代码就是 Template Method(模板方法)设计模式。这里只是遵循着单一职责原则,把重复的代码一点点地消除,结果就得到了一个设计模式。在真实项目中,可能很难一眼就看出当前场景是否适合使用某个模式,更实际的做法是遵循设计原则一点点去调整代码。
只要遵循同样的原则,大多数设计模式都可以这样一点点推演出来。因此,设计模式只是设计原则在特定场景下的应用。
设计模式与 SOLID 原则的对应
| 设计模式 | 主要体现的 SOLID 原则 | 说明 |
|---|---|---|
| Strategy(策略模式) | OCP | 新增策略不需修改上下文 |
| Factory Method(工厂方法) | DIP | 客户端依赖抽象产品接口 |
| Observer(观察者模式) | OCP | 新增观察者不需修改主题 |
| Adapter(适配器模式) | ISP | 适配不同接口 |
| Decorator(装饰器模式) | OCP + SRP | 动态添加职责,不修改原类 |
| Template Method(模板方法) | OCP | 子类扩展步骤,不修改骨架 |
开眼看模式
学习设计模式,还应该有一个更开阔的视角。
语言的局限
首先是要看到语言的局限。虽然设计模式本身并不局限于语言,但很多模式之所以出现,就是受到了语言本身的限制。
例如,Visitor 模式主要是因为 C++、Java 之类的语言只支持单分发(只能根据一个对象来决定调用哪个方法)。对于支持多分发的语言,Visitor 模式存在的意义就不大了。
Peter Norvig(Google 公司研究总监)早在 1996 年就曾做过一个分享《动态语言的设计模式》,他在其中敏锐地指出,设计模式在某种意义上就是为了解决语言自身缺陷的一种权宜之计,并列举了某些设计模式采用动态语言后的替代方案。
模式本身也在经历变化
随着时代的发展,有一些设计模式本身也在经历变化。例如 Singleton 模式是很多面试官喜爱的一个模式,因为它能考察很多编程技巧——通过将构造函数私有化保证不创建出更多的对象、在多线程模式下要进行双重检查锁定(double-check locking)等等。
然而,Singleton 并不是一个好的设计模式,它会影响系统的可测试性。从概念上说,"系统里只有一个实例"和"限制系统里只能构建出一个实例",这实际上是两件事。
尤其是在 DI 容器普遍使用的今天,DI 容器缺省情况下生成的对象就是只有一个实例。所以,在大部分情况下,完全没有必要使用 Singleton 模式。当然,如果场景非常特殊,那就另当别论。
好做法被吸收到程序库和语法
在讲语法和程序库时曾提到,一些好的做法会逐渐被吸收到程序库,甚至成为语法。设计模式常常就是好做法的来源,所以一些程序库就把设计模式的工作做了。
例如,Observer 模式早在 1.0 版本的时候就进入到 JDK,被监听的对象要继承自 Observable 类,用来监听的对象实现一个 Observer 接口就行。
当然,继承不是一个特别好的选择,Observable 是一个要去继承的类,所以它做得也并不好。从 Java 9 开始,这个实现就过时(deprecated)了。JDK 中提供的替代方案是 PropertyChangeSupport,简言之,用组合替代了继承。
更值得欣赏的替代方案是 Guava 的 EventBus,甚至都不用实现一个接口,只要用一个 Annotation 标记一下就可以监听了。
Annotation:消灭设计模式的利器
Annotation 可以说是消灭设计模式的一个利器。语言本身的局限造成了一些设计模式的出现,这一点在 Java 上表现得尤其明显。随着 Java 自身的发展以及 Java 生态的演进,有一些设计模式就越来越少用到了。
例如,Builder 模式通过 Lombok 这个库的一个 Annotation 就可以做到:
@Builder
class Student {
private String name;
private int age;
...
}而 Decorator 模式也可以通过 Annotation 实现。一种使用 Decorator 模式的典型场景是实现事务,很多 Java 程序员熟悉的一种做法就是使用 Spring 的 Transactional:
class Handler {
@Transactional
public void execute() {
...
}
}Lambda 简化 Command 模式
随着 Java 8 引入 Lambda,Command 模式的写法也会得到简化。例如写一个文件操作的宏记录器,之前的版本需要声明很多类:
Macro macro = new Macro();
macro.record(new OpenFile(fileReceiver));
macro.record(new WriteFile(fileReceiver));
macro.record(new CloseFile(fileReceiver));
macro.run();而有了 Lambda,就可以简化一些,不用为每个命令声明一个类:
Macro macro = new Macro();
macro.record(() -> fileReceiver.openFile());
macro.record(() -> fileReceiver.writeFile());
macro.record(() -> fileReceiver.closeFile());
macro.run();甚至还可以用 Method Reference 再简化:
Macro macro = new Macro();
macro.record(fileReceiver::openFile);
macro.record(fileReceiver::writeFile);
macro.record(fileReceiver::closeFile);
macro.run();因此,学习设计模式除了学习标准写法的样子,还要知道随着语言的不断发展,新的写法变成了什么样子。
设计模式的演变趋势
图注:设计模式从设计原则推演而来,部分模式因语言局限而出现;随着语言和生态的演进,模式被程序库吸收、语法特性替代、Annotation 消灭,或直接不再适用。
核心要点
- 设计模式是针对反复出现的设计问题、经过验证的可复用解决方案,每一种模式都是一个特定问题的解决方案
- 23 种 GoF 设计模式只是 23 个例子,并非全部;学习设计模式不要贪多求全
- 理解设计模式的关键在于它解决了什么问题(意图),而非"代码怎么写"(结构)
- 设计原则是公理,设计模式是定理——设计模式只是设计原则在特定场景下的应用
- 很多设计模式的出现是因为程序设计语言自身能力的不足,随着语言发展,一些模式有了新的写法或不再适用
- 学习设计模式,从设计原则开始,不局限于模式
6.2 简单设计:不要一开始就做复杂
概述
在学习设计原则和模式时,看着每次的代码调整结果虽然不错,但一个自然的疑问是:如果每段代码都这么写,会不会把设计做复杂了?确实,几乎每个人在初学设计时都会有用力过猛的倾向。如何把握设计的度,是每个做设计的人需要耐心锤炼的。行业里有人总结了一些实践原则,给出了启发性的规则,帮助把握设计的度。这些原则并不是指导具体如何编码的原则,更像是一种思考方法、一种行为准则。
KISS 原则
KISS 原则是"Keep it simple, stupid"的缩写,即保持简单、愚蠢。它告诫开发者,对于大多数系统而言,与变得复杂相比,保持简单能够让系统运行得更好。
很多人知道这条原则,却很少人知道它实际上出自美国海军。因此,它的适用范围远比程序员社区要广泛得多。无论是制定一个目标、设计一个产品,还是管理一个公司,都可以用 KISS 作为统一的原则指导工作。
这个原则看起来有点抽象,每个人对它都会有自己理解的角度。越是资深的人越会觉得它有道理,因为资深的人通常都在自己的工作领域中见识过因为复杂而引发的各种问题——例如堆了太多功能导致调整起来很费劲。专栏前面讲过的各种问题,很多时候都是由于复杂引起的。
每个人都可以针对自己的工作场景给出自己的阐释,例如:
- 如果有现成的程序库,就不要自己写
- 能用文本做协议就别用二进制
- 方法写得越小越好
- 能把一个基本的流程打通,软件就可以发布,无需那么多的功能
- ……
这种级别的原则听上去很有吸引力,但问题是,并不能用它指导具体的工作。因为怎么做叫保持简单、怎么做就叫复杂了,这个标准是没办法确定的。所以,有人基于自己的理解给出了一些稍微具体一点的原则,例如 YAGNI 和 DRY。
YAGNI 原则
YAGNI 是"You aren't gonna need it"的缩写,即"你用不着它"。这个说法来自极限编程社区(Extreme Programming,简称 XP),可以理解为:如非必要,勿增功能。
在开篇词中曾提到,软件设计对抗的是需求规模。一方面,通过努力让软件在需求规模膨胀之后依然能平稳发展;另一方面,还应该努力控制需求的规模。
YAGNI 告诫开发者,很多需求是不需要做的。很多产品经理以为很重要的功能实际上没什么用。人们常说二八原则,真正重要的功能大约只占 20%,80% 的功能可能大多数人都用不到。做了更多的功能,并不会得到更多的回报,但软件本身却会不断膨胀,变得越发难以维护。
在现实世界中,经常看到一些功能简单的东西不断涌现,去颠覆更复杂的东西。例如,虽然 Word 已经很强大了,但对于很多人而言,它还只是一个写字的工具,甚至重点排版功能都用得非常少。
于是,这就给了 Markdown 一个机会。它可以让人专注写内容,而且简单的排版标记在日常沟通中也完全够用。
YAGNI 是一种上游思维,就是尽可能不去做不该做的事,从源头上堵住。从某种意义上说,它比其他各种设计原则都重要。
DRY 原则
DRY 是"Don't repeat yourself"的缩写,即不要重复自己。这个说法源自 Andy Hunt 和 Dave Thomas 的《程序员修炼之道》(The Pragmatic Programmer)。其阐述如下:
在一个系统中,每一处知识都必须有单一、明确、权威地表述。
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
每个人对 DRY 原则的理解千差万别,最浅层的理解就是"不要复制粘贴代码"。不过,两位作者在二十年后的第二版特意强调,这个理解远远不够。DRY 针对的是对知识和意图的复制。它强调的是,在两个不同地方的两样东西表达的形式虽然不同,但其要表达的内容却可能是相同的。
下面通过一个例子来看如何在实际工作中运用 DRY 原则。这是一段打印账户信息的代码,这种写法在实际工作中非常常见:
public void printBalance(final Account account) {
System.out.printf("Debits: %10.2f\n", account.getDebits());
System.out.printf("Credits: %10.2f\n", account.getCredits());
if (account.getFees() < 0) {
System.out.printf("Fees: %10.2f-\n", -account.getFees());
} else {
System.out.printf("Fees: %10.2f\n", account.getFees());
}
System.out.printf(" ----\n");
if (account.getBalance() < 0) {
System.out.printf("Balance: %10.2f-\n", -account.getBalance());
} else {
System.out.printf("Balance: %10.2f\n", account.getBalance());
}
}在这段代码中,隐藏着一些重复。例如,对负数的处理显然是复制的,可以通过增加一个方法消除它:
String formatValue(final double value) {
String result = String.format("%10.2f", Math.abs(value));
if (value < 0) {
return result + "-";
} else {
return result + " ";
}
}
void printBalance(final Account account) {
System.out.printf("Debits: %10.2f\n", account.getDebits());
System.out.printf("Credits: %10.2f\n", account.getCredits());
System.out.printf("Fees:%s\n", formatValue(account.getFees()));
System.out.printf(" ----\n");
System.out.printf("Balance:%s\n", formatValue(account.getBalance()));
}还有,数字字段格式也是反复出现的,不过格式与抽取出来的方法一致,所以可以复用:
String formatValue(final double value) {
String result = String.format("%10.2f", Math.abs(value));
if (value < 0) {
return result + "-";
} else {
return result + " ";
}
}
void printBalance(final Account account) {
System.out.printf("Debits: %s\n", formatValue(account.getDebits()));
System.out.printf("Credits: %s\n", formatValue(account.getCredits()));
System.out.printf("Fees:%s\n", formatValue(account.getFees()));
System.out.printf(" ----\n");
System.out.printf("Balance:%s\n", formatValue(account.getBalance()));
}再有,打印格式实际上也是重复的——如果要在标签和金额之间加一个空格,相关的代码都要改,所以这也是一个可以消除的重复:
String formatValue(final double value) {
String result = String.format("%10.2f", Math.abs(value));
if (value < 0) {
return result + "-";
} else {
return result + " ";
}
}
void printLine(final String label, final String value) {
System.out.printf("%-9s%s\n", label, value);
}
void reportLine(final String label, final double value) {
printLine(label + ":", formatValue(value));
}
void printBalance(final Account account) {
reportLine("Debits", account.getDebits());
reportLine("Credits", account.getCredits());
reportLine("Fees", account.getFees());
System.out.printf(" ----\n");
reportLine("Balance", account.getBalance());
}经过这样的修改,如果要改金额打印的格式,就去改 formatValue 方法;如果要改标签的格式,就去改 reportLine 方法。
如果仔细品味这个修改,就能感觉到它与之前说的分离关注点和单一职责原则有异曲同工的地方。在讲分离关注点和单一职责原则时,强调的重点也是粒度要小。这个例子从某种程度上说,也是为它们增加了注脚。
虽然这里讲的是代码,但 DRY 原则并不局限于写代码,例如:
- 注释和代码之间存在重复,可以尝试把代码写得更清晰
- 内部 API 在不同的使用者之间存在重复,可以通过中立格式进行 API 的定义,然后用工具生成文档、模拟 API 等等
- 开发人员之间做的事情存在重复,可以建立沟通机制降低重复
- ……
所有这些努力都是在试图减少重复,同时也是为了减少后期维护的成本。
简单设计原则
上面三个原则都偏思维方式的层面,而简单设计(Simple Design)原则稍稍往实际的工作中靠了一些。
这个原则来自极限编程社区,提出者是 Kent Beck。简单设计之所以叫简单设计,因为它只包含了 4 条规则:
- 通过所有测试
- 消除重复
- 表达出程序员的意图
- 让类和方法的数量最小化
这 4 条规则看起来很简单,但想做到,对于很多人来说是一个非常大的挑战。Kent Beck 是极限编程这种工作方式的创始人,想满足他提出的简单设计原则,最好要做到与之配套的各种实践。
简单设计四原则的优先级
图注:简单设计四原则按优先级排列——先正确、再清晰、然后消除重复、最后精简元素。不能为了简洁牺牲正确性。
逐条解读
第 1 条:保证系统能够按照预期工作。 这一点对于大多数项目而言,已经是很高的要求了。怎么才能知道系统按照预期工作?那就需要有配套的自动化测试。大多数项目并不拥有自己的自动化测试,更何况是在开发阶段使用的单元测试,尤其是还得保证测试覆盖了大多数场景。
在 XP 实践中,想要拥有这种测试,最好能够以测试驱动开发(Test Driven Development,简称 TDD)的方式工作。而要做好 TDD,最根本的还是懂设计,否则代码就是不可测的,想给它写测试就是难上加难的事情。
后 3 条:重构的方向。 重构也是 XP 的重要实践。
- 第 2 条,消除重复——正如前面讲 DRY 原则所说的,得能够发现重复,这需要对分离关注点有深刻的认识。
- 第 3 条,表达出程序员的意图——需要编写有表达性的代码,这也需要对"什么是有表达性的代码"有认识。在讲 DSL 时曾说过,代码要说明做什么,而不是怎么做。
- 第 4 条,让类和方法的数量最小化——告诉开发者不要过度设计,除非已经看到这个地方必须要做一个设计(比如留下适当的扩展点),否则就不要做。
但有一点需要知道:能做出过度设计的前提,是已经懂得了设计的各种知识,这时才需要用简单设计的标准对自己进行约束。所以,所谓的简单设计,对大多数人而言,并不"简单"。
简单设计的理念来自于极限编程社区,这是一个重要的敏捷流派。谈到敏捷,很多人以为做敏捷是不需要设计的,事实上这是严重的误解。在敏捷实践的工程派(即 XP 这一派)中,如果单看这些实践的步骤,都会觉得非常简单。无论是 TDD 也好,重构也罢,如果没有对设计的理解,任何一个实践都很难做好。
没有良好的设计,代码就没有可测试的接口,根本没有办法测试,TDD 也就无从谈起。不懂设计,重构就只是简单的提取方法、改改名字,对代码的改进也是相当有限的。
简单设计,是 Kent Beck 这样的大师级程序员在经历了足够的积累、返璞归真之后提出的设计原则。它确实可以指导日常工作,但前提是需要把基础打牢。片面地追求敏捷实践而忽视基本功,往往是舍本逐末的做法。
设计模式 vs 简单设计
图注:简单设计的路径是"先实现→重构→模式自然浮现";过度设计的路径是"先套模式→过度抽象→维护困难"。模式应该在重构过程中自然浮现,而非一开始就强行套用。
核心要点
- KISS 原则(Keep it simple, stupid):保持简单,让系统运行得更好
- YAGNI 原则(You aren't gonna need it):如非必要,勿增功能——这是一种上游思维,从源头上堵住不必要的复杂性
- DRY 原则(Don't repeat yourself):不要重复自己,消除各种重复——不仅限于代码复制粘贴,更针对知识和意图的复制
- 简单设计四原则(按优先级):通过所有测试 → 消除重复 → 表达意图 → 最少元素
- 简单设计 ≠ 没有设计——它是一种审慎的、渐进的设计策略
- 先让代码工作,再让代码正确,最后让代码快速
- 重构是简单设计的核心实践——持续小步改进
- 简单设计对大多数人并不"简单"——前提是设计基本功要扎实
6.3 加餐:再八卦几门语言
概述
软件设计是一个比较烧脑的话题,在前面高密度地学习了一段时间之后,适当放松有助于消化吸收。本节从程序设计语言的发展历史出发,聊聊几门比较吸引眼球的程序设计语言,帮助理解技术发展趋势。判断技术趋势是投资未来的重要参考,而了解技术的发展历史,正是判断未来趋势的基础。
C#
当年 Java 开始起势的时候,微软还处于自己的巅峰,当然不想错过 Java 这么有前景的东西。但是,微软从来就不会老老实实按照标准做事,所以微软手中的 Basic 已经很不像 Basic 了,微软的 C++ 也有着自己的扩展。
于是,微软也想做出一个自己的 Java,J++ 就出现了。但这不是一个正常的 Java,引发了 SUN 的不满,将微软告上法庭。最终双方庭外和解,微软不再"祸害"Java,J++ 停止更新。
但有一点不得不承认,微软在 Windows 上的 JVM 性能是当时最好的,因为操刀 J++ 的是 Anders Hejlsberg,他是全世界最顶级的程序员。微软为了不与 Java 开启的受控(Managed)代码浪潮擦肩而过,转身又推出了 C# 和 .NET。
C# 的初版本简直和 Java 一模一样,一个 Java 程序员几乎不用培训就可以成为一个 C# 程序员。所以,从语言的角度来说,最初的 C# 并没有对行业做出什么贡献。
不过,既然有 Anders Hejlsberg 在背后,事情当然不会如此简单收尾。C# 在语言特性上开始一路狂奔,一个更强大的 C# 崭露头角。像 Lambda、类型推演这些特性早早就落户 C# 了。
然而,C# 时运不济,它的上升期遇到了微软的下降期。越来越多的公司选择了 Java,越来越多的程序员拥抱了 Java,而语言模型上表现优秀的 C# 则遭遇了冷落。
当年 Java 号称"一个语言,多个平台",而 .NET 则是"一个平台,多个语言"。结果呢,.NET 的"一个平台"并不足以吸引更多的公司和程序员的投入,除了微软自己,其他在上面开发语言的尝试通常都是浅尝辄止。而 JVM 虽然目标不是为了多语言,但丝毫不妨碍很多人在上面开发新语言,例如 Groovy、Scala、Clojure 等等。
最终,JVM 成了"多个语言,多个平台"。随着微软的逐步开放,.NET 也开始迈向了多平台,C# 也成了一门跨平台的语言,遗憾的是为时已晚,Java 已经成就了一番霸业。
如果出于学习的目的,C# 绝对是值得一学的程序设计语言,毕竟微软在语言设计上还是很有一套的。Java 语言的进化是非常缓慢的,尤其是 SUN 的衰退又耽误了很多年。所以,从语言特性上来看,说 C# 领先 Java 十年并不夸张。
JavaScript
JavaScript 从诞生之日起就扮演着一个不受待见的角色。Brendan Eich 发明 JavaScript 完全是为了应付工作,因为他当时供职的 Netscape 需要让网页上的元素动起来。
"雷锋和雷锋塔有什么关系?Java 和 JavaScript 有什么关系?"这是一个经常被人提起的段子,但实际上,JavaScript 和 Java真的有关系——关系就是蹭热度。当时的 Java 给世界描绘了一个美好的未来,让无数人心潮澎湃,JavaScript 就想借一下 Java 的东风。
JavaScript 仅仅用 10 天就设计出来,所以在它的实现中包含了各种奇怪的问题。不过,它还是体现出了 Brendan Eich 的功底——例如 JavaScript 提供了对各种编程范式的支持。事实上他真正想做的是一门函数式编程的语言,但向现实妥协的结果就是,借助了 C 风格的语法,函数式编程的底子却留在了 JavaScript 里。
虽然在今天看来,在浏览器上 JavaScript 一枝独秀,但当年它也是有竞争对手的。那个年代无处不在的微软,出手做了一个 VBScript。但是,如同微软错过了互联网时代一样,与 Windows 结合更加紧密的 VBScript 也在这场竞争中败下阵来。
当年,与 JavaScript 联系在一起的,更多的是像走马灯之类的页面特效。让 JavaScript 真正第一次得到重视的是 Ajax 这门技术。Ajax 的出现让页面的元素可以与远程的服务器进行交互,JavaScript 开始由一个小玩具变成了一门值得研究的技术,前端的表现力得到了大幅度的提升。
但很长一段时间里,JavaScript 一直都不是一门正式的语言,对于很多人来说,它只是要做前端时顺便学习的语言。这种现象一直持续到 Node.js 的诞生。
Node.js 实际上是一个集成商,它之所以能有良好的表现要归功于 V8 这个 JavaScript 引擎。而 V8 的出现则要归因于 Google 对于网络应用前景的格局判断。
想当年的浏览器大战,Netscape 和 IE 拼得你死我活,最终 IE 凭借 Windows 的优势成了赢家,Netscape 也退出了历史舞台。然而,胜利后的微软认为天下已平,竟然解散了 IE 的团队,导致程序员们要在很长时间内忍受 IE 这个既不标准又慢的浏览器。
这就给后来居上者留下了空间。最有名的两个后来者,一个是 Netscape 的转世 Firefox,另外一个就是 Google 出品的 Chrome。
Chrome 认为未来的页面一定要有更强的表现力,所以一个高效强大的浏览器是必需的。既然慢是个大问题,Chrome 就着力解决慢这个问题,甚至不惜开发了一个新的 JavaScript 引擎——V8,它的重点就是解决 JavaScript 执行慢的问题。可等微软看懂 Google 的操作、幡然悔悟重新投入浏览器的开发之时,大势已去。Chrome 成了新的霸主。
Chrome 有一点做得很好,V8 一开始就是一个独立的 JavaScript 引擎。所以 Node.js 才可以很方便地把它借鉴过去。除了 V8 的性能优势,Node.js 还引入了异步 IO 的模型,这刚好与 JavaScript 事件驱动的特点相吻合。
Node.js 刚一登场便赢得了满堂喝彩。因为人们认识到,JavaScript 原来不只能在浏览器中运行,也可以跑在服务器端。很快,NPM 这个包管理器登场,降低了众多开发者参与的门槛,JavaScript 迎来了属于自己的爆发,各种各样的程序库让人眼花缭乱。
前端开发也由少数人的爱好成为了一个专属的职位,像 React、Angular、Vue 等框架的出现,更是让前端开发有了工程的味道,而不再是小打小闹了。
一旦 JavaScript 突破了浏览器的限制,给人们的想象空间就大了许多。除了服务器端,有人想把 JavaScript 用在嵌入式开发中,有人想把它用在手机开发中。JavaScript 成了一门全平台覆盖的语言,大有一统天下的架势。
不过,JavaScript 作为一门语言,其问题之多也是由来已久的。虽然 JavaScript 本身也在不断进化,但沉重的历史包袱让很多人都想开发出新的语言去替代它。所以,在 JavaScript 社区中,很多人把它看成了一种 Web 上的汇编语言,把新的语言编译成 JavaScript,这样就可以在浏览器上运行了。从早先的 CoffeeScript 到现在的 TypeScript,甚至新一代的 JavaScript 标准都是以这种方式进行开发的。
当然还有人有更高的追求,他们认为仅仅在语言层面屏蔽 JavaScript 是不够的。WebAssembly 就是想成为 Web 上真正的汇编,真正取 JavaScript 而代之,事实上它也得到了很多人的支持。不过,这种努力至今仍在继续中,还有很长的路要走。
JavaScript 就是这样,从一出生就不受待见,到今天很多人仍想把它干掉。但这并不妨碍它在软件开发的历史中写下浓墨重彩的一笔。
Go 和 Rust
在系统编程方面,C 语言是当之无愧的霸主,然而 C 语言已经快 50 岁了。在计算机这个快速变化的行业里,50 年长得令人发指。在这 50 年中,C 从被人质疑发展到如日中天,再到应用开发的地位逐步被取代。如今,它只在系统编程有着无可替代的作用。事实上,人们也一直想着替代它。
C 的强项是对于计算机模型的适度抽象,弱项却是在程序的组织上。因为在 C 诞生那个年代,程序的规模还不算太大。然而 C 的成功却让程序的规模越来越大,大到超出了 C 语言的能力范畴。于是,有人想着把面向对象加到 C 语言里,扩大程序的组织规模。这方面的尝试,我们都熟悉的是 C++。
不过,C++ 只风光了一段时间,就被 Java 盖了过去。C++ 本身有一段时间变成了语言特性的试验田,泛型编程尤其是模板元编程的出现,一度让人怀疑人生。它成了高手极度喜爱、普通人一脸懵硬着头皮写的程序语言。
但更重要的是,C++ 背负了 C 语言所有的历史负担。所以很多 C 的问题在 C++ 里面依然存在,例如内存管理。虽然 C++ 有各种补丁方案,但必须对 C++ 极其了解才能写好 C++,然而这个要求对于一个工程化的语言来说实在是太高了。
所以,无论是 C 还是 C++,都是在执行性能上无可挑剔,在代码编写上一地鸡毛,人们还是需要一门更有开发效率的系统编程语言。
Go 语言
时间来到新千年,又有人出手想代替 C 语言,这回出手的人物背景强大——Ken Thompson,C 语言的亲爹。2009 年,如日中天的 Google 推出了 Go 语言,再加上 Ken Thompson 和 Rob Pike 这样早期的 Unix 先驱站在它背后,Go 语言的前景给人无限的遐想。
Go 语言的语法设计是简单的,基本上花一个晚上就可以把 Go 语言完整地学习一遍。它在接口设计和并发上的处理方式都给人眼前一亮的感觉。人们热切地期盼着它成为下一个系统编程语言的霸主。
但事实并没有像人们想象的那样发生,除了初生之时引起了一片欢呼,Go 语言很长一段时间都在低位徘徊。比较有趣的是,中国有很多开发者对于 Go 的喜爱程度极高,一度让 Go 语言在中国的热度远远超过了全球的平均水平。之所以 Go 没有很快赢得人们的关注,因为它关注的系统编程领域并没有太多的机会留给它,人们嘴上喊着热爱,手里还依然用 C 写着代码。
不过,机会总是留给做好准备的人,语言也不例外。随着 Docker 这套虚拟化软件登上历史舞台,Go 语言终于有了用武之地。人们开始意识到,原来云计算领域还有一些基础设施要写,用 C 的话不好维护;用 Java 的话浪费资源;Go 恰如其分地解决了大部分问题。
一批新生代的基础设施纷纷出炉,除了 Docker 之外,还有帮助人们实现容器部署的 Kubernetes(k8s),以及辅助 Service Mesh 的 Istio 等等。
虽然在云计算基础设施中 Go 赢得了一席之地,这属于开辟了一片蓝海,但在传统系统编程的红海中,Go 语言实际上并没有做出什么特别的成绩。对于实时性和性能要求极高的领域,Go 语言有一个拿不出手的弱项——它的 GC。
自动的内存管理固然是简化程序员工作的一项重要手段,但对于系统编程这个领域而言,GC 显然还没有表现得能够赢得大家的信任,而且在可见的未来也不会有明显的起色。
所以,在系统编程领域替代 C 的征程上,大家都还有机会。
Rust 语言
这条赛道上目前最有力的竞争者是 Rust。
Rust 出自 Mozilla,这是浏览器 Firefox 背后的公司。它原本是 Mozilla 员工 Graydon Hoare 的个人项目,后来得到了公司赞助,由一个练手的项目成为了一个正式的项目。
Rust 对初学者并不友好,对于习惯"少废话、先动手"的程序员而言,Rust 的初体验可能一点都不好——按照习惯方式写出来的代码很可能是无法编译的。例如,Rust 的"变"量缺省是不变的;再例如,想写好 Rust 程序,先要了解所有权的概念。不过,也恰恰是因为这些限制,让 Rust 写出来的程序犯下低级错误的概率大大降低了。
如果理解系统编程面临的问题以及现代软件开发的趋势,会发现 Rust 提供的选项很好地规避了许多问题。例如,之所以要用不变性,是因为它可以规避掉很多因为"变"带来的问题,这是函数式编程给软件开发贡献的一个重要思路。再例如,所有权的概念也是为了防止一块内存不同的人去改造成各种问题,同时给内存管理提供了新的思路。
内存不能让程序员管,这已经成了共识,但主流的 GC 方案又不能满足系统编程的需要。Rust 则给出了第三种方案:把内存当作一种资源,申请下来就初始化好,出了生命周期就销毁掉。之所以能够做到这点,还是要拜 Rust 强大的编译器所赐——因为所有权的存在,编译器可以很好地分析出内存到底该什么时候释放。
Rust 成为系统编程语言的有力竞争者还有一个原因:它背靠着 LLVM。LLVM 是一套编译器的基础设施,它的出现是因为传统的工具链 GCC 太过沉重。LLVM 把编译器的前端和后端分离开来,语言开发者只要关注前端、设计好各种语言特性,就可以利用 LLVM 后端进步的优势,例如不断优化带来的性能提升。对系统编程语言来说,一个重点就是可移植性。
系统编程一个重要的战场就是各种嵌入式设备,而绝大多数设备都只支持 C/C++ 语言。一个重要的原因就是谁来移植编译器——C/C++ 的后端常常是厂商提供支持的,而其他语言则多半无人理睬。现在有了 LLVM 的基础设施,一个芯片厂商只要支持了 LLVM 的后端,用 LLVM 前端开发出的语言也就都得到了支持。这对于新兴语言来说,绝对是一个巨大的好消息。
Rust 在语言层面表现出来的安全特性,帮它赢得了像微软、亚马逊这样大厂的注意;占用资源少的内存管理方式,让一些人开始尝试使用它编写 Linux 驱动;更多的移植可能,也让它成为了嵌入式开发的一种考虑。在这场 C 语言替代者的竞争中,Rust 值得期待!
语言发展脉络
图注:程序设计语言的发展脉络——系统编程领域 C/C++ 面临 Go 和 Rust 的挑战;应用开发领域 Java/JVM 生态占据主导;Web 领域 JavaScript 从浏览器走向全平台。
核心要点
- C#:语言特性领先 Java 十年,但时运不济——上升期遇到微软下降期;JVM 最终成为"多个语言多个平台"的生态
- JavaScript:从 10 天诞生的"小玩具"到全平台覆盖的语言;Ajax 让其受到重视,Node.js 让其突破浏览器限制,NPM 催生生态爆发
- Go:语法简单、接口设计和并发处理亮眼;在云计算基础设施(Docker、K8s、Istio)中找到蓝海,但 GC 是其在传统系统编程领域的弱项
- Rust:通过不变性和所有权规避常见问题,提供内存管理的第三种方案;背靠 LLVM 获得可移植性优势;在 C 语言替代者竞争中值得期待
- 了解技术发展历史是判断未来技术趋势的重要基础
6.4 加餐:函数式编程拾遗
概述
函数式编程是一个待人发掘的宝库,里面的好东西太多了。之前主要选择了函数式编程在设计上有较大影响的组合性和不变性来讲,但函数式编程中还有一些内容,虽然不一定在设计上影响那么大,但作为编程技巧也非常值得了解。即便使用的不是函数式编程语言,这些内容同样很有帮助。
惰性求值
回顾之前的学生类,简化后只使用其中的几个字段:
class Student {
// 学生姓名
private String name;
// 年龄
private long age;
// 性别
private Gender gender;
public Student(final String name,
final long age,
final Gender gender) {
this.name = name;
this.age = age;
this.gender = gender;
}
}来看一段代码,先猜猜执行结果会是什么样子:
// 数据准备
Student jack = new Student("Jack", 18, Gender.MALE);
Student rose = new Student("Rose", 18, Gender.FEMALE);
List<Person> students = asList(jack, rose);
// 模拟对象
Function<Person, String> function = mock(Function.class);
when(function.apply(jack)).thenReturn("Jack");
// 映射
students.stream().map(function);
// 验证
verify(function).apply(jack);这段代码用到了 mock 框架 mockito,核心就是验证 function 变量是否得到了正确的调用,其中用到了之前讲过的 map 函数。
虽然按照普通的 Java 代码执行逻辑,verify 的结果一定是 function 得到了正常的调用,但实际上,这里的 function 并没有调用。换言之,虽然看上去 map 函数执行了,但并没有调用到 function 的 apply 方法。
原因在于这段代码是惰性求值的。
惰性求值(Lazy Evaluation)是一种求值策略,它将求值的过程延迟到真正需要这个值的时候。惰性求值的好处就在于可以规避一些不必要的计算,尤其是规模比较大或是运行时间比较长的计算。
如果学习过设计模式,惰性求值这个概念应该并不陌生。有一些设计模式就是典型的惰性求值——例如 Proxy 模式,它就是采用了惰性求值的策略,把一些消耗很大的计算延迟到不得不算的时候去做。还有 Singleton 模式有时也会采用惰性求值的策略,在第一次访问的时候再去生成对象。
在函数式编程中,惰性求值是一种很常见的求值策略,也正是因为惰性求值的存在,可以做出很多有趣的事情。
无限流
在传统的编程方式中,熟悉的集合类都是有限长度的,因为集合中的每个元素都是事先计算好的。但现在有了惰性求值,就可以创造出一个无限长的集合。
无限长集合中的元素并不是预置进去的,而是在需要的时候才计算出来的。无限长集合真正预置进去的是元素的产生规则。这样一来,元素就会像流水一样源源不断地产生出来,这种集合称为无限流(Infinite Stream)。
例如,要产生一个自然数的集合,可以这么做:
Stream.iterate(1, number -> number + 1)在这里,定义了集合的第一个元素,然后给出了后续元素的推导规则,一个无限流就产生了。
当然,因为惰性求值的存在,这样定义的无限流并不会做真正的计算,只有在需要用到其中的一些元素时,计算才会执行。例如,可以按需取出一些元素——在下面这段代码中,跳过无限流的前两个元素,然后取出三个元素,将结果打印出来:
Stream.iterate(0, number -> number + 1)
.skip(2)
.limit(3)
.forEach(System.out::println);什么情况下无限流才会真正求值呢?之前讲组合性时提到过,列表操作可以分为两类:中间操作(Intermediate Operation)和终结操作(Terminal Operation)。像 map 和 filter 这一类就是中间操作,而像 reduce 一类就属于终结操作。只有终结操作才需要给出一个结果,所以只有终结操作才会引起真正的计算。
无限流的概念很有意思,而且有实际用途。如果对无限流有了认识,很多系统的设计都可以看作一个无限流。例如一些大数据平台,就是有源源不断的数据流入其中,而要做的就是给这个无限流提供各种转换——现在炙手可热的 Flink,使用的就是这种思路。
记忆(Memoization)
惰性求值还带来了另一个有趣的做法:记忆(Memoization)。
前面说过,Proxy 模式之所以要采用惰性求值的策略,一个重要的原因就是真正的计算部分往往是消耗很大的。所以一旦计算完成,一个好的策略就是将计算的结果缓存起来,这样再次调用时就不必重新计算了。这种做法就是记忆。
记忆在 Wikipedia 上是这样定义的:
在计算中,记忆是一种优化技术,主要用于加速计算机程序,其做法就是将昂贵函数的结果存储起来,当使用同样的输入再次调用时,返回其缓存的结果。
这里的一个重点是"同样的输入"。函数式编程中的函数是纯函数,同样的输入必然会给出同样的输出。因此,记忆这种技术在函数式编程中的作用就不难理解了。
实现记忆这种技术并不难,下面给出一个实现,用到了 Java 并发库中的类 AtomicReference,从而消除了可能产生的多线程问题:
public static <T> Supplier<T> memoize(Supplier<T> delegate) {
AtomicReference<T> value = new AtomicReference<>();
return () -> {
T val = value.get();
if (val == null) {
synchronized(value) {
val = value.get();
if (val == null) {
val = Objects.requireNonNull(delegate.get());
value.set(val);
}
}
}
return val;
};
}这个实现用起来也很简单:
long ultimateAnswer = memoize(() -> {
// 这里有一个常常的计算
// 返回一个终极答案
return 42;
})memoize 是一个通用的实现,适用范围很广。仔细对比不难发现,这里已经实现了 Proxy 模式的能力——换言之,有了它,可以不再需要 Proxy 模式。之前讲到设计模式时也提到,一些设计模式是受限于程序设计语言自身能力不足而出现的,这里也算为那个观点添加了一个注脚。
Optional
回到学生的例子上。如果想获取一个学生出生的国家,直觉上的写法是这样的:
public Country getBirthCountry() {
return this.getBirthPlace() // 获取出生地
.getCity() // 获取城市
.getProvince() // 获取省份
.getCountry(); // 获取国家
}然而,在真实项目中,代码并不能这么写,因为这样可能会出现空指针,所以不得不把代码写成这样:
public Country getBirthCountry() {
Place place = this.birthPlace;
if (place != null) {
City city = place.getCity();
if (city != null) {
Province province = city.getProvince();
if (province != null) {
return province.getCountry();
}
}
}
return null;
}这是一段令人头疼的代码,但不得不这么写,因为空指针总是一个令人头疼的问题。事实上,作为程序员,经常会有忘记做空指针检查的时候。这不是一个人的问题,而是整个行业的问题。
我将其称为自己犯下的十亿美元错误……
I call it my billion-dollar mistake…
——Sir C. A. R. Hoare,空引用的发明者
难道空指针就是一个无解的问题吗?程序员们并不打算束手就擒,于是产生了一种新的解决方案——可选对象。这个解决方案在 Java 8 中叫 Optional,在 Scala 中叫 Option。接下来以 Java 8 中的 Optional 为例进行讲解。
Optional 的基本概念
Optional 是一个对象容器,其中可能包含着一个非空的值,也可能不包含。如果包含值,就对应着有值的场景;如果不包含,则对应着值为空的场景。
如何创建一个 Optional 对象:
- 如果有一个非空对象,可以用
of()将它包装成一个 Optional 对象 - 如果要表示空,可以返回一个
empty() - 如果有一个从别处传来的对象,不知道它是不是空,可以用
ofNullable()
Optional.of("Hello"); // 创建一个Optional对象,其中包含了"Hello"字符串
Optional.empty(); // 创建了一个表示空对象的Optional对象
Optional.ofNullable(instance); // 创建了一个Optional对象,不知instance是否为空直接使用对象都解决不了问题,把对象放到一个容器里就能解决?还真能。因为要用这个对象的时候,需要把对象取出来,而要取出对象,就需要判断一下这个对象是否为空:
if (country.isPresent()) {
return country.get();
}只有 Optional 里包含的是一个非空的对象时,get() 方法才能正常执行,否则就会抛出异常。当调用 get() 的时候,意图是很明显的——要处理的是一个非空的值,所以必须加上一段判断对象是否存在的代码。
这比直接访问对象多用了一步,但正是这多出的一步让大脑必须想一下,自己是否需要加上判空的处理,而不是像普通对象一样一下子就滑了过去。
而且因为 get() 本身是有意图的,用工具也可以扫描出缺失的判断。例如用 IntelliJ IDEA 写程序,不加判断直接 get() 的话,它就会给出一个警告。
Optional 的链式操作
使用 Optional,还可以给空对象增加一些额外的处理,例如给个缺省值:
country.orElse(china); // 返回一个缺省的对象也可以生成一个新的对象:
country.orElseGet(Country::new); // 调用了一个函数生成了一个新对象或是抛出异常:
country.orElseThrow(IllegalArgumentException::new);拿到一个值之后,往往要做更多的处理。使用 Optional,甚至可以不用把其中的值取出来,直接就做一些处理。它提供 map、flatMap、filter 等方法,就是当 Optional 包含的对象不为空时调用对应的方法做处理,为空的时候直接返回表示空的 Optional 对象。
Optional 解决空指针问题
在日常工作中怎么用 Optional 呢?很简单:在方法需要返回一个值时,如果返回的对象可能为空,那就返回一个 Optional。这样就给了方法使用者一个提示——这个对象可能为空,小心处理。
例如,获取学生的出生地,方法可以这么写:
Optional<Place> getBirthPlace() {
return Optional.ofNullable(this.birthPlace);
}回到前面的问题——获取一个学生出生的国家,如果相应的方法都改写成 Optional,代码写出来会是这个样子:
public Optional<Country> getBirthCountry() {
return Optional.ofNullable(this.birthPlace)
.flatMap(Place::getCity)
.flatMap(City::getProvince)
.flatMap(Province::getCountry);
}虽然不能说这段代码一定有多优雅,但至少比层层嵌套的 if 判断要整洁一些了。
Optional 与 Monad
Optional 和函数式编程有什么关系呢?Optional 将对象封装起来的做法来自于函数式编程中一个叫 Monad 的概念,可以简单地把它理解成一个对象容器。Optional 就对应着其中的一种:Maybe Monad。
正是因为这个容器的存在,解决了很多问题。Monad 的概念解释起来还有很多东西要说,篇幅所限不过多阐述,有兴趣不妨自己去了解一下。
这种对象容器的思想也逐渐在开枝散叶。例如,在 Rust 的标准库里有一个 Result,用来定义可恢复的故障。它可以是一个正常值,也可以是一个错误值:
enum Result<T, E> {
Ok(T),
Err(E),
}下面是一段摘自 Rust 标准库文档的代码,有了前面对于 Optional 的讲解,理解起这段代码也就容易多了:
enum Version { Version1, Version2 }
// 定义一个解析版本的函数
fn parse_version(header: &[u8]) -> Result<Version, &'static str> {
match header.get(0) {
None => Err("invalid header length"), // 无法解析,返回错误
Some(&1) => Ok(Version::Version1), // 解析出版本1
Some(&2) => Ok(Version::Version2), // 解析出版本2
Some(_) => Err("invalid version"), // 无效版本,返回错误
}
}
let version = parse_version(&[1, 2, 3, 4]);
// 根据返回值进行处理
match version {
Ok(v) => println!("working with version: {:?}", v),
Err(e) => println!("error parsing header: {:?}", e),
}函数式编程核心概念关系
图注:函数式编程拾遗的核心概念关系——惰性求值衍生出无限流和记忆,纯函数是记忆和 Optional 的基础,Monad 是 Optional 和 Result 的理论基础。
核心要点
- 惰性求值:将求值过程延迟到真正需要的时候,规避不必要的计算;Proxy 模式和 Singleton 模式都是惰性求值的体现
- 无限流:利用惰性求值创造无限长集合,真正预置的是元素的产生规则;只有终结操作才会引起真正的计算;Flink 等大数据平台的设计思路即源于此
- 记忆(Memoization):将昂贵函数的结果缓存起来,同样输入再次调用时返回缓存结果;纯函数保证了记忆的正确性;memoize 的通用实现可以替代 Proxy 模式
- Optional:用对象容器解决空指针问题,强制调用者思考判空处理;提供 map、flatMap、filter 等链式操作;源自函数式编程的 Maybe Monad
- Rust 的 Result:与 Optional 同属 Monad 家族,用于可恢复的故障处理
- 花点时间学习函数式编程,其中的优秀内容值得借鉴
本篇核心概念关系
图注:设计模式篇核心概念关系——设计原则是公理,设计模式是定理;简单设计让模式自然浮现而非强行套用;语言演进和函数式编程不断简化或替代传统设计模式。
与其他知识点的关联
- 05-设计原则篇:设计模式是 SOLID 原则在具体场景的应用;简单设计四原则中的"消除重复"与 SRP/分离关注点异曲同工
- 04-编程范式篇:大多数设计模式依赖多态机制;函数式编程的惰性求值、Memoization、Optional 等技巧为传统设计模式提供了替代方案
- 07-领域驱动设计篇:DDD 中的领域服务和资源库可视为特定场景下的模式应用
- 01-设计认知篇:简单设计从最简模型开始,逐步演化;YAGNI 是控制需求规模的上游思维
- 08-设计实战篇:改进设计的过程就是持续重构的过程;简单设计的核心实践是重构