Edited at

【forが嫌い!可読性を上げたい!】楽するために学ぶ配列の高階関数(map, filter, reduce等)


複雑すぎるforの処理に悩まされたことはありませんか?

プログラミング習いたての頃、forに悩まされた記憶はありませんか?

また、業務で複雑すぎるfor文を見て、これくらい理解できないとやっていけないのか…と悩んだ記憶はありませんか?

実はそのfor…もっと読みやすい書き方が出来て、簡単に読めるとしたら楽じゃないですか?

いやいや、単にもっと楽したくありませんか?

今回は個人的に「苦手なfor文」の書き換え(map, filter, reduce等)について、短くなるだけじゃないところを紹介したいと思います。

コードを読む事に神経をとがらせて疲弊したくない人には、オススメしています。(頭を使う労力が減ってると信じたい...)


本記事について

【2019/08/26】sliceをその他の便利関数に移動しました。LINQ, StreamAPIの説明に誤解がないように追記しました。

【2019/08/25】サンプルにimmutable.jsを追加しました。

【2019/08/25】JSでZip等の高階関数が実装されているライブラリについて、その他に追記しました。


読み方

読み方.png

重要なことだけ教えろって方は、大項目と強調文字だけ読んでください。


対象者

対象者.png


  • 2-3年目のエンジニア or コードレビュアー

  • アロー式(ラムダ式)を理解している方

  • リーダブルコードを書きたい方


目的

目的.png


  • 読む側にとって負担のないコードを書く。

  • より良い変数の書き方を理解する。


注意点


  • あくまでも、個人的意見です。

  • サンプルコードはJavaScriptで書いています。(ただし、一部C#の有能なLINQを紹介しています。)

  • すべての言語に適用可能でない可能性があります。

  • 業務でコーディングする際は、プロジェクトのコーディング規約に従ってください。

  • 速度を求められる汎用的なライブラリや専門的な処理はパフォーマンス重視のため、本記事の内容は適用されません。


リーダブルコード

リーダブルコードについては、下記を読んでください。

【変数を笑う者は変数に泣くぞ】20分で学ぶ!読みやすくバグを生まないローカル変数を書く方法


配列の高階関数

これから紹介するmap, filter, reduce等は良く使われるものですが、初級者の方には難しいと思います。

ただ、早めに触れて理解しておく方が可読性の点から良いと思います。

これらは関数型プログラミングの考え方から、作られたものだったと記憶しています。

また、高階関数とも呼ばれています。

高階関数とは、関数を引数にしたり、あるいは関数を戻り値とするような関数のことです。

wiki

C#ではLINQ, JavaではStreamAPI等の名前がついていますが、基本的な使い方はどれも似たようなものです。

これらは高階関数名ではなく、配列操作の高階関数と、共に使うと便利な配列操作の関数をまとめたライブラリ名(機能名)です。

配列以外にコレクションにも適用できます。

(遅延評価だったり、インターフェースに実装されていたり、書き方が少しめんどくさいものもあります。)


map

mapは引数にとった関数を使って新しい配列を作り出す関数です。

Array.prototype.map()

const new_array = arr.map(function callback(currentValue[, index[, array]]) {

// 新しい配列の要素を返す
}[, thisArg])


サンプル

とりあえず例を見てみましょう。

配列の値を2倍して新しい配列を作るだけのコードです。


map

const numbers = [1, 2, 3, 4, 5]

const doubledNumbers = numbers.map(number => number * 2)
console.log(doubledNumbers) // [2, 4, 6, 8, 10]


for

const numbers = [1, 2, 3, 4, 5]

const doubledNumbers = []
for (const number of numbers) {
doubledNumbers.push(number * 2)
}
console.log(doubledNumbers) // [2, 4, 6, 8, 10]

また、コールバック関数の引数にindexや処理対象のarrayを使う事も出来ます。


mapで書くメリット

まずは、簡潔に書けていることです。

また、mapは「配列を加工して、別の配列を作り出す」という目的が明確に定まっています

そのため、mapが出てきた時点で「ここの処理は別の配列に加工しているんだな」と判断することができます

forのように幅広い用途がなく目的が明確なため、違和感(不具合)に気づきやすくなり、バグを生む確率がグンと減ります。


mapのアンチパターン

使用目的が定まっているため、map内で何かを実行したり、map外の何かを操作したり、加工以外の別の処理を行う事は完全なアンチパターンです。


Mapのアンチパターン

const numbers  = [1, 2, 3, 4, 5]

const doubledNumbers = []
numbers.map(number => doubledNumbers.push(number * 2)) // NG. スコープ外のdoubleNumbersの操作を行っている。
console.log(doubledNumbers)
numbers.map(number => doSomething(number))) // NG. 実行のみを行っている。加工の用途ではない。


