# 软件设计导读：怎样形成可检查的判断

作者：Hex4C59 · 发布于 2026-10-14

原文：https://hex4c59.cc/articles/software-design-00-reading-guide/


这个系列要讲的是：一个能运行的小程序，随着功能增加，代码该怎样安排，才能继续容易看懂、容易修改。每次分出一段代码、增加一项检查，都需要说明它解决了什么困难，又增加了什么工作。

软件开发里，这类关于代码如何配合、数据由谁管理、变化应当影响哪里的选择，通常属于软件设计。系列标题中的抽象、契约和复杂度，会在具体问题出现时解释，不要求读者先记住这些名词。

## 用一个程序看清整条主线

我们会逐步讨论一个待办事项程序。起初它只有任务列表，能添加任务、把任务标为完成。功能少的时候，几项工作放在一起也能运行。

需求增加后，原来的安排会遇到新的选择：

| 程序发生的变化 | 需要作出的选择 |
| --- | --- |
| 列表页和详情页都能完成任务 | 两处各写一遍，还是共同使用一段完成任务的代码？ |
| 用户点了两次完成，或任务已不存在 | 怎样说明结果，哪些失败要由页面处理？ |
| 退出后还要保留任务 | 完成任务和保存文件怎样配合，保存失败时告诉用户什么？ |
| 改变文件内容或查找任务的方式 | 能否集中修改一处，让页面继续按原来的方式使用？ |
| 代码需要重新整理 | 怎样确认原来的功能、结果与失败处理没有被改坏？ |

每次拆分都要说明具体收益和代价。我们会比较具体方案：哪项需求支持现在的选择，增加什么需求后要重新考虑，调用和修改会经过哪些地方。

第一篇用版本查询这个很短的例子，比较是否值得增加一层函数。第二篇进入待办事项，讨论列表页和完成任务的代码需要共同遵守什么约定。第三篇继续加入按操作名称选择功能的需求；之后再展开数据管理、失败处理、检查与保存。

