Help us understand the problem. What is going on with this article?

filterメソッドを実装して学ぶ関数の抽象化

More than 3 years have passed since last update.

よくある古いコード

 関数型プログラミングについていまさら語るのもどうかなと思うのですが、大抵の関数型の話はmapやfilterを導入して便利だなで終わっているような印象があります。

 だから、今回は関数を進化させていくというプロセスに着目して、話を進めてみようかなと思います。1歩進みつつ、それでいて難しくならない程度に説明できたらいいかと思います。

 まず、下のコードを見てください。これが関数型が登場する前の典型的なコードです。

// 配列から2を消す
const arr = [0, 1, 2, 3, 4];
const newArr = [];
arr.forEach(v => {
    if(v !== 2) {
        newArr.push(v);
    }
});
// 結果 : newArr = [0, 1, 3, 4]

 見ての通り、このコードはいくつかの点で物足りません。具体的には

  • 条件(v === 1)がオンコード化されており、再利用できない
  • forEachでのループであるため、何がしたいのか読まないとわからない

 今回はこのコードからスタートして、より面白い関数を作っていきたいと思います。

引数を使って抽象化する

 まずは、処理をできれば関数化して、使いまわしたいです。消したいものが1であっても、2であっても処理を同じように書きたいです。

 なので、条件を外からもらうようにしましょう。ここでは、消したい数字を引数nで受け取る関数deleteIfを定義しました。

// 配列からnを消す
const deleteIf = (arr, n) => {
    const newArr = [];
    arr.forEach(v => {
        if (v !== n) {
            newArr.push(v);
        }
    });
    return newArr;
};
const arr = [0, 1, 2, 3, 4];
const newArr = deleteIf(arr, 1);
const newArr2 = deleteIf(arr, 2);
// 結果
// newArr = [0, 2, 3, 4]
// newArr2 = [0, 1, 3, 4]

 目的は達成されましたね。これでどんな数字が来ても大丈夫です。この引数を使って、処理を抽象的にしていくのが関数の基本です。

 もう1つ注目するとすれば、forEachに渡した無名関数がdeleteIfに渡した引数nを参照できているということです。基本的にJavaScriptの関数は自分の外にある変数を使用することができます。

 この仕組みはクロージャというもので、関数を扱うときにはとても重要なものです。

引数に関数を渡す

 上の例は十分に便利ですが、例えば偶数だけを取り出したいとか、素数だけ取り出したいというケースに対応できませんね。

 そういう時にどうするかというと、引数として関数を渡します。ここでは、偶数の計算方法を渡すことで、関数に計算してもらうことにしましょう。

// 配列から条件にあった数値を消す
const deleteIf2 = (arr, pred) => {
    const newArr = [];
    arr.forEach(v => {
        if (!pred(v)) {
            newArr.push(v);
        }
    });
    return newArr;
};
const arr = [0, 1, 2, 3, 4];
const newArr = deleteIf2(arr, n => n === 2 );
const newArr2 = deleteIf2(arr, n => n % 2 === 0);
// 結果
// newArr = [0, 1, 3, 4]
// newArr2 = [1, 3]

 これでどんな数字でも判定方法さえ渡せば、フィルタリングできます。

 ちなみに変数predはpredicate(述語)という意味で、真偽値を返す関数という意味です。絞り込みの関数ではよく使いますね。

短くする

 しかし、長いのが少し気に入りません。ES2015には配列をループさせて、新しい結果を返すreduceという関数があります。

 それを使って短く書いてみましょう。これでforEachとはおさらばです。

// 配列から条件にあった数値を消す
const deleteIf3 = (arr, pred) =>
    arr.reduce((acc, v) => !pred(v)? [...acc, v] : acc, []);
const arr = [0, 1, 2, 3, 4];
const newArr = deleteIf3(arr, n => n === 2 );
const newArr2 = deleteIf3(arr, n => n % 2 === 0);

