解释说,数组和每个元素都是新的,所以修改结果不会影响输入。可调用方把 label 转成大写后,原数据也跟着变了。问题出在浅复制保留了嵌套对象的引用。
下载 parcel preview 完整练习。代码、数据和故意写错的解释均为本文从零构造的教学材料。需要 Node.js 22 或更高版本,无需安装依赖、凭据或网络访问。
把断言与源码放在一起阅读
要查的是:修改复制后的 parcel.label,会不会连原输入一起改掉?把复制函数和调用方放在一起读。
在 Yenpo 打开解压后的 parcel-preview-lab 文件夹。按 Command-P(前往文件),把 claims.md、src/copy-parcels.mjs 和 src/preview.mjs 打开在标签页中,对照复制函数及其调用方阅读断言。用全文搜索定位 row.label.text,继续检查周围的 uppercase 条件;命中这一行,不代表每次调用都会修改输入。
这一步阅读不需要连接模型或安装语言服务器。可以获取 Mac 版 Yenpo并打开同一个例子,或查看文件与文本搜索帮助。下文的 observer 和检查由你在 Terminal 中运行,再把实际证据写回笔记。
把解释拆成可以分别判断的断言
先看待核验的教学解释:
copyParcels创建新数组,并为每个 parcel 创建新对象。它的 label 也是独立副本,因此preview可以把文字转成大写而不改变原输入。
第一句包含两个断言。第二句又加入所有权判断和行为结论。如果只给整段话判“对”或“错”,就会连同错误一起丢掉其中有用的事实。
| 编号 | 断言 | 什么证据可以支持或推翻它? |
|---|---|---|
C1 |
返回的是新数组。 | 比较输入和输出数组的引用身份。 |
C2 |
每个外层 parcel 都是新对象。 | 比较对应 parcel 的引用身份。 |
C3 |
每个嵌套 label 都独立。 | 比较 label 的引用身份,不能只看文字。 |
C4 |
预览转大写不会改变输入。 | 进入这个分支,再检查调用方原来的输入。 |
C5 |
只要输入发生修改,就是业务 bug。 | 查需求或所有权约定;源码本身不能提供这条要求。 |
C5 是读者可能继续推导的结论,不在原解释中。单列它,是为了避免推翻 C4 时顺便替产品决定需求。
本文范围是稠密数组,其中每个 parcel 都是普通可变对象,含一个普通 label 对象和字符串 text。练习不涉及 getter、Proxy、类实例或外部 I/O。让这个范围跟着解释一起保留,不要把一次运行推广到所有 JavaScript 值。
沿引用跨过函数边界
打开 src/copy-parcels.mjs:
export function copyParcels(parcels) {
return parcels.map((parcel) => ({ ...parcel }));
}
对象字面量在每次回调中创建新的外层对象。它复制 label 属性的值,而这个值是对另一个对象的引用;它没有创建新的嵌套 label。MDN 的展开语法说明和浅复制说明解释了这个区别。
继续打开真正的调用方 src/preview.mjs:
import { copyParcels } from './copy-parcels.mjs';
export function preview(parcels, { uppercase = false } = {}) {
const rows = copyParcels(parcels);
if (uppercase) {
for (const row of rows) {
row.label.text = row.label.text.toUpperCase();
}
}
return rows;
}
关键写入发生在 row.label.text。给 row.id 重新赋值,只会影响新外层对象;沿 row.label 写入,则会到达共享的嵌套对象。对于本例 fixture,复制完成后是这样的引用关系:
input ──> 原数组 ──> 原 parcel ──┐
├──> 同一个 label 对象
rows ──> 新数组 ──> 新 parcel ──┘
找到赋值语句,还不足以解释某次具体运行。默认分支会跳过它。要检验 C4,必须传入 uppercase: true,并选择转大写后确实不同的文字。
运行能反驳解释的分支
解压下载包,进入 parcel-preview-lab,运行:
node observe.mjs
观察程序为每个分支创建新的 fixture。调用 preview 前,先用字符串保存原来的文字,再打印引用身份和结果。实际输出如下:
| 观察项 | uppercase: false |
uppercase: true |
|---|---|---|
| 数组是同一个吗? | false |
false |
| 外层 parcel 是同一个吗? | false |
false |
| 嵌套 label 是同一个吗? | true |
true |
| 调用前保存的文字 | fragile |
fragile |
| 调用后输入中的文字 | fragile |
FRAGILE |
| 调用后预览中的文字 | fragile |
FRAGILE |
这构成 C4 的一个可达反例:调用方传入数据,选中转大写分支,并能观察到自己持有的嵌套文字发生变化。C1、C2 仍然成立。共享的 label 身份已经推翻 C3,包括没有执行赋值的分支。
如果一开始就是大写文字,探针就弱了:赋值仍会执行,但前后值相同。空数组则根本不进入循环。这些输入可以通过“文字没变”的检查,却不能证明嵌套对象独立。证据测试同时包含这两个对照。
值相等不等于对象独立
写入发生前,下面的断言会通过:
assert.deepEqual(rows, input);
它说明比较到的结构和值相等,不能说明对应的嵌套对象具有不同身份。同一时刻,以下三个断言也全部通过:
assert.notStrictEqual(rows, input);
assert.notStrictEqual(rows[0], input[0]);
assert.strictEqual(rows[0].label, input[0].label);
练习从 node:assert/strict 导入 assert。比较语义可查 Node 的断言文档。两类证据都要保留:引用身份说明哪些对象共享,前后值说明可达写入造成什么后果。
不要用 const before = input 充当保存好的“之前状态”。这个变量仍然指向同一个数组,之后也可能读出修改后的值。观察程序在调用前保存的是原始字符串 beforeText。保存一个值和保留可变对象的引用,回答的是不同问题。
把结果写回解释
分别运行下面两个命令;第一个命令故意预期失败:
node --test checks/original-claims.test.mjs
node --test checks/evidence.test.mjs
原解释的断言运行结果为 2 项通过、2 项失败,退出码非零。C1、C2 通过,“label 独立”和“输入不变”失败。证据检查 6 项全部通过,退出码为零。运行方式见 Node 的测试运行器文档。两次运行之间没有修改程序源码;变的是对同一程序所作的断言。
记录结论及其适用范围,不只记录红色或绿色:
| 编号 | 判断 | 证据与边界 |
|---|---|---|
C1、C2 |
在所述范围内有依据 | 数组和外层 parcel 是新的:map、对象字面量与引用身份检查相互印证。 |
C3 |
已被推翻 | 嵌套 label 共享:输入和输出持有同一个引用。 |
C4 |
已被推翻 | 转大写会改输入:true 分支把 fragile 改成 FRAGILE。 |
C5 |
尚不能判断 | 不能认定违反需求:没有提供所有权约定。 |
修订后的解释可以写成:
copyParcels创建新数组和新的外层 parcel 对象,但保留每个嵌套 label 的引用。uppercase: false时,preview不执行文字赋值;uppercase: true时,它通过共享 label 写入大写文字。在fragile这个 fixture 中,调用方输入中的文字会变成FRAGILE。这种修改是否可接受,取决于所有权约定。
ZIP 中的 claims.md 保留了这份记录,同时附有观察程序、两份测试和文件哈希清单。
把证据带回实际阅读任务
记下源码版本、检查过的说法,以及测试的输入和输出。接着问:“两个 label 指向同一个对象,转大写也改了原数据。请据此改写解释。”再用同一份代码检查新回答。
Yenpo 的 AI 代码阅读功能把本地源码浏览、聚焦提问和引用跳转放在一起,便于沿调用方继续阅读。练习和自己的检查由你在 Terminal 或现有测试工具中执行;Yenpo 不代你运行 shell 命令或测试。
如果下一步要决定是否改变行为,先确认需求。面对实际补丁,可继续读用反例审查 AI 生成的代码;如果要在重构前记录陌生代码的现状,可读刻画测试。