filter

filterは引数にとった関数がtrueを返す値で新しい配列を作り出す関数です。

Array.prototype.filter()

const newArray = arr.filter(callback(element[, index[, array]])[, thisArg])


サンプル

偶数だけに絞って新しい配列を作る例です。


filter

const numbers = [1, 2, 3, 4, 5]

const evenNumbers = numbers.filter(i => i % 2 === 0)
console.log(evenNumbers) // [2, 4]


for

const numbers = [1, 2, 3, 4, 5]

const evenNumbers = []
for (const i of numbers) {
if (i % 2 !== 0) continue
evenNumbers.push(i)
}
console.log(evenNumbers) // [2, 4]


filterで書くメリット

これもMapと同様に簡潔に書ける事、別の配列を作り出すこと、目的が明確なことです。


filterのアンチパターン

使用目的が定まっているため、mapと同様にfilter内で何かを実行したり、filter外の何かを操作したり、評価以外の別の処理を行う事は完全なアンチパターンです。


reduce

mapでは実現できないような、配列の関係性から新しい要素を作り出せます。

Array.prototype.reduce()

const newArray = arr.reduce(callback(accumulator, currentValue[, currentIndex [, array]])[, initialValue])


サンプル

合計値を計算する例です。


reduce

const numbers = [1, 2, 3, 4, 5]

const numbersSum = numbers.reduce((sum, i) => sum + i)
console.log(numbersSum) // 15


for

const numbers = [1, 2, 3, 4, 5]

let numbersSum = 0
for (const i of numbers) {
numbersSum += i
}
console.log(numbersSum)

使い方や実現出来る事を知っておかないと、使いたいという気にならないと思います。

そのため、しっかりと理解しておいた方が良いです。(特に合計値の計算、グループ化)


reduceで書くメリット

もちろん、簡潔に書けていることは言うまでもないと思います。

また、forで書く場合は必ずletでsumを定義しなければ、合計を計算できません。

letは極力なくした方が良いという考え方から、reduceで書く方が良いと思います。

(letは再代入可能なため、書き換えられる可能性が残る。)

mapでは実現できない配列の関係性から新しい配列を作り出せます。

つまり、reduceが出てきた時点で「ここの処理はmapでは実現できないような合計値を算出したり、グループ化を行っているな」と判断することができます。


reduceのアンチパターン

mapやfilterと同様に加工以外の処理はアンチパターンです。

またmapで実現できることをreduceで書くこともまたアンチパターンです。

(メリットにあるように、reduceが出た時点でmapで実現できない何かという考え方になるため)


reduceのアンチパターン

const numbers = [1, 2, 3, 4, 5]

const doubledNumbers= numbers.reduce(((current, number) => {
current.push(number * 2)
return current
}), [])
console.log(doubledNumbers) // [2, 4, 6, 8, 10]


forEach


サンプル


forEach

const numbers = [1, 2, 3, 4, 5]

numbers.forEach(i => console.log(i))


for

const numbers = [1, 2, 3, 4, 5]

