第一級関数の使い方が分からないJavaディベロッパに教えたいその使いどころ:部分適用・遅延評価編
目次
こんにちは、アーキテクトのQZ西垣とフロントエンドリードのゆうみです。
今回は第一級関数の使いどころ解説の2回目、部分適用と遅延評価についてです。
第一級関数って?という方は前回を参照してください。
部分適用とは
前回は内容が薄かったので、今回は掘り下げて解説します。
部分適用とは「複数の引数を持つ関数に対して、一部の引数を適用し、残りの引数を要求し元の関数の戻り値を返却する新しい関数を作成すること」です。
以下がサンプルです。
1 2 3 4 5 6 7 8 9 10 |
// Integer型の値を2つ受け取り、Integer型の値(合計)を返却する関数 BiFunction<Integer, Integer, Integer> func0 = (arg0, arg1) -> arg0 + arg1; System.out.println(func0.apply(1, 3)); // 4 // Integer型の値を1つ受け取り、「Integer型の値を1つ受け取りInteger型の値を返却する関数」を返却する関数(func0のカリー化) Function<Integer, Function<Integer, Integer>> func1 = arg0 -> arg1 -> func0.apply(arg0, arg1); // 部分適用する Function<Integer, Integer> func2 = func1.apply(3); System.out.println(func2.apply(4)); // 7 System.out.println(func2.apply(6)); // 9 |
(カリー化についてはこちらを参照ください。)
関数の引数を一部固定してしまうといったところでしょうか。
部分適用を使用すれば、複数回同じメソッドを呼び出す際に、同じ引数を指定し続ける冗長性をなくすことができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
private static int multiply(int w, int x, int y, int z) { return w * x * y * z; } public static void main(String args[]) { // 四つの引数のうち1,2,3が同じなので冗長 System.out.println(multiply(1, 2, 3, 4)); System.out.println(multiply(1, 2, 3, 6)); System.out.println(multiply(1, 2, 3, 9)); // 1,2,3は一度しか記述されていない Function<Integer, Integer> func = z -> multiply(1, 2, 3, z); System.out.println(func.apply(4)); System.out.println(func.apply(6)); System.out.println(func.apply(9)); } |
また、部分適用の考え方を応用するとメソッドの引数を減らすことができます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
private static int func(int b, int c, int d) { return b * (c + d); } private static int calc1(int status, int a, int b, int c, int d) { switch (status) { case 1: return a + func(b, c, d); case 2: return a * 10 + func(b, c, d); default: return a; } } private static int calc2(int status, int a, Function<Integer, Integer> funcObj) { switch (status) { case 1: return funcObj.apply(a); case 2: return funcObj.apply(a * 10); default: return a; } } public static void main(String args[]) { // 部分適用を使用しない場合、b, c, dの3つの引数がありfuncはcalc1内部で呼び出される int b = 2; int c = 3; int d = 4; System.out.println(calc1(1, 10, b, c, d)); System.out.println(calc1(2, 20, b, c, d)); System.out.println(calc1(3, 30, b, c, d)); // 部分適用を使用する場合、3つの引数はfuncObjとしてあらかじめfuncに部分適用できる Function<Integer, Integer> funcObj = a -> a + func(b, c, d); System.out.println(calc2(1, 10, funcObj)); System.out.println(calc2(2, 20, funcObj)); System.out.println(calc2(3, 30, funcObj)); } |
なお最初の引数を受け取って戻り値の関数を返却するまでに、処理を記述することもできます。
1 2 3 4 5 6 |
Function<Integer, Function<Integer, Integer>> func0 = x -> { int x2 = x * x + 1; return y -> x2 * y; }; Function<Integer, Integer> func1 = func0.apply(3); System.out.println(func1.apply(4)); // 40 |
これら部分適用の特性をうまく使用すれば、Java7以前ではインターフェイスを作成する必要が生じたりそもそも不可能であった最適化が可能になります。
遅延評価
遅延評価とは、事前に処理を関数として定義しておき、必要なタイミングで処理を実行することです。
1 2 3 4 5 6 7 8 9 10 11 12 13 |
private static void process(int x) { Thread.sleep(10 * 1000); System.out.println(x); } public static void main(String args[]) { // この時点では process は実行されない(コンソールには何も出力されない) Consumer<Integer> p = x -> process(x); if (args[0].equals(“run”)) { // ここで process が実行される(10秒後にコンソールに “10” が出力される) p.accept(10); } } |
遅延評価の効果が発揮されるのはメソッドの引数に関数を指定する時です。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
private static int slowMethod(int x) { Thread.sleep(10 * 1000); return 2 * x; } private static void printIfTrue(boolean flg, int x) { if (flg) { System.out.println(x); } } private static void printIfTrue2(boolean flg, Supplier<Integer> supp) { if (flg) { System.out.println(supp.get()); } } public static void main(String args[]) { printIfTrue(true, slowMethod(1)); // (1) printIfTrue(false, slowMethod(1)); // (2) printIfTrue2(true, () -> slowMethod(1)); // (3) printIfTrue2(false, () -> slowMethod(1)); // (4) } |
slowMethodは渡された数を2倍にして返す関数で、printIfTrueとprintIfTrue2は共にflgがtrueのときだけ数を表示する関数ですので、(1)と(3)では2が表示され、(2)と(4)では何も表示されません。
これだけだとどの書き方でも同じように見えますが、実際は待ち時間が異なります。
slowMethodは値を返すまで10秒待つ関数なので、(1)と(2)ではprintIfTrueに渡される前に10秒待たされます。
しかし、(3)と(4)ではprintIfTrue2に渡された時点ではslowMethodが実行されていないので待ち時間がありません。slowMethodが実行されるのはflgがtrueのときだけです。したがって、(3)では10秒待った後に2が表示されますが、(4)では待ち時間がなく、すぐに処理が終わります。
この例では待ちが発生する処理が単なるsleepでしたが、データベースやAPIへのアクセスなど比較的長い時間やリソースの消費を必要とする処理だった場合、printIfTrueよりもprintIfTrue2のほうが余計な計算やアクセスを減らせます。
必要な処理を必要なときだけ実行するのが遅延評価のミソです。
(実際はprintIfTrue2の中でsuppが何度も実行される可能性を考慮して、suppの実行結果をキャッシュすることも考えなければいけません。)
関数型インターフェイスごとの使い分け
Javaには java.util.function.Function 等の関数型インターフェイスが定義されています。
また新しく関数型インターフェイスを定義することもできます。
これらは引数と戻り値の有無の観点から、大別して4つに分類することができます。
それぞれ部分適用と遅延評価の観点から解説します。
Function
Functionは引数を一つと戻り値を持つ関数型インターフェイスです。
引数と戻り値の両方を持つ関数型インターフェイスには、引数を2つ取るBiFunctionや引数と戻り値の型が同じOperatorなどがあります。
Function関連の関数型インターフェイスは、最も基本的かつ応用が効き、部分適用にも遅延評価にも使用できます。
Supplier
Supplierは引数を持たず、戻り値だけを持つ関数型インターフェイスです。
戻り値だけを持つ関数型インターフェイスには、intを返却するIntSupplierなどがあります。
Supplier関連の関数型インターフェイスを使用すれば、全ての引数を部分適用しておくことで、必要なタイミングで遅延評価し、戻り値を取得することができます。
Consumer
Consumerは戻り値を持たず、引数を一つ持つ関数型インターフェイスです。
引数だけを持つ関数型インターフェイスには、引数を二つ持つBiConsumerや、intを引数に持つIntConsumerなどがあります。
Consumer関連の関数型インターフェイスは、戻り値を持たないため通常は副作用をもたらす遅延評価を行います。必要に応じて部分適用しておくことも可能です。
Runnable
Javaにデフォルトで定義済みの関数インターフェイスの中でも、特異なのが java.lang.Runnable です。
Runnableは、引数も戻り値も持たない関数型インターフェイスです。そのため、評価時に外部から影響を受ける(=引数を受け取る)ことも影響を与える(=戻り値を返す)こともありません。
引数も戻り値もないメソッドを実装することはほとんどないので、Runnableは実質的に、事前に引数を部分適用しておき、必要なタイミングで副作用を及ぼす遅延評価専用インターフェイスです。
おわりに
今回は部分適用と遅延評価の視点から第一級関数を簡単に解説してみました。
部分適用・遅延評価または前回で解説したクロージャのいずれかを使用することによって、第一級関数はそのメリットを大いに享受できます。
前回でも述べましたが、第一級関数は概念的に難しいものの、理解すればコーディングにおいて極めて強力な武器となります。
是非とも使いこなしてください。