Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
Help us understand the problem. What is going on with this article?

JavaScriptでのreduceを使ったパイプラインのことを、腑に落ちるまで手を動かして検証してみた

More than 1 year has passed since last update.

記事の本文の前に

記事の投稿後、コメントで認識の誤りのご指摘を頂いております。

このため、この記事は修正を予定しています。

カリー化など関する僕の理解の問題で、まだ修正できずにいるため、内容に誤りがある可能性がありますので、大変申し訳ないのですが、読んでいただく際はコメントの訂正欄も含めご確認いただければ幸いです。

なお、記事自体削除して新たに書き直した方が良いかとも思ったのですが、頂いたコメントの内容がとても勉強になる有意義な内容でしたので、打ち消し線や修正の内容を追記する形を取ろうと思います。

この後、本文にも記載しておりますが、この記事は、元々は、@jlkiri さんの書かれた「Pipe関数の力」の内容に関して、僕が理解を深めるために色々と試行錯誤した過程が大半を占めています。

その過程で僕が理解した内容に誤りがあったのですが、「Pipe関数の力」自体は、大変分かりやすく良い記事ですので、是非、ご参照頂ければと思います。

また、お忙しい時間を割いて誤りのご指摘を頂きました皆様、本当にありがとうございます。

はじめに

関数型のプログラミング、勉強したいなと思いつつ、ずっと先送りにしていたのですが、先日、「Pipe関数の力」という記事を拝読し、これを機に、分かる所から手を付けてゆこうと決意した次第です。

せっかく記事を読ませて頂いたので、まずは、パイプラインから手を付けようと思います。

なお、そもそも関数型プログラミングとは何なのか?何が便利なの?といった話は、僕もまだよく理解できていないため、今回のスコープから外しますが、以下の記事などが参考になりましたので、ご興味ある方は、ご参照いただければと思います。

方針

Pipe関数の力」がとても分かりやすい良記事なので、JavaScriptのreduce()の動きを正しく理解されてる方であれば、パイプラインに関しては、そちらを読んで頂ければ充分だと思います。

ですが、僕の場合、「reduce、配列の要素を前から1つずつ足したりする時に使うヤツでしょ?」くらいのコトしか知らなかったので、パイプラインに関しても、「何となく分かったけど、どうやって使ったらいいか、イメージできない・・・」という状態でした。

ですので、細かい点も含めて、自分の腑に落ちるまで手を動かして検証してゆきます。

パイプラインの目的

まずは、パイプライン、何のために使うの?という、そもそもの話から。

ある関数を実行し、その実行結果を別の関数に渡して値を取得したい、というケース、よくあると思います。

例えば、次のケースを考えます

  • 三角形の面積を求めたい。(底辺×高さ÷2)
  • ただ、既に、長方形の面積を求める関数calcRectangularAreaが存在している。(横幅×高さ)
  • なので、三角形の面積を求める関数をゼロから新たに作るのではなく、関数calcRectangularAreaで取得した値を2で割って三角形の面積を求めるようにしたい。

ひとまず、下記の手順で試してみます。
A:長方形の面積を求めて
B:その結果を2で割って三角形の面積を求める

【長方形の面積 ⇒ 三角形の面積】

function calcRectangularArea(bottom, height){
  return bottom * height;
}

function divideByTwo(n){
  return  n / 2;
}

let rectangularArea = calcRectangularArea(8, 5); //長方形の面積を算出
let triangleArea = divideByTwo(rectangularArea); //長方形の面積を2で割って三角形の面積を算出
console.log(triangleArea); // => 20

正しく計算できました。

ただ、この場合、正方形の面積をいちいち変数(rectangularArea)に入れて、それを関数divideByTwo()に渡す、という面倒な作業が必要になります。

ということで、
A:関数calcRectangularArea()を実行し、
B:その結果をdivideByTwo()に渡す。
という関数を合成してみます。

【関数を合成】