// 結果
// newArr = [0, 1, 3, 4]
// newArr2 = [1, 3]

 accはaccumulator(蓄積)という意味の変数です。reduceの2つ目の引数はaccの初期値です。

 ここではaccにフィルタリングしない要素をどんどん追加しています。そして、最終結果を返しています。

 簡単に言うと、ここまで使ってきたnewArrとaccはまったく同じ意味です。

 配列への要素への追加はpushが基本ですが、これはリスト自体を変更します。関数型は基本的に計算によってどんどん値を変えるほうを好みます。

 なので、ここではスプレッド演算子(...)と配列リテラル([])で、新しい配列に要素を追加しています。

 if文もできれば、3項演算子で置き換えて書いたほうが短く、さらに関数的に書けるのでそうしました。

 ここまで書ければ、大体のゴールです。現実のJavaScriptではfilterという関数で下記のように書きます。

// 配列から条件にあった数値を消す
const arr = [0, 1, 2, 3, 4];
const newArr = arr.filter(n => n !== 2 );
const newArr2 = arr.filter(n => n % 2 !== 0);

// 結果
// newArr = [0, 1, 3, 4]
// newArr2 = [1, 3]

 こちらは指定した式に合致したものを残すので、等号は逆になっています。これで、最近のAPIには大体追いついたことになります。

細かい要望に対応する

 もう少し遊びたいので、条件を変えてみます。

 filter関数は指定した要素を全部消してしまうのが面白くありません。現実的には最初の要素さえ消してしまえば、事足りるケースも多いです。

 findIndexという指定した条件で最初に見つかった要素のインデックス値を探してくれるAPIがあるので、それを使いましょう。

 またfilter関数は条件としてインデックス値を使うことができます。それらを組み合わせると2行で書けます。

// 配列から条件にあった数値を消す
const arr = [0, 1, 2, 2, 3, 4];
const index = arr.findIndex(n => n === 2);
const newArr = arr.filter((_, i) => i !== index );

// 結果
// newArr = [0, 1, 2, 3, 4]

 正直まったく悪くないのですが、個人的には2回ループを行っていることが気になります。
 とてつもない巨大な配列を扱う場合は、できれば1回のループで抑えたい所です。

 改善バージョンがこちら。

// 配列から条件にあった数値を消す
const arr = [0, 1, 2, 2, 3, 4];
let done = false;
const once = pred => v => {
        if (done || !pred(v)) {
            return true
        };
        done = true;
        return false;
    };
const newArr = arr.filter(once(n => n == 2));

// 結果
// newArr = [0, 1, 2, 3, 4]

 はい。一気に難しくなりましたね。

 まず、1回目かどうかを判定するためにはローカル変数に初回かどうかを保持しておく必要があります。それがdone変数です。

 onceは条件判定を行う関数でdone変数と受け取った関数の結果を用いて、条件を満たしているかを計算します。

 filterには条件を関数で渡さないといけないので、once関数は関数を返します。矢印が連続しているところがありますね。これが関数を返す関数(高階関数)です。

 これでも動きますが、残念なことにdone変数がスコープの外に出てしまっています。

 また、全体の処理が関数化されていないので、テストも難しいです。なので、別の関数として切り出してみます。

const deleteIfFirst = (arr, pred) => {
    let done = false;
    const once = v => {
        if (done || !pred(v)) {
            return true
        };
        done = true;
        return false;
    };
    return arr.reduce((acc, v) => once(v)? [...acc, v] : acc, []);
};

// 配列から条件にあった数値を消す
const arr = [0, 1, 2, 2, 3, 4];
const newArr = deleteIfFirst(arr, n => n === 2);

// 結果
// newArr = [0, 1, 2, 3, 4]

 最後は素直にfilterでもいいのですが、ここはあえてreduceで書きました。高階関数を1つ減らして、実際どのように処理しているかイメージしやすくするためです。

 それは置いておいて、いくつかの変数を関数内に持ち込むことで比較的コードがシンプルになりましたね。

 同じことができるとしても、読みやすいほうをとっていくという姿勢が大事です。

 ここらへんは正解がないので、onceをユーティリティとして使いまわすという判断もありえます。その場合は、done変数を関数内のローカル変数にして外部に公開しないようにはします。

