JavaScriptでのビット演算が想像していたものとちょっと違っていた話
きっかけ
にて乱数ジェネレータを書きました。
この中でXorshiftという手法で乱数を生成したのですが、ビット演算でjs特有かもしれないなと思った事があったので、記録しておきます。
JavaScriptのnumber
- IEEE 754 の倍精度 64ビットバイナリ形式と書いている
- (1).toString(16) の結果は 1
- (1.0).toString(16) の結果は 1
- (1.5)toString(16) の結果は 1.8
- (-1).toString(16) の結果は -1
内部の保持状態とは別に16進数出力時には別途再計算?されているみたいです。
またこの際、「負の場合、符号は保存されます。2 の補数ではなく、先頭に - 符号がついた 正のバイナリー表現です。」とのこと。
JavaScriptのバイナリービット演算子
- & ビット論理積 (AND)
- | ビット論理和 (OR)
- ^ ビット排他的論理和 (XOR)
種類と記号は他の言語と比べても違和感無いと思います、たぶん。
この三種の説明に共通しているのが 「オペランドは 32 ビットの整数値に変換され、ビット (ゼロまたは 1) の並びによって表現されます。32 ビットを超える数値は最上位のビットが破棄されます。」 という一文。
console.log((2147483647).toString(16));
// 7fffffff
console.log((4294967295).toString(16));
// ffffffff
console.log((4294967296).toString(16));
// 100000000
console.log((15.5).toString(16));
// f.8
通常、同じ値同士でビット論理積を取ると同じ値になりますが、上記の通り「32 ビットの整数値に変換され、32 ビットを超える数値は最上位のビットが破棄され」るため、以下のような結果となります。
console.log((2147483647&2147483647).toString(16));
// 7fffffff
console.log((2147483647&2147483647));
// 2147483647
console.log((4294967295&4294967295).toString(16));
// -1
console.log((4294967295&4294967295));
// -1
console.log((4294967296&4294967296).toString(16));
// 0
console.log((4294967296&4294967296));
// 0
console.log((15.5&15.5).toString(16));
// f
console.log((15.5&15.5));
// 15
32ビットの整数値というのはInt32の事を指すようで、32ビット目が立っていると負数に変わります。
また、整数値に変換される為、小数点以下も消失します。
Int32Array等のArrayBufferにfrom等で設定する際も上記と同様のロジックで動いているようにみえました。
JavaScriptのBigInt
- BigInt 値は任意に巨大な 整数 に使用することができます。
Numberより大きい整数が扱えるらしいです。
リテラルとして書く場合は整数値の末尾に n を追加する事でbigintとなります。
console.log((2147483647n).toString(16));
// 7fffffff
console.log((4294967295n).toString(16));
// ffffffff
console.log((4294967296n).toString(16));
// 100000000
バイナリービット演算子にはBigIntに関する説明が無いように見えたので、環境依存する可能性があるかもしれませんが、[1]Chromeで実行した際は以下となりました。
console.log((2147483647n&2147483647n).toString(16));
// 7fffffff
console.log((2147483647n&2147483647n));
// 2147483647n
console.log((4294967295n&4294967295n).toString(16));
// ffffffff
console.log((4294967295n&4294967295n));
// 4294967295n
console.log((4294967296n&4294967296n).toString(16));
// 100000000
console.log((4294967296n&4294967296n));
// 4294967296n
32ビットの制約が無くなるようです。
128ビット相当の値まで試してみましたが、破棄は発生しませんでした。
console.log(0xffffffff_ffffffff_ffffffff_ffffffffn);
// 340282366920938463463374607431768211455n
console.log(0xffffffff_ffffffff_ffffffff_ffffffffn&0xffffffff_ffffffff_ffffffff_ffffffffn);
// 340282366920938463463374607431768211455n
乱数ジェネレータを作成した際、途中からBigIntで作成していましたが、速度が劇的に遅くなった為Numberに戻しました。32ビット未満で収まる処理では使わない方が良さそうです。
余談ですが、16進数リテラルでは_を加えても無視されるので1バイト区切りや4バイト区切りで挟むのがおススメです。
負のバイナリ表現
64ビットまでの限定となりますが、
- Uint8Array
- Uint16Array
- Uint32Array
- BigUint64Array
を使用する事で実質のバイナリ表現を作る事が可能です。(LINEのオープンチャットで教えていただいた方法です)
console.log(Uint32Array.from([-1])[0].toString(16));
// ffffffff
64ビットを超える値の場合は64ビット区切りでBigUint64Arrayに入れる等で頑張りましょう。
また、上記以外の方法として、補数表現であることが分かっていれば計算することも可能です。
console.log((((0xffffffff&0xffffffff)+0x100000000)%0x100000000).toString(16));
// ffffffff
あくまでビット演算した際に32ビットに丸まりますが、numberもしくはbigintは32ビット以上を表現できる為、強制的に符号なし整数相当の数にします。
まとめ
- ビット演算では、内部に保持されている値とは別にビット演算用に別途値が用意される
- numberの場合は32ビットまでで切り捨てが発生する。その結果値が壊れる場合がある為、使用の際は0x7f_ff_ff_ff以内の値であるか意識しておく必要がある
- bigintの場合は概ね意図通りの結果が得られたが、
MDNに記載が無い為、今回検証した結果は環境依存である可能性があることと[2]numberと比べると倍くらい処理速度が遅くなる事は覚悟する必要がある
お疲れさまでした。
Discussion
こんにちは。こちらに関して少し補足させていただきます。
たしかにMDNに記載がありませんが、JavaScript言語仕様書でBigIntに対するビット演算の挙動が定義されています。そのため、環境依存になることはありません。(ただ、BigInt値はどんな大きさの数値でも扱えますが実際の実装ではメモリの制約があり、そこは実装依存になっています。)
JavaScriptという言語は仕様書を通じてかなりしっかりと定義されており、環境依存な動作は非常にまれです。参考になりましたら幸いです。
ありがとうございます
MDNを盲信し過ぎていました、気を付けたいと思います💦