バイナリデータの扱い - ArrayBufferと型付き配列とDataView
《 初回公開:2023/03/05 , 最終更新:未 》
【 目次 】
型付き配列(typed arrays)
JavaScript の型付き配列は配列状のオブジェクトであり、生のバイナリーデータにアクセスする手段を提供します。すでにご存知のとおり、Array オブジェクトは動的に拡大または縮小され、任意の JavaScript 値を持つことができます。JavaScript エンジンは、これらの配列を高速化するために最適化を実施します。
しかしながら、オーディオやビデオの操作、WebSocket を使った生データへのアクセスなどの機能が加わり、Web アプリケーションがどんどん強力になるにつれて、JavaScript コードで型付き配列を用いて生データを高速に簡単に操作できると便利な場面があることが分かってきました。そこで、型付き配列の登場です。JavaScript の型付き配列の各エントリは、8 ビットの整数から 64 ビットの浮動小数点数まで、いくつかのサポートされている形式のうちの 1 つの生のバイナリー値です。
ただし、型付き配列を通常の配列と混同してはいけません。型付き配列に対して Array.isArray() を呼び出すと false を返します。また、通常の配列では使用できるが型付き配列ではサポートされないメソッドがあります (例えば push や pop)。
javascriptの数値型
javascriptの数値型は以下の2種類、BigInt型はあまり使う事は無いので実質Number型のみ。
Number型 は 倍精度64ビット浮動小数点形式 (IEEE 754)
BigInt型 は 任意精度演算
これではバイナリーデータ列を扱うには不便。
そこで、型付き配列というものが定義された。
型付き配列にはそれぞれ扱うデータのバイト数に応じて以下の種類のものがある。
- Int8Array
- 8ビット符号付き整数
- Uint8Array
- 8ビット符号なし整数
- Uint8ClampedArray
- 8 ビット符号なし整数(切り詰め)
- Int16Array
- 16ビット符号付き整数
- Uint16Array
- 16ビット符号なし整数
- Int32Array
- 32ビット符号付き整数
- Uint32Array
- 32ビット符号なし整数
- BigInt64Array
- 64ビット符号付き整数
- BigUint64Array
- 64ビット符号なし整数
浮動小数点数型も用意されていて
- Float32Array
- 32ビットIEEE 浮動小数点数
- Float64Array
- 64ビットIEEE 浮動小数点数
整数型に対して8ビット,16ビット,32ビット,64ビットの符号付きと符号なしの型が用意されている。
TypedArrayとArrayクラス
それぞれの型付き配列のスーパークラスの名前を__proto__
プロパティを使って調べてみると。
for (let c of [Int8Array, Uint8Array, Uint8ClampedArray,
Int16Array, Uint16Array,
Int32Array, Uint32Array,
BigInt64Array, BigUint64Array,
Float32Array, Float64Array,
]) {
console.log(c.__proto__.name); // TypedArray
}
すべての型付き配列は共通のスーパークラスTypedArrayから直接派生しているようだ。
従って、それぞれの型付き配列はTypedArrayクラスのメソッドやプロパティが使えることになる。
更に、Int8Arrayクラスインスタンスの__proto__
プロパティをたどってTypedArrayクラス継承関係をさぐってみると
let o = new Int8Array();
let s = "";
o = o.__proto__;
while (o) {
if (s) {
s += "=>"
}
s += o.constructor.name;
o = o.__proto__;
}
console.log(s); // Int8Array=>TypedArray=>Object
この事はTypedArrayクラスはObjectクラスから直接派生している事を示している。
つまり、TypedArrayとArrayクラスには継承関係関係は無く、TypedArrayはArrayクラスと動作が似ているが別個のクラスという事になって。
通常JavaScriptで配列を扱うにはArrayクラスを使用するが
型付き配列はArrayクラスとは別のバイナリーデータ列を扱うための特別な配列ということになる。
そして型付き配列はArrayクラスと似てはいるが、Arrayクラスとは異なりArrayクラスが持っているpushやpop等のメソッドを使う事はできない。
以下のようなコードを使って、Int8ArrayクラスがTypedArrayを継承している事を確認しようとしても
console.log(new Int8Array() instanceof TypedArray);
「Uncaught ReferenceError: TypedArray is not defined」といエラーになってしまう。
つまりTypedArrayクラスは直接公開されておらず、TypedArrayクラスのメソッドやプロパティはTypedArrayクラスから直接アクセスする事はできない。
TypedArrayクラスのメソッドやプロパティが使うためにはInt8Arrayクラス等のTypedArrayクラスを継承したクラスからのみアクセスできるようだ。
※クラスの継承関係を調べるコードについては、以下の記事を参照。
型付き配列に対して Array.isArray() を呼び出すと false を返す。
Uint8ClampedArray
特殊なのはUint8ClampedArrayで、8ビット符号なし整数の範囲0~255の値を超えた場合、0 または 255 が代わりに設定される。整数以外を指定しようとすると、最も近い整数が設定されると説明されている。
以下のようなコードを使ってUint8ClampedArray型の要素にそれぞれ値を代入してそれがどのような値として格納されるのかその動作を確認してみると
let ary = [-256.5, 256.5, "-256.5", "256.5", "foo", "bar"]
let uintc8 = new Uint8ClampedArray(ary.length);
// 配列の値を代入
for (i = 0; i < uintc8.length; i++) {
uintc8[i] = ary[i];
}
// Uint8ClampedArray型の値を表示
for (i = 0; i < uintc8.length; i++) {
console.log(i, ary[i], uintc8[i]);
}
実行結果
0 -256.5 0 1 256.5 255 2 '-256.5' 0 3 '256.5' 255 4 'foo' 0 5 'bar' 0
この結果から想像するに、
0以下の数値をUint8ClampedArrayの要素に代入すると0(に切り詰められて)が代入される。
255以上の数値を代入すると255(に切り詰められて)が代入される。
文字列を代入するとその文字列が数値に変換できる場合はいったん数値に変換されたあとに切り詰めがおこなわれる。
数値に変換できない場合は0が代入される。
型付き配列のコンストラクタ
型付き配列はコンストラクタでインスタンス化されると配列の各要素は 0で初期化される。
型付き配列のコンストラクタの構文は引数の指定のしかたによって以下の四つ。
new TypedArray();
new TypedArray(length);
new TypedArray(typedArray);
new TypedArray(object);
new TypedArray(buffer [, byteOffset [, length]]);
前述のようにTypedArrayクラスは直接コードとして記述できない。
Int8ArrayクラスなどTypedArrayクラスを継承した各型付き配列を指定する。
Int32Arrayクラスを例に型付き配列のコンストラクタによるインスタンスの生成方法を示すと。
// 引数なしのコンストラクタ new TypedArray() でインスタンスを生成
var int32 = new Int32Array();
console.log(int32.length); // 0
console.log(int32.BYTES_PER_ELEMENT); // 4
// 長さ(要素数)の指定して型付き配列を生成 new TypedArray(length)
var int32 = new Int32Array(2);
// 配列の各要素は 0で初期化される。
console.log(int32.toString()); // 0,0
int32[0] = 42;
console.log(int32[0]); // 42
console.log(int32.length); // 2
// (配列)オブジェクトから new TypedArray(object);
var arr = new Int32Array([21, 31]);
console.log(arr[1]); // 31
// (反復可能)オブジェクトから これも new TypedArray(object);
var iterable = function* () { yield* [1, 2, 3]; }();
var int32 = new Int32Array(iterable);
console.log(int32.toString()); // 1,2,3
// 他の型付き配列から new TypedArray(typedArray);
var x = new Int32Array([21, 31]);
var y = new Int32Array(x);
console.log(y[0]); // 21
// ArrayBuffer から new TypedArray(buffer [, byteOffset [, length]]);
var buffer = new ArrayBuffer(16);
var z = new Int32Array(buffer, 0, 4);
console.log(z.toString()); // 0,0,0,0
上記のコードでBYTES_PER_ELEMENTは型付き配列のバイト数を示す値。
最後の例のArrayBufferクラスについては後述する。
TypedArray.fromメソッド
各型付き配列の静的メソッドfromはコンストラクタnew TypedArray(object)
と同様にオブジェクトから各型付き配列の生成をおこなう。
- 構文
- TypedArray.from(source[, mapFn[, thisArg]])
引数
- source
- 型付き配列に変換する配列風オブジェクトか反復可能オブジェクト
- mapFn 省略可
- 型付き配列のすべての要素に適用される map 関数。
- thisArg 省略可
- mapFn を実行するときに this として使う値。
fromメソッドの例
// 反復可能オブジェクトから (Set)
const s = new Set([1, 2, 3]);
var uint8ary = Uint8Array.from(s);
console.log(uint8ary.toString()); // 1,2,3
// 文字列から
console.log(Int16Array.from('123').toString()); // 1,2,3
// アロー関数をマップ関数として使用して要素を操作します。
Float32Array.from([1, 2, 3], x => x + x);
const f32Ary = Float32Array.from([1, 2, 3], x => x + x);
console.log(f32Ary.toString()); // 2,4,6
// 数列を生成する(lengthプロパティは要素数を示すと解釈される)
var uint8ary = Uint8Array.from({ length: 5 }, (v, k) => k);
console.log(uint8ary.toString()); // 0,1,2,3,4
文字列をユニコードのコードポイントの配列として処理したい事もあるが、
fromメソッドを利用して。
let s="あいうえお";
let uint32Array = Uint32Array.from(s);
console.log(uint32Array.toString()); // 0,0,0,0,0
これでは配列の要素の値がすべて0になってしまってうまくいかない。
おちいりやすい罠として、文字を数値に変換しようとして変換できなくて値が0になってしまう。
このコードの2行目以降を以下のように修正する事で解決できる。
let uint32Array = Uint32Array.from([...s].map(c => c.codePointAt(0)));
console.log(uint32Array.toString()); // 12354,12356,12358,12360,12362
通常の配列と型付き配列の相互変換
通常の配列と型付き配列の相互変換はコンストラクタやfromメソッドを使って簡単に記述できる。
型付き配列⇒通常の配列
let normalArray = new Array(typedArray);
let normalArray = Array.from(typedArray);
スプレッド演算子を利用して同じ事をおこなう事ができる。
const normalArray = [...typedArray];
通常の配列⇒型付き配列
型付き配列のコンストラクタを使う
let int32 = new Int32Array([21, 31]);
let int32 = Int32Array.from([21, 31])
変換時に配列の要素は型付き配列の型に丸め(切り捨て)られる。
var int32 = new Int32Array([21.33, 31.56, "a", "b", "123.45"]);
console.log(int32.toString()); // 21,31,0,0,123
範囲外のデータの代入
型付き配列は範囲外のデータを代入してもエラーは発生しない。
無視される。
配列の範囲外へのアクセスの例
var int16Array = new Int16Array(3);
for (let i = 0; i < 3; i++)
int16Array[i] = i;
console.log(int16Array.toString()); // 0,1
型付き配列のデータ型を超えるデータを代入
var int16Array = new Int16Array(3);
int16Array[1] = 0x123456;
for (let i = 0; i < int16Array.length; i++)
console.log(int16Array[i].toString(16)); // 0.. 3456.. 0..
型付き配列の便利なメソッドたち
型付き配列にはArrayクラスと同様に配列を操作するための便利なメソッドが豊富に用意されている。
前項の「型付き配列のデータ型を超えるデータを代入」の例をTypedArray.prototype.forEachメソッドを使って修正したのが以下のコード。
var int16Array = new Int16Array(3);
int16Array[1] = 0x123456;
int16Array.forEach((
element, index) => console.log('a[' + index + '] = ' + element.toString(16)));
ArrayBufferクラス
ArrayBuffer オブジェクトは、一般的な固定長の生のバイナリーデータバッファーを表現するために使用します。 ArrayBuffer はバイトの配列で、他の言語ではよく「バイト配列」と呼ばれます。 ArrayBuffer の内容を直接操作することはできません。 代わりに、バッファーを特定の形式で表現する型付き配列オブジェクトまたは DataView オブジェクトのいずれかを作成して、バッファーの内容を読み書きします。
ArrayBuffer() コンストラクターは、指定した長さの ArrayBuffer をバイト単位で作成します。
ArrayBufferはメモリーから指定した連続したバイト数分固定のデータエリアを確保するために使用される。
ArrayBufferはC言語におけるmallocに相当する機能と考えられる。
そして型付き配列はmallocで確保したメモリーをint型等にキャストする動作を思い浮かべると理解しやすいかな。
ArrayBufferを利用してメモリエリアを確保してこれを型付き配列で利用する例
const buffer = new ArrayBuffer(8);
const view = new Int32Array(buffer);
byteLengthプロパティ
byteLengthプロパティによりArrayBufferが確保しているバイト数を知る事ができる。
console.log(buffer.byteLength); // 8
型付き配列からArrayBufferオブジェクトを取得
型付き配列のコンストラクタや型付き配列のfromメソッドは裏で自動的にArrayBufferオブジェクトを生成していて、 型付き配列のbufferプロパティからそのArrayBufferオブジェクトを取得する事ができる。
let int16Ary = Int16Array.from('123456');
let arrayBuffer = int16Ary.buffer;
// 6 * 2byte
console.log(arrayBuffer.byteLength); // 12
sliceメソッド
ArrayBufferオブジェクト の begin から end の手前までをコピーして新しいArrayBufferオブジェクトを返す。
- 構文
- arraybuffer.slice(begin[, end])
次のコードは文字列の2バイト目(0から始まる)から10バイト目のの手前までのデータをsliceメソッドで取り出してそれをInt8型でアクセスする例。
let int16Ary = Int16Array.from('0123456');
let buffer = int16Ary.buffer;
let sliceBuffer = buffer.slice(2, 10);
let int8Ary = new Int8Array(sliceBuffer);
console.log(int8Ary.toString()); // 1,0,2,0,3,0,4,0
エンディアン
DataViewの話題に入る前に、それと関わりのあるエンディアンについて。
エンディアン(英: endianness)は、複数のバイトなどを並べる順序の種類である。一般的な用語による表現ではバイトオーダ(英: byte order)、ないしそれを一部訳して日本語ではバイト順とも言う。 英語の「endian」という単語自体には元々は「配置方式」「並び順」といった意味はなかった(#語源を参照)。日本では総称として「エンディアン」と呼ぶことが多いが、英語でそれに相当する語は英: endianness(エンディアンネス)である。
型付き配列のビューは、プラットフォームのネイティブのバイトオーダ (Endianness) になります。 DataView では、バイトオーダを制御できます。デフォルトはビッグエンディアンですが、getter/setter メソッドでリトルエンディアンに設定できます。
エンディアンとは複数のバイトなどを並べる順序を示していて。
エンディアンにはリトルエンディアンとビッグエンディアンがあり、コンピュータのCPUのアーキテクチャによって決定される。
MOTOROLA系のCPUは,ビッグエンディアンであり,インテル系のCPUは,リトルエンディアンである。
インテルのCPUが主に使われるWindows環境ではリトルエンディアン。
上位バイトが先にくるのがビッグエンディアン、そして下位バイトが先に来るのリトルエンディアンと呼ばれる。
例えば、型付き配列のInt16Arrayの要素が0x1234の場合ビッグエンディアンでのバイトオーダーは0x12,0x34であるが、リトルエンディアンでは逆順の0x34,0x12になる。
異なるコンピュータ同士でデータをやりとりする場合に、バイト順が異なると正しいデータのやり取りができないのでどちらかのコンピュータで相手方のコンピュータのエンディアンに合わせる処理が必要になってくる。
型付き配列のビューは、プラットフォームのネイティブのバイトオーダなので、Windowsの場合はリトルエンディアン。
これをビッグエンディアンのコンピュータと通信する場合はDataViewをつかってバイト順を合わせる必要がある。
ちなみに、私の環境はリトルエンディアン。
DataView
DataView ビューは ArrayBuffer の多様な数値型を、プラットフォームのエンディアンに関係なく読み書きするための低水準インターフェイスを提供します。
コンストラクター
- 構文
- new DataView(buffer [, byteOffset [, byteLength]])
- buffer
- ArrayBufferオブジェクト
- byteOffset
- オフセットバイト
- byteLength
- バイト長
プロパティ
コンストラクターによりインスタンス生成時に指定されたbuffer, byteOffset, byteLengthの各値は読み取り専用のプロパティとして取得できる。
メソッド
各データ型ごとに値を設定取得するためのメソッドが用意されている。
設定 | 取得 | データ型 |
---|---|---|
getInt8 | setInt8 | 8ビット符号付き整数 |
getUint8 | setUint8 | 8ビット符号なし整数 |
getInt16 | setInt16 | 16ビット符号付き整数 |
getUint16 | setUint16 | 16ビット符号なし整数 |
getInt32 | setInt32 | 32ビット符号付き整数 |
getUint32 | setUint32 | 32ビット符号なし整数 |
getBigInt64 | setBigInt64 | 64ビット符号付き整数 |
getBigUint64 | setBigUint64 | 64ビット符号なし整数 |
getFloat32 | setFloat32 | 32ビットIEEE |
getFloat64 | setFloat64 | 64ビットIEEE |
型付き配列のUint8ClampedArray(8ビット符号なし整数(切り詰め))に相当する型に対する設定取得するためのメソッドが用意されていない。
DataViewオブジェクト自体は、エンディアンには関係ないバイトの単なる並び。
setXXX,getXXXメソッドの時にビッグエンディアンかリトルエンディアンを指定する事になる。
エンディアンを指定しない場合はビッグエンディアンが指定されたものとして動作する。
16ビット符号付き整数に対するgetInt16,setInt16メソッドを例にとると。
- 構文
- dataview.getInt16(byteOffset [, littleEndian])
- dataview.setInt16(byteOffset, value [, littleEndian])
byteOffsetは
アライメントの強制はありません。複数バイトの値はどのオフセットからも読み取ることができます。
- littleEndian
- リトルエンディアンで値を設定取得するかを示すBoolean型の値
省略した場合はfalse(ビッグエンディアン)となる。
プラットフォームのネイティブのバイトオーダでは無いので注意。
getInt8,setInt8(8ビット符号付き整数)やgetUint8,setUint8(8ビット符号なし整数)は引数littleEndianは意味をなさない。
以下のコードはプラットフォームのネイティブのバイトオーダがリトルエンディアンかどうかを示す。
var littleEndian = (function() {
var buffer = new ArrayBuffer(2);
new DataView(buffer).setInt16(0, 256, true /* リトルエンディアン */);
// Int16Array はプラットフォームのエンディアンを使用する
return new Int16Array(buffer)[0] === 256;
})();
console.log(littleEndian); // true または false
バッファーとビュー
これまでみてきたようにArrayBufferはメモリー内にデータを一時的に保持しておくための記憶領域を確保するためにのものでバッファーと呼ばれる。
バッファーとは
英語のbufferのことであり、緩衝器や緩和物といった意味を持ちます。ビジネス用語としては「時間・資源的なゆとり、余裕」として用いられ、IT用語しては「データを一時的に保持しておくための記憶領域」として用いられます。
ArrayBuffer自体は確保したメモリエリアを操作するAPIを持たなくて、ArrayBuffer内のメモリエリアを参照する窓のようなもの(これをビューと言う)を使ってデータを操作する。
ビューとは
ビューとは、見る(こと)、視点、視界、視野、景色、眺望、見方、見解、見識、眺める、などの意味を持つ英単語。ITの分野ではデータなどの「見え方」「表示の仕方」の意味で用いられることが多い。
ビューには型付き配列とDataViewがあり、
型付き配列はバッファー内のメモリエリアを配列ようにアクセスするのに便利であるが、プラットフォームのネイティブのバイトオーダ (Endianness)でしかアクセスする事ができない。
これに対してDataViewはデフォルトはビッグエンディアンであるが、getter/setter メソッドを使ってリトルエンディアンでもビッグエンディアンでもアクセス可能。
どちらかと言うと型付き配列の方が配列としてデータを扱えるので連続したデータを処理するには便利であるが、異なるエンディアンでエンコードされたデータを扱うにはDataViewを使う必要がある。
ArrayBuffer内のデータを異なるバイト位置,異なるバイト長を指定して複数のビューを使って処理する事ができる。
言い方を替えると、1つのバイト列を複数の窓から異なるデータ型をとうしてみる事ができるという事。
以下のコードはArrayBufferの同じ領域にDataViewでリトルエンディアンにより値をセットして、その同じデータを型付き配列により取得する例である。
結果はプラットフォームのネイティブのバイトオーダによって異なる。
let buffer = new ArrayBuffer(20);
const byteOffset = 2;
const byteLength = 16;
let dataView = new DataView(buffer, byteOffset, byteLength)
for (let i = 0; i < (16 / Int16Array.BYTES_PER_ELEMENT); i++) {
dataView.setInt16(i * Int16Array.BYTES_PER_ELEMENT, i, true);
}
let int16Ary = new Int16Array(buffer, byteOffset, byteLength / Int16Array.BYTES_PER_ELEMENT);
for (let i = 0; i < (16 / Int16Array.BYTES_PER_ELEMENT); i++) {
console.log(int16Ary[i], dataView.getInt16(i * Int16Array.BYTES_PER_ELEMENT, true));
}
アライメント(バイト境界)に注意
バッファーを扱う際には、アライメントに気をつける必要がある。
バッファーとアライメントについて、今はやりのChatGPTに説明してもらうと。
アライメント(Alignment)とは、メモリのアドレスをどのように配置するかに関する規則のことです。バッファーを扱う際には、アライメントに気をつける必要があります。
JavaScriptのバッファーは、バイト配列であるため、基本的には1バイト単位でアライメントが行われます。しかし、ビューを使用する場合には、データ型によってはアライメントが必要になります。
例えば、Int16Arrayのような2バイトのデータ型を扱う場合、アライメントは2バイト単位でなければなりません。これは、アドレスが2の倍数であることを意味します。もし2バイト単位でアライメントが行われていない場合、アクセスが遅くなる原因となります。
同様に、Int32Arrayのような4バイトのデータ型を扱う場合は、アライメントは4バイト単位で行われる必要があります。また、Float32Arrayのような単精度浮動小数点数を扱う場合は、アライメントが4バイト単位で行われる必要があります。一方、Float64Arrayのような倍精度浮動小数点数を扱う場合は、アライメントが8バイト単位で行われる必要があります。
したがって、ビューを使用する場合には、データ型によって適切なアライメントを行うことが重要です。アライメントが正しく行われていない場合、パフォーマンスが低下する可能性があります。
この事に留意してアライメント違反をした時にどうなるかをサンプルコードでしめしてみると。
let buffer = new ArrayBuffer(16);
let int16Array = new Int16Array(buffer, 5, 3);
int16Array[0] = 5;
実行結果
Uncaught RangeError: start offset of Int16Array should be a multiple of 2 at new Int16Array ()
環境にもよるのかもしれないが、アライメント違反をした場合には「パフォーマンスが低下する」というよりはエラーが発生するようだ。
最後に他のサイトの参考記事も示しておく