function calcRectangularArea(bottom, height){
  return bottom * height;
}

function divideByTwo(n){
  return  n / 2;
}

//                 B:右の1.の結果を2で割る 
//                 ↓           ↓ A:底辺×高さの値を算出し 
let triangleArea = divideByTwo(calcRectangularArea(8, 5));
console.log(triangleArea); // => 20

正しく計算できました。

そして、長方形の面積を求める部分の記述をカットできました。

それで、ここからがようやくパイプラインの話になりますが、パイプラインは、この、関数の合成の部分だけを外に切り出した関数のコトを指します。

関数を繋げて、順番に返り値を渡し、実行してゆく、その部分の関数ですね。

・・・と、言葉にしてみると、自分でも何を言ってるのか分からないのですが、実例を見た方が分かりやすいので、上の例を、パイプラインを使って実装してみます。

【パイプラインを使って関数を繋げる】

function calcRectangularArea(bottom, height){
  return bottom * height;
}

function divideByTwo(n){
  return  n / 2;
}

//パイプライン
function pipe(...fs){
  return fs.reduce((acc, f) => f(acc));
}

//三角形の面積を求める
let triangleArea = pipe(calcRectangularArea(8, 5), divideByTwo);
console.log(triangleArea); // => 20

正しく計算されました。

パイプライン部分、いきなり、ちょっと何やってるか分かんない感じになりましたね・・・

そのあたりを、この後、手を動かして検証してゆきたいのですが、実は、上記の例、JSでパイプラインを実装する際の一般的な書き方ではありません。(少なくとも、MDNで紹介されている記述方法とは若干異なります。)

パイプによって関数を合成する - Array​.prototype​.reduce() - JavaScript | MDN

// Building-blocks to use for composition
const double = x => x + x;
const triple = x => 3 * x;
const quadruple = x => 4 * x;

// Function composition enabling pipe functionality
const pipe = (...functions) => input => functions.reduce(
   (acc, fn) => fn(acc),
   input
);

// Composed functions for multiplication of specific values
const multiply6 = pipe(double, triple);
const multiply9 = pipe(triple, triple);
const multiply16 = pipe(quadruple, quadruple);
const multiply24 = pipe(double, triple, quadruple);

// Usage
multiply6(6); // 36
multiply9(9); // 81
multiply16(16); // 256
multiply24(10); // 240

ですので、以下、まずは、別の例(MDNで紹介されているのと同じパイプラインの記述方法)で、手を動かして不明点を一つずつ解消してゆきたいと思います。

パイプラインを使った例

次の具体例で検証してゆきます。

//パイプラインを使って関数を合成する

//1.合成される関数群
const add1 = arr => arr.map( n => n + 1);          //受け取ったarrの各要素を全て+1した配列を返す
const selectOdd = arr => arr.filter( n => n & 1);  //受け取ったarrの各要素のうち奇数だけを抽出した配列を返す
const triple = arr => arr.map(n => n * 3);         //受け取ったarrの各要素を全て3倍した配列を返す

//2.上記「1」を合成して順番に実行するための関数(パイプライン)
const pipe = (...fs) => initialValue => fs.reduce((acc, f) => f(acc), initialValue);

//3.元となる値
const nums = [1, 2, 3, 4, 5];

//実行 & コンソールへの出力
let result = pipe(add1, selectOdd, triple)(nums);
console.log(result) // => [9, 15]

/*【実行の過程】
・配列[1, 2, 3, 4, 5]が関数add1に渡り、全ての要素が+1される    ⇒ 返り値:[2, 3, 4, 5, 6]
・配列[2, 3, 4, 5, 6]が関数selectOddに渡り、奇数だけ抽出される ⇒ 返り値:[3, 5] 
・配列[3, 5]が関数tripleに渡り、全ての要素が3倍される          ⇒ 返り値:[9, 15] 
*/

不明点、曖昧な点を一つずつ潰してゆく

