TypeScript Uint8Arrayの必要な範囲だけをArrayBufferへコピーする
バイナリデータを扱うAPIでは、Uint8ArrayからArrayBufferへ変換して渡したい場合があります。
このとき、単に.bufferを取り出すと、必要な範囲より多くのデータを渡してしまうことがあります。
今回は、小さな配列を使って原因を確認し、必要な範囲だけを渡す変換関数を作ります。暗号処理などの詳しい知識がなくても、配列の一部を切り出す例から理解できます。
入れ物と、そこを見る範囲を分けて考える
ArrayBufferはバイト列を保存する入れ物です。Uint8Arrayは、その中を1バイトずつの整数として読み書きするためのビューです。
ビューは、入れ物の全体を見る場合も、一部分だけを見る場合もあります。
一部分を見る例const source = new Uint8Array([9, 1, 2, 9]);
const view = source.subarray(1, 3);
console.log([...view]); // [1, 2]
console.log(view.byteLength); // 2
console.log(view.byteOffset); // 1
console.log(view.buffer.byteLength); // 4subarray(1, 3)は、位置1から位置3の直前までを参照します。新しい保存領域へコピーする処理ではありません。同じバッファーを共有する動作は、MDNのsubarrayの説明でも確認できます。
つまり、viewで見えているのは2バイトでも、その入れ物は4バイトです。
bufferだけを渡すと範囲の情報が失われる
入れ物全体を取り出した場合const source = new Uint8Array([9, 1, 2, 9]);
const view = source.subarray(1, 3);
console.log([...new Uint8Array(view.buffer)]); // [9, 1, 2, 9].bufferだけでは、どこから何バイトを見ていたかが伝わりません。
APIがUint8Arrayを受け取れるなら、ビュー自体を渡す方法もあります。ArrayBufferが必要なときは、対象範囲だけの入れ物を作ると扱いやすくなります。
コピーする処理を1つにまとめる
copyToArrayBuffer.tsexport const copyToArrayBuffer = (bytes: Uint8Array): ArrayBuffer => {
const buffer = new ArrayBuffer(bytes.byteLength);
new Uint8Array(buffer).set(bytes);
return buffer;
};ビューが見ている長さだけの領域を確保し、その内容をコピーします。.set(bytes)がコピーするのは、元のバッファー全体ではなく、渡されたビューの要素です。
関数名にcopyを入れておくと、保存領域を共有する変換ではないことも呼び出し側へ伝えられます。
コピー結果の確認import { copyToArrayBuffer } from './copyToArrayBuffer';
const source = new Uint8Array([9, 1, 2, 9]);
const view = source.subarray(1, 3);
const copied = copyToArrayBuffer(view);
source[1] = 8;
console.log([...view]); // [8, 2]
console.log([...new Uint8Array(copied)]); // [1, 2]コピー後に元の配列を変更しても、コピー先には影響しません。
共有するのか、独立させるのかを決める
範囲だけを指定した別のビューが欲しいなら、コピーしない方法もあります。
同じ範囲を共有する例const sharedView = new Uint8Array(
view.buffer,
view.byteOffset,
view.byteLength,
);ここでのviewは、前の例のようなUint8Arrayです。この方法は範囲を保ちますが、元のデータと変更を共有します。
| 方法 | 元データと領域を共有するか | 主な用途 |
|---|---|---|
subarray | 共有する | 一部分をコピーせず読み書きする |
| offsetとlengthを指定したビュー | 共有する | 同じ範囲のビューを作る |
| 今回のコピー関数 | 共有しない | 範囲を限定した独立データを渡す |
呼び出し箇所ごとに違う変換を書いていると、ある処理だけ余分な範囲を渡したり、元の配列の変更を受けたりする原因になります。コピーが必要な場所では同じ関数を使うと、意図をそろえられます。
コピーには費用もかかる
新しいバッファーを作るため、データ量に応じたメモリーとコピー時間が必要です。大きなファイルを何度もコピーすると負担になります。
APIがビューを受け取り、元データを変更しない約束も明確なら、毎回コピーする必要はありません。
また、複数の処理が同時に書き換える共有メモリーからのコピーについて、今回の関数は一貫した時点の内容を保証する仕組みではありません。ここでは通常のバッファーを、コピー中に変更しない前提で扱います。
全体ではなく一部分のテストも用意する
全体を表すUint8Arrayだけで確認すると、誤って.bufferを返す実装でも正しく見える場合があります。
| 確認する入力 | 期待する結果 |
|---|---|
| 配列全体 | 全要素がコピーされる |
| 先頭以外から始まるビュー | 前後の余分なバイトを含めない |
| 空のビュー | 長さ0のバッファーになる |
| コピー後に元の配列を変更 | コピー先は変わらない |
型が同じでも、参照する範囲や共有の有無は異なります。変換関数を作るときは、戻り値の型に加えて、コピーするのか共有するのかまで決めておくと、利用側の思い違いを減らせます。