导读
软件设计导读:怎样形成可检查的判断
说明软件设计系列要解决的问题与待办事项主线,再从短小的版本查询练习区分程序行为、设计目的和判断依据。
Hex4C59 6 分钟阅读 更新于 2026-10-17
软件工程·原创·概念与设计
这个系列要讲的是:一个能运行的小程序,随着功能增加,代码该怎样安排,才能继续容易看懂、容易修改。每次分出一段代码、增加一项检查,都需要说明它解决了什么困难,又增加了什么工作。
软件开发里,这类关于代码如何配合、数据由谁管理、变化应当影响哪里的选择,通常属于软件设计。系列标题中的抽象、契约和复杂度,会在具体问题出现时解释,不要求读者先记住这些名词。
用一个程序看清整条主线 #
我们会逐步讨论一个待办事项程序。起初它只有任务列表,能添加任务、把任务标为完成。功能少的时候,几项工作放在一起也能运行。
需求增加后,原来的安排会遇到新的选择:
| 程序发生的变化 | 需要作出的选择 |
|---|---|
| 列表页和详情页都能完成任务 | 两处各写一遍,还是共同使用一段完成任务的代码? |
| 用户点了两次完成,或任务已不存在 | 怎样说明结果,哪些失败要由页面处理? |
| 退出后还要保留任务 | 完成任务和保存文件怎样配合,保存失败时告诉用户什么? |
| 改变文件内容或查找任务的方式 | 能否集中修改一处,让页面继续按原来的方式使用? |
| 代码需要重新整理 | 怎样确认原来的功能、结果与失败处理没有被改坏? |
每次拆分都要说明具体收益和代价。我们会比较具体方案:哪项需求支持现在的选择,增加什么需求后要重新考虑,调用和修改会经过哪些地方。
第一篇用版本查询这个很短的例子,比较是否值得增加一层函数。第二篇进入待办事项,讨论列表页和完成任务的代码需要共同遵守什么约定。第三篇继续加入按操作名称选择功能的需求;之后再展开数据管理、失败处理、检查与保存。
这样,各篇应该逐渐形成一个完整功能的分析。读完时,你应当能为自己的代码安排写出具体理由,并说明这种安排的成本和适用条件。完整篇目与进度见系列目录。
怎样阅读后面的代码 #
本系列面向能读写简单 JavaScript、开始维护完整功能的开发者,不预设熟悉软件设计术语。每篇先解释中心概念,再把概念对应到代码。若几种方案都能完成当前功能,就放在相同需求下比较,避免只看某一段代码是否简短。
阅读时可以区分三件事:程序实际做了什么;我们希望它做到什么;当前例子与检查能够支持哪些判断。例如,返回了一个对象是程序行为。以后改动数据来源时,读取结果的代码要不要跟着改,还要看这些代码知道了哪些内部细节。
下面保留一个很小的版本查询,练习沿代码观察输入、结果和调用。它是阅读准备,不是整个系列的功能目标。
先说清这段代码做了什么 #
假设有一个演示应用,它的版本信息保存在对象里。下面是一个完整例子:
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 放进结果。由此可以观察到“返回字段经过了选择”。至于为什么选择这些字段,需要需求或设计说明提供依据。仅凭代码,还不能判断内部构建标记是否敏感、是否必须隐藏,也不能断言这个函数让整个项目更容易维护。
函数值与函数调用要分开读 #
下面的片段接在前一个例子后面运行:
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,在保存它的目录运行:
node --version
node reading.mjs在 2026-10-14 的 macOS arm64、Node.js v25.9.0 环境中,输出为:
1.0.0
false文件使用 Node.js 内置断言,断言不成立会以错误结束。它只检查这组示例的字段值与是否为同一份数据,没有检查实际应用的版本来源。
再问调用者需要知道什么 #
现在有两种取得版本的写法。以下是比较片段,依赖前面定义的对象和函数:
const versionA = appInfo.version;
const versionB = getAppVersion().app;第一种要求调用者知道内部对象名和属性名。第二种要求调用者知道函数名、返回结果中的 app 字段,以及调用会立即返回普通对象。第二种写法增加了一个函数,但是否减少了调用者需要掌握的细节,要看它后面承担了多少工作。
如果应用只有一处显示版本,数据结构也很稳定,直接访问可能已经足够。假如多个调用者分别处理不同的数据来源,查询函数又把这些来源和转换集中到一起,比较结果就可能改变。第 01 篇会把这些情况拆开运行。
在这个系列里,接口指调用者通过什么名称、参数和结果使用一项功能;抽象让调用者通过这套使用方式完成工作,暂时不必知道内部的一部分实现细节。例如,查询版本的代码可以只读取返回字段,而不关心版本原来存在哪里。我们会继续问:具体允许忽略哪些细节,又留下了哪些必须理解的约定?
用一项变化检验设计理由 #
假设原来的 appInfo.version 改成了 appInfo.release.version,而关于页仍应显示同一个版本。这是内部数据表示变化,用户需要的结果暂时没有变化。
直接访问的调用者需要调整属性路径。通过函数查询的调用者能否保持原样,要检查函数是否继续返回原来的 app 字段,是否仍然直接交回普通结果,以及调用者是否还在别处直接读取内部对象。函数名相同,并不能保证这些约定保持不变。
我们由此可以写出一句带条件的判断:“只要查询函数仍然直接返回带有原有版本字段的对象,而且调用者只使用查询结果,内部属性路径的调整就可以集中在查询函数里。”接着就能用修改前后的例子检查这些条件。
《How to Design Programs》第 2 版第 3.1 节把数据描述、函数目的、输入输出示例与测试纳入函数设计过程。这为这里的阅读方法提供了一个起点:先写清函数应该完成什么,再判断当前实现是否满足它。本系列采用原创 JavaScript 案例,具体语言行为另行核对。
读一篇时留下什么 #
对每个案例,试着留下能够让别人复核的材料:需求和约束、你比较过的两种实现、一次具体变化,以及运行或调用分析得到的结果。如果你认为一层封装值得保留,再补一句它承担的职责;如果你选择删除它,说明什么新需求出现时会重新考虑。
例如,对开头的函数可以先写下:
当前只需要取得应用版本。查询函数选择了一个返回字段,并创建新对象。它能否隔离数据来源变化,还需检查导出方式和实际调用者;如果只是把一次属性读取换成一次函数调用,当前收益可能很小。
这段说明包含已观察到的行为、尚未检查的条件和选择所依据的需求。带着这些问题进入第 01 篇:抽象的收益与代价,比较三种方案在同一项变化下的表现。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