概要
Airbnb の JavaScript Style Guide を適用する eslint のプラグイン eslint-config-airbnb を入れてコーディングをすると、 for ... of を使うなと怒られる。仕方ないので、map とか forEach とか every とか使っているうちに、for..of を使わないでやっていけるようになったという話。
1. 状況整理
1-1. 怒られる理由
for..of を使うと eslint-config-airbnb が怒る。理由は no-restricted-syntax 。詳しいことは、Qiita の記事「ESLint対応物語 ~no-restricted-syntax~」 と、その記事へのコメントが詳しくて、抜粋すると以下。
for-of 構文を Babel (babel-preset-es2015) で変換すると regenerator-runtime が必要になるため、変換後のコードが巨大になります。利点欠点を比較すると、そこまでして使う利点はないよね、というお話。同時に、ループ文自体を避けてより目的にあった Array メソッドを使うのはベストプラクティスです
1-2. 回避できないの?
eslint の設定で簡単に回避できる。Qiitaの記事「ESLintのno-restricted-syntaxでfor-ofだけを許可」を参照。
私はこの方法は選ばず、Airbnb の軍門に下ることにした。つまり、for..of を使わないという選択をした。eslint の設定ファイルを毎回いじるのがめんどくさいというのが一番大きな理由かも。関数型プログラミングに拒否感がないのも理由に挙げられる。
1-3. for..of の代替
for..of を回避するのはいいとして、代替案はあるの?ということだけど、Airbnb のスタイルガイド では、以下を推奨している。
Use map() / every() / filter() / find() / findIndex() / reduce() / some() / ... to iterate over arrays, and Object.keys() / Object.values() / Object.entries() to produce arrays so you can iterate over objects.
まあ、よくある話で、反復処理は関数型なパラダイムで行いなさい ということらしい。
2. 代替をきちんと考える
2-1. 代替のポイント
特に何も考えなくても、以下のように書き換えれば大体の場合 for..of は使わなくて済む。じゃあ何が問題なのだろう?
// 書き換え前
for (const x of arr) {
// do something with x
}
// 書き換え後
arr.forEach((x) => {
// do something with x
});
二つ問題がある。一つ目は break の扱いだ。for 文は一つずつ取り出して何かをする際に使われるが、break 文を使うと途中でとりだすのをやめることができる。これによって、反復を打ち切られ、計算量を削減したり、反復要素数が無限の場合を扱えたりする。一方、forEach, map, reduce では、どれも途中で打ち切ることはできない。
二つ目は反復対象の多様性だ。for..of はイテラブルプロトコルを実装したオブジェクトを反復対象とできるが、map, forEach, reduce などは Array のメソッドである。
2-2. breakと向き合う
まず考えたいのがそもそも打ち切らないという割り切りだ。要素数が数百程度なら、最近のCPUパワーでは問題にならないし、コードベースで複雑になるくらいなら、break 自体をしないという選択はありだと思う。
さて、一番簡単な対策は for(;;) を使うことだ。これなら eslint に怒られない。後述の対策が有効でないような場合は、常にこの選択肢にフォールバックする。とはいえ、積極的に for(;;) を使いたいという人はいないだろう。
もし、途中で打ち切ることができるメソッドが用途があえばこれを使う。具体的には find(), some() を使う。breakを使うような場合に限れば、意外とこれらでこと足りる場合は多い。具体的には、反復の目的が、条件を満たす要素の存在確認だったりその要素を取り出す場合だ。
const scores = [{ name: 'alice', score: 40 }, { name: 'bob', score: 60 }, { name: 'chales', score: 80 }];
scores.find(obj => obj.score > 50); // -> { name: 'bob', score: 60 }
Ramda の reduced を使うという代替案もある。JavaScript で本格的に関数型プログラミングをしようとすると、Ramda を導入することが多いと思う。もし Ramda が使える環境なら、Ramda の提供する reduce およびその reducer は、途中で打ち切ることができることを知っておくべきだ。map も reduce に書き換えができるので、Ramda を使えば map も reduce も途中で打ち切れる。
// NOTE: この例では、Array.prototype.find() を使った方がよいが・・・
const R = require('ramda');
const scores = [{ name: 'alice', score: 40 }, { name: 'bob', score: 60 }, { name: 'chales', score: 80 }];
const pickup = R.reduce((acc, obj) => (obj.score > 50 ? R.reduced(obj) : {}), {});
pickup(scores); // -> { name: 'bob', score: 60 }
2-3. Array 以外の反復
前述のように for..of はイテラブルプロトコルを実装した任意のオブジェクトで使える。具体的には、配列、文字列、Map、Set がビルトインで提供されている。
配列・文字列 の反復は特に問題ない。
Map や Set の反復操作の場合は、Map.prototype.forEach
もしくはSet.prototype.forEach
が使えるし、配列に変換したいなら Array.from
に渡してあげたり、spread syntax を使えば配列に変換できるので Ramda と連携させることもできる。
Object の反復は、Object.entries()
で各プロパティを配列にして、forEach で処理する。
Object.entries(obj).forEach(([k, v]) => console.log(`${k}: ${v}`))
3. やっぱ for..of いらないわ
for..of やめていいことがあるよ、ということを例を挙げてみよう。
for..of で囲まれた部分は、ある意味処理をベタ書きしているのと同じなので、少しでも長くなると読むのが負担になる(インデントが深くなると脳内レジスタが溢れてしまう)。一方、forEach, map, reduce はそうならない。もちろん、コールバック関数や mapper, reducer を匿名関数にするならば一緒なのだが、そうではなく、関数を別の場所で定義できるのが非常に大きなメリットになる。
const users = ['alice', 'bob', 'chales'];
const [listOK, listNG] = [[], []];
for (const user of users) {
try {
const address = getAddress(user);
const msg = createMessage(user, address);
sendmail(msg);
listOK.push(user);
} catch (err) {
emitWarnLog(`fails to send mail to ${user}, errmsg:${err}`);
listNG.push(user);
}
}
console.log(`OK:${listOK.length}, NG:${listNG.length}`);
上記の例は、for文のなかに try..catch が入ってきてるし、処理が結構長めなので読みづらい。for文のスコープの中で、listOK, listNG を操作しているというのもテストをしずらくする一因となっている。これを forEach で書き換えるとこうなる。
function doSend (user, OK, NG) {
try {
const address = getAddress(user);
const msg = createMessage(user, address);
sendmail(msg);
OK();
} catch (err) {
emitWarnLog(`fails to send mail to ${user}, errmsg:${err}`);
NG();
}
}
const users = ['alice', 'bob', 'chales'];
const [listOK, listNG] = [[], []];
users.forEach(user => doSend(user, x => listOK.push(x), x => listNG.push(x)));
console.log(`OK:${listOK.length}, NG:${listNG.length}`);
関数をくくり出せたので、本文のインデントが 0 になった。 for..of のインデントの深さが2 なので forEach を使うとインデントの深さを 2 小さくできた。また、doSend という名前をつけることができたので反復操作の目的が名前から推測できるようになった。
また、関数をくくり出せるのでテストがしやすくなった。listOK や listNG を弄るのは厄介だぞっと実装時に気づいて、コールバックを引数に取るように doSend を実装できたのもポイントが高い。ふつうにやると doSend に listNG や listOK を参照渡しするようになってしまって、関数の純粋性が破壊されるだけでなく、バグの温床になったり、メンテナンス性や拡張性に問題を残すことになってしまっただろう。
4. まとめ
まあ、for..of なくてもなんとかなるよ。
5. 補足
5-1. await について
はてブや、本稿のコメントでも指摘があったが、「for..of を使わないと非同期タスクの直列化ができないのでは?」という疑問があろう。それには私はこう答えたい。「あなたに必要なのは for..of ではない。必要なのはPromise.allによく似たユーティリティ関数だ」。
確かに、forEachでは非同期の直列化はできないし、実現するためには、for(;;) もしくは for..of での実装が必要だろう。しかし、直列化が必要なときに毎回 for..of を使うのが妥当なのだろうか?複数の非同期タスクを並列化する際には、毎回 for..of で実装するのではなく、Promise.all を使うよね?それと同じで、直列化のたびに for..of を書くのは DRY に反すると考えるがいかがだろう?
// こうは書かない
const xs = [1, 2, 3];
const ps = xs.map(asyncTask);
for (const p of ps) {
await p;
}
// 普通はこうかく
await Promise.all(xs.map(asyncTask));
// こうは書かない???
const xs = [1, 2, 3];
for (const x of xs) {
await asyncTask(x);
}
// こうかくべき?
await serialize(xs, asyncTask);
これを実現するための serialize 関数の実装例を紹介するよ。
async function serialize2(xs, fn) {
const ret = [];
for (let i = 0; i < xs.length; i += 1) {
// eslint-disable-next-line no-await-in-loop
ret.push(await fn(xs[i]));
}
return ret;
}