const once = pred => {
    let done = false;
    return v => {
        if (done || !pred(v)) {
            return true
        };
        done = true;
        return false;
    };
};
const newArr = arr.filter(once(n => n == 2));

使いやすくできないか検討する

 さて、次はせっかくなので最初の要素だけでなく、n件目まで削除できるようにしてみましょう。これは比較的簡単です。

 引数でnをもらって、それを毎回増やしていくわけです。ここではstepという変数を割り当てましょう。

const deleteIfByStep = (arr, pred, step = 1) => {
    let done = 0;
    const once = v => {
        if ((done === step) || !pred(v)) {
            return true
        };
        done++;
        return false;
    };
    return arr.reduce((acc, v) => once(v)? [...acc, v] : acc, []);
};

// 配列から条件にあった数値を消す
const arr = [0, 1, 2, 2, 2, 3, 4];
const newArr = deleteIfByStep(arr, n => n === 2);
const newArr2 = deleteIfByStep(arr, n => n === 2, 2);

// 結果
// newArr = [0, 1, 2, 2, 3, 4]
// newArr2 = [0, 1, 2, 3, 4]

 せっかくなので、stepは初期値を1にしておきましょう。こうしておくことで、前に作ったremoveFirstと同じ使い方が簡単にできるようになります。

 真偽値が数値に変わっただけなので、比較的簡単ですね。

 ただ、クロージャーによって関数に明確な状態を持ち込めるようになったということは注目すべきです。これによって、色んな条件をカスタマイズできるようになったわけです。

 今回はここまでで一応終わりです。やりたければ、doneが素数の時のみ処理を実行するとかいくらでも改造はできます。

 ここまで使った機能を使えば、関数の表現力をかなり高めることができます。

まとめ

 大きな手順としては以下のように進めました。

  • 数値を引数をとる関数にする
  • 数値を計算する関数をとる関数にする
  • より細かい状況に対応できる関数を作る

 ここでの意図はより抽象的な状況に対応したいというものです。まずは1でも、2でも対応したいというところから始まって、次は偶数、素数でも対応したいという風に考えました。

 さらに全部をまとめて処理するのではなく、先頭n件に限定したいという意図から、さらに条件の細かい関数を作りました。

 つまり動けばいいという次元から、もっといろんな状況に対応できるような抽象を定義するというふうに進化させてきたつもりです。

 また、関数を作る際に気を付けたのは以下の通りです。

  • 既存の変数を変更しない
  • 使用する変数の数を少なくする
  • より効率のよい実装にする
  • 使いやすくするために工夫する(デフォルト値の設定など)

 コードというのは人が読むものですから、できる限り短いほうがいいです。その分だけ、見つけられる不具合も減ります。

 また、効率の良いコードや使いやすいユーティリティは開発効率を向上させます。

 関数は抽象的であればよいというわけではなく、どれだけ開発者にやさしいかという視点で設計しなくてはいけません。

 短いコードで高い表現力があり、不具合も少ないというのはとても理想的だと思いませんか?

 関数を使ったプログラミングに慣れてくると、短時間で効率の良い実装ができるようになります。ぜひ、少しずつ関数の品質を高めていきませんか。
 
 気が向いたら、map、reduce、groupByとかを自分で実装しながら、欲しいものをいろいろ作って学ぶのがいいと思います。

参考リンク

 関数型でデータ変換をできるだけ、挙げてみました。学んだことが活かせると思います。

 データ構造を変換して遊ぼう~ES2015版~

dexia
動的言語大好き人間です。JavaScript、Lisp、Rubyあたりがマイブームです。
http://howitworks.hatenablog.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away