for (const i of numbers) {
console.log(i);
}

forEachは実行のみを行います。

Array.prototype.forEach()

arr.forEach(callback(currentValue [, index [, array]])[, thisArg]);

個人的には使用していません。理由は次に示す通りです。


forEachで書くメリットとデメリット

書くメリットは簡潔にかけ、明確に実行のみの処理と明記できるという点です。

しかし、下記の理由から個人的には使用していません。


  • forEachを使わなくても、map, select, reduce等を意識して使う事でforが実行のみを担う形になる。

  • 言語によってはforEach内で発生した一部の例外がforEach外のtry-catchでは拾えない。(Javaのチェック例外)

  • 言語によってはforEachが実装されていない(C#のIList以外)

  • 個人的に色々な言語を触ってるので、関数レベルで影響のある書き方に依存したくない。

C#ですが、下記も参考になると思います。

なぜ List.ForEach は使うべきでないか

このためだけに言語毎に書き方を考えるのはコストがかかりすぎると考えているため、個人的にはforEachでなく実行はfor文で書くことをおすすめします。(特に可読性に大きく影響しないため)

もし他にも何か良い情報をお持ちの方がいらっしゃいましたら、ぜひコメントください!


その他


sort

ソートします。ただ、JavaScriptに関しては新しい配列を生成するものではないので注意が必要です。

Array.prototype.sort()

arr.sort([compareFunction])

C#であれば、OrderBy()、OrderByDescending()は、戻り値があるので、mapやfilter感覚で使えます。


C#

var numbers = Enumerable.Range(1, 10) // 1~10の連番の配列のようなものを生成する

.Select(i => i * 10); // C#ではmapをSelectと書きます。 10倍しています。
.OrderByDescending(i => i) // 降順に並び替える
foreach (var number in numbers)
{
Console.WriteLine(number); // 100, 90, ... 10 と出力されていく。
}


zip

2つ以上の配列から新しい配列を作り出します。

JavaScriptには標準では存在しません。(自作している、ライブラリで実装されてるものを使用してる方はいると思います。)

しかし意外と使い道が多く、非常に便利なので覚えておいた方が良いと思います。

下記では同じ配列を組み合わせて、各項目の前後の差分を出力しています。


C#

var numbers = new int[] { 1, 8, 14, 25, 37, 61, 99 };

var differences = numbers.Zip(numbers.Skip(1), (prev, next) => Math.Abs(prev - next));
foreach (var differece in differences)
{
Console.WriteLine(differece); // 7, 6, 11, 12, 24, 38と順に出力
}


その他(一緒に使う高階関数ではない便利関数)


slice

配列から範囲を指定して取り出したものを新しい配列とします。

Array.prototype.slice()

const newArray = arr.slice([begin[, end]])

C#ではskip, take

Javaではskip, limitで同様の事が実現できます。


実はまだまだある便利な関数

まだまだありますが、JavaScriptには実装されていないものが多いと思います。

C#のLINQやJavaのStreamAPIには多くのサンプルが存在するので、同じようなライブラリをjsで探すか自作すると良いと思います。

(使ったことはありませんが、linq.jsなるものもあります。他にもライブラリは多いはず。)

C# LINQ

Java StreamAPI

(追記 2019/08/26)

PHPもLaravelでコレクションのラッパーに様々な関数がありました。(Laravel Collection)

また、jsで良さそうなライブラリを調べましたがimmutable.jsが該当しそうです。

メソッドチェーン可能、遅延評価可能、zip等の便利な高階関数多数あり


immutable.jsをnode.jsで試したもの

const { List } = require('immutable');

const list = List.of(1, 2, 3, 4, 5, 6, 7);
const newList = list.toSeq() // toSeq()とすることで以降を遅延評価
.filter(i => i % 2 === 0)
.map(i => i * 2);

for (const i of newList) {
console.log(i) // 4, 8, 12と順に出力
}


少し古いですが、下記が参考になります。

JSのコレクション操作ライブラリーに対する雑な所感


まとめ


目的を明確化することで可読性は上がり、バグは減る

forでは色々な処理が実装可能なため、細かく処理を追わなければ全体像が分からなくなる可能性があります。

しかし、今回あげたような「目的が明確な関数」を使用することでコードを読む負担が減ります。

逆に「目的が明確な関数」を使用せずforで実装している場合、下記と判断して注意して読み進める必要があると解釈できます。


  • forでなければ実現が難しい処理

  • パフォーマンスを上げなければならない処理

このように実装しておく事で読む側の負担が大きく減ると同時に、概要を見渡せるので体感バグがかなり減ります。


紹介したものを使ったサンプル

紹介したものを使って下記を書いてみました。(わざと最適化せずにパフォーマンスは無視しています。)

「3の倍数以外の値を2倍して100以下の値を合計する」というものです。

例が良くありませんが、さらにindex等を判定や計算に組み込むとforの方はかなり読むのに苦労すると思います。

サンプルのコメントは私が読んでいくとしたら、こういう思考で読んでいるというものです。


高階関数

const numbers = Array.from(new Array(100).keys()).map(i => ++i) // 1..100の連番の配列生成

const result = numbers // 配列を...
.filter(number => number % 3 !== 0) // 何かの条件で絞って...
.map(number => number * 2) // 加工して...
.filter(number => number <= 100) // さらに絞って...
.reduce((sum, number) => sum + number) // 合計していると。
console.log(result) // 1734


高階関数(immutable.js)

const { Range } = require('immutable');

const numbers = Range(1, 100); // 1..100の連番生成
const result = numbers // 連番を...
.filter(number => number % 3 !== 0) // 何かの条件で絞って...
.map(number => number * 2) // 加工して...
.filter(number => number <= 100) // さらに絞って...
.reduce((sum, number) => sum + number) // 合計していると。
console.log(result) // 1734



for

const numbers = Array.from(new Array(100).keys()).map(i => ++i) // 1..100の連番の配列生成

let result = 0 // 0... 集計用だろうな?
for (const number of numbers) { // 配列でループさせる...
if (number % 3 === 0) continue // 3の倍数の時はスルー...
const temp = number * 2 // 値を2倍する...
if (temp > 100) continue // 100を超えるとスルー...
result += temp // あ、ここでやっと集計するのか...
}
console.log(result) // 1734

(追記 2019/08/25)

immutable.jsで書く方が連番生成がスマートになり、かつ遅延実行なのでより理想的な気がします。

思考コメントを読むと、「for」で書いたものは「条件→何」なのに対して、「高階関数」で書いたものは「何をするか→条件」なので、全体的な概要を把握しやすく、読み飛ばしやすいという事が分かると思います。

概要を把握して全体像が分かる分、バグが混在しても意外とすぐに分かったりします。

forを読み慣れている方からすれば、どうってことなく可読性にそこまで差はないかもしれません。

しかし、forは出来る事が広い分処理が明確ではなく、forのスコープ外の配列に対してpushするような事もできるため

読み切らなければ、処理を理解することが難しいと思います。


後書き

雑なところ多いので随時改善していきます…。

Twitterにmapで書いて欲しいとボヤいたところ、反応が多かったので…実はmapで書いていない人が多い…?と思い、書きました。

知る限りでは、各言語にmapやfilter等は実装されていると思います。(個人的にはC#のLINQが一番有能だと思ってます。)

JavaScriptでは遅延評価は行われないのでパフォーマンス的なデメリットは大きいと思いますが、パフォーマンスを考える際に書き換えれば良いと思っています。(そもそも事前に分かっている場合はforで書く、または大量のデータを扱う設計側を見直す)

可読性は上がるので積極的に使っていって欲しいなと個人的には思っています。特にコードレビュアーが幸せになれるのではないかと…。