解釋說,陣列和每個元素都是新的,所以修改結果不會影響輸入。可是呼叫端把 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 產生的程式碼;如果要在重構前記錄陌生程式碼的現狀,可讀刻畫測試。