JavaScript

反復系コレクション操作メソッド入門


はじめに

コレクション操作メソッド、使っていますでしょうか。

いわゆるループ処理で書いてたような処理を見やすく書ける文法なのですが、散発的に各言語に導入され、時期もバラバラだったため、


  • プログラム言語の進化の大きな流れとしてイマイチ認識されてない

  • (歴史的経緯はともかく実用上は)ループ処理の発展系・進化史として組み込まれてもいいはずなのに、体系化されて纏められてない

  • (無名関数や高階関数、返り値を駆使しているため)制御構文覚えたてのプログラム初心者へ指導するのは厳しい

と、広まるにはハードルが高いように感じました。ある程度、経験積んだ経験者が草の根の普及記事や言語の新仕様の紹介記事をたまたま見る、あるいはソースコードに直面して学ぶケースも多いのではないかと。

そこで普及の一助になればと考え、どのように進化してきたかという歴史紹介及び、活用するにあたってどのように考え方を変えるべきかを自分の経験談を基に纏めてみることにしました。ただJS以外の言語の細かい仕様や歴史に関してはかなり疎く、妄想や後付け解釈混じりまくってるので、コメ欄で教えていただければ幸いです。

また文中でコレクションという言葉を多用していますが、初心者の方は具体例である「配列」という言葉に置き換えて読み進めてください。


例題

ボウリング大会を開かれ、まず予選として6人で3ゲーム行いました。

合計点上位3名が決勝進出となります。ただし1Gでも20点以下の場合、足切り対象となり次点が繰り上がりとなります。

以上の条件で決勝に進出する3人を抽出して下さい。また(決勝の組み分けに使用するので)決勝進出者については合計点をtotalプロパティとして追加しておいてください。

//旧式の書き方にも対応させるため、ここはあえてvar。今ならconst

var players = [
{ name:"A", scores:[100, 70, 30]},
{ name:"B", scores:[80, 80, 50]},
{ name:"C", scores:[30, 10, 100]},
{ name:"D", scores:[20, 150, 50]},
{ name:"E", scores:[50, 90, 80]},
{ name:"F", scores:[70, 40, 60]}
];
var numberOfWinners = 3;
var minimumScore = 20;


for文の時代(第1世代/~90年代)

最初は誰もが知るfor文から。


//すいませんそのうちちゃんと例文のお題を解きます……
for(var i; i < array.length; i++){
var element = array[i];
...
}

この時代は「カウンタが上がっていくループ装置」に「コレクションと処理を放り込む」的な認識で、(現在基準で見ると)超難解なフロー、スパゲティコードになっていることも珍しくありませんでした。1個のfor文の中で入れ子になって絡み合った複雑な条件分岐、indexを利用して複数の配列に同時アクセス&操作するのも当たり前、さらには途中でcontinue等ですっ飛ばしたり等……:scream:

よく問題点として上げられるのはイチイチ、インデックス絡みの処理書くのは見辛い/ミスの原因になる(特に多重ループした場合に複雑度が跳ね上がる)、あるいは終了条件まで設定できるので無限ループになりうるとかですかね。


for構文拡張の時代(第2世代/90年代後半~00年代前半)

for文の難解さはカウンタ周りの設定が可能なことにあると考え、「1つの配列について決め打ちで最初から最後まで回すようにしよう」ということでfor-in文が導入されました。


for(var i in array){
var element = array[i]
//省略
}

まだカウンタ変数が存在するのでやろうと思えば他の配列を同時操作可能です。とはいえ、ループの「軸」が特定のコレクションになるため、「ループ装置」としての概念は薄くなり主体としてのコレクションの存在感が増しましたね。


for構文拡張の時代(第3世代/00年代前半)

for each(var element in array){

//省略
}

ここで例文のelementの部分にarrayの要素が先頭から自動で代入されるため、要素の取り出し処理を書く必要がなくなりました!スゴイ!:thumbsup:あとインデックスが存在しないため複数の配列を同時に操作不可能に。ただ無くしたことにより、かえって書き辛い処理も多少あり、旧来のfor,for-in文とは使い分けされる形に。


コレクション操作メソッド時代(第1世代/00年代中盤~)

その後、関数型言語やその影響を受けたライブラリが流行り(適当すぎてごめんなさい:cry:)、コレクションを操作するメソッドとして追加されました。実は導入の速かった言語では00年代後半(JavaScriptが05年のver1.6で仕様決まってるので、とりあえずここ基準)から存在します。他の言語でもC#がLINQという形で導入したりと、関数型言語以外の言語へ普及し始めます。

