はじめに
こんにちは。iimonでエンジニアをしている金庭です。
今回は、複数の引数を取る関数を、単一引数をとる関数の連続呼び出しに置き換えるという、カリー化について調べたのでご紹介します。
カリー化とは
カリー化(Currying)とは、一言でまとめてしまえば複数の引数をとる関数を、単一引数をとる関数の連続呼び出しに変形させることです。はい、前段で述べたことの全くの繰り返しになってしまいましたが、これがカリー化です。
文系畑出身の当方の頭では意味が分かりませんでしたので、コードを例にみていきます。
例えば以下のような、受け取った日付(or場所)の天気情報を出力する関数があったとします。
const hoge = (subject:string, weather:string) => { console.log(`${subject}の天気は${weather}`) } hoge('今日', '雨') // 今日の天気は雨
これをカリー化すると以下の形になります。
const hoge = (subject:string) => (weather:string) => { console.log(`${subject}の天気は${weather}`) } hoge('今日')('雨') // 今日の天気は雨
hoge(a, b)と呼んでいたのが、hoge(a)(b)の形で呼び出す形に変わりました。
カリー化後のhoge()では、subjectを受け取る関数 → weatherを受け取る関数というチェーン構造になっているのが分かるかと思います。
部分適用
・・・それで、だからなんだというのでしょうか?
例えば、中央区の天気は◯◯のような呼び出しがいくつもある場合を考えてみます。
const hoge = (subject:string) => (weather:string) => { console.log(`${subject}の天気は${weather}`) } hoge('中央区')('雨') hoge('中央区')('晴れ') hoge('中央区')('曇り')
これを、一旦第1引数の中央区を渡した関数を設定することで、あとはそれぞれ天気情報を流し込む(一部の引数を先に決定した状態を作れる)という使い方ができます。
const hoge = (subject:string) => (weather:string) => { console.log(`${subject}の天気は${weather}`) } // 第1引数だけ部分的に設定しておく const fuga = hoge('中央区') fuga('雨') // 中央区の天気は雨 fuga('晴れ') // 中央区の天気は晴れ fuga('曇り') // 中央区の天気は曇り
fugaに中央区を設定しておくことで、hoge()の第1引数subjectが中央区で確定したfugaが誕生します(hoge関数に途中まで引数を適用した状態)。
これに、fuga(天気)とすることで残りの第2引数の天気情報が渡され、中央区に絞った天気出力ができるようになります。
これを、部分適用と呼びます。
要は、複数ある処理の一部を共通化したい場面で有用ということです。
(注:カリー化と部分適用は厳密には別物の概念です。 カリー化とは、あくまで複数の引数をとる関数を、1つの引数を受け取る関数のチェーンに変換することですが、 部分適用とは、関数の一部の引数をあらかじめ設定しておき、より少ない引数の新しい関数を作ることになります。それにカリー化が適しているということです)
再利用性の向上
上記の部分適用の考えを踏まえて、カリー化のメリットともいえる再利用性という観点からカリー化を考えていきます。
以下のような、税率と価格から税込価格を返す関数があったとします。
const calcPriceWithTax = (rate, price) => { const taxPrice = price + Math.floor((price * rate) / 100); return taxPrice; };
これを毎回
calcPriceWithTax(10, 2000)、
calcPriceWithTax(8, 2000)
のように呼ぶのは冗長なかんじがします(冗長ということにしてください)。
一旦これを以下のようにカリー化して、
const calcPriceWithTax = (rate) => (price) => { const taxPrice = price + Math.floor((price * rate) / 100); return taxPrice; }
calcPriceWithTaxの第1引数を部分適用であらかじめ定義した、つまり税率を固定した関数を作っておきます。
// 標準税率10% const calcPriceWithStandardTax = calcPriceWithTax(10); // 軽減税率8% const calcPriceWithReducedTax = calcPriceWithTax(8);
こうすることで、ベースのロジックはそのままに、税率に応じて関数を再利用することができます。
calcPriceWithStandardTax(2000) // -> 2200 calcPriceWithReducedTax(2000) // -> 2160
ルールと処理の分離
このあたりから設計思想の話になってきますが、カリー化を使って業務ルールと実処理を分けることで、コードの責任範囲を明確にすることができます。
例えば、ユーザの入力値に対してバリデーションチェックを行う以下の処理があります。
const validateInputVal = (value) => { const rule = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const errorMsg = "無効なアドレス形式です"; if(!rule.test(value)) return errorMsg; return value; }
関数内部でチェックルールとエラー文言の設定、チェック処理をまとめて行なってしまっており、「エラー文言を変えたい」「電話番号のチェックも追加したい」というような場合に、直接validateInputValの内部をいじらなければなりません。
これを以下のようにチェックルール部分と入力値の受け取り部分で分離します。
// 正規表現+エラー文言と入力値の受け取りをカリー化で分離 const validateInputVal = (rule, errorMsg) => (value) => { if(!rule.test(value)) return errorMsg; return value; } // メールアドレス用のチェック const validateEmail = validateInputVal(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, '無効なアドレス形式です'); // 電話番号用のチェック const validatePhoneNumber = validateInputVal(/^0\d{1,4}-\d{1,4}-\d{4}$/, '正しい電話番号の形式で入力してください'); validateEmail('test@example.com') validatePhoneNumber('08099999999') // '正しい電話番号の形式で入力してください'
結果としてvalidateInputValは単に渡された入力値とルールを照らし合わせてチェックを行うことに専念でき、ルールやエラー文言の具体的な内容までは知る必要がなくなりました。
このようにカリー化を使ってルールと実処理を分離することで、再利用性を高めることができ、テストもしやすくなります。
関数合成について
さらに発展した考えである、関数合成についてご紹介します。
関数合成とは、複数の関数を組み合わせて一つの関数を作ることです。
関数合成の基本として、ある合成関数が関数Aと関数Bから構成される場合、関数Aの戻り値は一つで、その結果を受けて処理を実行する関数Bの引数も一つでなければなりません。
以下のような形になります。
f(g(x))のとき、g(x)の戻り値は一つで、続いて動くf()も一つの引数をとる。
実際にコードを例にとってみていった方が分かりやすいので、コード上で解説していきます。
「数字に2を足す」関数と、「数字に3をかける」関数を合成したいとします。
// 「xを受け取り、yを受け取って x + y を返す関数」を返すカリー化済関数 const add = x => y => x + y; // 「xを受け取り、yを受け取って x * y を返す関数」を返すカリー化済み関数 const multiply = x => y => x * y; // 部分適用で2を足す関数を定義 const addTwo = add(2); // 部分適用で3をかける関数を定義 const multiplyThree = multiply(3); // 複数の関数を受け取り、順々にデータを渡していく関数合成用pipe関数 const pipe = (...funcs) => initialValue => funcs.reduce((currentValue, currentFunction) => currentFunction(currentValue), initialValue); // addTwoとmultiplyThreeを合成したhoge関数。 const hoge = pipe(addTwo, multiplyThree) // addTwoとmultiplyThreeが連鎖的に実行される。実行結果は21。5 + 2 = 7 -> 7 * 3 -> 21 hoge(5)
ベースとなる加算用のaddと乗算用のmultiplyはカリー化した状態で定義しておき、それぞれaddTwo、multiplyThreeとして部分適用を行います。
これを、関数合成を行ってくれるpipe関数に渡した合成関数hogeを定義することで、addTwo、multiplyThreeが連鎖的に実行される、関数合成を実現することができます。
ややこしいところですが、関数合成の処理の核となるpipe()について解説します。
const pipe = (...funcs) => initialValue => funcs.reduce((currentValue, currentFunction) => currentFunction(currentValue), initialValue);
(...funcs)に合成したい関数の配列が入ってきます([addTwo, multiplyThree])。
initialValueは次点のfuncs.reduceで処理される最初の引数データです(5)。
fucs.reduceで、関数の配列を順々に畳み込んでいきます。一周目ではinitialValue(5)イコール currentValueです。
currentValueは現在のデータ(前回の関数の処理結果。一週目ではinitialValue)、currentFunctionは現在実行しようとしている関数です(一周目ではaddTwo())。
(currentValue, currentFunction) => currentFunction(currentValue)の結果が、次の処理に渡されます(addTwo(5) -> 7)。前回の関数の処理結果である7がcurrentValueとして渡され、currentFunctionのmultiplyThree()がcurrentValueを引数として実行されます(multiplyThree(7) -> 21)。
配列の関数を全て実行した結果、最終的な戻り値として21が返ります。
このようにJavaScriptで関数合成を実現するには、pipeのような合成用の関数を定義してあげる必要があります。
カリー化と関数型言語
ここから少し関数型言語という考えについて軽く触れていきたく思います。
前項で、関数合成においては関数Aの戻り値は一つ、関数Bの引数は一つというような話をしました。また、それに際しカリー化が有用でした。
これは、関数型言語において核となる考えになります。
関数型言語では、文字通り関数を主軸とした実装を行うのですが、ここでの関数は純粋関数であり、参照透過性を持つことが必須となります。
純粋関数と参照透過性
純粋関数を一言で言ってしまえば「同じ引数を与えれば常に同じ結果を返す」関数のことです。 また、その結果として副作用を持たない状態を、参照透過性があると表現します。
副作用とは、例えば関数内部でスコープ外の変数の状態を書き換えてしまうようなことを指します。
実際にコードを例に見ていきましょう。
例えば、以下の例は関数内部でスコープ外のresults配列を書き換えてしまっているため、副作用がある→参照透過性がないといえます。
const results = []; const numbers = [1, 2, 3]; const doubleAll = (numArr) => { for (const n of numArr) { results.push(n * 2); // 外部のresultsを変更 } } doubleAll(numbers)
上記のような、配列の破壊的操作(ミュータブル)などがこれにあたります。
次に、副作用をもたない純粋関数の例を見てみます。
以下は引数に2をかけた結果を返す関数です。
const multiplyTwo = (x) => { const result = x * 2; return result; }
この関数に例えば5を渡せば必ず10が返却されます。言い換えれば、5を渡す限り10以外の結果が返ってくることはありません。
つまり、このmultiplyTwo関数は前後の文脈を意識する必要がないため、副作用を持たない(参照透過性をもつ)純粋関数であるといえます。
このような、文脈に依存しない純粋関数を使うことで、関数の再利用性と保守性が高まります。
純粋関数型言語であるHaskell
上記のような純粋関数を用いた言語に、Haskellがあります。 何を隠そうこのHaskellという名称は、カリー化の名前の元となったアメリカの数学者もとい論理学者、ハスケル・カリー(Haskell Curry)さんから取られています。
この言語は、1990年代に純粋関数型言語として誕生しました。
Haskellでは参照透過性を重視しているため、コードに動作の予測がしやすいといったメリットを持たせることができます。
このような思想を背景に、Hakellではデフォルトでカリー化が採用されています。
例えば、JavaScriptではカリー化を以下のように記述していました。
// xとyを足す関数 const add = x => y => x + y; const addTwo = add(2); addTwo(5); // 7
これをHakellを使うと、以下のように記述することができます。
// すでにカリー化されてる add x y = x + y // 部分適用 addTwo = add 2 addTwo 5 // 7
Haskellでは、関数は常に一つの引数を受け取り次の関数を返す形になっているため、部分適用や関数合成が容易という利点があります。
一度定義した変数の値は二度と変更できず(イミュータブル)、関数はただデータを受け取り、新しいデータを返すことにのみ専念できます。
これにより、不確定要素を可能な限り排除した、予測可能な関数を作ることができます。
バグを極力潜り込ませないという強い意志が感じられますね。
まとめ
カリー化について調べ始めた当初は、慣れない記法でメリットと言われてもピンときませんでしたが、関数内部を段階的に分けることで再利用性、保守性の寄与につながる点は慣れれば有用なのかなとは思いつつ、カリー化や関数合成といったものを、Java(Type)Scriptで無目的に推し進めていってもかえって読みづらくなりそうですし、無理に使う必要はないのかなというのが率直な感想です。
というよりも正確には、カリー化はHaskellのような純粋関数型言語でこそ活かされる技術であり、それをJavaScriptのような言語で表現しようとするとこうなるという話なのだと思います。
そもそもの主戦場が違うわけですね。
ただ、このカリー化といった仕組みや純粋関数の考え方を知っておくのとそうでないのとでは、言語の種類に関わらずコードに対する理解や解像度もだいぶ変わってくるのかなとも感じます。
ここまで読んでいただきありがとうございます!
下記リンクよりご応募お待ちしております!
iimon採用サイト / Wantedly / Green
参考文献・記事
・【TypeScript】カリー化・部分適用は便利だよ! #関数型プログラミング - Qiita
・【Javascript】高階関数の基礎とカリー化・部分適用 | エンジニアBLOG
・TypeScriptのTips - カリー化で業務ルールと処理を分離する
・なっとく!関数型プログラミング を読んで関数型プログラミングを学んだ #JavaScript - Qiita
・カリー化と部分適用(JavaScriptとHaskell) #JavaScript - Qiita
・TypeScriptで学ぶ、カリー化と関数合成を使った実用的なフィルター実装 #TypeScript - Qiita
・純粋関数とは何か:状態と副作用を捨てると何が得られるか #Rust - Qiita