第 02 篇
接口与契约:两段代码怎样可靠地配合工作
以待办事项程序为主线,从接口与契约的含义讲到输入、结果、失败、数据修改和重复操作,再比较不同设计及需求变化后的影响。
Hex4C59 25 分钟阅读 更新于 2026-10-17
软件工程·原创·概念与设计
一个待办事项程序,最初可能只有一张列表:写上任务,做完后打个勾。功能少的时候,显示任务和修改任务的代码放在一起,也能工作。
后来,程序增加了任务详情页。列表页有“完成”按钮,详情页也有。再往后,用户可能连续点两次按钮,某条任务可能已经被另一处删除,程序还可能需要把完成结果保存下来。
这些变化带来一个问题:两处页面都要完成任务,它们应当怎样使用同一项功能,才能对“做完了什么、没做成怎么办”有一致的理解?
这个系列讨论的,就是小程序增加功能后,代码怎样安排才能继续容易理解、容易修改。每次多分出一段代码、多增加一项检查,都要说明它解决了什么困难,又增加了什么维护工作。上一篇用版本查询讨论了多写一层函数是否值得;这一篇把问题推进到两段代码怎样配合。
本文先解释接口和契约,再为“完成任务”写一份具体约定,比较几种选择,最后用可运行的代码检查这些选择。你不需要先学过软件设计术语。阅读示例会用到少量 JavaScript;关键名称和写法会随例子说明。正文主线一直是待办事项,版本查询留在文末作为更短的练习。文中的页面是使用方式的示意,示例包运行的是任务操作与结果输出。
接口:其他代码怎样使用这项功能 #
接口就是一段代码提供给其他代码的使用方式。 它让使用者知道有哪些功能、怎样提出请求、需要提供什么信息,以及怎样取得结果。
假设列表页负责显示所有任务,详情页负责显示某一条任务。这两部分都能显示按钮,但它们不应该各自猜测“完成任务”究竟要怎样修改数据。
我们可以把完成任务的工作放进一段能够单独执行的代码。这种代码叫函数。本例给它起名为 completeTask,意思是完成任务。
列表页想完成编号为 t1 的任务,可以写:
completeTask("t1");这里先只观察用法,函数的具体代码在后面。
让函数执行,叫调用;发起这次执行的代码,叫调用者。本例中,列表页和详情页都是调用者。括号里的 "t1" 是交给函数的信息,叫参数。执行结束后,函数交回的结果叫返回值。交给函数的信息也常叫输入,后文的输入检查就是检查这些信息是否符合要求。
在本例中,completeTask 这个名字、任务编号这个参数和交回的结果,共同说明使用这项功能的方式。
函数内部怎样找到任务、怎样修改数据,叫它的实现。列表页需要按接口使用功能,具体查找任务的工作由实现完成。
同一个词会出现在不同场景中。网站提供给另一段程序使用的请求方式,可以叫接口;一个代码文件提供给其他代码使用的功能,也可以叫接口。本文先讨论函数这个最小的例子,不要求它经过网络。MDN 的程序接口说明也把可供其他代码使用的能力与规则放在一起讨论。
契约:双方可以依靠哪些条件和承诺 #
只知道可以写 completeTask("t1"),还不足以写好页面。
例如,t1 不存在时,页面应该显示什么?它已经完成时,再调用一次算成功还是失败?函数给回“成功”时,任务数据是否已经改好?页面修改返回的数据,会不会顺带改掉任务列表?
这些问题需要双方作出一致的约定。
契约是代码双方配合时,需要满足的条件和应当履行的承诺。 它说明使用者提供什么,执行这项功能的代码在不同情况下保证什么。使用者因此能依据说明编写自己的代码,而不必每次都翻开实现猜测行为。
接口与契约经常写在同一份说明里。说接口,侧重怎样使用一项功能;说契约,侧重使用这项功能时哪些行为可以依靠。参数的要求可以同时属于接口说明和契约的一部分,不需要把两者分成互不相干的东西。
Bertrand Meyer 在软件协作约定的讨论中,把这种关系描述为双方的责任:使用一项功能前需要满足什么,执行后应当保证什么。本文借用这个分析视角,具体的任务规则和失败处理由我们根据演示需求选择。
接下来先明确页面究竟需要知道什么,再比较怎样把操作结果和失败原因交给它。
先看清我们要处理的数据 #
本例只有两条任务:
const tasks = [
{ id: "t1", title: "写周报", completed: false },
{ id: "t2", title: "整理笔记", completed: false },
];方括号里的多条数据构成一个数组,可以把它理解为按顺序放在一起的列表。每条任务用一个对象表示,也就是按名称保存几项相关数据的值。
程序中的数据有不同类别,这种类别叫类型。下面会用到文字和表示是非的值两种类型。
这里的三个名称分别表示:
| 名称 | 含义 | 例子 |
|---|---|---|
id | 区分任务的编号 | "t1" |
title | 任务标题 | "写周报" |
completed | 是否已经完成 | false 表示还没完成,true 表示已完成 |
"t1"、"写周报" 这样的文字,在程序里叫字符串,代码里用引号表示。true 和 false 表示“是”和“否”,这类值叫布尔值。
程序当前保存了哪些任务、每条任务是否完成,就是这里所说的状态。例如,t1 从 completed: false 变为 completed: true,就是状态发生了变化。
“完成任务”还有一件与版本查询不同的事:它除了交回结果,还要改变已经保存的数据。函数给回结果之外造成的变化,叫副作用。副作用可以是合理且必要的工作,完成任务本来就需要改变任务数据;我们要说明的是它会改什么、什么时候改、哪些东西应该保留原样。
本例采用的初始任务已经满足几个条件:编号互不重复,标题是文字,完成标记是布尔值。本篇只处理完成任务,不实现创建任务、读取损坏文件或修复错误初始数据。
调用前,要提供什么信息 #
先决定编号怎么传。
一种写法是传字符串 "t1",另一种写法是传一个包含编号的对象,例如 { id: "t1" }。两种都可以实现。
本例只需要编号,没有其他操作选项,因此先采用 completeTask("t1")。这让使用者容易看出需要提供什么。如果以后确实增加了完成时间、备注等信息,再评估是否改用包含多项内容的对象;提前增加一个对象,也会让每个调用者多理解一层结构。
然后区分两种不一样的失败:
- 交来的东西不是编号,例如数字
3、空字符串"",或者没有提供任何值。 - 交来的是一段编号文字,但当前列表里没有这条任务,例如
"t9"。
前一种没有满足我们对编号的要求,后一种需要检查当前数据才能知道结果。把它们混成一个“失败”,页面就难以选择合适的提示。
本例这样规定:编号必须是非空字符串,完全由空格组成的文字也不接受;符合这个要求后,再按原样与任务编号比较。函数不自动删除编号两侧的空格。
| 提供的信息 | 本例怎样处理 |
|---|---|
"t1" | 编号形式符合要求,并且能找到任务 |
"t9" | 编号形式符合要求,但找不到任务 |
3 | 不是字符串,不符合编号要求 |
"" | 没有编号内容,不符合要求 |
" " | 只有空格,不符合要求 |
" t1 " | 不自动改写编号,因此不等于 "t1",找不到任务 |
不自动修改编号,是本例作出的选择。若某个产品希望用户输入 " t1 " 也能查到任务,可以明确增加去掉两侧空格的规则。需要把这项处理写进约定,避免页面和函数各自采用不同做法。
成功时,仅仅返回 true 够不够 #
设想函数只返回一个布尔值:true 表示完成了,false 表示没有完成。
第一次把 t1 标为完成,返回 true 很直观。但它已经完成后再调用一次,返回什么?如果返回 false,页面无法判断是没有找到任务,还是它本来就完成了。如果返回 true,页面又不知道这次是否真的改变了数据。
只要页面只关心一个是非问题,布尔值就可能足够。本例还需要说明失败原因和是否发生修改,因此选择返回一份包含更多信息的结果。下面是结果的形式示意,不是一段单独执行的程序:
{
ok: true,
changed: true,
task: { id: "t1", title: "写周报", completed: true }
}这是结果的形式示意,具体的 return 代码在后面。ok 表示操作是否成功,changed 表示这次是否实际改变了任务数据,task 是完成后的任务信息。
失败结果的形式示意如下,其中写出原因:
{ ok: false, reason: "not-found" }其中 reason 表示原因。这里的 "not-found" 是供程序识别的固定名称,意思是没有找到任务;"invalid-id" 表示提供的编号不符合要求。这样的固定名称也常被叫作错误码,它可以是一段文字,不一定是数字。
页面可以根据原因显示适合用户的中文提示。程序识别的名称与显示给人的文字分开后,修改提示措辞就不必改变页面判断失败原因的方式。
代价是调用者需要读懂结果中的这些项目,不能再把返回值简单当作 true 或 false 使用。因此,返回内容增加到什么程度,应当跟着实际需求走。
为完成任务写一份完整约定 #
把刚才的选择放在一起,得到本例要实现的规则。
| 情况或问题 | 本例的约定 |
|---|---|
| 使用方式 | completeTask(id),编号以字符串提供 |
| 编号不符合要求 | 返回 { ok: false, reason: "invalid-id" },所有任务保持原样 |
| 编号符合要求,但找不到任务 | 返回 { ok: false, reason: "not-found" },所有任务保持原样 |
| 找到未完成的任务 | 把它标为完成,返回 ok: true、changed: true 和完成后的任务信息 |
| 找到已完成的任务 | 仍返回 ok: true,但 changed: false,任务数据不再改变 |
| 成功发生在什么时候 | 函数交回成功结果时,本次任务数据已经更新 |
| 可以修改哪些数据 | 只改目标任务的完成标记;编号、标题和其他任务不变 |
| 返回的数据归谁使用 | 返回一份独立的任务数据,调用者修改它不会改动内部任务 |
| 是否保存到文件 | 本例没有文件保存;成功只表示这份程序中的任务数据已处理完成 |
这份说明既包含调用者的要求,也包含实现必须履行的承诺。它还说明没有承诺什么:现在返回成功,并不表示退出程序后还能找回任务。
这里的初始任务由演示代码建立。调用者不应该绕过完成任务的函数,另找一个途径直接修改内部任务列表;后面的代码会把这份数据留在管理它的函数里,其他代码取得的是结果或副本;副本就是另外创建的一份数据。
这些条件也有专业名称 #
我们已经用日常语言说清了要求,现在再对应几个经常在技术资料里出现的词。
前置条件是执行某项操作之前需要满足的要求。本例要进入正常完成任务的处理,编号需要是非空字符串,并且对应任务存在。对于不满足这些要求的请求,完整接口另外规定了失败结果,并没有省略检查。
后置条件是操作完成后应当成立的条件。例如,返回成功时,目标任务应当处于完成状态,结果里的编号应当与查到的任务一致。
不变量是程序在符合约定的操作前后都应当保持的规则。例如,本例的任务编号仍然互不重复,完成标记仍然只能是 true 或 false。完成任务不应该顺便改掉另一个任务的编号,也不应该把完成标记变成文字 "done"。
这几个词帮助我们检查不同阶段的问题:开始之前要求什么,结束之后保证什么,操作前后哪些规则要保留。它们不是写上名字就能自动成立,需要代码、数据来源和检查共同支持。
Meyer 的论述把这些条件与双方责任联系起来。我们的任务示例把预期失败也写成明确结果,是为了让页面能处理这些情况;这不等于所有程序都必须采用同一种返回方式。
让实现逐项履行约定 #
下面的函数使用前面那份 tasks 数据,可以接在那段数据定义后运行:
function completeTask(id) {
if (typeof id !== "string" || id.trim() === "") {
return { ok: false, reason: "invalid-id" };
}
let task;
for (const item of tasks) {
if (item.id === id) {
task = item;
break;
}
}
if (task === undefined) {
return { ok: false, reason: "not-found" };
}
const changed = task.completed === false;
task.completed = true;
return {
ok: true,
changed,
task: {
id: task.id,
title: task.title,
completed: task.completed,
},
};
}先看最上面一段。typeof id 检查收到的值属于什么类型,"string" 表示字符串。trim() 会取得去掉两侧空格后的文字;这里用它检查编号是否只包含空格,没有用它替换原来的编号。
如果不符合要求,函数立即 return 一份失败结果。return 表示结束这次函数执行,把后面的值交回调用它的代码。因为这一步在修改任务之前,失败时任务列表没有改变。
接着的 for...of 逐条查看任务。找到编号相同的一条后,把它记在 task 中,break 表示停止这次查找。如果一直没找到,task 的值仍然是 undefined,这里表示尚未取得任务,函数就返回没有找到的结果。
找到任务后,先记录它之前是否未完成,再把完成标记设为 true。这样第一次执行的 changed 为 true,再次执行则为 false。
最后,函数重新建立一份任务信息作为结果。它只复制当前需要的编号、标题和完成标记,不直接把内部使用的那份任务交给调用者。这一选择与约定中的“修改返回结果不影响内部数据”相对应。
每一步都能找到对应规则:先检查信息,再查找目标,再修改允许修改的部分,最后交回约定的结果。代码里没有保存文件、记录时间或修改其他任务的操作,不能把这些能力算进当前的成功承诺。
列表页和详情页怎样使用它 #
两处页面都可以取得这项功能,但各自决定怎样显示结果。下面是使用结果的片段,假设 completeTask 已按上面定义:
const result = completeTask("t1");
if (result.ok) {
console.log(`已完成:${result.task.title}`);
} else if (result.reason === "not-found") {
console.log("这条任务已不存在,请刷新列表");
} else {
console.log("未能完成任务,请检查提供的信息");
}if 根据条件决定执行哪部分代码。成功时,页面读取完成后的任务标题;找不到任务时,页面可以提示刷新;其他失败则显示一般提示。
列表页可能把这条任务划掉,详情页可能显示一个完成标记。它们的显示工作不同,但都依据同一份完成任务的约定。两处不必分别决定重复完成算不算成功,也不必各自修改内部任务列表。
这项分工也带来责任:调用者必须读取 ok,再决定能否使用成功结果里的 task。失败结果里没有任务信息,若不先检查就读取 result.task.title,调用者自己的使用方式便不符合结果约定。
这种明确要求,会让调用代码多几个分支,但能避免把不同情况都当成同一个成功画面。是否值得增加这些分支,要看真实功能中是否会发生这些情况。
为什么选择返回结果,而不是直接抛出错误 #
现在回头比较失败的表达方式。
一种做法是返回结果,例如 { ok: false, reason: "not-found" }。调用者取得结果后,检查 ok,再决定是否显示成功。
另一种做法是抛出错误:让当前函数停止,把错误交给外面的代码处理。JavaScript 使用 throw 表达这件事;调用者可以用 try...catch 尝试执行操作,并在失败时转入专门的处理代码。
还有一种更简短的做法,是没找到时返回 undefined,也就是不提供正常结果。它可以表示“没有这项数据”,但本身没有解释为什么没有。
| 表达方式 | 调用者需要做什么 | 适合本例时要考虑的事情 |
|---|---|---|
| 返回包含原因的结果 | 检查 ok,需要时再看 reason | 能把任务不存在、编号不符合要求分别说明;每个调用者都要记得检查 |
| 抛出错误 | 在适当位置用 try...catch 处理 | 不会继续走普通成功分支,但要明确错误由哪一处代码处理 |
返回 undefined | 先判断有没有值,再使用正常结果 | 写法简单;如果页面需要区分失败原因,还需要补充信息 |
本例选择第一种,是因为任务不存在可能是一个页面需要正常处理的情况。例如,列表还显示着某条任务,用户在详情页删除了它,随后又回到旧列表点“完成”。页面可以提示刷新,这不一定意味着整个程序应该停止工作。
如果一项内部操作只接受固定名称,写错名称通常意味着代码本身有问题,抛出错误也可能合适。文末的版本查询练习就采用这种方式。两个例子的需求不同,不需要为了写法统一而隐藏差别。
这也说明“返回错误”和“抛出错误”不能只按字数评判。应当看调用者要处理哪些情况、失败会经过哪些代码、正常工作是否需要继续,以及约定是否容易被遵守。
本例已经明确的失败都写在结果里;它没有文件、网络等额外操作。以后增加保存功能时,还要重新列出新增的失败情况。
修改返回值时,旧代码可能仍能运行,却已经判断错了 #
假设早先的接口只返回 true 或 false。页面可能这样使用它:
if (completeTask("t9")) {
console.log("显示完成");
} else {
console.log("显示失败");
}这是对旧用法的示意,故意放在新实现旁边比较。现在函数找不到 t9 时,给回的对象形式如下:
{ ok: false, reason: "not-found" }在 JavaScript 的条件判断中,对象会被当作条件成立来处理。对象里面的 ok 是 false,不代表整个对象会自动变成 false。所以上面那段旧代码会显示“完成”。
这个问题很难用“函数还能不能调用”来发现。函数名没有变,传入编号的写法也没有变,程序没有在这一行报错,但页面对结果的理解已经不符合新的规则。
调用者应当检查约定的项目:
const result = completeTask("t9");
if (result.ok) {
console.log("显示完成");
} else {
console.log("显示失败");
}示例包里的 contract-change.mjs 实际运行这组对比,输出:
实际结果: {"ok":false,"reason":"not-found"}
旧写法直接判断整个结果: 显示完成
新写法检查 ok: 显示失败当原有使用方式需要调整,才能继续得到正确行为时,我们说这项改动影响了兼容性。这里的意思是,原来按旧约定写出的代码,能否继续正确工作。
这组观察只对应本例的 JavaScript 调用方式,不代表所有返回值修改都会悄悄失败。有些修改会在执行时直接报错,有些需要观察结果才能发现;检查修改影响时,需要同时看结果含义与实际用法。
结果交回后,谁还能修改内部任务 #
前面的约定说,调用者修改返回的任务信息,不会改掉内部任务。为什么需要专门写这一条?
先看一个可以独立运行的小例子:
const task = { id: "t1", completed: true };
const result = { task };
result.task.completed = false;
console.log(task.completed);输出是 false。result 是一个新对象,但它的 task 指向的仍然是原来的那份任务。两个位置联系着同一份对象数据,这种联系叫引用。从其中一个位置改动这份数据,另一个位置也会看到变化。
如果完成任务的实现直接返回内部的 task,页面便可能在显示或整理结果时,把内部任务也改了。这样,页面绕过了完成任务的规则,数据仍然发生了变化。
所以本例的实现交回的是:
return {
ok: true,
changed,
task: {
id: task.id,
title: task.title,
completed: task.completed,
},
};这段摘自前面的函数。task 里面的三个项目重新写进了一个新对象,这就是前面约定的任务副本。当前三个项目都是字符串或布尔值,调用者修改副本中的标题或完成标记,不会改掉内部任务。
副本并不意味着结果不能修改。本例允许页面整理自己拿到的数据,约定的是这些改动不会传播回内部任务列表。
这个判断也有范围。如果以后任务增加了一个保存多个标签的数组,直接把原数组放进结果,两个位置仍可能联系着同一份数组。那时需要继续检查新增内容的使用方式;当前三个简单项目的结论不能直接当成所有数据都已隔离的保证。
这项设计增加了建立新对象的代码,换来的是明确的数据修改途径。对于这个教学程序,我们选择让任务变化经过任务操作。如果某种接口有意提供可以直接编辑的对象,就需要另行说明谁能修改、修改后要维持哪些规则,不能让调用者靠猜测发现这种能力。
重复完成一次任务,为什么结果可以不同 #
用户连续点击两次“完成”,第一次与第二次执行前的数据不一样:
| 执行 | 执行前的完成标记 | 执行后的完成标记 | 结果中的 changed |
|---|---|---|---|
| 第一次 | false | true | true |
| 第二次 | true | true | false |
我们选择让两次都成功,因为第二次执行后,任务仍然满足“已经完成”的目标。调用者可以根据 changed 决定是否需要额外更新显示。
重复执行后,最终的任务数据与执行一次相同。针对这项数据变化,我们可以说它具有幂等性:重复做同一操作,不会不断产生新的数据变化。关于重复请求的效果,技术标准中的相关说明也区分了操作产生的效果与每次交回的结果内容。
本例两次交回的 changed 不同,所以这里说的是任务数据变化的性质,不是两次返回的每一项都相同。这个限定会影响调用者怎样使用结果,不能省略。
如果以后增加“完成时间”,也要重新作选择。第一次完成后,再点一次是否应当覆盖原来的时间?若每次都写入新的时间,数据就会继续变化。若希望保留第一次完成的时间,就需要把这项规则写进约定,再修改实现和检查。
我们没有在当前源码中增加时间项目。这里是在说明:即使按钮和函数名没有变化,新增一项数据也可能改变重复操作的含义。
成功只是改好当前数据,还是已经保存到了文件 #
本例的任务列表留在内存里,也就是程序执行期间保存数据的地方。当前代码没有把任务写入文件,退出这次程序后,不能靠这份代码取回刚才的修改。
把数据保存到程序退出后仍能重新读取的地方,例如文件,这项工作常叫持久化。一个待办程序迟早可能需要它,但“完成任务”与“已经保存完成”是否是一回事,仍需明确选择。
假设现在增加文件保存:
- 在当前数据中把任务标为完成。
- 把修改后的任务写入文件。
- 告诉页面操作成功。
如果第 2 步失败,页面收到什么?任务在当前程序里已经完成,文件里却没有这次修改。若仍然给回原来的成功结果,调用者可能误以为退出后也能保留它。
至少有两种不同约定:
| 选择 | 成功意味着什么 | 页面需要怎样配合 |
|---|---|---|
| 保存完成后才给回成功 | 约定的保存步骤也已完成 | 需要处理保存失败,并接受结果不会立即交回的可能 |
| 先修改当前数据,稍后保存 | 当前数据已完成修改,保存另有进展与结果 | 需要区分“当前已完成”和“已经保存”,不能只显示一个含糊的成功 |
哪一种合适,要看用户希望看见什么、是否允许暂时没有保存、失败后怎样继续操作。它们对应不同的产品行为,不能仅凭函数还叫 completeTask 就认为原来的约定仍然足够。
同样,最初“失败时任务保持原样”的承诺也要复查。先改数据再保存,保存失败后却返回失败,可能与这项承诺冲突。需要决定恢复原数据、保留未保存的修改,还是把完成与保存拆成两个明确的结果。
本篇没有实现文件保存,上面的流程和选择是后续需求的分析。它说明契约为什么要随着需求一起检查:增加功能时,原来一句“返回成功”可能已经表达不了页面需要知道的事实。
约定哪些事,才能保留以后修改实现的余地 #
如果契约什么都不说,调用者只能读源码猜行为。但如果把内部每一步都写成必须遵守的规则,以后也可能没有修改余地。
例如,完成任务的说明需要保证正确任务被完成、失败时数据怎样保留、返回结果代表什么。通常不必要求它“必须用一个数组保存任务,并从第一条开始查找”。
在本例的调用方式中,页面依赖的是任务编号和结果内容。将来换一种查找方式,只要这些约定继续成立,页面可以继续工作。若页面直接读写内部数组,它就同时依赖了保存方式与位置。
模块可以先理解为共同负责一项工作的代码部分。Parnas 在模块划分的论文中讨论了围绕可能变化的设计决定组织代码,以及通过接口限制这种决定的传播。在我们的例子里,负责管理任务数据的那部分代码,可以隐藏它怎样保存和查找数据的决定。
这并不是说每种变化都能被隐藏。增加“保存到文件后才算成功”,改变了调用者需要知道的结果含义;把 ok 改名为 success,改变了读取结果的方式。它们需要让使用者知道。
| 修改 | 对当前调用者可能有什么影响 |
|---|---|
| 在函数内部换一种查找方式 | 如果输入、结果与数据变化规则保持原样,当前页面可以继续使用 |
把内部参数名 id 改成 taskId | 当前页面按位置提供一个编号,不依赖内部参数名 |
将结果中的 ok 改名为 success | 读取 result.ok 的页面需要修改 |
| 找不到任务时从返回失败改成抛出错误 | 原来的结果分支可能不再执行,需要重新安排错误处理 |
| 给失败增加保存失败等新原因 | 需要核对页面怎样处理原因,是否能给出正确提示 |
| 改成保存完成后才报告成功 | 要重新说明成功含义、等待过程和失败后的数据 |
这一比较针对正文中的调用方式。如果使用者还要读取函数本身的源码文字、逐个检查结果里的所有项目,或者要求内部必须使用某种数据形式,实现变化就可能影响这些额外用法,也要一起检查。契约的作用,是让双方明确哪些行为可以依靠。
类型检查能表达一部分约定,但不能替你决定规则 #
前面已经见过字符串与布尔值两种类型。TypeScript 在 JavaScript 的基础上增加了描述类型的写法,让工具能在执行程序之前检查代码中的一些使用错误,这项工作叫类型检查。
例如,为编号写上 id: string,表示这里应当提供字符串。把这种类型要求写在代码旁边,叫类型标注。它可以帮助检查把数字 3 直接交给函数的错误。
但 "t1"、"t9" 和 "" 都是字符串。仅凭这个标注,工具并不知道当前列表里有没有任务,也不能替我们决定空编号和不存在的任务应当怎样处理。
结果类型也不能独自说明全部行为。完成后的 completed 是否真的为 true、其他任务有没有被修改、重复完成是否保持原数据,都要对照实现与实际例子检查。
TypeScript 的函数类型说明介绍了怎样描述参数和结果;类型标注被移除的说明则解释了为什么类型描述不会自动成为程序执行时的输入判断。
本篇不需要先写出一组复杂的类型,才能讨论这些规则。文末保留一个更短的版本查询练习,只用两个字符串名称和一个字符串返回类型,观察类型工具确实能发现什么。
怎样把契约变成可以检查的例子 #
文字约定需要落到具体观察。对完成任务,可以分别记录:执行前有哪些数据,提供什么编号,应该取得什么结果,执行后的数据是什么。
| 例子 | 预期结果 | 还要检查什么 |
|---|---|---|
第一次完成 t1 | ok: true,changed: true | t1 已完成,t2 和标题不变 |
再次完成 t1 | ok: true,changed: false | 数据与第一次完成后相同 |
完成不存在的 t9 | ok: false,原因是 not-found | 所有任务保持原样 |
| 提供空编号 | ok: false,原因是 invalid-id | 所有任务保持原样 |
| 修改结果中的任务标题 | 页面自己的结果改变 | 内部任务标题保持原样 |
把这些例子交给程序自动执行,再对照实际结果是否符合要求,就是这里的测试。测试会在不符合时报告失败,帮助发现实现与约定之间的差别。
只检查返回的 ok 不够。如果函数说成功,目标任务却没改好,仍然违反约定;如果失败时把另一条任务改了,也不能算处理正确。所以测试既观察结果,也观察操作前后的任务数据。
示例包为每组任务检查建立一份独立数据,避免前一个例子已经完成任务,影响后一个例子的起点。代码里的 createTaskList 就负责建立这一份演示任务,并交回完成任务与查看任务的功能。
创建函数的初始数据要求已经说明:编号唯一,标题是字符串,完成标记是布尔值。这些检查没有覆盖损坏文件的读取,因为本篇没有读取文件。把某一组例子运行成功,也不能推断所有未来输入与需求都已得到验证。
运行完整示例 #
下载本篇完整示例包,解压后进入 software-design-02-examples 目录。Node.js 是在电脑上执行这些 JavaScript 文件的工具。安装好它之后,不需要另外下载运行所需的代码包:
node tasks-demo.mjs
node contract-change.mjs
node --test tasks.test.mjs check.test.mjstasks-demo.mjs 的实际输出如下:
初始任务: [{"id":"t1","title":"写周报","completed":false},{"id":"t2","title":"整理笔记","completed":false}]
第一次完成 t1: {"ok":true,"changed":true,"task":{"id":"t1","title":"写周报","completed":true}}
再次完成 t1: {"ok":true,"changed":false,"task":{"id":"t1","title":"写周报","completed":true}}
查询不到 t9: {"ok":false,"reason":"not-found"}
传入空编号: {"ok":false,"reason":"invalid-id"}
最终任务: [{"id":"t1","title":"写周报","completed":true},{"id":"t2","title":"整理笔记","completed":false}]任务部分有 11 项测试,版本查询练习有 5 项,本次全部通过。任务检查覆盖返回结果、前后数据、重复执行、非法编号、找不到任务、两侧空格、读入的请求,以及修改副本后的影响。
核验环境为 2026-10-14、Apple Silicon Mac 上的 macOS、Node.js v25.9.0。数据全部由示例构造,没有访问真实任务、网络或文件保存位置。
补充练习:用版本查询观察类型与错误 #
如果希望先用更短的代码观察参数、返回值和类型检查,可以单独运行这个练习。它不参与前面的任务数据变化。
const versions = {
app: "1.0.0",
node: "node-demo",
};
function getVersion(component) {
return versions[component];
}
console.log(getVersion("app"));
console.log(getVersion("appp"));component 是要查询的名称。第一条输出 1.0.0,第二条多写了一个 p,因为没有对应名称而输出 undefined。本例的版本值都是演示文字。
若选择名称不符合要求时抛出错误,可以写成下面这样。这段接着使用同一份 versions:
function getVersion(component) {
if (component !== "app" && component !== "node") {
throw new Error("只支持 app 或 node");
}
return versions[component];
}new Error(...) 建立一份带有说明文字的错误,throw 让当前调用停止。调用者可以处理它:
try {
getVersion("appp");
} catch (error) {
console.log(error.message);
}这里的 catch 取得错误,error.message 是说明文字。输出为“只支持 app 或 node”。这是另一种明确的失败约定,并不要求前面的完成任务也采用相同形式。
在 TypeScript 中,可以给同一个函数增加类型标注:
function getVersion(component: "app" | "node"): string {
if (component !== "app" && component !== "node") {
throw new Error("只支持 app 或 node");
}
return versions[component];
}component: "app" | "node" 表示参数只允许两个具体的字符串值,竖线表示“或者”。括号后的 : string 表示正常返回的结果应当是字符串。这些标注描述的参数类型与返回类型,称为类型签名。
如果在接受检查的代码中写 getVersion("appp"),工具就能发现这个名称不符合要求。它并没有因此证明版本值正确:把 "1.0.0" 误改成 "不知道",仍然是字符串,还需要对照数据或示例发现问题。
如果名称来自配置文件,也仍需实际检查。配置文件是保存程序设置的文件。下面用 JSON 保存设置;它的全名是 JavaScript Object Notation,意思是“JavaScript 对象表示法”,是一种用文字保存数据的格式:
const text = '{"component":"appp"}';
const request = JSON.parse(text);
getVersion(request.component);JSON.parse 把文字读成对象,request.component 取得的仍是错误名称。类型标注不会自动把它改成 app,函数中的条件判断才会在程序执行时检查它。
先运行 JavaScript 版本:
node before.mjs
node demo.mjsbefore.mjs 输出 1.0.0 与 undefined。demo.mjs 展示检查后的版本查询、错误提示与再次查询。
若想观察 TypeScript 的提示,还可以安装本例固定的检查工具。npm 是安装所需工具、执行预先写好命令的工具;TypeScript 的工具可以检查类型并把代码转换成 JavaScript,称为编译器。文中的类型要求就由这项工具检查。
npm ci --ignore-scripts --no-audit --no-fund
npm run check:types
npm run check:invalidcheck:types 检查正常代码,应当通过。check:invalid 检查故意写错的 getVersion("appp"),应当给出参数不符合要求的错误;这条命令预期检查失败。本次核对到这一条预期错误。
可选检查使用 npm 11.12.1、固定的 TypeScript 5.9.3。命令只检查,不生成文件;没有在其他版本组合上运行这些例子。
换一个操作,自己写出约定 #
可以先不增加代码,试着为 deleteTask("t1") 写一份说明。deleteTask 表示删除任务,仍以编号提出请求。
需要决定:找到任务时交回什么,找不到是否算失败,再次删除是否应当成功,删除后其他任务是否保持原样。再列出一组成功例子、一组没有找到的例子、一组重复执行的例子,并写出每次执行前后的任务数据。
接着再加一个要求:删除结果必须保存后才算成功。检查原来的每一项约定,说明哪些需要增加、哪些仍然成立。不要只给函数起一个新名字,要能让使用它的页面知道如何工作。
下一篇:间接调用与依赖会沿着同一个任务程序,增加“按收到的操作名称选择功能”的需求,比较直接指定函数、逐项判断和集中登记操作三种方式。届时会追踪每种写法需要经过哪些代码,新增操作时又要修改哪些地方。
一起讨论
选中正文中的文字,可以针对这段内容发起讨论。
正在读取讨论…
在 GitHub 查看与管理讨论 ↗