動機
前回の記事を投稿したことを某SNSで通知したところ、そのSNSでこんなコメントをいただいた。転記する許可を取ったわけでは無いので私なりに要約させていただくと、
なぜそんなトリッキーな書き方をしてまでforを使うのを避けるのか
そんな書き方をして可読性を下げるくらいなら素直にforを使う方が良い
ということだと理解している。
なるほど、一理ありそうだ。しかし一方で、前回貼ったStackOverflowのQ&Aはなかなかの人気記事(質問に1243ポイント、回答に最大で1559ポイント)なので「多少トリッキーなことをしてでもforを書きたくない!!」という意見をもつプログラマも一定以上いるのだろう。当然私もその1人だ。
ということで、この記事で「なぜそこまで意固地になってまでforを書きたくないのか」を説明することにする。
尚、今回は前回の記事つながりで言語はJavaScriptを使うが、基本的にはどの言語でも同じだと思って欲しい。
理由1 そもそもforの構文って結構複雑
こんな配列があるとする。
const suites = ["H","C","D","S"]; //H:ハート C:クローバー D:ダイア S:スペード
const nums = ["A","2","3","4","5","6","7","8","9","T","J","Q","K"]; //トランプの数字
この配列を元にジョーカーを除いたトランプ52枚すべてを標準出力(console.log
)したい。
例えば、ハートの2なら"H:2"、スペードのキングなら"S:K"と出力する。
for
を使って素直に書くならこんな感じだろうか。
for(let i = 0; i < suites.length; i++){
for(let j = 0; j < nums.length; j++){
console.log(suites[i] + ":" + nums[j]);
}
}
初学者だった時やしばらくプログラミングから離れて久々に書いた時のことを思い出してほしい。
あれ、
i < suites.length
でいいんだっけ?i < suites.length - 1
だっけ?
それとも、i <= suites.length
だっけ??
こんなこと悩んだ経験はないだろうか?
初学者だけではなく、ベテランであっても仕事が立て込んでいて疲れていればi <= suites.length
と誤って書いてしまうかも知れない。
貴方はソースレビューでこのバグを発見できる自信があるだろうか?テストフェーズで発見できれば良いが、うまく境界値がテストパターンからすり抜けてしまったら...?
一方で、for
を避けてArray#forEach
で実装した例を見てみよう。
suites.forEach(suite => nums.forEach(num => console.log(suite + ":" + num)));
どうだろう?少なくともforEachの文法を覚えていれば、<
か<=
かといった問題やlength - 1
かどうかといった問題に悩まされることも疲れていて間違えてしまうことも無い。ソースレビューで誰がどう読んだってsuites
の要素数とnums
の要素数分ループするのは明らかである。
前者の例と後者の例、どちらが安全だろうか?
for
とは本質的にバグりやすい複雑な構文なのである。
理由2 配列に添字でアクセスしたくない
同じサンプルを再掲する。
const suites = ["H","C","D","S"]; //H:ハート C:クローバー D:ダイア S:スペード
const nums = ["A","2","3","4","5","6","7","8","9","T","J","Q","K"]; //トランプの数字
for(let i = 0; i < suites.length; i++){
for(let j = 0; j < nums.length; j++){
console.log(suites[i] + ":" + nums[j]);
}
}
今度は、
console.log(suites[i] + ":" + nums[j]);
に注目して欲しい。
今回はたまたまsuites
の添字をi
に、nums
の添字をj
にしてループしたのだが、両者を取り違えてsuites[j]
やnums[i]
にアクセスしてしまう可能性はどれくらいあるだろか?
「いやいや、馬鹿にするな!それくらい覚えてるよ!!」
と言えるのは、今回のサンプルがたまたま短くて単純だからだ。
実際の現場で書くソースコードはもっと長くてもっと複雑だ。もちろん、常に短くてシンプルなコードを書くという心意気は大事だが、現実にはそうはならないことだって多々あるのも事実だ。
その前提でもう一度尋ねる。貴方は添字を取り違えたりしないだろうか?
添字を取違いが起きていることをソースレビューで指摘できるだろうか?
Array#forEach
で書く例も再掲する。
suites.forEach(suite => nums.forEach(num => console.log(suite + ":" + num)));
こちらではそもそも各要素に添字でアクセスする代わりにsuite
やnum
のような関数への引数を通じてアクセスしている。添字の取り違えなんて起きようがない。
理由3 副作用がないことを保証したい
極端な例で恐縮だが、
const suites = ["H","C","D","S"]; //H:ハート C:クローバー D:ダイア S:スペード
const nums = ["A","2","3","4","5","6","7","8","9","T","J","Q","K"]; //トランプの数字
for(let i = 0; i < suites.length; i++){
for(let j = 0; j < nums.length; j++){
console.log(suites[i] + ":" + nums[j]);
i = 0;
}
}
こんなコードを書いたらi = 0;
のおかげでこのループは無限ループになってしまう。
「当たり前だし、こんなことをわざとやる奴はいない」って?
ではこんなパターンはどうだろう。
const suites = ["H","C","D","S"]; //H:ハート C:クローバー D:ダイア S:スペード
const nums = ["A","2","3","4","5","6","7","8","9","T","J","Q","K"]; //トランプの数字
for(let i = 0; i < suites.length; i++){
for(let j = 0; j < nums.length; j++){
console.log(suites[i] + ":" + nums[j]);
i++;
j++;
}
}
これなら、徹夜明けか何かで疲れていて、「自分はwhile
でループを書いている」と錯覚していれば起こり得そうだ。
わざとやる奴はいない?そりゃそうだ。わざとじゃないからバグなんだ。
やる奴がいるかどうかが問題なのではなくて、起こり得てしまうことが問題な訳で。
繰り返しになるが、このサンプルのような短いシンプルなコードでfor
を使っているのが問題なのではない。実戦で書くもっと長くて複雑なコードで問題が表面化する。
本当は書きたくも無いし読みたくも無いのだが、実戦ではこんなコードはざらに存在する。
for(let i = 0; i < arr1.length; i++){
/**
* なんか100行くらいの処理
*/
//途中で多重ループに入る。。。
for(let j = 0; j < arr2.length; j++){
//このループの中もとても長い。。
}
/**
* さらに100行くらい。。
*/
}
このループが安全であることを確かめるために200行以上のソースを読まなければいけないのが大変な訳だ。
せめてforEach
を使ってくれていれば少なくともループは安全に回っていそうというのが一目で分かる。この心理的効果が意外と大きい。
forEach
を使うとどうなるか。
suites.forEach(suite => nums.forEach(num => console.log(suite + ":" + num)));
そもそもi
やj
がいないのでi++;
などをやりようが無いというのもあるし、仮に何らかの事情で添字を使う場合でも、
suites.forEach((suite, i) => nums.forEach((num, j) => {
console.log(suite + ":" + num);
i++;//関数の引数であるiがインクリメントされるだけなのでループは普通に回る
}));
このように仮に間違えがあってもループそのものは安全である。添字でのアクセスを避けているのでOutOfIndexにならないのも大きい。
理由4 結果の形や処理・変換の意図をより明確化したい
少し要求を変えて、trump
という変数(もしくは定数)に['H:A','H:2',...,'S:K']
という配列を格納したい場合。
for
で書くとこう。
let trump = [];
for(let i = 0; i < suites.length; i++){
for(let j = 0; j < nums.length; j++){
trump.push(suites[i] + ":" + nums[j]);
}
}
for
を避けて、今回はforEach
ではなくmap
で書くとこうなる。
const trump = suites.map(suite => nums.map(num => suite + ":" + num)).flat();
例によって、この程度の長さのシンプルなロジックであれば大した差は無い。問題は、処理が複雑化した時だ。
let result = [];
for(let i = 0; i < arr1.length; i++){
for(let j = 0; j < arr2.length; j++){
/**
* なんか色々処理。分岐もあったりとか。
*/
result.push(somevalue);
}
}
この処理を見て、最終的なresultの形を予想できるだろうか?
arr1.length * arr2.length
の配列になる?
いやいや、途中の処理でbreak;
してたら?分岐の中にresult#push
しないパスがあったら?
もっと言えば、プログラマがその複雑な処理に気を取られて、うっかりresult.push[somevalue];
を書くのを忘れていたら?
もっと極端なこと言えば、途中でresult = undefined;
なんて代入がある可能性すらある。
ここにあげた例のようなことが「無い」って証明するためには、forの中身をいちいち全部読まなければいけない。
これはしんどい。
というかそもそも、この形を見た時に、
このプログラマはresultにarr1とarr2から生成した新たな配列を格納しようとしている
って読み取れるだろうか?読み取れるのはおそらくソースを全部読んだあとだろう。
一方で、
const result = arr1.map(valOfArr1 => arr2.map(valOfArr2 => {
/**
* なんか色々処理。分岐もあったりとか。
*/
return somevalue;
})).flat();
この場合は、resultの中身が長さarr1.length * arr2.length
の配列であることが保証される。
確かに、jsにおいてはreturn
を忘れて、全て値がundefined
になっている可能性はある。
しかし、TypeScriptであれば型チェックに引っかかるのでreturn忘れを事前に気づくことができる。
上のfor
の例でresult.push(someValue);
を忘れた場合は型チェックでは引っかからない。
それよりも何よりも、こちらの方がプログラマが「arr1とarr2を組み合わせて新しい配列を作ろうとしている」という意図が明確になる。
これがかなり重要で、意図が明確かつ結果が保証されているかどうかで、
/**
* なんか色々処理。分岐もあったりとか。
*/
を読むストレスが全然違う。これは大きい。
(少し寄り道)可読性って何だろうね
こういう議論をしているとたまに「可読性」の定義が合わない人がいる。
例えば、
初心者でも(誰でも)読みやすく書くのが可読性の向上だ
という人がいるが、それは違うと思う。
その理屈が通るのであれば、日本語から漢字を撤廃して全てひらがなに統一すべきだ。その方が初心者である幼児や勉強初めたての外国人の方にとってわかりやすい。そうならないのは何故だろうか。
「読める人の多さ」と「表記としての効率性」はトレードオフだ。
幼稚園児に読ませる文章を書くときは全てひらがなで書くべきだが、それを大体の日本人の大人相手にやっていたら効率が悪い。
もっと言えば、例えばある程度オブジェクト指向を勉強した相手に、
ここもうちょっとカプセル化しない?
って提案するのはシンプルでわかりやすいが、プログラミング覚えたての初心者には伝わらない。
このsetterにnull突っ込んだらどうなると思う?
インスタンス化したあと、setHoge()を呼ばずにexecuteFoo()を読んだらエラーになるよね?ちょっと工夫してみようよ。
というように噛み砕いて伝えなければいけない。「効率」を考えた時どちらの方が良いだろうか。
可読性の向上というのは、
想定読者のレベルをある一定だと想定して、問題なく読めてかつ効率的な表記を目指す
ことだと思う。
例えば、初心者に教えている際に
[...Array(100).keys()].map(n => n + 1).map(n => n % 15 === 0 ? 'FizzBuzz' : n % 3 === 0 ? 'Fizz' : n % 5 === 0 ? 'Buzz' : n.toString()).forEach(s => console.log(s));
こんなワンライナーを書けるようになりましょうね
って言ったら流石にバカだと思う。ちゃんとfor
で回すループを教えるべきだ。
(というか上級者相手でもここまでのワンライナーはほぼ"遊び"だろうが。。)
でも、ある程度ES6を勉強している向けに、
const suites = ["H","C","D","S"]; //H:ハート C:クローバー D:ダイア S:スペード
const nums = ["A","2","3","4","5","6","7","8","9","T","J","Q","K"]; //トランプの数字
const trump = suites.map(suite => nums.map(num => suite + ":" + num)).flat();
このコードを書くのはむしろ読みやすいし、何より(今まで説明した通り)安全な記述だ。
前回の記事でいう、
const N = 100;
[...Array(N).keys()].forEach(i => {
//...なんか処理(iには0...N-1(=99)が格納される)
});
というハックはそのあたりのバランスが確かに微妙だとは思う。
人によっては、「可読性を損なうデメリットの方が大きい」という人もいるだろう。
もうこれ以上は宗教戦争の域に入りそうだが、それでも私は僅差でfor
を避けるメリットの方を推したい。
それでもforを勉強すべき理由
さて、散々for
の悪口を言っていたのだが、だからと言ってfor
(やwhile
)を勉強しなくても良い理由にはならない。きちんと勉強すべきだ。
理由は以下の通りだ。
1.様々な言語に対応できる
近頃は大体の言語で配列のmap/filter/reduceなり拡張forなりをサポートしている。あのJavaですら(失礼?)近年ではStreamAPIを導入して関数型っぽい記述をできるようになっている。
ただ、例えば私が知る限りでは例えばC言語には拡張forやmap/filter/reduceなどの組み込み関数は無いように思う。
基本的なfor
やwhile
はそれぞれの言語で方言はあれどほぼ全ての言語で使える文法のはずなので、やはり勉強しておくべきである。
2.どうしても使わなければならない/使った方がわかりやすいロジックやアルゴリズムに直面することがある
例えば、ソートやシャッフルなど。
下記はこちらから引用させていただいたフィッシャー・イエーツシャッフルの実装だが、
const shuffle = ([...array]) => {
for (let i = array.length - 1; i >= 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
このように添字を使ってアクセスした方がはるかにわかりやすいパターンがある。
こういったアルゴリズムをわざわざfor
を避けて書くのはあまり賢い選択では無いだろう。
3.漏れのある抽象化の法則
本記事の冒頭で書いたSNSで議論させていただいた相手に教えて頂いた(不勉強ながら初めて知りました。。ありがとうございます!)
かの有名なJoel on Softwareの一節で、この記事の文脈に沿って要約すると、
Array#forEachやArray#mapを使っていて何か問題が生じた時、forやwhileの書き方を知らなかったら問題解決できないよね?
ということらしい。
それは本当にその通りだと思う。
わかりやすい例で言えば、Array#forEach
ではbreak;
によるループからの離脱ができない。
どうしてもbreak;
が必要な場合はfor
やwhile
に書き換える必要があるが、裏を返せばそれができないレベルのプログラマにはArray#forEach
を使いこなすのが難しいということだ。
まとめ:forを"ダサい"と表現したのは軽率だったと反省している
前回の記事で、典型的なfor
文を書いて
こんなダサいループを回したく無い
と書いた。
正直、記事冒頭で読み手を惹きつけるためのネタのつもりで書いたのだが、軽率であったと反省している。
そんなことを書いたせいかどうかはわからないが、前回紹介したようなハックに対して
わざとトリッキーに書いて頭いいぶってカッコつけている
という印象を持たれてしまったようだ。それははっきり言って心外だ。
前述した通り、確かに
const N = 100;
[...Array(N).keys()].forEach(i => {
//...なんか処理(iには0...N-1(=99)が格納される)
});
は可読性的には微妙なラインだ。
でも、それを天秤にかけてもfor
を避けたい理由がキチンとある。この記事に書いた通り、実利的な理由だ。
前回も引用したStackOverflowの質問者さんの質問文に、
To me it feels like there should be a way of doing this without the loop.
意訳: ループをしないでやる何かうまい方法があるような気がするんだ
と書いてある。
私もそれを思っていた。でも私は、こういった局面で何も工夫をせずに妥協してfor
を使っていた。
それを妥協しないでちゃんと質問した質問者さんも、それに見事なハックで答えた回答者さんも本当にかっこいいと思う。
"カッコつけている"わけではなくて。