3MIKAN
仮想通貨直コン

Setでオブジェクトの重複が消えない理由|比較するキーと残す値を決める

同じidのオブジェクトをSetに入れても重複が残るのはなぜ?参照の同一性、Mapで使う複合キー、先と後の入力のどちらを残すかを、単独実行できる例で確認します。

3MIKANのキャラクターが机に並ぶ同じ見た目の二つの箱に手を添え、別の物体として見比べるイラスト

一覧の重複を取り除こうとnew Set(records)を使ったのに、同じIDの行が残っている。これはSetが壊れているのではなく、オブジェクトの中身を比較しているわけではないためです。

IDを基準にまとめたいなら、そのIDを比較用のキーにします。ただし、そこで終わりではありません。IDがどの範囲で一意なのか、同じキーの行が来たらどちらを残すのかも決める必要があります。

まずは手元で再現する

実行用サンプルを開く・保存するから、object-deduplication.mjsという名前で保存して実行してください。

node object-deduplication.mjs

確認環境はNode.js v24.14.0です。追加パッケージ、アカウント、ネットワーク通信、ファイルの書き込みは不要です。最後に「すべての実行例が期待値と一致しました。」と出れば、重複判定と残す値、入力の不備まで照合できています。以下のJavaScriptの枠も、それぞれ別の.mjsファイルで実行できます。

同じ内容の別オブジェクトは、Setでは別の値

const same = { id: 'row-1' };
console.log(new Set([same, same]).size); // 1
console.log(new Set([{ id: 'row-1' }, { id: 'row-1' }]).size); // 2
console.log(new Set([1, 1, '1']).size); // 2
console.log(new Set([NaN, NaN]).size); // 1

最初の例は、同じオブジェクトを指す参照を2回入れています。次の例は、同じ内容のオブジェクトを二つ作っています。この違いが、1件になるか2件残るかを分けます。

Setはオブジェクトを参照の同一性で比較します。各プロパティをたどって中身が同じかを調べる処理ではありません。また、数値の1と文字列の"1"は別の値で、NaN同士は同じ値として扱われます。MDNのSetの同一性規則

idだけでよいか、一意になる範囲を確認する

今回の実行用サンプルでは、データの所属先をnetwork、その中でのIDをidとし、次の3行を扱います。名前は説明用の架空の値です。

network id value
alpha row-1 10
beta row-1 20
alpha row-1 30

ここではalphabetaで同じIDが使われても、別の記録として残したいとします。idだけをキーにすると、3行とも同じキーへまとめられ、必要なbetaの行まで失います。

採用するルールは「空でない文字列のnetworkidの組が同じなら重複」です。これはこのデータのために決めたルールで、あらゆるAPIのIDがこの組で一意になるという意味ではありません。

Mapに入れる配列も、参照で比較される

二つの値を組にしたくても、新しく作った配列をそのままMapのキーへ使うと、再び参照の比較になります。

const byPair = new Map();
byPair.set(['alpha', 'row-1'], 10);
byPair.set(['alpha', 'row-1'], 30);
console.log(byPair.size); // 2
console.log(byPair.get(['alpha', 'row-1'])); // undefined

これらは中身が同じでも別の配列です。Mapのオブジェクトキーも、内容ではなく参照で比較されます。Map.setのキーの説明

実行用サンプルでは、型を確認した二つの文字列をJSON.stringify([row.network, row.id])で一つの文字列にしています。単純にnetwork + '|' + idとつなぐ方法と違い、値に区切り文字が含まれても、どこで分かれるかを表現できます。

実際に["a|b", "c"]["a", "b|c"]を別のキーとして残すことを確認しています。文字列の組という条件で使う方法であり、任意のオブジェクト全体をJSON化すれば正しく重複比較できる、という勧めではありません。

先の入力を残すか、後の入力へ更新するか

キーが決まったら、同じキーが来たときの扱いを決めます。次の例は実行用サンプルの処理と同じ考え方で、型のそろった小さな入力に対して二つの方針を比べます。

const records = [
  { network: 'alpha', id: 'row-1', value: 10 },
  { network: 'beta', id: 'row-1', value: 20 },
  { network: 'alpha', id: 'row-1', value: 30 },
];
const first = new Map();
const last = new Map();

for (const row of records) {
  const key = JSON.stringify([row.network, row.id]);
  if (!first.has(key)) first.set(key, row);
  last.set(key, row);
}

console.log([...first.values()].map((row) => row.value).join(',')); // 10,20
console.log([...last.values()].map((row) => row.value).join(','));   // 30,20
console.log(first.values().next().value === records[0]); // true

先の入力を残す場合は、まだそのキーがないときだけ追加します。後の入力を残す場合は、同じキーの値を更新します。どちらが正しいかは、データを使う処理の目的で決まります。

Mapで既存キーの値を更新しても、そのキーが最初に入った位置は変わりません。そのため後の入力を残した結果は30,20で、20,30にはなりません。値を選ぶルールと表示順を並べ替えるルールは別です。Mapの挿入順

また、ここでいう「後」は入力配列で後ろにあるという意味です。観測時刻や更新時刻が新しいとは限りません。本当に新しい記録を残したいなら、時刻や版番号をどう比較するかを別に定めます。

重複を消す前に、残すための条件を決める

実行用サンプルのdeduplicate()は、networkidが空文字列・欠落・文字列以外ならエラーにします。入力不備のある行を、まとめて同じキーにして黙って落とさないためです。firstlast以外の方針名も受け付けません。

結果を新しい配列にしても、残した各行は元のオブジェクトへの参照です。重複除去はデータ全体のコピーではないため、後で行を書き換える処理があるなら、その扱いも分けて考えます。

比較する範囲、同じキーで残す値、出力順。この三つを決めると、「Setに入れたのに減らない」だけでなく、「必要な別の行まで消えた」という問題も防ぎやすくなります。

確認した一次情報