JavaScript の for (let i = 0; i < array.length; i = (i + 1) | 0) { } における「| 0」とはなんなのか
この記事は2022/06/02に作成されました。
前書き
Web 開発をしていると避けては通れない言語が JavaScript です。
人によっては嫁かと言わんばかりに、持てる愛と時間の全てを注ぎ込む言語ですね。
世のつよつよエンジニアが JavaScript と愛し合っている記事を見ても正直何を言っているか分からないこともしばしばです。
片や僕はどちらかというとサーバーサイドで PHP や SQL をこねくり回していることの方が多いので、JavaScript のことはそこそこ好きですが、おぼろげな外見だけしか知らないで仕事をしてきました。
休日は何をしているか、靴下は穴があくまで履くタイプか、ネギマの葱と肉のどちらが主役だと信じているか……好意を寄せている存在なら当然その程度の調べはついているハズですが、僕は JavaScript のことをそんなに知らずに付き合ってきた気がします。
今回はそんな JavaScript のつむじがどの辺に位置しているか程度の話をします。
そしてそのつむじから風呂敷を広げていきたいと思います。
先に書いておくと、つむじとはビット演算のことです(?????????)。
本題
初心者向けの for 文解説には、大抵以下のようなサンプルコードが載っている気がします。
const array = ['りんご', 'ごりら', 'らっぱ'];
for (let i = 0; i < array.length; i++) {
console.log(array[i]);
}
//りんご
//ごりら
//らっぱ
//...とコンソールに表示される
特に当たり障りのないコードで、エンジニアだったら特にコメントがついていなくても、array の各要素に対して処理をしたいのだなと理解できます。
僕も for 文を書くときはこのような書き方をしていたのですが、ある日何かの記事で高速な for 文の書きかたについて紹介されているのを目にしました。
以下はその上記のコードと同じ処理をその書き方で書き直したものです。
const array = ['りんご', 'ごりら', 'らっぱ'];
for (let i = 0, max = array.length; i < max; i = (i + 1) | 0) {
console.log(array[i]);
}
さて、() の中身がまるっと変わっていますが、一つ目と二つ目の理解は簡単です。
ここで for 文の構文についておさらいしましょう。
構文
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for#syntax
for ([initialization]; [condition]; [final-expression])
statement
initialization
ループが始まる前に一度だけ評価される (代入式を含む) 式または変数宣言。ふつうはカウンター変数を初期化するために使われます。この式では任意で、 var キーワードを用いて新しい変数を宣言することもできます。 var で宣言された変数はループ内のローカル変数にはなりません。すなわち、 for ループが属するスコープと同じスコープになります。 let で宣言された変数は文内のローカル変数になります。
この式の結果は捨て去られます。
condition
ループのそれぞれの反復処理が行われる前に評価される式です。この式が true と評価された場合は、 statement が実行されます。この条件テストは省略可能です。省略された場合は、条件は常に true に評価されます。もしこの式が false と評価された場合は、実行は for 構造に続く最初の式に飛びます。
final-expression
ループのそれぞれの反復処理の最後に評価される式です。これは、次の condition の評価前に行われます。一般的には、カウンター変数を更新または増加するために使われます。
statement
条件が true と評価された場合に限り実行される文です。ループ内で複数の文を実行するには、ブロック文 ({ … }) を使用して文をグループ化してください。ループ内で文を実行しないようにするには、空文 (;) を使用してください。
上記 initialization は一回しか評価されないのに対し、 condition はループの度に評価されます。
改めて二つのコードを見比べてみましょう。
for (let i = 0; i < array.length; i++)
for (let i = 0, max = array.length; i < max; i = (i + 1) | 0)
上のコードは毎回 array の長さを数えているのに対し、下では最初の一回だけ数えているのが分かると思います。
array の数を数える回数がループ回数から一回だけに減るので高速化が見込めるというわけですね。
問題は final-expression の部分です。
i++
i = (i + 1) | 0
どちらも変数 i をインクリメントしていますね。ただし、下のコードはただインクリメントするだけなら、
i = i + 1
で良いハズです。
| 0
とはいったい何をしているのでしょう?
僕が見つけた記事では確か「JavaScript の実行エンジンで数値を数値だと認識させることで高速化させる」的なことが書いてあったかと思います。
二進数とは、ビットとは
上記コードは詳細を知らずとも正しく動くこともあって、僕は | 0 がなんであるか特に調べることも無く開発を進めていました。
しかし、もはや無意識で「| 0」を付け足すようになった頃、僕は全く別の調べものがきっかけで、「| 0」正体を知ることになります。
それは Linux におけるパーミッションの表記についての調べものでした。
ちょっと JavaScript から離れて説明します。
Linux および Unix のような OS ではファイルやディレクトリそれぞれに
- 「所有者」
- 「所有グループ」
- 「その他」
に対して
- 「読み取り」
- 「書き込み」
- 「実行」
の何ができるかの設定が存在します。
これによって重要だけど広く使われるファイルは「所有者」のみが「書き込み」でき、「その他」は内容の「読み取り」あるいは「実行」しかできない、等のセキュリティ対策が可能になるわけですね。
さて、実際この設定がどのような形で表されているかというと、
- 「所有者」
- 「所有グループ」
- 「その他」
それぞれに二進数で 0 ~ 111 の数値が設定されています。
十進数で言い換えれば 0 ~ 7 です。
これを上記順に並べ、
751 (所有者=111=7, 所有グループ=101=5, その他=1=1)
などと表記します。
さて、何故これでそれぞれのユーザーに対して
- 「読み取り」
- 「書き込み」
- 「実行」
のどれが許可されているのか判断できるのでしょう?
そもそも二進数とは、一桁が「0」と「1」の二つだけで表される表記法です。
普段我々が扱っているのは十進数ですが、これは一桁が「0」~「9」となっています。
さて、パーミッションに使用される二進数は「0」~「7」ですが、ここでこの範囲の二進数・十進数を対応させた表を見てみましょう。
十進数 | 二進数 |
0 | 000 |
1 | 001 |
2 | 010 |
3 | 011 |
4 | 100 |
5 | 101 |
6 | 110 |
7 | 111 |
十進数で言うところの「7」は二進数では「111」となるため、二進数は 3 桁までの表示で統一しました。
ここで二進数と一緒に紹介したい概念があります。「ビット」というものです。
ビットとは二進数の各桁をマスに見立て、それが「0」なら false(=フラグが立っていない, off の状態), 「1」なら true(フラグが立っている, on の状態) とした概念です。
上記の表で表した「7」までの二進数は桁数、つまりマスの数が 3 つなので 3 ビットのデータと見ることができます。
よく名前を聞く 8 ビットは 8 桁までの二進数を表すデータとなります。8桁全てが 1、つまり 11,111,111 は 10 進数で 255 です。
0 ~ 255 までの 256 通りの数を表せるわけですね。言い換えれば 8 つのマスそれぞれに true / false のフラグを持ったデータ郡と考えることもできます。
さて、上記表の二進数を改めて桁ごとのマスとして見てみましょう。ここでは敢えて十進数で言うところの 1, 2, 4 だけを抜き出してみます。
十進数 | 二進数 |
1 | 001 |
2 | 010 |
4 | 100 |
これらの二進数の各桁を見てみると、ある一桁だけが 1, それ以外が 0 となっているのが分かるでしょうか。
ここでネタバラしすると、
- 「読み取り」
- 「書き込み」
- 「実行」
のそれぞれを行使できるか否かは
- 「読み取り」=> 三桁目
- 「書き込み」=> 二桁目
- 「実行」 => 一桁目
と定義されています。
つまり、パーミッションが「1」の場合は二進数では「001」なので、
- 「読み取り」=> 三桁目が「0」なので不可能
- 「書き込み」=> 二桁目が「0」なので不可能
- 「実行」 => 一桁目が「1」なので可能
となります。
「5」の場合は二進数では「101」なので、
- 「読み取り」=> 三桁目が「1」なので可能
- 「書き込み」=> 二桁目が「0」なので不可能
- 「実行」 => 一桁目が「1」なので可能
「7」の場合は二進数では「111」なので、
- 「読み取り」=> 三桁目が「1」なので可能
- 「書き込み」=> 二桁目が「1」なので可能
- 「実行」 => 一桁目が「1」なので可能
となります。
つまり、
751
とは
- 「所有者」 => 全ての操作が可能
- 「所有グループ」 => 「読み取り」「実行」のみ可能
- 「所有者」 => 「実行」のみ可能
となるわけですね。
このように、二進数を活用したビットは、実体はただの数値ながら、複数の on/off(=true か false)のデータを持つことができるデータなのです。
| はビット演算における「論理和」
かなり脱線しましたが、いい加減 JavaScript に戻って来ましょう。
JavaScript における「|」とはビット演算における「論理和」を表す演算子です。
と言われても理解が難しいので、身近な例を出しましょう。例えば「+」。
「+」は文字列を結合する場合など、他の用途でも使用される記号ですが、数値型の値を両端に取る場合は四則演算における「和」を表す演算子です。
「+」を例に取ると、二つの数値を組み合わせて算出後の数値を一つ弾きだします。
1 + 2 // 3
つまり、二つの数値を組み合わせて新たな一つの数値を得る、という操作を表すのが「演算子」というわけですね。
改めて、「|」の説明ですが、そもそもビット演算とは四則演算とは違い、対象となる数値を二進数の「ビット」として計算するものです。
先ほどの 3 ビットであるパーミッションを JavaScript で扱ったとしましょう。
「論理和」は組み合わされた両者二進数の各桁を見て、片方でも「1」であれば算出後の該当桁を「1」、両者とも「0」であれば「0」とする演算です。
試しに「5」と「3」を論理和で計算してみましょう。
結果は「7」になります。
十進数と二進数の対応表を見てみます。
十進数 | 二進数 |
3 | 011 |
5 | 101 |
まず、百の位は 3 の 0 と 5 の 1 を組み合わせて、結果1となることが分かります。
十の位は 3 の 1 と 5 の 0 で 1。
一の位は 3 も 5 も 1 なのでで 1となります。
結果全ての桁が 1 となるので、
十進数 | 二進数 |
7 | 111 |
で「7」となるわけですね。
もう一例、「2」と「3」の論理和は「3」となります。
2 | 3 //3
十進数 | 二進数 |
2 | 010 |
3 | 011 |
- 百の位: 0 と 0 = 0
- 十の位: 1 と 1 = 1
- 一の位: 0 と 1 = 1
結果
十進数 | 二進数 |
3 | 011 |
ビット演算の実用例
もう一つだけビット演算を紹介させてください。
「|」は「論理和」を算出する演算子だったのに対し、「&」は「論理積」を算出する演算子です。
これは論理和が「どちらかが 1 なら 1」を算出するものだったのに対し、論理積は「どちらも 1 なら 1、そうでなければ 0」を算出します。
先ほども例として挙げた「5」と「3」の論理積は「1」となります。
十進数 | 二進数 |
3 | 011 |
5 | 101 |
- 百の位: 0 と 1 = 0
- 十の位: 1 と 0 = 0
- 一の位: 1 と 1 = 1
結果
十進数 | 二進数 |
1 | 001 |
さて、頭が痛くなってきたところで実例です。こんな計算をして現実世界ではなにが嬉しいのでしょう?
まず前提として、人間には計算し辛いビット演算ですが、コンピューターは四則演算よりもビット演算のほうが早く計算することができます。なぜなら、全てのデータは 0 と 1 の組み合わせ、つまり二進数だからですね。
なので、同じ処理結果でも四則演算を使用するよりビット演算を使用した処理の方が早くなる可能性があります。
しかし、往々にしてそのようなプログラムは難解なものになりがちなので、前章の「パーミッション」を JavaScript で扱った例を紹介します。
まずは「ある権限が許可されているか」のような判定をしたいケースです。
例えば「その他」のユーザーが「読み取り」と「実行」の権限のみを与えられていたとしましょう。
この二つのみが許可されている二進数は 101 なので、十進数では「5」です。
十進数 | 二進数 |
5 | 101 |
この数値に対して「書き込み」のみが許可されている「2」(二進数: 010)の「論理積」を算出してみます。
5 | 2 //0
十進数 | 二進数 |
5 | 101 |
2 | 010 |
結果
十進数 | 二進数 |
0 | 000 |
全桁が 0 になってしまいましたので、算出結果は「0」です。
では、「実行」のみが許可されている「1」(二進数: 001)の場合はどうでしょう?
5 | 1 //1
十進数 | 二進数 |
5 | 101 |
1 | 001 |
結果
十進数 | 二進数 |
1 | 001 |
結果が「0」ではなくなりました。
このように、二進数に対して
- 読み込みのみ: 4 => 100
- 書き込みのみ: 2 => 010
- 実行のみ : 1 => 001
の論理積を取った結果が「0」ならその権限を有さない二進数で、「0」以外ならその権限を有していることとなります。
JavaScript では「0」は false と判定されるので、実際は以下のように書くことができます。
//0 ~ 7 の、判定対象数値
const permission = 7;
if (permission & 4) {
//読み込み権限を有す時の処理
}
if (permission & 2) {
//書き込み権限を有す時の処理
}
if (permission & 1) {
//実行権限を有す時の処理
}
更に踏み込んで、複数の条件、たとえば「『読み込み権限』と『書き込み権限』のどちらかが許可されているか」等を判定する際は論理和が活躍します。
先ほど判定に使用した 3 種類の数値ですが、
- 読み込みのみ: 4 => 100
- 書き込みのみ: 2 => 010
- 実行のみ : 1 => 001
これらの論理和を算出することで、「どちらか一方」の権限を有するか判定する数値を生成できます。
//0 ~ 7 の、判定対象数値
const permission = 7;
//この結果は「6」となる
const readOrWrite = 1 | 2;
if (permission & readOrWrite) {
//「読み込み」もしくは「書き込み」権限を有す時の処理
}
最後にもう一つだけ、分かりやすい例を紹介しましょう。
ある数値が奇数か偶数かを判定する際、普通は以下のような書き方をする思います。
//奇数か偶数かを判定したい数値
const num = 1;
if (num % 2 === 0) {
//偶数
}
else {
//奇数
}
これはビット演算を用いると以下のように書き直せます。
//奇数か偶数かを判定したい数値
const num = 1;
if (num & 1 === 0) {
//偶数
}
else {
//奇数
}
なにが起こっているのかを説明しましょう。
改めて十進数と二進数の対応表を載せます。
十進数 | 二進数 |
0 | 000 |
1 | 001 |
2 | 010 |
3 | 011 |
4 | 100 |
5 | 101 |
6 | 110 |
7 | 111 |
「1」の二進数は「001」なので、末尾一の位以外は 0 です。
つまり、「1」との論理積を求める場合、末尾一の位以外は全て「0」と組み合わせられるので、どんな数が来ようとも「0」となってしまいます。
残った末尾一の位は「1」なので、相手の末尾一の位も「1」であれば 1, 「0」であれば 0 となります。
結果得られるのは
001 (十進法: 1)
000 (十進法: 0)
のどちらかに絞られますが、上記表のとおり、二進数は、
- 奇数の場合: 末尾一の位は「1」
- 偶数の場合: 末尾一の位は「0」
なので、結果として偶奇判定ができるようになるわけです。
本題再び
本題を忘れるところでした。「|」がビット演算である論理和であることを踏まえ、くだんの for 文を見てみましょう。
for (let i = 0, max = array.length; i < max; i = (i + 1) | 0)
論理和の組み合わせとして 0 を取っていますが、ちょっと待ってください。論理和とは「どちらか片方が 1 なら 1, どちらも 0 なら 0」を算出するハズです。
0 は二進数で表すと
000000000….
となります。これに対して試しに「28」との論理和を求めてみましょう。「28」の二進数は「11100」です。
11100
00000
↓
11100
もう勘づいたかもしれませんが、今度は「4654」(二進数は「1001000101110」)との論理和を確認してみます。
1001000101110
0000000000000
↓
1001000101110
そう、0 と任意の数 x の論理和は必ず x そのままとなるのです。
ということは、
i = (i + 1) | 0
は結局、
i = (i + 1)
i++
となんら変わらない結果となるわけです。
なぜそんなことをするのでしょう?
記事の最初の方に書きましたが、
僕が見つけた記事では確か「JavaScript の実行エンジンで数値を数値だと認識させることで高速化させる」的なことが書いてあったかと思います。
とのことで、どうやらこの操作で JavaScript を実行するエンジンが変数 i を数値として認識するようです。
しばらく色々な記事を調べた所、MDN で以下のような言及がされている情報を見つけました。
オペランドは 32 ビットの整数値に変換され、ビット (ゼロまたは 1) の並びによって表現されます。32 ビットを超える数値は最上位のビットが破棄されます。例えば、次の 32 ビットを超える整数は 32 ビット整数に変換されます。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Bitwise_OR
オペランドとは演算子の対象となる数値のことですが、 JavaScript の数値には小数はもちろん、BigInt なども用意されているのでそれらの可能性が「|」によって除外するとエンジンがなにかしらの最適化を行うのだと推測されます。
開発者からすれば整数しか来ないことが分かり切っている i ですが、JavaScript 側からすれば何が来るか分からないので、最初から整数に絞ってくれると余計な手続きを省けるのでしょう。
後書き
長い旅路の果て得た情報は案外あっけないかつ曖昧なものでした。
しかし、ちょっと気になっていたけど放っておいた Tips が全然違う領域と繋がっていると思うと、そこそこ感慨深いものです。
なにより、PC 関連の仕事をしているのにも関わらず、理解をおざなりにしていたビット演算のいい勉強になりました。
しっかりコンピューターサイエンスを学んだことのない筆者ですが、仕事を通じての勉強は楽しいと感じる今日この頃です。
プログラマー/N.Go