JSでバイナリを扱う 第2回 バイナリを扱うオブジェクト
前回、すべてのファイルはバイト列だと確認しました。今回はそのバイト列をJavaScriptで扱うためのArrayBuffer・TypedArray・DataViewを、「メモリ領域」と「それを解釈する窓」という分離の視点から読み解きます。
- カテゴリー
- JavaScript/TypeScript >
- ECMAScript
発行
はじめに
前回は、すべてのファイルが0〜255の数値が並んだバイト列であること、そして「テキスト」と「バイナリ」はそのバイト列に対する見かたの違いにすぎないことを確認しました。最後にTextEncoderで文字列をバイト列に変換し、Uint8Arrayという器が登場したところで終わりました。
今回は、そのUint8Arrayを入り口に、JavaScriptがバイト列を扱うために用意しているオブジェクトを見ていきます。鍵になるのは、「生のメモリ領域(箱)」と「それを解釈する窓」を分けて考えるという視点です。
前回、Uint8Arrayを「バイト列を入れる器」と表現しましたが、今回はその器の内側を、この箱と窓の2つに分けて捉え直します。箱はバイト列そのものを保持するメモリ領域で、それ自体は中身をどう読むかを持ちません。窓はその箱に被せて、中身を数値として読み書きするための道具です。実はUint8Arrayは箱そのものではなく、箱に被せる「窓」の側なのです。
この分離さえつかめば、ArrayBuffer・TypedArray・DataViewという、これから見ていく3つの役割が、すっきり整理できます。詳しい説明に入る前に、この3つが箱と窓のどちらにあたるかを示しておきます。
- 箱
ArrayBuffer:バイト列そのものを保持する。中身をどう読むかは持たない
- 窓(箱に被せて中身を読み書きする)
TypedArray(Uint8Array・Uint16Arrayなどの総称):中身を同じ型の数値の並びとして読むDataView:任意の位置から任意の型で読み書きできる、より柔軟
ここで1つ注意があります。ArrayBufferとDataViewは、それぞれその名前でnewして使う具体的なオブジェクトです。一方TypedArrayは少し性質が違い、Uint8Array・Uint16Arrayなどをひとまとめにした総称です。コードで直接TypedArrayというオブジェクトを書くことはありません。実際に使うのはUint8ArrayやUint16Arrayなどで、前回取り上げたUint8Arrayも、この「窓」のグループの一員でした。次の節から、まず箱にあたるArrayBufferから順に見ていきます。
ArrayBuffer:中身を解釈できない「箱」
ArrayBufferは指定したバイト数のメモリ領域を確保するだけのオブジェクトです。
ArrayBufferを作る
const buffer = new ArrayBuffer(8);
console.log(buffer.byteLength);
// 8
new ArrayBuffer(8)で、8バイト分のメモリ領域が確保されます。ところがこのArrayBuffer、それ単体では中身を読み書きできません。buffer[0]のようにアクセスしても値は取れません。
これは不便なようでいて、前回の考えかたと地続きです。確保されたばかりのメモリ領域は、ただの0が並んだバイト列であり、それを「1バイトずつの数値」と見るのか「2バイトずつの数値」と見るのかは、まだ決まっていません。ArrayBufferは、「中身をどう解釈するか」をあえて持たない生のメモリ領域なのです。
補足:「Buffer」という名前について
コンピューターの世界でbuffer(バッファ)は、もともと「データを一時的に保持する、中身の解釈を問わない生のメモリ領域」を指す言葉です。ArrayBufferもこの意味での「箱」で、1バイト単位でも4バイト単位でも読める、特定の解釈に縛られない領域です。名前が示すとおり、中身をどう読むかはArrayBuffer自身は持ちません。なお「バイトの配列」に当たるのは、このあと出てくるUint8Arrayです。
TypedArray:箱に被せる「窓」
箱の中身を読み書きするために、箱に窓を被せます。その窓がTypedArrayです。TypedArrayは単独の名前ではなく、Uint8ArrayやUint16Array、Float32Arrayといった一連のオブジェクトの総称です。
これらの名前は、その窓が「何を1つの数値と見るか」を表しています。前回も触れたように、UintはUnsigned integer、つまり符号なし整数(マイナスを含まない0以上の整数)のことです。続く数字は8・16・32とビット数を表し、8ビット=1バイト、16ビット=2バイト、というように「1つの数値を何バイトで表すか」を示します。Floatなら整数ではなく小数(浮動小数点数)です。この規則で読み解くと、Uint16Arrayは「16ビット(2バイト)の符号なし整数の並び」、Float32Arrayは「32ビット(4バイト)の小数の並び」だとわかります。
なぜUint8Arrayだけでなく、こんなに種類があるのでしょうか。それは、バイト列が表しているデータの「本来の型」がさまざまだからです。同じバイト列でも、それが「0〜255の値の並び」なのか、「2バイトで1つの整数の並び」なのか、「4バイトで1つの小数(浮動小数点数)の並び」なのかは、データの作られかた次第です。たとえばPCM音声では16ビット整数が使われることがあり、3Dデータの座標には32ビットの浮動小数点数がよく使われる、というように、フォーマットごとに「何ビットを1つの数値と見るか」「整数か小数か」が決まっています。その本来の型に合った窓を選べば、バイトから数値を組み立て直す手間なく、正しい値をそのまま読めるわけです。Uint8Arrayは、そのうち「1バイトを1つの符号なし整数と見る」窓にすぎません。
前回、Uint8Arrayは「符号なし8ビット整数(0〜255)の配列」だと説明しました。これをArrayBufferに被せると、箱の中身を「1バイトずつの数値の並び」として読み書きできるようになります。
ArrayBufferにUint8Arrayの窓を被せる
const buffer = new ArrayBuffer(4);
const bytes = new Uint8Array(buffer);
bytes[0] = 255;
bytes[1] = 0;
console.log(bytes);
// Uint8Array(4) [ 255, 0, 0, 0 ]
new Uint8Array(buffer)で、bufferという箱に窓を被せています。これで普通の配列のようにbytes[0]で読み書きできます。書き込んだ値は、箱(ArrayBuffer)の中身そのものを書き換えています。
ここでbytes[0] = 255のように書き込んでいるのは、前回触れた「文字としての数字」ではなく、数値そのものです。文字コードの表を通して読まれる文字列の"255"とは違い、255という数値が1バイトの値としてそのままメモリに置かれます。前回「この区別はこのあと効いてくる」と述べたのは、まさにこの読み書きのことです。
同じ箱でも、窓を変えれば別の数値に読まれる
先ほど、データの本来の型に合った窓を選ぶ、と述べました。これを逆から見ると、同じバイト列でも、被せる窓が違えば違う数値として読まれる、ということでもあります。
ここでは、その読み方の違いを実際に確かめてみましょう。次のコードで、4バイトの箱にUint8Arrayで0x01 0x02 0x03 0x04と書き込み、そこにUint8Array・Uint16Array・Uint32Arrayの3種類の窓をあえて被せて見比べます。
同じ4バイトを別の窓で見る
const buffer = new ArrayBuffer(4);
// 箱に4バイト書き込む
new Uint8Array(buffer).set([0x01, 0x02, 0x03, 0x04]);
// 同じ箱に別々の窓を被せて、要素数と値を見る(出力はリトルエンディアン環境の場合)
console.log(new Uint8Array(buffer));
// Uint8Array(4) [ 1, 2, 3, 4 ]
console.log(new Uint16Array(buffer));
// Uint16Array(2) [ 513, 1027 ] → [0x0201, 0x0403]
console.log(new Uint32Array(buffer));
// Uint32Array(1) [ 67305985 ] → [0x04030201]
出力のうちUint16ArrayとUint32Arrayに注目すると、書き込んだバイトの順(0x01 0x02 …)とは逆さまに並んで見えます(0x0201・0x0403など)。これは間違いではなく、エンディアンと呼ばれるバイトの並び順によるもので、後ほど詳しく扱います(そのため、ここで示した数値は実行環境によって変わります)。ここではまず「窓を変えると読まれる数値が変わる」ことに注目してください。
書き込みに使ったのはUint8Arrayの窓ですが、書き込んだ値は箱(ArrayBuffer)の中身そのものです。だからあとからUint16ArrayやUint32Arrayの窓を被せても、同じバイト列がそれぞれの窓を通して見えます。同じ4バイトの箱が、窓の種類によって次のように見えます(リトルエンディアン環境の場合)。
Uint8Array:1バイトずつなので4個。値は書き込んだバイトそのままで1, 2, 3, 4Uint16Array:2バイトをまとめるので2個。最初の513は0x0201、次の1027は0x0403にあたるUint32Array:4バイトまとめて1個。67305985は0x04030201にあたる
窓は、箱のバイト列を「何バイトを1つの数値として読むか」を決める役割を持っています。だからこそ、先ほど述べたように、データの本来の型に合った窓を選ぶ必要があるわけです。
補足:0x01は10進数の1
0xは16進数を表す接頭辞です。0〜9の範囲では16進数と10進数は値が一致するので、0x01 0x02 0x03 0x04は10進数の1 2 3 4と同じです(set([1, 2, 3, 4])と書いても結果は変わりません)。値が10以上になると表記が変わり、たとえば0x0Aは10、0x10は16です。ここで16進で書いているのは、「バイトの値は16進数2桁で表す」習慣(第1回参照)に揃え、このあとの数値の見え方の説明とつなげるためです。
1つの箱に複数の窓を重ねる
ここが今回のハイライトです。1つのArrayBufferには、複数の窓を同時に被せられます。そしてそれらの窓は、同じメモリ領域を共有しています。
一方の窓での変更が、もう一方から見える
const buffer = new ArrayBuffer(2);
const viewA = new Uint8Array(buffer);
const viewB = new Uint8Array(buffer);
viewA[0] = 65; // Aの窓から書き込む
console.log(viewB[0]); // Bの窓から読む -> 65
viewAに書き込んだ値が、viewBからそのまま読めます。コピーされているのではありません。2つの窓が同じ箱を覗いているから、片方の変更がもう片方に即座に反映されるのです。
「箱(メモリ領域)」と「窓(解釈)」が分離していることが、この挙動の正体です。データの実体は箱に1つだけあり、窓はそれをどう読むかを与えているにすぎません。
DataView:自由な位置に自由な型で読み書きする
TypedArrayは「同じ型の数値がずらっと並んだもの」を扱うのに向いています。一方、実際のバイナリフォーマット――たとえばPNG画像のヘッダ――は、「ここは種別を表す文字、ここは画像の幅を表す4バイトの数値……」というように、異なる型のフィールドが混在しています。
こうしたデータを読むにはDataViewが向いています。DataViewは、任意の位置(オフセット)から、任意の型の値を読み書きできる窓です。
DataViewで位置と型を指定して読み書きする
const buffer = new ArrayBuffer(8);
const view = new DataView(buffer);
view.setUint8(0, 0xFF); // 0バイト目に8ビット整数
view.setUint16(1, 0x1234); // 1バイト目から16ビット整数
console.log(view.getUint8(0)); // 255
console.log(view.getUint16(1)); // 4660 (0x1234)
setUint16(1, ...)のように、1バイト目という中途半端な位置から2バイトの値を書く、といったことが自由にできます。使い分けの目安は、同種の数値の並びならTypedArray、異種のフィールドが混在するフォーマットならDataView、と考えておくとよいでしょう。
実例:PNG画像の幅と高さを読む
DataViewのありがたみは、実際のフォーマットを読むときにはっきりします。身近な例として、PNG画像のヘッダを覗いてみましょう。PNGファイルの先頭は、次のような構造になっています。
| 位置(バイト目) | バイト数 | 内容 |
|---|---|---|
| 0 | 8 | PNGであることを示す署名(固定値) |
| 8 | 4 | 続くデータの長さ |
| 12 | 4 | チャンクの種別(IHDRという文字) |
| 16 | 4 | 画像の幅(数値) |
| 20 | 4 | 画像の高さ(数値) |
| 24 | 1 | ビット深度(数値) |
固定値の署名あり、文字あり、長さや幅・高さの数値あり――と、まさに異種フィールドの集まりです。この中から「幅」と「高さ」だけをDataViewで抜き出してみます。
PNGヘッダから幅と高さを読む
// あるPNG画像の先頭26バイト(署名 + IHDRの一部)
const bytes = new Uint8Array([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG署名
0x00, 0x00, 0x00, 0x0d, // 続くデータの長さ(13)
0x49, 0x48, 0x44, 0x52, // "IHDR"
0x00, 0x00, 0x07, 0x80, // 幅
0x00, 0x00, 0x04, 0x38, // 高さ
0x08, 0x02, // ビット深度, カラータイプ
]);
const view = new DataView(bytes.buffer);
console.log(view.getUint32(16, false)); // 1920(幅)
console.log(view.getUint32(20, false)); // 1080(高さ)
ここでnew DataView(bytes.buffer)のbytes.bufferは、そのUint8Arrayが使っている箱(ArrayBuffer)を取り出すプロパティです。窓(bytes)から箱を取り出し、その同じ箱にDataViewの窓を被せています。だから両者は同じバイト列を読めるわけです。
getUint32(16, false)は「16バイト目から4バイトを符号なし整数として読む」という意味です。結果は1920、つまりこの画像の幅が1920ピクセルだとわかります。全体はUint8Arrayでバイト列として持ちつつ、DataViewで必要な位置の数値だけを取り出す。箱と窓の使い分けが、実際のフォーマット読み取りでこう効いてくるわけです。
ところで、getUint32の第2引数falseは何を指定しているのでしょうか。これが次に説明するエンディアンです。PNGの幅・高さはビッグエンディアンという並び順で書かれており、これを読み違えると、幅が1920ではなく2147942400といったとんでもない値に化けてしまいます。
エンディアン:バイトの並び順
2バイト以上の数値を扱うとき、避けて通れないのがエンディアンです。これは、複数バイトの数値をどの順番でメモリに並べるかという規則のことです。
たとえば0x0102という2バイトの数値は、01 02と並べる方式(ビッグエンディアン)と、02 01と逆に並べる方式(リトルエンディアン)があります。DataViewでは、この並び順を引数で明示できます。
ここで、先ほど見た「1つの箱に複数の窓を重ねる」を実際に使ってみましょう。同じ箱にUint8ArrayとDataViewの2つの窓を被せ、書き込みはUint8Arrayの窓から、読み取りはDataViewの窓から行います。同じ箱を共有しているので、一方で書いた値をもう一方でそのまま読めます。
別々の窓で書いて読む(並び順も変えて確かめる)
const buffer = new ArrayBuffer(2);
const bytes = new Uint8Array(buffer); // 書き込み用の窓
const view = new DataView(buffer); // 読み取り用の窓(同じ箱を共有)
// Uint8Arrayの窓から、先ほどと同じ 01 02 を書く
bytes.set([0x01, 0x02]);
// DataViewの窓から、並び順を指定して読む
console.log(view.getUint16(0, false));
// 258(ビッグエンディアン: 0x0102)
console.log(view.getUint16(0, true));
// 513(リトルエンディアン: 0x0201)
なお、書き込みに使ったbytes.set([0x01, 0x02])はエンディアンを気にしていません。Uint8Arrayは1バイトずつを指定した位置にそのまま置くだけなので、書いた順(01 02)がそのまま箱の中身になります。エンディアンが関わるのは、複数バイトをまとめて1つの数値として読む/書くときだけです。ここでは読み取りのgetUint16がまさにそれにあたります。
getUint16の第2引数がlittleEndianを表します。falseなら先頭バイトを上位として0x0102(258)、trueなら先頭バイトを下位として0x0201(513)と、まったく同じバイト列が別の数値として読まれます。この513は、先ほどUint16Arrayで見た値([ 513, 1027 ]の513)と同じです。Uint16Arrayは環境依存でリトルエンディアンとして読んでいたので、DataViewでtrue(リトルエンディアン)を指定した結果と一致するわけです。バイナリフォーマットを読むときは、その形式がどちらのエンディアンを採用しているかを知っている必要があります。
先ほどのPNGの例でgetUint32(16, false)とfalseを渡していたのは、これだったのです。PNGはビッグエンディアンを採用しているのでfalseを指定して読みました。もしtrue(リトルエンディアン)で読むと、00 00 07 80が逆順の80 07 00 00として解釈され、1920がさきほどの2147942400に化けてしまうわけです。
補足:TypedArrayは環境のエンディアンに依存する
DataViewは並び順を明示できますが、Uint16ArrayなどのTypedArrayは実行環境のエンディアンに従います。多くのPCやスマートフォンはリトルエンディアンですが、絶対ではありません。先ほど「Uint16Arrayの具体的な数値はエンディアン次第」と述べたのはこのためです。環境に左右されず確実に読み書きしたい場面では、DataViewを使うのが安全です。
なぜ並び順が2種類もあるのか
そもそも1バイトに収まる数値(0〜255)なら置き場所は1つで、順番の問題は起きません。やっかいなのは2バイト以上の数値で、これは複数のメモリ位置にまたがって置くため、「大きい桁のバイトを先に置くか、小さい桁を先に置くか」という選択が生まれます。これがエンディアンの正体です。
困ったことに、どちらが正しいということはありません。両方に言い分があります。ビッグエンディアンは、人間が数字を書く順番(1234は大きい桁から書く)と一致するため、メモリの中身を見たときに読みやすく、ネットワーク通信やPNGなどのフォーマットで採用されています。一方リトルエンディアンは、数値の一番小さい桁のバイトを先頭に置きます。そのおかげで、大きな整数の先頭を少しだけ読めばその下位部分がそのまま取り出せ、同じデータを異なるサイズの整数として読み替える処理を扱いやすくなります。この性質からx86のPCや多くのスマートフォンが採用しています。CPUを設計する人たちがそれぞれ別の理由で選んだ結果、どちらも世の中に残ってしまったのです。車の右側通行・左側通行のように、「全員が揃えばどちらでもいいが、統一されなかった」ものと考えるとしっくりきます。
ちなみに「エンディアン」という名前は、ガリヴァー旅行記に出てくる「ゆで卵をどちらの端から割るか」――大きい端から割るBig-End派と、小さい端から割るLittle-End派が対立する小人の国のエピソードに由来します。本質的にはどうでもいいのに決着がつかない争いの比喩として、コンピューター技術者がこの言葉を持ち込みました。名前自体が「どちらか一方だけが正しいわけではない」ことを物語っているわけです。
まとめ
今回のポイントは、「箱」と「窓」の分離でした。
ArrayBufferは、中身の解釈手段を持たないメモリ領域(箱)TypedArray(Uint8Arrayなど)は、箱を同じ型の数値の並びとして読む窓DataViewは、箱の任意の位置から任意の型で読み書きする柔軟な窓- 1つの箱に複数の窓を重ねられ、それらは同じメモリを共有する
2バイト以上の数値にはエンディアン(並び順)が関わる
ここまでは、メモリ上に確保したバイト列を扱ってきました。次回は、これが「ファイル」という、実務で実際に手に取る対象とどうつながるのかを見ていきます。