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