3MIKAN
仮想通貨直コン

Uint8Arrayのsliceとsubarrayは何が違う?Bufferも含めてコピーと共有を確認する

切り出したバイト列を変更したら元データも変わった。Uint8Arrayのslice・subarrayとNode.jsのBuffer.sliceの違い、Buffer.fromで範囲を広げてしまう例を手元で再現します。

3MIKANのキャラクターが一続きの色付き部品に透明な枠を当て、別に置いた複製と見比べるイラスト

バイト列を別の変数へ切り出し、そこで値を変えたら元のデータまで変わった。「新しい変数に入れたから独立している」と思うと、気付きにくい不具合です。

通常のUint8Arrayでは、slice()は値のコピー、subarray()は保存領域を共有する別の見方を作ります。ただしNode.jsのBuffer.slice()はコピーではなく共有です。名前だけで判断せず、扱う型と必要な範囲を確認しましょう。

まずは手元で再現する

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

node byte-copy-and-view.mjs

確認環境はNode.js v24.14.0です。追加パッケージ、アカウント、ネットワーク通信、ファイルの書き込みは不要です。サンプルは変更前後の値を照合し、最後に「すべての実行例が期待値と一致しました。」と表示します。以下のJavaScriptの枠も、それぞれ別の.mjsファイルで実行できます。

subarrayは、同じ保存領域の一部を見る

まず、4バイトのうち中央の2バイトを切り出します。1, 3は、位置1から位置3の手前までという指定です。

const source = new Uint8Array([10, 20, 30, 40]);
const view = source.subarray(1, 3);
const copy = source.slice(1, 3);

view[0] = 99;
console.log([...source].join(',')); // 10,99,30,40
console.log([...view].join(','));   // 99,30
console.log([...copy].join(','));   // 20,30
console.log(view === source);        // false
console.log(view.buffer === source.buffer); // true

viewsourceは別のオブジェクトですが、バイトを置くArrayBufferを共有しています。このように、ある保存領域を指定の範囲や型で扱うものをビューと呼びます。別オブジェクトであることと、データが独立していることは同じではありません。

subarray()で作ったビューからの変更は元へ反映され、元からの変更もビューで見えます。一方、slice()の結果は切り出した時点の値を持つコピーです。MDNのsubarray解説sliceの解説

実行用サンプルでは、さらにsource[2] = 77と変更し、viewだけが変わってcopy20,30のまま残ることを確認しています。

Buffer.sliceはコピーではない

同じslice()という名前でも、Node.jsのBufferでは動作が異なります。

import { Buffer } from 'node:buffer';

const source = Buffer.from([10, 20, 30]);
const view = source.slice(1);
const copy = Buffer.from(view);
view[0] = 99;

console.log([...source].join(',')); // 10,99,30
console.log([...copy].join(','));   // 20,30
console.log(source instanceof Uint8Array); // true

BufferUint8Arrayの一種ですが、slice()は従来との互換性のために保存領域を共有します。コピーしたい場合、この例ではBuffer.from(view)を使っています。共有する意図を表すなら、Bufferでもsubarray()を使えます。Node.jsのBufferとTypedArrayの違い

そのため、instanceof Uint8Arraytrueだからslice()も必ずコピーになる、と判断することはできません。

.bufferだけを渡すと、見ていた範囲より広くなる

もう一つの落とし穴は、範囲を切り出した後の.bufferです。これは切り出した部分だけを持つ箱ではなく、元の保存領域を指している場合があります。

import { Buffer } from 'node:buffer';

const owner = new Uint8Array([17, 34, 51, 68]);
const window = owner.subarray(1, 3);
const all = Buffer.from(window.buffer);
const ranged = Buffer.from(window.buffer, window.byteOffset, window.byteLength);
const independent = Buffer.from(window);

console.log([...all].join(','));         // 17,34,51,68
console.log([...ranged].join(','));      // 34,51
console.log([...independent].join(',')); // 34,51

owner[1] = 9;
console.log([...ranged].join(','));      // 9,51
console.log([...independent].join(',')); // 34,51

Buffer.from(window.buffer)にはビューの開始位置や長さを渡していません。そのため、今回の例では元の4バイト全体を扱う結果になります。

byteOffsetbyteLengthを渡せば、共有する範囲を中央の2バイトに限定できます。ただし、範囲を正しく指定してもコピーにはなりません。最後の出力が示すように、元の変更は引き続き反映されます。Buffer.fromのArrayBuffer入力

「どの範囲か」と「独立させるか」を別々に決める

元の現在値を見続けたいなら共有、変更前の値を比較用に残したいなら、その時点でコピーする必要があります。たとえば後でハッシュの入力を比較する場合、共有ビューを保存しただけでは、変更前の値を残したことにはなりません。

まず必要なバイト範囲を決め、次に元の更新を反映させるかを決める。この二つを分けると、余分なバイトを渡す問題と、元の変更が伝わる問題を別々に直せます。

この記事はUint8ArrayBufferの8ビットの値を扱う例です。文字をどのバイト列へ変換するかは、文字列とUTF-8のバイト列のような別の問題です。文字の表現、扱う範囲、コピーか共有かを混同しないようにしましょう。

確認した一次情報