var winners = players.filter(//1.最低点で足切り

function(player){
return player.scores.every(
function(score){ return score > minimumScore; }
);
}
)
.map(//2.合計点を算出して付与(本当は.mapで破壊的操作するのどうかなと思うんですが、チェーンのインパクト重視)
function(player){
var total = 0;
player.scores.forEach(function(score){ total += score; })
player.total = total;
return player;
}
)
.sort(//3.ソート
function(previous,current){ return current.total - previous.total; }
)
.filter(//4.通過人数で絞り込み
function(player,index){ return index < numberOfWinners; }
)


使いこなすためのヒント1

ここでは大幅に考え方ややり方が変わっており、躓くと思うので要点を3つ上げます。


  • 主体が名実ともに「1つの」コレクションになった(=コレクションに含まれる要素を処理することに特化)

  • 関数の再利用性や見やすさの兼ね合いから、処理の工程ごとにできるだけ細かく分割することが推奨されている

  • 分割した関数を繋ぎ合わせることで処理を構築できるようになっている

こんな感じでしょうか。実際書いていく上ではまず.forEach 以外を使ってフローを検討した後、駄目だったり、あるいは破壊的な処理(副作用のある処理)するのが明確な場合に、チェーンの締めとして.forEach使う感じですね。


使いこなすためのヒント2

要点1に関連して処理の中の「分岐」を「対象を選択」として捉え直すことも必要になります。要するに昔のfor文内でifやswitchを使って分岐しているような処理を出来る限り.filterに置き換え別関数として分割してしまえということですね。

「選択?ってことは選択しなかった方は処理できないの?使えなくない?」と怪訝に思ったそこの貴方!

「.forEachでbreakできない(or以後の処理を飛ばす方法)」云々で検索したことのある貴方!(ちなみにこれはワイもやらかしました:scream:

まさしくここで躓いています!コレクション操作メソッドはあくまで1つのコレクションが主体なので、分岐して(別々の処理が必要になって)いる時点で、それは1つのコレクションではなく複数のコレクションであり、分割してそれぞれ処理すべきなんです。

例題でいうと、決勝進出者と予選敗退者に別の処理が必要な場合、

var winners = [];

var losers = [];
players.forEach(
function(element,index,array){
//決勝進出かどうか判定して地道に外部のwinnersとlosersに.pushする
}
)

winners.forEach(
//決勝進出者の処理
);

losers.forEach(
//予選敗退者の処理
);

という形になります。……思想的には正しいんですが、正直もうちょっとスマートに振り分けできるようにならんかなぁとは思いますね:sweat_smile: 一見冗長に見える処理ですが、変数名がつくことによって意識の上でも明確に分離される感じがします。見通しが良くなるので、思っているほど悪いものでもないですよ。


使いこなすためのヒント3

上記見て計算量増えてない?遅いのでは?と思った方、正解です。メソッドごとにコレクションを1回走らせるので、$O(n)$ 1つで済んでたのがメソッド数の数だけ増えることになります。計算量の世界では整数倍は無視していいことになっていますが、個人的にはガンガン使いだすと馬鹿にならない印象なので、早め早めに.filterで絞り込みかけたり処理順を見直す等、多少は意識した方が良い気がしますね……。


コレクション操作メソッド時代(第2世代/現在)

そして現在。当初は配列を念頭に発展していましたが、データの集まり=「コレクション」ならば扱えるようにしようということで対象を拡大する流れになっています。またメソッドの種類も順調に増えており、.reduce、.find、.flat等、元のコレクションから大幅に構造を変えた要素が一発で取得できるようになったり等、多彩になっています。

アロー関数等も駆使して書いてみましょう。


const winners = players.filter(//1.最低点で足切り
player => player.scores.every(score => score > minimumScore)
)
.map(//2.スプレッド構文でplayerをコピーしつつ、合計点を算出して付与
player => { //player => {...player,(以下省略)}とすると、外側の波括弧が関数の処理を括るものと判断されるらしく不可)
return { ...player, total : player.scores.reduce((previous,current) => previous + current)}
}
)
.sort((previous,current) => current.total - previous.total)//3.ソート
.filter((player,index) => index < numberOfWinners)//4.通過人数で絞り込み

あとJavaScriptに関して、現状async,awaitといった非同期処理には対応しておらずfor-of文を使う必要があるので注意してください。

一方、他言語でも2014年にJavaがStreamという形で導入、新言語だとSwiftやRustなんかも最初から導入済み等、大きな流れになっています。細かい仕様や書式は異なりますが色んな言語で導入されているので、是非試してみて下さい。


更に学びたい人向けのオススメ記事

なぜfor文は禁止なのか?ポエム版

JavaScript で forEach を使うのは最終手段

Swiftのmap, filter, reduce(などなど)はこんな時に使う!


参考記事

連休だしJavaコレクションの歴史を振り返ってみる

C# やるなら LINQ を使おう