LoginSignup
0

More than 1 year has passed since last update.

配列の生成をループ&pushではなく宣言的に行う

Last updated at Posted at 2022-08-11

ひょっとしてまだ、配列をループ&pushで初期化する人生を送っていますか?

先日、仕事中に他の方が作ったJavaScriptコードでエラーが出ているのに気が付きました。
古いコードではありません。現在進行形のプロジェクトです。対象ブラウザはChrome/Edgeの最新版です。

// cdが50を超えるものを抽出
var list = [];
for (i = 0; i <= ret.data.length; i++){
    var item = ret.data[i];
    if ( item.cd > 50 ) {
        list.push(item);
    }
}

「ret.data」という配列があり、その配列の要素のcdが50を超えていたらlistにpushする、というものです。
つまり、ret.dataのデータを「cd > 50」という条件でフィルタリングした結果をlistに入れる、ということですね。

さて、どこでエラーになっているかお分かりでしょうか。
そうです、ループ条件の「i <= ret.data.length」ですね。10個しかない配列にdata[10]でアクセスしようとしておかしくなっています。

不具合うんぬんの前に 今でも(注:2022年夏)こんな処理をforループで書く人がいるんだな… と思いました。

今だったら、こんなコードはfilter関数で一発です。配列のインデックス境界バグなんぞ無縁です。コードの意図も明確になりました。この方法を使って悪い事が一つたりともありません。

const list = ret.data.filter( item => item.cd > 50 );

filter関数を知らない方は、ぜひこの機会に覚えて頂ければと思います。

もし「古いブラウザも対応しないといけないから使えない」といわれた人がいたら、mdnのArray.prototype.filterのページを開いてください。

上のページを開いて、一番下までスクロールして「ブラウザの互換性」のとこ、みて下さい。

IEですら、対応しています。そもそもIE自体、もう終わりましたから、ブラウザの互換性とか気にして使わないのは、もったいないことです。

item => item.cd > 50 がわからない、という方は、おそらくアロー関数式を使ったことがないか、ブラウザの互換性の問題で使わせて貰えないのかもしれません。
これも同様に、mdnを見てみましょう。

上のページを開いて、一番下までスクロールして・・・(以下略)
アロー関数式は、ES6(ECMAScript2015)の仕様です。現在は、2022年です。2022年、夏です。

7年前のブラウザのまま放置されてるPCを、いつまでもサポートし続ける必要は本当にあるのでしょうか。
もう最新OSにアップデートできなくてブラウザも更新できないような10年前のタブレット(タブレットは通常、3年前後は最新OSがサポートされる)、本当にサポートしなきゃならないものでしょうか。

最悪、アロー関数式が使えないとしても、以下のように匿名関数を使う事もできます。

const list = ret.data.filter( function(item) { return item.cd > 50 } );

可読性も低いので、ここはアロー関数式を使いたいところですけども。
アロー関数式にすると、functionやreturn、{}ブロックを省略することができます。簡潔なコードは正義です。

話を戻すとして、JavaScriptそろそろ、はっきりとこう言える時期になっていると思います。

「配列をループ&pushで初期化する考え方は、そろそろ捨てよう」

var list = [];
for ( ループ ){
    前処理;
    list.push( 何か );
}

こんなコードがあったら、十中八九、リファクタリングが必要です。
「配列Aを元に、別の配列Bに要素を追加していく」という考え方は捨てて、「配列Aを配列Bに変換する」という考え方にシフトしましょう。

「変換」がただの「条件による抽出」ならば、前述のArray.prototype.filterが使えます。
しかし時には次のような「整形処理」が必要になるケースもあるでしょう。

商品リストをoptionsに変換
// 商品リスト
const items = [
    { itemcd : "1111", itemname : "商品A", price : 1000 },
    { itemcd : "2222", itemname : "商品B", price : 2000 },
    { itemcd : "3333", itemname : "商品C", price : 3000 },
    { itemcd : "4444", itemname : "商品D", price : 4000 },
];

// SELECT要素のOPTION子要素の為の{ text, value }型のオブジェクトに変換する
const options = [];
for ( const i = 0; i < items.length; i++ ){
    const item = items[i];
    const option = {};
    // 商品A(1000) というtextに変換
    option.text = item.itemname + "(" + item.price + ")";
    option.value = item.cd;
    options.push(option);
}

この時期を読んでいるあなたなら、きっと良いプログラマーを目指していることでしょう。であれば、こんなループ処理をいつまでも書いているのはそろそろやめたほうが良いでしょう。

{itemcd, itemname, price}の配列を、{text, value}の配列に変換する」という考え方にシフトさせます。
その為に、まずは要素単位での変換を考えます。