这样，各篇应该逐渐形成一个完整功能的分析。读完时，你应当能为自己的代码安排写出具体理由，并说明这种安排的成本和适用条件。完整篇目与进度见[系列目录](https://hex4c59.cc/articles/software-design/)。

## 怎样阅读后面的代码

本系列面向能读写简单 JavaScript、开始维护完整功能的开发者，不预设熟悉软件设计术语。每篇先解释中心概念，再把概念对应到代码。若几种方案都能完成当前功能，就放在相同需求下比较，避免只看某一段代码是否简短。

阅读时可以区分三件事：程序实际做了什么；我们希望它做到什么；当前例子与检查能够支持哪些判断。例如，返回了一个对象是程序行为。以后改动数据来源时，读取结果的代码要不要跟着改，还要看这些代码知道了哪些内部细节。

下面保留一个很小的版本查询，练习沿代码观察输入、结果和调用。它是阅读准备，不是整个系列的功能目标。

## 先说清这段代码做了什么

假设有一个演示应用，它的版本信息保存在对象里。下面是一个完整例子：

```js
const appInfo = {
  version: "1.0.0",
  internalBuildTag: "demo-build",
};

function getAppVersion() {
  return { app: appInfo.version };
}

const result = getAppVersion();
console.log(result.app);
```

`appInfo` 是一个对象，`version` 和 `internalBuildTag` 是它的属性。`appInfo.version` 读取 `version` 的值，当前是字符串 `"1.0.0"`。

`getAppVersion` 定义了一个没有参数的函数。执行 `getAppVersion()` 时，函数读取版本值，创建一个带有 `app` 属性的新对象，再把对象返回给调用处。`result` 接住返回值，所以最后一行输出 `1.0.0`。

这个函数没有把 `internalBuildTag` 放进结果。由此可以观察到“返回字段经过了选择”。至于为什么选择这些字段，需要需求或设计说明提供依据。仅凭代码，还不能判断内部构建标记是否敏感、是否必须隐藏，也不能断言这个函数让整个项目更容易维护。

## 函数值与函数调用要分开读

下面的片段接在前一个例子后面运行：

```js
const query = getAppVersion;
const first = query();
const second = query();

console.log(first.app);
console.log(first === second);
```

`const query = getAppVersion` 把函数值交给另一个名称。这里没有括号，所以这一行没有执行查询。`query()` 才会调用函数，与 `getAppVersion()` 执行同一段函数体。

两次调用都返回包含 `app: "1.0.0"` 的对象，但每次执行 `{ app: appInfo.version }` 都会创建一个新对象。这里的 `===` 比较两次返回值是否指向同一个对象，所以第二行输出 `false`。两份对象的内容相同，与它们是否为同一份数据，是两个需要分别检查的问题。

可以下载[导读的完整检查文件](reading.mjs)，保存为 `reading.mjs`，在保存它的目录运行：

```sh
node --version
node reading.mjs
```

在 2026-10-14 的 macOS arm64、Node.js v25.9.0 环境中，输出为：

```text
1.0.0
false
```

文件使用 Node.js 内置断言，断言不成立会以错误结束。它只检查这组示例的字段值与是否为同一份数据，没有检查实际应用的版本来源。

## 再问调用者需要知道什么

现在有两种取得版本的写法。以下是比较片段，依赖前面定义的对象和函数：

```js
const versionA = appInfo.version;
const versionB = getAppVersion().app;
```

第一种要求调用者知道内部对象名和属性名。第二种要求调用者知道函数名、返回结果中的 `app` 字段，以及调用会立即返回普通对象。第二种写法增加了一个函数，但是否减少了调用者需要掌握的细节，要看它后面承担了多少工作。

如果应用只有一处显示版本，数据结构也很稳定，直接访问可能已经足够。假如多个调用者分别处理不同的数据来源，查询函数又把这些来源和转换集中到一起，比较结果就可能改变。[第 01 篇](https://hex4c59.cc/articles/software-design-01-abstraction/)会把这些情况拆开运行。

在这个系列里，**接口**指调用者通过什么名称、参数和结果使用一项功能；**抽象**让调用者通过这套使用方式完成工作，暂时不必知道内部的一部分实现细节。例如，查询版本的代码可以只读取返回字段，而不关心版本原来存在哪里。我们会继续问：具体允许忽略哪些细节，又留下了哪些必须理解的约定？

## 用一项变化检验设计理由

假设原来的 `appInfo.version` 改成了 `appInfo.release.version`，而关于页仍应显示同一个版本。这是内部数据表示变化，用户需要的结果暂时没有变化。

直接访问的调用者需要调整属性路径。通过函数查询的调用者能否保持原样，要检查函数是否继续返回原来的 `app` 字段，是否仍然直接交回普通结果，以及调用者是否还在别处直接读取内部对象。函数名相同，并不能保证这些约定保持不变。

我们由此可以写出一句带条件的判断：“只要查询函数仍然直接返回带有原有版本字段的对象，而且调用者只使用查询结果，内部属性路径的调整就可以集中在查询函数里。”接着就能用修改前后的例子检查这些条件。

《How to Design Programs》第 2 版[第 3.1 节](https://htdp.org/2024-11-6/Book/part_one.html)把数据描述、函数目的、输入输出示例与测试纳入函数设计过程。这为这里的阅读方法提供了一个起点：先写清函数应该完成什么，再判断当前实现是否满足它。本系列采用原创 JavaScript 案例，具体语言行为另行核对。

## 读一篇时留下什么

对每个案例，试着留下能够让别人复核的材料：需求和约束、你比较过的两种实现、一次具体变化，以及运行或调用分析得到的结果。如果你认为一层封装值得保留，再补一句它承担的职责；如果你选择删除它，说明什么新需求出现时会重新考虑。

例如，对开头的函数可以先写下：

> 当前只需要取得应用版本。查询函数选择了一个返回字段，并创建新对象。它能否隔离数据来源变化，还需检查导出方式和实际调用者；如果只是把一次属性读取换成一次函数调用，当前收益可能很小。

这段说明包含已观察到的行为、尚未检查的条件和选择所依据的需求。带着这些问题进入[第 01 篇：抽象的收益与代价](https://hex4c59.cc/articles/software-design-01-abstraction/)，比较三种方案在同一项变化下的表现。