立てた方針に従って、少しでも分からない点やあいまいな点があったら、一つずつ手を動かして解消してゆきます。

アロー関数 : =>

パイプライン部分ですが、必ずしもアロー関数を使わないといけないワケでは有りません。

ただ、多くの場合はアロー関数で書かれてますし、アロー関数を使った方が圧倒的に読みやすくなるので、まずはアロー関数から。

アロー関数、この例ですと、パイプライン部分よりも、他の関数の方が単純なので、まずは関数リテラルadd1で確認します。

関数リテラルadd1をfunction文で書くと

//関数リテラルadd1
const add1 = arr => arr.map(n => n * 1);

//関数リテラルadd1をfunction文に置き換え
function add1(arr){
  return arr.map(n => n + 1);
}

//map関数の部分もfunction文に置き換え
function add(arr){
  return arr.map(function(n){
    return n + 1;
  });
}

アロー関数を使わずにfunction文で書いた場合、カナリ長くなってしまいますね。

簡単に解説すると

function 関数名(引数1, 引数2){
  何らかの処理1
  何らかの処理2
  return 処理した結果;
}

「function」「=>」で代用できる。

//↓let 変数名 でも可
const 定数名 = (引数1, 引数2) => {
  何らかの処理1
  何らかの処理2
  return 処理した結果;
}

処理が1つのみで1行で書ける場合、「return」と「{}(ブロック)」 を省略可能

const 定数名 = (引数1, 引数2) => 何らかの処理1; 

更に、引数が1つの場合、引数を受け取る「()」も省略可能。

const 定数名 = 引数 => 何らかの処理1;

また、map等の高階関数などでよく使われる 無名関数の場合も、有名関数と同じルールで「function」「{}」「()」を省略できる ので、

arr.map(function(仮引数){
  return 何らかの処理1;
});

arr.map(仮引数 => 何らかの処理1)

と書くことが出来ます。

これらを踏まえて、改めてパイプラインの部分を確認すると

//アロー関数を一切使わずに記述
function pipe(...fs){
  return function(initialValue){
    return fs.reduce(function(acc, f){
      return f(acc);
    }, initialValue)  
  }
}


/**********************************************************************
全部を一気にアロー関数に変更すると、どこがどう変わったのか分かりにくいので、
内側から順番にアロー関数に変更してみる。
**********************************************************************/

//reduce()内の無名関数をアロー関数に変更
function pipe(...fs){
  return function(initialValue){
    //                  ↓  引数が2つなので()の省略不可
    return fs.reduce((acc, f) => f(acc), initialValue); //reduceの動きは後述
  }
}

//引数initialValueを受け取る無名関数をアロー関数に変更
function pipe(...fs){
  //         ↓  引数が1つなので()を省略
  return initialValue => fs.reduce((acc, f) => f(acc), initialValue); 
}

//関数文pipeをアロー関数を使った関数リテラルに変更
//              ↓  この引数の「...」の記述 & ここの()が省略できない理由は後述
const pipe = (...fs) => initialValue => fs.reduce((acc, f) => f(acc), initialValue);

と、ちょっと複雑ではありましたが、pipe部分の記述、だいぶスッキリしました。

アロー関数に関しては、下記が参考になりました。

スプレッド構文 & 可変長引数 : (...fs)の部分

まずはスプレッド構文について。

ピリオドを3つ並べて「...」としている部分のことを指します。

例えば、こんな使い方が出来たりします。

//arr1を別の配列の中で展開して一次元配列を作成
let arr  = [2, 3, 4];
let nums = [1, ...arr, 5];  
console.log(nums) // => [1, 2, 3, 4, 5];

//分割代入
//  「代入される側の変数の数」 = 「代入する側の値の数」のケース(一般的な分割代入)
[one, two, three] = [1, 2, 3];
console.log(one);   // => 1
console.log(two);   // => 2
console.log(three); // => 3

