この記事は JavaScript2 Advent Calendar 2018 の1日目の記事です。
こんばんは。@diescake です。
今年は、JavaScript 経験の浅い新人さんや外注さんをリードする立場として、
とにかく幅広いメンバーのコードレビューをする機会に恵まれたのですが、
事ある毎に Array.prototype.forEach
を利用する人が多かったため、初心者向けに要点を整理してみました。
以下 ES2015 以降のバージョンをサポートするブラウザ、あるいは polyfill を利用していることを前提としています。
結論
配列に対して何らかの操作を行う際は、
filter
, find
, map
, reduce
などのメソッドを利用できないか検討し、
いずれのメソッドでも実現ができない場合の最終手段として forEach
を選択しましょう。
下記に、いくつかのサンプルコードを例に説明していきます。
forEach ➔ filter
まず、最初に Array.prototype.filter
の例です。
const pomeranians = [];
dogs.forEach(dog => {
if (dog.type === 'pomeranian') {
pomeranians.push(dog);
}
});
このコードでは、配列 dogs
のうち特定の条件に合致した要素を全て抜き出しています。
勿論この実装でも正常に動作しますが、
このケースでは下記のように filter
を利用することでより簡潔に記述できます。
const pomeranians = dogs.filter(dog => {
return dog.type === 'pomeranian';
});
さらに、実際には arrow function の shorthand を利用することでさらにコンパクトになります。
(これ以降の例では shorthand によるコードのみを紹介します)
const pomeranians = dogs.filter(dog => dog.type === 'pomeranian');
さて、forEach
から filter
に書き換えたことによって、コードが短く簡潔になったことはわかると思いますが、
それよりも重要なメリットは、
配列のループ処理に filter
が利用されていることで、このコードを見た人に、
このループが dogs の subset を抽出している、という意図が瞬間的に伝わる ということです。
forEach
は配列をループするという目的と意味しか持たないため、
何のためにループを行っているのか把握するためには、実装の詳細を追う必要がありますが、
filter
が利用されている場合、コードの詳細を読まずとも、処理全体として特定条件の要素を抜き出す処理を行っているだろうことが伝わります。
コードリーディングにおいて、ループの処理を読み解くのは脳に対する負荷は高くなりがちですが、
前もって処理全体の目的が分かれば、詳細を理解するにあたって大きくコストを削減できます。
ここでは filter
の例で説明しましたが、それ以外の find
, map
, reduce
についても同様のメリットがあり、
適切なメソッドを選択することで、リーダビリティに優れたコードとなるため重要です。
forEach ➔ find
続いて Array.prototype.find
です。
let myDog;
dogs.forEach(dog => {
if (dog.name === 'ポメラニアス3世') {
myDog = dog;
}
});
このコードでは、配列 dogs
から、特定の要素を抜き出しています。
また、forEach
ではループの break
が実現できない点が気にかかり、
もっと素朴に for
文で記述する人も見かけましたが、
いずれもあまり良いコードとはいえません。
let myDog;
for (let i = 0; i < dogs.length; i++) {
if (dog.name === 'ポメラニアス3世') {
myDog = dog;
break;
}
}
このケースでは下記のように find
を利用することでより目的を明確にし、簡潔に記述できます。
const myDog = dogs.find(dog => dog.name === 'ポメラニアス3世');
ちなみに、filter
を使うと以下のように書けます。
const myDog = dogs.filter(dog => dog.name === 'ポメラニアス3世')[0];
コードの実装量からすると、どちらも大した差はないように見えますが、
先程説明したように、find
を使うことで、配列から特定の要素1つを検索して抜き出すという意図が明確になるため、
filter
より find
を利用するのが良いでしょう。
特にこのケースで filter
の戻り値を配列のまま変数で受けると、
myDogs[0]
のように index を指定して先頭の要素のみを参照することになりますが、
この場合、配列の先頭は何を意味するのか?、先頭以外の要素はなぜ参照しないのか?といった労力を読み手に課すことになるため、避けたほうが良いでしょう。
forEach ➔ map
3番目、Array.prototype.map
です。
const dogNames = [];
dogs.forEach(dog => {
dogNames.push(dog.name);
});
このコードは、配列 dogs
の各要素を参照して、別の配列を作り出す例です。
別の構成の配列を作り出すようなケースでは map
を使うときれいにかけます。
const dogNames = dogs.map(dog => dog.name);
この例ではあまりにシンプル過ぎてメリットが伝わらないかもしれませんが、
React で JSX を扱う場合は、必ず利用するといっても良いくらい活躍の場は多いです。
render() {
return (
<ul>
{this.props.dogs.map(dog => <li>{`${dog.name}: ${dog.description}`}</li>)}
</ul>
);
};
また、下記のように、特定の要素のみを加工して配列に作るというケースはあると思いますが、
map
だけでは要素の取捨選択を行うことはできないため filter
と組み合わせて実現することになります。
const pomeranians = [];
dogs.forEach(dog => {
if (dog.type === 'pomeranian') {
pomeranians.push({
id: uuid(),
name: dog.name,
});
}
});
const animals = dogs.filter(dog => dog.type === 'pomeranian')
.map(dog => ({
id: uuid(),
name: dog.name,
})
);
map
で処理する前に filter
で条件にあった配列を抜き出しています。
const animals = dogs.map(dog => {
if (dog.type !== 'pomeranian') {
return null;
}
return {
id: uuid(),
name: dog.name,
};
}).filter(v => v);
上記は逆に map
で処理してから filter
する例です。
この例では前者の方が簡潔ですが filter
する条件が複雑化した場合、
map
関数中で適宜 early return した null
を後からまとめて filter
で弾く方が見通し良く記述できる場合があります。
ちなみに、配列に対する .filter(v => v)
という記述で、
配列中の falsy な値を全て除くことができるというのは覚えておいて損はないと思います。
forEach ➔ reduce
最後に Array.prototype.reduce
です。
let total = 0;
dogs.forEach(dog => {
total += dog.price;
});
このコードは、配列 dogs
の各要素を参照して、合計値を計算している例です。
実は前3つと比較すると reduce
を使った方が良いです!!とコメントしたケースは少なかったのですが、
こういった配列の合計値を求める処理や、文字列連結を行う際に、
変数宣言を let
から const
に書き換えることができるという点が便利ですね。
const total = dogs.reduce((acc, dog) => acc + dog.price, 0);
また、次の項目で紹介しますが、
内部状態を持つインスタンスを生成し、ループ中でインスタンスメソッドを呼び出し、
最終的に内部状態が変更されたインスタンスを戻り値として返すような処理がワンライナーで書くことができるので、ビシッと嵌まるときがあります。
ただ、reduce
も forEach
程ではないですが、比較的万能なので、
乱用すると意図がよくわからないコードになりがちな気はします。
forEach が妥当なケース
さて、逆に Array.prototype.forEach
での記述が妥当だと感じた例です。
dogs.forEach(dog => {
console.log(dog.name);
});
外部のスコープに対して直接関与しない場合は、forEach
での記述は妥当な気がします。
(すぐに思い浮かびませんが、例外は何かあるかも…)
また、上記例に近くはありますが fetch
や通知など、非同期処理を呼び出す場合は、
forEach
でループごとに await
することができないことと、実際には Promise.all
を利用して並行処理できるケースが多いと思うので、
この場合は Promise の配列を返すために map
を利用することになると思います。
await Promise.all(dogs.map(async dog => await dog.eat('ペディグリーチャム'));
次に、ある内部状態を持つインスタンスに対して、インスタンスメソッドで操作する例です。
const pomeranian = new Pomeranian();
foods.forEach(food => {
if (food.type === 'beef') {
pomeranian.add(food);
}
});
ちなみに、reduce
で記述すると下記のように書けます。
const pomeranian = foods.reduce((pomeranian, food) => {
if (food.type === 'beef') {
pomeranian.add(food);
}
return pomeranian;
}, new Pomeranian());
ただこれは forEach
に比べて簡潔になったかというと微妙で、
関数の戻り値としてインスタンスを受けたいケースでない場合は、forEach
で記述されていても、
好みの範疇で特に問題はないかなと感じます。
まとめ
というわけで、ちょっと微妙なサンプルもあったと思いますが、forEach
を書き換える例を紹介しつつ、
なぜ、書き換えた方が良いか?という話をしてみました。
forEach
を利用すると何でもできてしまうが故に、
1つのループ中で、複数意図の処理を詰め込み、
if
文を乱立させたり、for
をネストさせて…、と、スパゲッティ化した PR をいくつも見ました。
元々 forEach
は戻り値を持たないので、意味のある処理を実現しようとすると外部スコープに対する操作が前提となり、
ともすると、あれもこれもと、やり放題になってしまうのかなと感じています。
また、実際にウェブアプリを実装する際には、
lodash, immutable.js, Ramda などの colletion を操作するライブラリを利用するのは有効ですが、
bundle したファイルサイズにも影響してきますので、無理がない場合は vanilla で書くことは良いユーザ体験に繋がると思います。
最後に、ほぼパフォーマンスについては言及しませんでしたが、
最も素朴に for
文で記述する方が恐らく速く(要出展)、リーダビリティとトレードオフになるとは思います。
ただし、for
が速いとはいえ、この書き換えによって、パフォーマンスに大きく差異がでるケースは稀だと思っていて、
チューニングフェーズで、実測して明らかにボトルネックであることが判明してから、書き換えを検討するという方向性が良いと思います。
(この書換えによって、パフォーマンスに大きく差異がでる程のループ処理を JavaScript 上で行う必要がある時点でサービス全体の設計に問題がある気が…)
さて、明日は @ttokutake さんです。 (╹◡╹)