{ itemcd, itemname, price }を{ text, value }に変換する関数
const converter = function( item ) {
    return {
        text : `${item.itemname}(${item.price})`,
        value : item.cd
    }
}

割とスッキリしましたね。
せっかくなのでテンプレートリテラルを使ってみました。テンプレートリテラルも、ECMAScript2015から使えます。

次に、上記の関数を、アロー関数式で簡潔に置き換えます。アロー関数式ではfunctionやreturnなどは省略されます。

{ itemcd, itemname, price }を{ text, value }に変換するアロー関数式
const converter = item => {
    text : `${item.itemname}(${item.price})`,
    value : item.cd
}

あとは、上記の関数を、items配列の各要素に適用してやればOKです。

const options = [];
for ( const i = 0; i < items.length; i++ ){
    options.push(converter(items[i]));
}

あれ? 結局ループの中で配列にpushしてない?
同じじゃない?

そうです、ここまで書いたのは、最後にArray.prototype.map関数を紹介する為の布石です。
Array.prototype.map関数とは、配列の各要素を別の要素に変換し、その結果を配列として返してくれるという関数です。

つまり、上記のforループでやってるような事を代わりにやってくれるんですね。

const options = items.map(converter);

こんなにシンプルなので、わざわざconverterみたいな名前を付けるまでもないですね。converter部分を元のアロー関数式に置き換えましょう。

const options = items.map(item => {
    text : `${item.itemname}(${item.price})`,
    value : item.cd
});

ああすっきり。コードが宣言的になって、とても見やすくなりました。

こんなに簡潔に書けてコードの意図も明確になり、index変数の管理も不要な為安全バグの混入可能性も減っている書き方、しない理由がありません。

ループで配列にpushするのは、時間を浪費する上にコードの品質も下げてしまっている

と言えます。

稀にループで配列にpushするコードの方が可読性が上がるケースがあるので一概には言えませんが、そもそもこの書き方を知った上でそれを選択するのと、最初から何も知らずに低品質コードを書く人とでは話の前提が違いそうです。

ちなみに、上記で言うconverterの中で「配列のインデックス番号を知りたい」という時があります。

例えば、「0:商品A(1000)」のように、textの先頭にインデックス番号を付与したい、みたいな例です。
こんな時も大丈夫。Array.prototype.map関数は優秀なので、第二引数にindexを渡してくれます。

const options = items.map((item, index) => {
    text : `${index}:${item.itemname}(${item.price})`,
    value : item.cd
});

どうしてこれまで、ループで配列にpushなんてしていたんでしょうね…?

さて、こうなってくると、こんなコードを見ても、「配列Aを配列Bに変換する処理(つまり、mapとfilterの組み合わせ)」として実装できないか、と考えるようになってきます。

pushを列挙
const prices = [];
prices.push(getPrice("apple"));
prices.push(getPrice("banana"));
prices.push(getPrice("orange"));

多くの人は、上記のコードを見てループに置き換えられるか考えるでしょう。

ループでpush
const fruits = [
    "apple",
    "banana",
    "orange",
];
const prices = [];
for (const fruit of fruits) {
    prices.push(getPrice(fruit));
}

しかし皆さんはもう、mapを知っていますから、次のようなコードが即座に頭に浮かぶはずです。

mapで変換
const fruits = [
    "apple",
    "banana",
    "orange",
];
const prices = fruits.map(fruit => getPrice(fruit));

なんならこうでも良いわけです。

mapで変換
const prices = ["apple","banana","orange"].map(fruit => getPrice(fruit));

元のpush列挙のソースを見比べると、「元となる配列」と「その変換方法」に見事にコードが分離されているのが分かると思います。

ループでpushのコードも良いところまで行っていますが、「配列を別の配列に変換したい」のであって「ループしたい」わけではないのです。ループは目的の為の手段にすぎません。ループは、目的と手段が混在したコードになってしまっています。

その「ループして各要素を別の要素に変換して別の配列にして返す」という「よくあるループ処理」を「map」という関数が堅牢に肩代わりしてくれるのです。いちいちこれをループで書いていた頃を思い出すと、気が遠くなりますね。

おっ、「待て待て、ループにもいいところはある。配列を作りながら合計の計算をしたりできるんだ」という声が聞こえてきました(幻聴ですが)。

ループでpush + sum
const fruits = [
    "apple",
    "banana",
    "orange",
];
const prices = [];
const total = 0; // 合計
for (const fruit of fruits) {
    const price = getPrice(fruit);
    prices.push(price);
    total += price;  // 加算する
}

私なんかは最近こういうコードを見ると、机の上を綺麗にして帰宅準備を始めたくなります。