//  「代入される側の変数の数」 < 「代入する側の値の数」でスプレッド構文を用いたケース(※)
[one, two, ...others] =  [1, 2, 3, 4, 5];
console.log(one);    // => 1
console.log(two);    // => 2
console.log(others); // => [3, 4, 5] スプレッド構文を用いた変数に対して、余った値を全て配列として代入

そして、関数に引数を渡す際、受取側の関数の引数の設定にスプレッド構文を使うと、受け取った引数が配列として処理されます。

動きとしては、上記(※)と同じですね。

【関数の引数にスプレッド構文を使用した例】

//全ての引数を1つにまとめた配列として受け取る
function func1(...nums){
  console.log(nums); // => [1, 2, 3, 4, 5]
}

//最初の2つの引数を1つずつ受け取り、残りは配列としてまとめて受け取る
function func2(one, two, ...others){
  console.log(one);    // => 1
  console.log(two);    // => 2
  console.log(others); // => [3, 4, 5]
}

func1(1, 2, 3, 4, 5); 
func2(1, 2, 3, 4, 5);

このような引数の受け取り方を可変長引数と言いますが、予め引数の数が決まってない場合などに凄く便利ですね。

ということで、ここで改めて、パイプラインの部分に立ち戻ってみると。

//              ↓  この引数の「...」の記述 & ここの()が省略できない理由は後述
const pipe = (...fs) => initialValue => fs.reduce((acc, f) => f(acc), initialValue);

後述すると記載していた (...fs) の部分ですが、下記になります。

  • いくつ受け取るか分からない引数を、まとめて配列として受け取れるようにしている。
  • 引数が1つではないので、()を省略の省略不可。
  • なお、引数が結果的に一つになっても問題ないが、その場合も、要素が1つの配列として処理される。

パイプラインの部分、少しずつ明らかになってきましたかね。
でも、まだ全体でどう処理が動いているかは分かりません。

1つずつ手を動かしての検証作業、まだ続きます。

なお、スプレッド構文や分割代入に関しては、下記のページなどを参考にさせて頂きました。

スプレッド構文、今回は、主に、パイプラインで使っている部分に関連することに絞って書きましたが、この記事で触れた使い方以外にも、文字列を一文字ずつ配列にしたり、配列から重複を除いた新たな配列を作成したり、など、色んな使い方が出来ますので、詳しく知りたい場合は、上記のページなどをご参照下さい。

reduce()について

自分がコードを書く際、JSでパイプラインを自由に使えるかどうかは、reduce()の挙動を正しく理解できていることが肝だと、個人的には思っています。

以下、僕なりの理解を書いてゆきます。

<注意点>

以下の説明は、JSでパイプラインを実装するのに関係ある部分にフォーカスを当てた(逆に言うと、それ以外の部分は省略された)、reduce自体の説明としては少し不正確な内容になります。

正確で詳しい挙動に関しては、下記のページや、他にも多くのページに詳しく書いてあるので、是非、それらを参照して頂ければと思います。

reduceの動きの確認(主にパイプラインの動きに関わる部分を抽出して)

