タイトルを読んでなんのことかわからない方には必要ない話なのですが、ちょいちょい質問があるわりにはきちんと解説されたものが見当たらないようだったので、そもそもforの動きから確認してみたいと思います。内容的にはJavascriptのお話ですが他の言語でも考え方については共通項があると思います。
結論
検索してきた人は長い記事読むのも面倒でしょうから先に結論書いておきます。
for…ofで扱うオブジェクトはイテラブルだからです。
以上、解決された方は以下は読まなくて大丈夫です。
「長いの読むのは嫌だけどイテラブルってなんやねん」という人は目次の11をクリックしてそこからお読みいただくと良いでしょう。
基礎から確認したい方は頑張って全部お読みください。途中でサンプルソースコードを示しますので適宜codepenやTypeScript PlayGroundなどに貼り付けてご確認いただくと理解が深まると思います。
そもそも何の話か
こういう繰り返し処理のソースコードのお話です。
for(const item of items){
console.log(item);
}
一般的にはforのこちらの記述を学んだ後に学習する内容なので「なんでletじゃないんですか」とよく訊かれます。こういうところに疑問を持てる方は学習意欲があって大変よろしいです。
for(let i=0;i<10;i++){
console.log(i)
}
そもそもforの引数がよくわからん
その他関数と異なりforの引数(というのかどうか)が異色なのが混乱の最初のポイントだと思うのでまずはこちらから順を追って検証していきます。
for(let i=0;i<3;i++)
{//ここから繰り返し
console.log(i);
}//ここまで3回繰り返されている
letかconstかで悩む人はこの「i」の有効範囲(以後「スコープ」)でこんがらがるわけです。
というわけでまずは「i」のスコープから確認していきます。
iのスコープ(仮説1)
iのスコープがどうなっているか考えてみます。
let i=0 が何度も繰り返されてしまうと永遠に終わりません(毎回0で始まる)
よってここは繰り返しの外で宣言されているはず。つまり可視化するとこんな感じになるはず。
for(let i=0;i<3;i++)
//let i=0; //繰り返しブロック外で初期化
{//ここから繰り返し
console.log(i);
}//ここまで3回繰り返されている
iのスコープ(仮説2)
iの加算と条件判定は繰り返しごとに行われます。
ということはこれらはループ内なのでしょうか?
for(let i=0;i<3;i++)
//let i=0; //ここで処理するイメージ
{//ここから繰り返し
//i<3 //ここで判定するイメージ?(仮説)
console.log(i);
i++;//ここで判定するイメージ?(仮説)
}//ここまで3回繰り返されている
iのスコープ(実験1)
もし3のスコープなら繰り返しブロック名で let i=0を宣言したら無限ループになるはずです。
以下、ソースコードで実験してみます。
for(let i=0;i<3;i++)
//let i=0;
{//ここから繰り返し
//i<3 ここで判定
//let i=0; //ここでブロック内のローカル変数「i」を宣言したら無限ループになる?
console.log(i);
i++;
}//ここまで3回繰り返されている
<結果>
0
0
0
表示(ローカルのi)は「0」になるが繰り返しのイテレータ「i」は3回で終わっていて無限ループにはなりません。
すなわちi++;はブロック内ではないということになります。
ということはスコープと判定は以下のようになるはず
for(let i=0;i<3;i++)
//let i=0;//ここで処理するイメージ
{//ここから繰り返し
//i<3 ここで判定
console.log(i);
}//ここまで3回繰り返されている
//i++; ここで加算
iのスコープ(仮説3)
しかしi++;は繰り返されないと数が増えません。
条件判定も同様。
すなわちこの形になります。
for(let i=0;i<3;i++)
//{ //ここは繰り返されない
//let i=0; //初期化ここで処理するイメージ
//{ //ここから
//i<3 ここで判定
{
console.log(i);
}
//i++; ここで加算
//}//ここまで3回繰り返されている
//}//ここは繰り返されない
//つまり()内の記述は初期化はふたまわり、その他はひとまわり上のブロックとなる
iのスコープ(実験2)
初期化iをconstにすると内包するブロックでインクリメントできないのでエラーになります(当然)
for(const i=0;i<3;i++)
//{ //ここは繰り返されない
//const i=0; //初期化//ここで処理するイメージ
//{ //ここから
//i<3 ここで判定
{
console.log(i);
}
//i++; ここで加算(できない)
//}//ここまで3回繰り返されている
//}//ここは繰り返されない
//つまり()内の記述は初期化はふたまわり、その他はひとまわり上のブロックとなる
<結果>
0 //一度は動いている
< TypeError: Attempted to assign to readonly property.
//その後のi++で上書きできないエラーが表示される
というわけで、ここまでfor(){}のスコープと判定の位置を確認してみました。
では本題。for…ofではなぜconstで再代入できているのか
const arr = [1,2,3];
for(const e of arr){
console.log(e);
}
<結果>
1
2
3
for同様に分解してみる
//{ //ここは繰り返されない
//const e of arr; //初期化
//{ //ここから繰り返し
{
console.log(e);
}
//}//ここまで3回(要素数分)繰り返されている
//}//ここは繰り返されない
しかし、これだとeの値が固定されてしまいます。(一度しか代入されていない)
const e of arrを繰り返してみる
//{ //ここは繰り返されない
//{ //ここから繰り返し
//const e of arr; //繰り返し内部で初期化
{
console.log(e);
}
//}//ここまで3回(要素数分)繰り返されている
//}//ここは繰り返されない
「ここから繰り返し」の繰り返しブロック内で毎回constで宣言され「ここまで」のところでeは破棄されるのでエラーとならないわけです。実際この動き(繰り返すたびにconstで新たに値を取得して設定)であることは間違いないでしょう。
ただ、この場合arrからの呼び出しは毎回最初の要素(今回の場合arr[0]すなわち「1」)になってしまうのではという疑問が沸きますね。
mdn(公式サイト)で「for of」の仕様を確認
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/for…of
for…of 文は、反復可能オブジェクトをソースとした一連の値を処理するループを実行します。
for…of ループが反復可能オブジェクトを反復処理する場合、最初にその反復可能オブジェクトの [Symbol.iterator]() メソッドが呼び出されます。これはイテレーターを返すので、その返されたイテレーターの next() メソッドを呼び出すことで、variable に代入される一連の値を生成することができます。
const arr[1,2,3」は配列。すなわち反復可能な「iterable(イテラブル)」オブジェクトであることがポイント。
イテラブルなオブジェクトは値を取り出されると最後の値に行くまでは.nextの要素を準備して待つ特性がある。
よって毎回constで取り出してもarrのほうが次々(.next)と値を変えてくれていることになる。
イテラブルなオブジェクトの動きを確認してみる
for ofのループ中でarrの参照位置を意図的に変更して(進めて)みます。
その後にarrの取り出しがどうなるか確認します。
ただし、今回のarrは配列(反復可能オプジェクト:イテラブル)です。これは厳密には「イテレータオブジェクト」ではありません。なのでnextメソッドで値を進めるには変換が必要となります。
arrを元にイテレータオブジェクトに変換したものを作成するとnextメソッドが使えるようになるのでこちらで挙動を確認してみます。
const arr = [1,2,3];
const iter = arr.values(); //配列を元にイテレータオブジェクトに変換。メソッド(.next)が使えるようになる
for(const e of iter)
//{ //ここは繰り返されない
//{ //ここから繰り返し
//const e of iter; //初期化(イテラブルなオブジェクトから「現在の参照値」)を取得する
{
console.log(e);
//iter.next(); //ここのコメントを外すとイテレータの「現在の参照値」がひとつ余計に進む。
//よって「2」を処理して表示(console.log)して「現在の参照値」は「3」になる
}
//}//ここまで3回(要素数分)繰り返されている
//}//ここは繰り返されない
太字の箇所のコメント「//」を外して動作を確認してみてください。
「1」を表示した後に配列が.next()によって意図的に進められるため、次のconstでの初期化(配列からの取り出し)の際にヘッドが「3」になっているのがわかると思います。
<結果>
1
3
ちなみに.nextメソッドは値を進めつつ戻り値として「次の値(value)」と「反復の終了フラグ(done)」のオブジェクトリテラルを返します。
よってiter.next()をそのままコンソールに書き出すと以下の結果となります。
const arr = [1,2,3];
const iter = arr.values(); //配列を元にイテレータオブジェクトに変換。メソッド(.next)が使えるようになる
for(const e of iter)
//{ //ここは繰り返されない
//{ //ここから繰り返し
//const e of iter; //初期化(イテラブルなオブジェクトから「現在の参照値」)を取得する
{
console.log(e);
console.log(iter.next()); //ここのコメントを外すとイテレータの「現在の参照値」がひとつ余計に進む。
//よって「2」を処理して表示(console.log)して「現在の参照値」は「3」になる
}
//}//ここまで3回(要素数分)繰り返されている
//}//ここは繰り返されない
結果:(TypeScript PlayGround https://www.typescriptlang.org/ にて実行 )
[LOG]: 1
[LOG]: { “value”: 2, “done”: false }
[LOG]: 3
[LOG]: { “value”: undefined, “done”: true }
「1」を表示後のnextの結果が値は2、反復の終了はfalse(まだ全部終わってないよ)なのがわかると思います。
「3」を表示後は次の値はなし(undefined)、反復の終了はtrue(全部終わったよ)となっています。この終了をもってfor…ofも終了しているわけです。
当たり前の話ですが、あくまで「毎回新しいブロックで宣言(初期化時)に自動的にイテレータの参照値が変わっている」ということが重要です。
同一ブロック内で再度eの値を変更しようとするとエラーが出ます。「const」なので。
const arr = [1,2,3];
const iter = arr.values();
for(const e of iter)
//{ //ここは繰り返されない
//{ //ここから繰り返し
//const e of arr; //初期化*ただし毎回違う(次の)値が参照されて代入される
{
console.log(e);
console.log(iter.next());
e++; //宣言した内側のブロックでeを上書きできない
}
//}//ここまで3回(要素数分)繰り返されている
//}//ここは繰り返されない
まとめ
というわけで最初にも申し上げたとおりfor…ofの初期化がconstでいい理由は
「扱う値がイテラブルだから」ということになります。
とはいえ、初学者にこのひと言を伝えて理解していただくのも難しいと思いましたので、今回くどいと思われるのを覚悟でforの挙動から改めて確認してみました。
この記事がjavascriptを学ぶ方、教える方に少しでも参考になれば幸いです。
役に立ったと思ったら当方のSNSアカウントもフォローしていただけますと今後もお役立ち情報をご提供できると思いますので宜しくお願い申し上げます。
(余談)もうひとつの「なんで」
今回は「なんでconstでエラーにならない(代入できちゃう)んですか」についてのお話でしたが、もうひとつよくある「なんで」が「なんでlet使っちゃいけないんですか」です。
これはべつに「letでもいいですよ」と言うと「あ。そうすか」みたいな感じで終わってしまうわけですけど、そもそもJS,TSでは「書き換えの必要がない場合は変数(let)ではなく定数(const)」を意識した方が予想外のエラーに悩まされることがなくなります。
たとえばこんな書き方はエラーも出ずに実行できます。
const arr = [1,2,3];
for(let e of arr){
e = e * 100;
console.log(e);
}
結果
100
200
300
でもこれ、他にも「e」の値を使いたいとなるとブロック内で位置によって異なる値をもつのがネックになることがありそうです。この程度の例ではあまり危険はないでしょうけどこんなケースですね。
const arr = [1,2,3];
for(let e of arr){
console.log(`${e}回目の繰り返し開始`);
e = e * 100;
console.log(e);
console.log(`${e}回目の繰り返し終了`);
}
結果:
“1回目の繰り返し開始”
100
“100回目の繰り返し終了”
“2回目の繰り返し開始”
200
“200回目の繰り返し終了”
“3回目の繰り返し開始”
300
“300回目の繰り返し終了”
もし内部的にeの値を加工して使いたいとしても、eそのものに値を再代入する必要性はほぼないでしょう。それならば定数contで良いとなるわけです。
というわけでもうひとつの「なんで」に関しては「予期せぬエラーを起こさぬ効率的プログラミングのためのテクニック」ということで回答とさせていただきます。


コメント