# 接口与契约：两段代码怎样可靠地配合工作

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

原文：https://hex4c59.cc/articles/software-design-02-contracts/


一个待办事项程序，最初可能只有一张列表：写上任务，做完后打个勾。功能少的时候，显示任务和修改任务的代码放在一起，也能工作。

后来，程序增加了任务详情页。列表页有“完成”按钮，详情页也有。再往后，用户可能连续点两次按钮，某条任务可能已经被另一处删除，程序还可能需要把完成结果保存下来。

这些变化带来一个问题：两处页面都要完成任务，它们应当怎样使用同一项功能，才能对“做完了什么、没做成怎么办”有一致的理解？

这个系列讨论的，就是小程序增加功能后，代码怎样安排才能继续容易理解、容易修改。每次多分出一段代码、多增加一项检查，都要说明它解决了什么困难，又增加了什么维护工作。[上一篇](https://hex4c59.cc/articles/software-design-01-abstraction/)用版本查询讨论了多写一层函数是否值得；这一篇把问题推进到两段代码怎样配合。

本文先解释接口和契约，再为“完成任务”写一份具体约定，比较几种选择，最后用可运行的代码检查这些选择。你不需要先学过软件设计术语。阅读示例会用到少量 JavaScript；关键名称和写法会随例子说明。正文主线一直是待办事项，版本查询留在文末作为更短的练习。文中的页面是使用方式的示意，示例包运行的是任务操作与结果输出。

## 接口：其他代码怎样使用这项功能

**接口就是一段代码提供给其他代码的使用方式。** 它让使用者知道有哪些功能、怎样提出请求、需要提供什么信息，以及怎样取得结果。

假设列表页负责显示所有任务，详情页负责显示某一条任务。这两部分都能显示按钮，但它们不应该各自猜测“完成任务”究竟要怎样修改数据。

我们可以把完成任务的工作放进一段能够单独执行的代码。这种代码叫**函数**。本例给它起名为 `completeTask`，意思是完成任务。

列表页想完成编号为 `t1` 的任务，可以写：

```js
completeTask("t1");
```

这里先只观察用法，函数的具体代码在后面。

让函数执行，叫**调用**；发起这次执行的代码，叫**调用者**。本例中，列表页和详情页都是调用者。括号里的 `"t1"` 是交给函数的信息，叫**参数**。执行结束后，函数交回的结果叫**返回值**。交给函数的信息也常叫**输入**，后文的输入检查就是检查这些信息是否符合要求。

在本例中，`completeTask` 这个名字、任务编号这个参数和交回的结果，共同说明使用这项功能的方式。

函数内部怎样找到任务、怎样修改数据，叫它的**实现**。列表页需要按接口使用功能，具体查找任务的工作由实现完成。

同一个词会出现在不同场景中。网站提供给另一段程序使用的请求方式，可以叫接口；一个代码文件提供给其他代码使用的功能，也可以叫接口。本文先讨论函数这个最小的例子，不要求它经过网络。MDN 的[程序接口说明](https://developer.mozilla.org/en-US/docs/Glossary/API)也把可供其他代码使用的能力与规则放在一起讨论。

## 契约：双方可以依靠哪些条件和承诺

只知道可以写 `completeTask("t1")`，还不足以写好页面。

例如，`t1` 不存在时，页面应该显示什么？它已经完成时，再调用一次算成功还是失败？函数给回“成功”时，任务数据是否已经改好？页面修改返回的数据，会不会顺带改掉任务列表？

这些问题需要双方作出一致的约定。

**契约是代码双方配合时，需要满足的条件和应当履行的承诺。** 它说明使用者提供什么，执行这项功能的代码在不同情况下保证什么。使用者因此能依据说明编写自己的代码，而不必每次都翻开实现猜测行为。

接口与契约经常写在同一份说明里。说接口，侧重怎样使用一项功能；说契约，侧重使用这项功能时哪些行为可以依靠。参数的要求可以同时属于接口说明和契约的一部分，不需要把两者分成互不相干的东西。

Bertrand Meyer 在[软件协作约定的讨论](https://archive.eiffel.com/doc/manuals/technology/bmarticles/sd/contracts.html)中，把这种关系描述为双方的责任：使用一项功能前需要满足什么，执行后应当保证什么。本文借用这个分析视角，具体的任务规则和失败处理由我们根据演示需求选择。

接下来先明确页面究竟需要知道什么，再比较怎样把操作结果和失败原因交给它。

## 先看清我们要处理的数据

本例只有两条任务：

```js
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`，页面又不知道这次是否真的改变了数据。

只要页面只关心一个是非问题，布尔值就可能足够。本例还需要说明失败原因和是否发生修改，因此选择返回一份包含更多信息的结果。下面是结果的形式示意，不是一段单独执行的程序：

```js
{
  ok: true,
  changed: true,
  task: { id: "t1", title: "写周报", completed: true }
}
```

这是结果的形式示意，具体的 `return` 代码在后面。`ok` 表示操作是否成功，`changed` 表示这次是否实际改变了任务数据，`task` 是完成后的任务信息。

失败结果的形式示意如下，其中写出原因：

```js
{ 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` 数据，可以接在那段数据定义后运行：

```js
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` 已按上面定义：

```js
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`。页面可能这样使用它：

```js
if (completeTask("t9")) {
  console.log("显示完成");
} else {
  console.log("显示失败");
}
```

这是对旧用法的示意，故意放在新实现旁边比较。现在函数找不到 `t9` 时，给回的对象形式如下：

```js
{ ok: false, reason: "not-found" }
```

在 JavaScript 的条件判断中，对象会被当作条件成立来处理。对象里面的 `ok` 是 `false`，不代表整个对象会自动变成 `false`。所以上面那段旧代码会显示“完成”。

这个问题很难用“函数还能不能调用”来发现。函数名没有变，传入编号的写法也没有变，程序没有在这一行报错，但页面对结果的理解已经不符合新的规则。

调用者应当检查约定的项目：

```js
const result = completeTask("t9");

if (result.ok) {
  console.log("显示完成");
} else {
  console.log("显示失败");
}
```

示例包里的 `contract-change.mjs` 实际运行这组对比，输出：

```text
实际结果： {"ok":false,"reason":"not-found"}
旧写法直接判断整个结果： 显示完成
新写法检查 ok： 显示失败
```

当原有使用方式需要调整，才能继续得到正确行为时，我们说这项改动影响了**兼容性**。这里的意思是，原来按旧约定写出的代码，能否继续正确工作。

这组观察只对应本例的 JavaScript 调用方式，不代表所有返回值修改都会悄悄失败。有些修改会在执行时直接报错，有些需要观察结果才能发现；检查修改影响时，需要同时看结果含义与实际用法。

## 结果交回后，谁还能修改内部任务

前面的约定说，调用者修改返回的任务信息，不会改掉内部任务。为什么需要专门写这一条？

先看一个可以独立运行的小例子：

```js
const task = { id: "t1", completed: true };
const result = { task };

result.task.completed = false;
console.log(task.completed);
```

输出是 `false`。`result` 是一个新对象，但它的 `task` 指向的仍然是原来的那份任务。两个位置联系着同一份对象数据，这种联系叫**引用**。从其中一个位置改动这份数据，另一个位置也会看到变化。

如果完成任务的实现直接返回内部的 `task`，页面便可能在显示或整理结果时，把内部任务也改了。这样，页面绕过了完成任务的规则，数据仍然发生了变化。

所以本例的实现交回的是：

```js
return {
  ok: true,
  changed,
  task: {
    id: task.id,
    title: task.title,
    completed: task.completed,
  },
};
```

这段摘自前面的函数。`task` 里面的三个项目重新写进了一个新对象，这就是前面约定的任务副本。当前三个项目都是字符串或布尔值，调用者修改副本中的标题或完成标记，不会改掉内部任务。

副本并不意味着结果不能修改。本例允许页面整理自己拿到的数据，约定的是这些改动不会传播回内部任务列表。

这个判断也有范围。如果以后任务增加了一个保存多个标签的数组，直接把原数组放进结果，两个位置仍可能联系着同一份数组。那时需要继续检查新增内容的使用方式；当前三个简单项目的结论不能直接当成所有数据都已隔离的保证。

这项设计增加了建立新对象的代码，换来的是明确的数据修改途径。对于这个教学程序，我们选择让任务变化经过任务操作。如果某种接口有意提供可以直接编辑的对象，就需要另行说明谁能修改、修改后要维持哪些规则，不能让调用者靠猜测发现这种能力。

## 重复完成一次任务，为什么结果可以不同

用户连续点击两次“完成”，第一次与第二次执行前的数据不一样：

| 执行 | 执行前的完成标记 | 执行后的完成标记 | 结果中的 `changed` |
| --- | --- | --- | --- |
| 第一次 | `false` | `true` | `true` |
| 第二次 | `true` | `true` | `false` |

我们选择让两次都成功，因为第二次执行后，任务仍然满足“已经完成”的目标。调用者可以根据 `changed` 决定是否需要额外更新显示。

重复执行后，最终的任务数据与执行一次相同。针对这项数据变化，我们可以说它具有**幂等性**：重复做同一操作，不会不断产生新的数据变化。关于重复请求的效果，技术标准中的[相关说明](https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2)也区分了操作产生的效果与每次交回的结果内容。

本例两次交回的 `changed` 不同，所以这里说的是任务数据变化的性质，不是两次返回的每一项都相同。这个限定会影响调用者怎样使用结果，不能省略。

如果以后增加“完成时间”，也要重新作选择。第一次完成后，再点一次是否应当覆盖原来的时间？若每次都写入新的时间，数据就会继续变化。若希望保留第一次完成的时间，就需要把这项规则写进约定，再修改实现和检查。

我们没有在当前源码中增加时间项目。这里是在说明：即使按钮和函数名没有变化，新增一项数据也可能改变重复操作的含义。

## 成功只是改好当前数据，还是已经保存到了文件

本例的任务列表留在**内存**里，也就是程序执行期间保存数据的地方。当前代码没有把任务写入文件，退出这次程序后，不能靠这份代码取回刚才的修改。

把数据保存到程序退出后仍能重新读取的地方，例如文件，这项工作常叫**持久化**。一个待办程序迟早可能需要它，但“完成任务”与“已经保存完成”是否是一回事，仍需明确选择。

假设现在增加文件保存：

1. 在当前数据中把任务标为完成。
2. 把修改后的任务写入文件。
3. 告诉页面操作成功。

如果第 2 步失败，页面收到什么？任务在当前程序里已经完成，文件里却没有这次修改。若仍然给回原来的成功结果，调用者可能误以为退出后也能保留它。

至少有两种不同约定：

| 选择 | 成功意味着什么 | 页面需要怎样配合 |
| --- | --- | --- |
| 保存完成后才给回成功 | 约定的保存步骤也已完成 | 需要处理保存失败，并接受结果不会立即交回的可能 |
| 先修改当前数据，稍后保存 | 当前数据已完成修改，保存另有进展与结果 | 需要区分“当前已完成”和“已经保存”，不能只显示一个含糊的成功 |

哪一种合适，要看用户希望看见什么、是否允许暂时没有保存、失败后怎样继续操作。它们对应不同的产品行为，不能仅凭函数还叫 `completeTask` 就认为原来的约定仍然足够。

同样，最初“失败时任务保持原样”的承诺也要复查。先改数据再保存，保存失败后却返回失败，可能与这项承诺冲突。需要决定恢复原数据、保留未保存的修改，还是把完成与保存拆成两个明确的结果。

本篇没有实现文件保存，上面的流程和选择是后续需求的分析。它说明契约为什么要随着需求一起检查：增加功能时，原来一句“返回成功”可能已经表达不了页面需要知道的事实。

## 约定哪些事，才能保留以后修改实现的余地

如果契约什么都不说，调用者只能读源码猜行为。但如果把内部每一步都写成必须遵守的规则，以后也可能没有修改余地。

例如，完成任务的说明需要保证正确任务被完成、失败时数据怎样保留、返回结果代表什么。通常不必要求它“必须用一个数组保存任务，并从第一条开始查找”。

在本例的调用方式中，页面依赖的是任务编号和结果内容。将来换一种查找方式，只要这些约定继续成立，页面可以继续工作。若页面直接读写内部数组，它就同时依赖了保存方式与位置。

**模块**可以先理解为共同负责一项工作的代码部分。Parnas 在[模块划分的论文](https://www.cs.lafayette.edu/~gexia/cs301/resources/parnas.html)中讨论了围绕可能变化的设计决定组织代码，以及通过接口限制这种决定的传播。在我们的例子里，负责管理任务数据的那部分代码，可以隐藏它怎样保存和查找数据的决定。

这并不是说每种变化都能被隐藏。增加“保存到文件后才算成功”，改变了调用者需要知道的结果含义；把 `ok` 改名为 `success`，改变了读取结果的方式。它们需要让使用者知道。

| 修改 | 对当前调用者可能有什么影响 |
| --- | --- |
| 在函数内部换一种查找方式 | 如果输入、结果与数据变化规则保持原样，当前页面可以继续使用 |
| 把内部参数名 `id` 改成 `taskId` | 当前页面按位置提供一个编号，不依赖内部参数名 |
| 将结果中的 `ok` 改名为 `success` | 读取 `result.ok` 的页面需要修改 |
| 找不到任务时从返回失败改成抛出错误 | 原来的结果分支可能不再执行，需要重新安排错误处理 |
| 给失败增加保存失败等新原因 | 需要核对页面怎样处理原因，是否能给出正确提示 |
| 改成保存完成后才报告成功 | 要重新说明成功含义、等待过程和失败后的数据 |

这一比较针对正文中的调用方式。如果使用者还要读取函数本身的源码文字、逐个检查结果里的所有项目，或者要求内部必须使用某种数据形式，实现变化就可能影响这些额外用法，也要一起检查。契约的作用，是让双方明确哪些行为可以依靠。

## 类型检查能表达一部分约定，但不能替你决定规则

前面已经见过字符串与布尔值两种类型。TypeScript 在 JavaScript 的基础上增加了描述类型的写法，让工具能在执行程序之前检查代码中的一些使用错误，这项工作叫**类型检查**。

例如，为编号写上 `id: string`，表示这里应当提供字符串。把这种类型要求写在代码旁边，叫**类型标注**。它可以帮助检查把数字 `3` 直接交给函数的错误。

但 `"t1"`、`"t9"` 和 `""` 都是字符串。仅凭这个标注，工具并不知道当前列表里有没有任务，也不能替我们决定空编号和不存在的任务应当怎样处理。

结果类型也不能独自说明全部行为。完成后的 `completed` 是否真的为 `true`、其他任务有没有被修改、重复完成是否保持原数据，都要对照实现与实际例子检查。

TypeScript 的[函数类型说明](https://www.typescriptlang.org/docs/handbook/2/everyday-types.html#functions)介绍了怎样描述参数和结果；[类型标注被移除的说明](https://www.typescriptlang.org/docs/handbook/2/basic-types.html#erased-types)则解释了为什么类型描述不会自动成为程序执行时的输入判断。

本篇不需要先写出一组复杂的类型，才能讨论这些规则。文末保留一个更短的版本查询练习，只用两个字符串名称和一个字符串返回类型，观察类型工具确实能发现什么。

## 怎样把契约变成可以检查的例子

文字约定需要落到具体观察。对完成任务，可以分别记录：执行前有哪些数据，提供什么编号，应该取得什么结果，执行后的数据是什么。

| 例子 | 预期结果 | 还要检查什么 |
| --- | --- | --- |
| 第一次完成 `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.zip)，解压后进入 `software-design-02-examples` 目录。[Node.js](https://nodejs.org/en/learn/getting-started/introduction-to-nodejs) 是在电脑上执行这些 JavaScript 文件的工具。安装好它之后，不需要另外下载运行所需的代码包：

```sh
node tasks-demo.mjs
node contract-change.mjs
node --test tasks.test.mjs check.test.mjs
```

`tasks-demo.mjs` 的实际输出如下：

```text
初始任务： [{"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。数据全部由示例构造，没有访问真实任务、网络或文件保存位置。

## 补充练习：用版本查询观察类型与错误

如果希望先用更短的代码观察参数、返回值和类型检查，可以单独运行这个练习。它不参与前面的任务数据变化。

```js
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`：

```js
function getVersion(component) {
  if (component !== "app" && component !== "node") {
    throw new Error("只支持 app 或 node");
  }

  return versions[component];
}
```

`new Error(...)` 建立一份带有说明文字的错误，`throw` 让当前调用停止。调用者可以处理它：

```js
try {
  getVersion("appp");
} catch (error) {
  console.log(error.message);
}
```

这里的 `catch` 取得错误，`error.message` 是说明文字。输出为“只支持 app 或 node”。这是另一种明确的失败约定，并不要求前面的完成任务也采用相同形式。

在 TypeScript 中，可以给同一个函数增加类型标注：

```ts
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 对象表示法”，是一种用文字保存数据的格式：

```js
const text = '{"component":"appp"}';
const request = JSON.parse(text);
getVersion(request.component);
```

`JSON.parse` 把文字读成对象，`request.component` 取得的仍是错误名称。类型标注不会自动把它改成 `app`，函数中的条件判断才会在程序执行时检查它。

先运行 JavaScript 版本：

```sh
node before.mjs
node demo.mjs
```

`before.mjs` 输出 `1.0.0` 与 `undefined`。`demo.mjs` 展示检查后的版本查询、错误提示与再次查询。

若想观察 TypeScript 的提示，还可以安装本例固定的检查工具。npm 是安装所需工具、执行预先写好命令的工具；TypeScript 的工具可以检查类型并把代码转换成 JavaScript，称为**编译器**。文中的类型要求就由这项工具检查。

```sh
npm ci --ignore-scripts --no-audit --no-fund
npm run check:types
npm run check:invalid
```

`check:types` 检查正常代码，应当通过。`check:invalid` 检查故意写错的 `getVersion("appp")`，应当给出参数不符合要求的错误；这条命令预期检查失败。本次核对到这一条预期错误。

可选检查使用 npm 11.12.1、固定的 TypeScript 5.9.3。命令只检查，不生成文件；没有在其他版本组合上运行这些例子。

## 换一个操作，自己写出约定

可以先不增加代码，试着为 `deleteTask("t1")` 写一份说明。`deleteTask` 表示删除任务，仍以编号提出请求。

需要决定：找到任务时交回什么，找不到是否算失败，再次删除是否应当成功，删除后其他任务是否保持原样。再列出一组成功例子、一组没有找到的例子、一组重复执行的例子，并写出每次执行前后的任务数据。

接着再加一个要求：删除结果必须保存后才算成功。检查原来的每一项约定，说明哪些需要增加、哪些仍然成立。不要只给函数起一个新名字，要能让使用它的页面知道如何工作。

[下一篇：间接调用与依赖](https://hex4c59.cc/articles/software-design-03-indirection/)会沿着同一个任务程序，增加“按收到的操作名称选择功能”的需求，比较直接指定函数、逐项判断和集中登记操作三种方式。届时会追踪每种写法需要经过哪些代码，新增操作时又要修改哪些地方。