//                   ↓ ※ただし2回目以降。初回はEの初期値が代入される。
A配列.reduce((BDによる何らかの処理が施された結果の値, C配列Aの要素) =>
               (D引数 B  C に対する何らかの処理 , E初期値最初の1回だけBに渡される)
//                          ↑ 配列Aの要素が残っている間は
//                このDの処理の結果がBに代入され
//                            処理が繰り返される
  1. 最初1回だけ、(E)初期値が(B)に代入される。(E)初期値は、その後は使われない。
  2. (A)の要素が、前から順番に、1つずつ、(C)に渡される。
  3. (B)&(C)に対して、(D)に記載した処理が実行される。
  4. (D)の処理結果が(B)に渡される。
  5. 配列(A)の最後の要素になるまで、上記2~4が繰り返される。
  6. この結果(配列(A)の最後の値に(D)の処理が実行された結果)が、reduceの実行結果として返ってくる。

言葉だと分かりにくいので、以下、具体例を。
加算するだけの処理だと、理解できたかどうか曖昧になるので、処理を少し複雑にして。

【reduceの具体的な処理】

let arr = [1, 2, 3];

//      (A)       (B) (C)   (D)処理           (E)初期値
let result = arr.reduce((acc, cur) => acc / 2 + cur * 4, 8);
console.log(result); // => 18

/*
<1回目>
・reduceの受け取る引数の値: acc=8, cur=1 (accが初期値、crrがarrの1つ目の値)
・処理(D)の実行結果     : 8 (<= 8÷2 + 1*4)

<2回目>
・reduceの受け取る引数の値: acc=8, cur=2 (accが1回目の結果、crrがarrの2つ目の値)
・処理(D)の実行結果     : 12 (<= 8÷2 + 2*4)

<3回目>
・reduceの受け取る引数の値: acc=12, cur=3 (accが2回目の結果、crrがarrの3つ目の値)
・処理(D)の実行結果     : 18 (<= 12÷2 + 3*4)
*/

少し補足を。

メソッド名、関数名(あとそれに伴う引数名や変数名)が何を意味しているのかを理解しておいた方が挙動の理解に役立つケースが結構あるかもと普段感じていますので、以下に記載します。

reduceは「減らす」とか「減る」だと思いますが、配列の要素を1つずつ処理して減らしてゆくのでreduceなのかな、と個人的には思っています。

reduceの第一引数は accumulator(アキュムレータ)と呼ばれてます。
上記の例ですと(B)に当たる部分ですね。

google翻訳で訳すと、カタカナで「アキュムレータ」と返ってくるので日本語訳は分からないのですが、でも、僕自身は、「アキュムレータ」という呼称を知ってから、何となくreduceを身近に感じられるようになりました。

reduceの第二引数(上記の例ですと(C)に当たる部分ですが)が currentValue で、現在処理されている配列の要素を指します。こちらはそのままですね。

reduceを使う際、accumulatorとcurrentValueの2つの引数は必須項目です。

reduceは、他に下記の引数をオプションで取れます。
オプションなので指定しなくても大丈夫です。
実際、今回の記事で例に出しているパイプラインでは、次の二つのオプション引数は使用していません。

  • 第三引数 currentIndex : 現在処理されている配列要素のインデックス
  • 第四引数 array :reduceが処理している配列自体 (上記の例で言うと(A)全体)

次に、reduceの初期値に関して。

初期値の指定は必須ではありません

初期値が渡されなかった場合は、最初のaccumulatorは配列内の1番目の要素、最初のcurrentValueは配列内の2番目の要素を取って、処理が進みます。それに合わせて、currentIndexも(0ではなく)1から始まります。

このあたりの詳細は割愛しますが、興味がある方は、是非、上記のMDNのサイトなどをご確認頂ければと思います。

パイプラインのreduce()部分が何をやっているのかの確認

パイプラインの例を再掲します。

//1.合成される関数群
const add1 = arr => arr.map( n => n + 1); 
const selectOdd = arr => arr.filter( n => n & 1);
const triple = arr => arr.map(n => n * 3); 

//2.上記「1」を合成して順番に実行するための関数(パイプライン)
const pipe = (...fs) => initialValue => fs.reduce((acc, f) => f(acc), initialValue);

//3.元となる値
const nums = [1, 2, 3, 4, 5];

//実行
let result = pipe(add1, selectOdd, triple)(nums);

これ、個人的には、

//          ↓    この部分が分かり難い  ↓
const pipe = (...fs) => initialValue => fs.reduce((acc, f) => f(acc), initialValue);

です。

ただ、reduceの部分注目して注意深く見て見ると、

//コメントを入れるために改行していますが、上記と同じ式です。

//              分割代入で引数を受取。
//              なので、渡された引数は、配列になっている。
//            ↓ そして、その変数名は「fs」。(1)
const pipe = (...fs) => 

//             ↓ こちらは、引数を単独で受け取っている。(2)
               initialValue => 

//                (1)で受け取った引数「fs」がココに来ている。
//                 つまり、ここは、分割代入で受け取って配列となった「fs」を
//               ↓ reduceで展開している。
                 fs.reduce((acc, f) => f(acc), initialValue);
//                                             ↑ initialValue がココに来ている。
//                                               つまり、(2)で受け取った引数を、
//                                               reduceの初期値にしている。

ということだと思います。

では、(...fs) と initialValue には、何を渡してるんでしょうか?

今度は、その点に注目して抽出してみると、

順番が前後しますが、まずは、(2)の位置の initialValue から。

//コメントの関係で定数や関数の表記順を入れ替えてますが、内容は同じです

const nums = [1, 2, 3, 4, 5];

//                                        ↓ 関数pipeの2つ目の括弧に、numsを渡している
let result = pipe(add1, selectOdd, triple)(nums);

//                       関数pipeの2つ目の括弧。
//                      ↓ つまり、ここで、numsを受け取っている。   
const pipe = (...fs) => (initialValue) => 
//                      ↑ <補足>
//                        引数が1つなので、ここの括弧は本来省略できますが、
//                        説明のために敢えて括弧を残しています

//                                           関数pipeの2つ目の括弧、
//                                           つまり、引数initialValueとして受け取ったnumsが、
//                                           ここで使われている。
//                                         ↓ つまり、reduceの初期値になっている
             fs.reduce((acc, f) => f(acc), initialValue);

そして、(1)の位置の fs に関して。

//コメントの関係で定数や関数の表記順を入れ替えてますが、内容は同じです

const add1 = arr => arr.map( n => n + 1);         //関数1
const selectOdd = arr => arr.filter( n => n & 1); //関数2
const triple = arr => arr.map(n => n * 3);        //関数3

//               ↓ 関数pipeの1つ目の括弧に、関数1~3を渡している
let result = pipe(add1, selectOdd, triple)(nums);


//             関数pipeの1つ目の括弧。
//           ↓ つまり、ここで、関数1~3を受け取っている。
const pipe = (...fs) => initialValue => 

//                 関数pipeの1つ目の括弧、
//                 つまり、引数(...fs)で受け取った関数1~3が、
//                 分割代入で受け取っているため配列となっており、ここで使われている。
//               ↓ つまり、reduceの処理対象となる配列として使われている。
                 fs.reduce((acc, f) => f(acc), initialValue);

となります。

ここまで理解した上で、reduceの処理自体に注目してみると、

//           ↓ 関数の受け取り。[add1, selectOdd, triple]の配列となる(A)
const pipe = (...fs) => initialValue =>

//                           reduceの第二引数で、展開された配列の要素(C)
// ↓ 配列Aをreduceで展開     ↓ つまり、関数add1, selectOdd, tripleが順番に渡される
   fs.reduce((acc,         f) => 
//            ↑ reduceの第一引数 accumulator(アキュムレータ)(B)
//              つまり、この後ろのDで処理された結果、
//              つまり、f(acc)の結果が毎回代入される。
//              ただし、初回だけは初期値Eが代入される。

//     処理(D)
//     fは配列Aの各要素。つまり、Cの関数。
//   ↓ つまり、順番にadd1、selectOdd、triple との関数を実行してゆくことになる。
     f(acc),  initialValue); 
//            ↑ reduceの初期値(E)

//   <補足>:f(acc)の意味するところに関して
//   処理(D)の所で説明した通り、fには、配列Aの各要素である関数
//   つまり、add1、selectOdd、triple が順番に渡されていっている。
//   なので、「f(acc)」は、「関数fに、引数accを渡す」という処理を表している。

//   更に補足すると、
//   accは、reduceの第一引数 accumulator(アキュムレータ)を表している。
//   つまり、初回は初期値Eが、2回目以降は、1つ前のf(acc)の結果の返り値を示している。
//   このため、f(acc)は、1つ前の処理結果を受け取って処理を行うことになる。

ということになります。

コメントが多くなってしまい逆に分かり難いかもですが・・・

全体のコードと処理の過程を再掲すると、以下のようになります。

//1.合成される関数群
const add1 = arr => arr.map( n => n + 1);          //受け取ったarrの各要素を全て+1した配列を返す
const selectOdd = arr => arr.filter( n => n & 1);  //受け取ったarrの各要素のうち奇数だけを抽出した配列を返す
const triple = arr => arr.map(n => n * 3);         //受け取ったarrの各要素を全て1倍した配列を返す

//2.パイプライン
const pipe = (...fs) => initialValue => 
//      (A)       (B)(C)  (D)処理 (E)初期値
             fs.reduce((acc, f) => f(acc), initialValue);

//3.元となる値
const nums = [1, 2, 3, 4, 5];

//実行 & コンソールへの出力
let result = pipe(add1, selectOdd, triple)(nums);
console.log(result) // => [9, 15]

/*recudeの実行過程
<1回目>
・reduceの受け取る引数の値: acc=[1, 2, 3, 4, 5], cur=add1 (accが初期値、crrがfsの1つ目の関数、つまり、add1)
・処理(D)の実行結果     : [2, 3, 4, 5, 6]
                           ↑ add1([1, 2, 3, 4, 5])の結果、つまり、
                  [1, 2, 3, 4, 5].map(n => n + 1)の結果

<2回目>
・reduceの受け取る引数の値: acc=[2, 3, 4, 5, 6], cur=selectOdd (accが1回目の結果、crrがfsの2つ目の関数、つまり、selectOdd)
・処理(D)の実行結果     : [3, 5] 
                           ↑ selectOdd([2, 3, 4, 5, 6])の結果、つまり、
                             [2, 3, 4, 5, 6].filter(n => n & 1)の結果

<3回目>
・reduceの受け取る引数の値: acc=[3, 5], cur=triple (accが2回目の結果、crrがfsの3つ目の値)
・処理(D)の実行結果     : [9, 15]
                           ↑ triple([3, 5])の結果、つまり、
                             [3, 5].map(n => n * 3)の結果
*/

1つずつ手を動かして検証することで、理解が深まってきました。

パイプラインがこんなにも理解しにくい理由を考えてみる

JavaScriptでのパイプラインの実装ですが、ここまで説明してきた例の書き方は、上述の通り、MDNで掲載されていた例に沿って実装しています。

この後の検証で使うので、再掲します。

// Building-blocks to use for composition
const double = x => x + x;
const triple = x => 3 * x;
const quadruple = x => 4 * x;

// Function composition enabling pipe functionality
const pipe = (...functions) => input => functions.reduce(
   (acc, fn) => fn(acc),
   input
);

// Composed functions for multiplication of specific values
const multiply6 = pipe(double, triple);
const multiply9 = pipe(triple, triple);
const multiply16 = pipe(quadruple, quadruple);
const multiply24 = pipe(double, triple, quadruple);

// Usage
multiply6(6); // 36
multiply9(9); // 81
multiply16(16); // 256
multiply24(10); // 240

ただ、個人的には、

const pipe = 

//      ↓ この行の記述が、パイプラインを理解しにくくしている
        (...fs) => initialValue =>

             fs.reduce((acc, f) => f(acc), initialValue);

と感じています。というのは、

const pipe = 

//        括弧1
//        関数pipeの一つ目の括弧。ここで、reduceに渡す要素、
//      ↓ つまり関数を配列としてまとめて受け取り、reduce実行時に一つずつ渡している。
        (...fs) => 

//          括弧2
//        ↓ 関数pipeの二つ目の括弧。ここで、reduceに渡す初期値を受け取っている。
          (initialValue) =>
//        ↑ この括弧は省略可能ですが、説明のために残しています。

            fs.reduce((acc, f) => f(acc), initialValue);

という風に、この例の実装が、「括弧1」と「括弧2」を別々の括弧にするために、パイプラインの関数が入れ子になっている からです。

これは、おそらく、「関数を受け取る括弧(括弧1)」と「初期値を受け取る括弧(括弧2)」とを別々の括弧にするための、あえての入れ子、なのだと思います。

この実装が入れ子になっていることを分かりやすくするために、該当箇所をアロー関数を使わずに記述すると、こうなります。

//             括弧1
//           ↓ 展開する関数を受け取る
function pipe(...fs){

//                 括弧2
//               ↓ 初期値を受け取る
  return function(initialValue){

    return fs.reduce((acc, f) => f(acc), initialValue); 
  }
}

ただ、入れ子にしなくても、分割代入を使ったり、関数を先に別の定数に配列としてまとめておくことで、関数と初期値を別々に受け渡しすること自体は可能です。

//例1 初期値を受け取る引数だけ別で用意しておく
const pipe = (initialValue, ...fs) => fs.reduce((acc, f) => f(acc), initialValue);
result = pipe(初期値, 関数1, 関数2, 関数3);

//例2 pipeで順番に実行する関数を予め定数に配列として格納してから、関数pipeに渡す。
const pipe = (initialValue, fs) => fs.reduce((acc, f) => f(acc), initialValue);
const arrFs = [関数1, 関数2, 関数3];
result = pipe(初期値, arrFs);

上記2つの例は、少なくとも僕の手元で検証する限りでは、正しく関数を結合して動いてくれました。

敢えて入れ子にして、「関数を受け取る括弧」と「初期値を受け取る括弧」を分けるよりも、上の例の方が理解しやすくて扱いやすいんじゃないかと、個人的には思っています。

また、(これは、入れ子にしていても入れ子の順番を入れ替えること実装可能ですが)特に例1に関して、pipe(初期値, 関数1, 関数2, 関数3) と初期値を前に持ってくる実装にした方が、

  1. 初期値を関数1渡して関数1を実行
  2. その結果を関数2に渡して関数2を実行
  3. その結果を関数3に渡して関数3を実行

という流れが左から順番に読んで分かるので、理解しやすいんじゃないかと個人的には思っています。

ただ、少なくともこの記事を書くためにネットで調べた限りでは、多くの場合、MDNに掲載されている記述方法がとられていました。

ですので、もしかしたら、まだ僕の理解できていない部分で、そう書くべき何らかの理由がある可能性があります。

ご存知の方、いらっしゃいましたら、ご指摘頂けると幸いです。

雑感

凄く長い記事になってしまいましたが、おかげさまで、JSでのパイプラインの実装に関しては、かなり理解できたと思います。

自分の腑に落ちるまで手を動かして考えるのは、本当に大事だと実感しました。

なお、今回のように、わざわざパイプライン用の関数を作らなくても、近い将来、JSでパイプラインが簡単に使えるようになるみたいです。

そうなったら便利ですね。待ち遠しい。

また、(本来は非推奨らしいのですが)遊びで?個人的にパイプライン演算子を実装された方がいらっしゃいました。

JavaScript: Object.prototypeにメソッドを生やしてパイプライン演算子っぽくしてみた

技術が高いというのは、凄いことですねー。

なお、現在、Pythonを勉強中なので、pythonでもパイプラインの実装を試してみようと思います。

長文になりましたが、読んでくださった方、ありがとうございました。

誤りやご指摘・ご意見等ありましたら、コメント頂けると助かります。

どうぞ、宜しくお願い致します。

kasashimakiyotake
JavaScript(主にGAS)などを使って、業務の自動化・効率化をしたりしています。 Qiitaの記事にはいつも大変お世話になってますので、恩返しの意味を込めて、非エンジニア目線の発信をしていきたいと考えてます。
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