解释说,数组和每个元素都是新的,所以修改结果不会影响输入。可调用方把 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 生成的代码;如果要在重构前记录陌生代码的现状,可读刻画测试。