zukucode
主にWEB関連の情報を技術メモとして発信しています。

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); // 4

subarray(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.ts
export 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のバッファーになる
コピー後に元の配列を変更コピー先は変わらない

型が同じでも、参照する範囲や共有の有無は異なります。変換関数を作るときは、戻り値の型に加えて、コピーするのか共有するのかまで決めておくと、利用側の思い違いを減らせます。


関連記事