上記のような、「ループしながら何か別のデータを作る」という機能は、もうArrayが全部知っています。
Arrayに任せましょう。
これまでのfilterやmapは、「配列から別の配列を生成する」でしたが、「配列の各要素を1まとめにする」機能もArrayにはあります。

それが、Array.prototype.reduceです。

reduceの動き
const list = [1, 2, 3, 4, 5 ];
const str = list.reduce( (sum, item) => sum + item ); // 15 が返る

reduce関数に渡すアロー関数式には、引数が2つあります。itemにはfilterやreduceと同様に各要素が入りますが、sumには、「前回このアロー関数式を呼び出した時の結果」、つまり上記の例だとsum + itemの結果が入っています。つまり、前回のアロー関数式の計算結果を引き継いで、それに次の要素を何かしらの方法で足し合わせて、それをまた次の要素に引き継いで・・・ということをやってくれるのがreduceです。最終的に、sumがreduceの結果として帰ります。布団を畳んでコンパクトにするのに似ているので「畳み込み関数」とも呼ばれます。

上記では、sum + item をしているので、単純に各要素が次々と足されていきます。

なので、先ほどの「ループしながら合計も計算できるよ」という意見については、こうなります。

mapで変換
const prices = ["apple","banana","orange"].map(fruit => getPrice(fruit));
const total = prices.reduce( (sum, item) => sum + item );

「そ、それだと、mapとreduceの中で結局ループ処理を2回回してる感じになるんじゃないか? 処理速度は大丈夫なのか?」

と、少し狼狽えながら指摘する声が聞こえてきました(幻聴)。

大丈夫です、その差が問題になるようなケースは稀です。もし問題が起きたら、その時にはループに置き換えてリファクタリングすれば良いでしょう。

最後に、こんな問題について考えてみましょう。
日付を指定すると、その日の天気予報を返してくれるサービスがあるとします(地域は今は考えません)。
それは次のAPIを使って利用できます。

天気予報サービス
const d = moment();  // 今日の日付を取得(moment.jsを利用)
const weather = getWeather(d); // "晴"

この天気予報サービスを使って、指定日付から7日分の天気を取得し、「日付:天気」の形式でリストを作成してください。

問題を解くときは、「入力と出力」に分けて考えなければなりません。
まず、最初の入力は「指定日付」です。天気予報サービスの入力は日付ですから、まず、「指定日付」から「7日分の日付」を生成する処理を考えます。

指定日付から7日分の日付を生成(ループとpush版)
const startDay = moment(); // 今日を指定
const dateList = [];
for ( const i = 0; i < 7; i++ ) {
    const d = startDay.clone().add(i, "d");
    dateList.push(d);
}

もちろんループもpushもダメなので、これを「map」を使って書きなおします。イメージできるでしょうか。
まず、[0, 1, 2, 3, 4, 5, 6] という配列が必要です。これがあれば、以下のようにdateListを定義できます

指定日付から7日分の日付を生成
const startDay = moment(); // 今日を指定
const dateList = [0, 1, 2, 3, 4, 5, 6].map( day => startDay.clone().add(day, "d") );

しかし残念ながらJavaScriptには、標準で連番を作り出す機能がありません。これは標準的な機能が欲しいところです。しかしないので、次のテクニックを使います。

0からの連番を生成するテクニック
const length = 7;
const range = Array.from(Array(length).keys()); // [0, 1, 2, 3, 4, 5, 6]

これをもう一歩推し進めて関数化

連番を生成する関数rangeを定義
const range = (start, length) => Array.from(Array(length).keys()).map(n => n + start);
const numbers = range(10, 5); // [10, 11, 12, 13, 14]

これを用いて、7日分の日付を生成するとこうなります。

指定日付から7日分の日付を生成
// 範囲を生成
const startDay = moment(); // 今日を指定
const dateList = range(0, 7).map( day => startDay.clone().add(day, "d") );

最後に、dateListを{date, weather}の配列に変換します。

const list = dateList.map( date => { date: date, weather : getWeather(date) } );

for (const item of list) {
    console.log( `${item.date.format("YYYY-MM-DD")}: ${item.weather}` );
}

全て併せると、こうなります。

// 連番を生成する関数rangeを定義
const range = (start, length) => Array.from(Array(length).keys()).map(n => n + start);

// 今日を指定
const today = moment(); 
// 一週間の日付リスト
const dateList = range(0, 7).map( day => today.clone().add(day, "d") );
// 天気予報結果のリスト
const list = dateList.map( date => { date: date, weather : getWeather(date) } );

// 結果の出力
for (const item of list) {
    console.log( `${item.date.format("YYYY-MM-DD")}: ${item.weather}` );
}

まとめ

配列の生成をループ&pushで行うのはやめて、宣言的に書くことで可読性を高め、バグの混入を減らしましょう。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0