はじめに
Javaにおける関数型プログラミングは、Java Goldを受験した皆様は触れていると思います。
私もJava Gold受験時に初めて出会いました。そして、「で、いつ使うん?」と思いました。
この記事では、Javaにおける関数型プログラミングのうまみは何なのか、いつ使うべきなのかをまとめています。
参考:
関数型プログラミングがJavaに導入されるまでの経緯を一発で理解する
関数型プログラミングとは
まずは関数型プログラミングの概要を理解しましょう。
関数とは
「関数」は数学で習いましたね、あれです。
プログラミングの文脈においては、以下の特徴を持つ計算を「関数」と呼びます。
- ある入力に対しては、常に同じ出力をする
- 外部状態を参照しない
- 外城状態を変更しない(副作用がない)
例えば、次のaddメソッドは「関数」と言えます
public int add(int x, int y) {
return x + y;
}
対して、次のaddメソッドは「関数」と言えません。
xの値によって結果が変わります。また、外部状態であるxを参照しています。
private int x;
public int add(int y) {
return x + y;
}
次のaddメソッドも「関数」と言えません。
こちらもxの値によって結果が変わります。また、外部状態であるxを変更しており副作用があります。
private int x;
public void add(int y) {
return x += y;
}
関数型プログラミングとは
平たく言うと、全ての処理(メソッド)を「関数」として定義する手法です。
どんな利点があるか
副作用がない
副作用はバグの元です。
「気づかないうちに値が書き換わっている」ということが往々にして発生します。
副作用に気をつけるにも、値を変更している箇所を漏れなく探し出し、全てのパターンに気をつける必要があります。絶対に漏れます。
先ほど見たとおり、「関数」は副作用がありません。副作用がないというだけで、安全な実装ができるのです。
テストが書きやすい
関数は「入力が決まれば出力も決まる」ものですので、テストが書きやすいです。
これは副作用がないことの恩恵の一つでもあります。
宣言的に実装できて読みやすい
Javaのような手続き型言語では、「計算の仕方」「手順」を書くことになります。
対して、関数型プログラミングでは「目的」を表現できます。
例えば、リスト内の要素の合計値を求めるコードを書いてみましょう。
List<Integer> list = Arrays.asList(2, 3, 5);
int sum = 0;
for (int value : list) {
sum += value;
}
System.out.println(sum);
「リストを回してsumに足していく」という手順を書いています。
(この程度だと読めてしまう人は多いと思いますが)パッとみて意図(合計値を出している)を読み取れるでしょうか?
関数型プログラミングで書いてみると以下のようになります。
sum = list.stream().mapToInt(n -> n).sum();
System.out.println(sum);
こちらは意図を読み取れるでしょうか?
ポイントは、呼んでいるメソッドの名前がそのまま「意図」であることです。
関数型(宣言型)だと、「どうやるか」(手続き)を関数として隠蔽し、「何をするか」(目的)を表現することができます。
処理を差し替えることができる
関数型プログラミングでは、関数のパラメータとして関数を渡すことができます。
より高いレベルでの共通化が可能です。
例えば、「前処理、本処理、後処理」という3段階の処理を持つタスクが複数種類あるとしましょう。
関数型では、それぞれのステップで「やること」を関数として表現し、「前処理、本処理、後処理」の流れだけが記載されたユーティリティ関数に「やること」を渡せば良いことになります。
また、失敗時のリトライ処理も同様にユーティリティを作ることができます。「やること」を関数化して、「リトライ処理」が記述されたユーティリティに渡すことで、各タスクごとにリトライ処理を定義する必要がなくなります。
小さい単位でモジュール化できる
関数型では副作用を排除する必要があるため、各関数が小さくなりがちです。
大きな「やること」を小さなタスクに分割して組み合わせることで実現します。
タスクは関数として実現されます。
関数なので状態に依存せずいつでもどこでも再利用可能なモジュールということです。
Javaにおける関数型プログラミング
Javaはオブジェクト指向言語なので、状態を持つのが基本です。
状態を持たないことはできますが、状態を持たないよう強制することはできません。
よって、Javaにおける関数型プログラミングは、純粋な関数をJavaで実現するのではなく、関数っぽい書き方をすることで、関数のうまみを享受しようとするものです。
なるべく副作用をなくす
上に記した通り、副作用はバグの元です。
なるべく減らしたいですが、Javaは状態を持つのが普通であり、状態を更新するのもまた普通のことです。
そこで、「関数型」を導入することで、なるべく副作用をなくす「意識」をする、というのが、Javaにおける関数型プログラミングの意義の一つです。
よって、「副作用を回避できない場合」には適さないとも言えます。
例えば、「I/O処理」は、プログラムの外のファイルなどを書き換えるものですから、副作用を避けられません。
純粋な関数としての定義はできないため、無理に関数化する必要はないでしょう。
宣言的に書いて可読性を上げる
Javaでは可読性を上げるための工夫として、このように宣言的な実装にすることはよく推奨されています。(関数型プログラミングが実現されたJava8以前から推奨されていたと思う)
例1:条件部分をメソッド化し名前をつける
if(age >= 18) {
// 大人の場合の処理
}
↓↓↓
private boolean isAdult() { return age >= 18; }
if(isAdult()) {
// 大人の場合の処理
}
例2:ストラテジーパターン(呼び出し元ではやりたいこと(export)のみを書くので宣言的)
if(type.equals("PDF") {
exportPdf(data);
} else if(type.equals("IMAGE") {
exportImage(data);
} else if(type.equals("CSV") {
exportCsv(data);
}
↓↓↓
// 呼び出し元
Expoter expoter = createExporter(type);
expoter.export(data);
// typeごとに以下のインターフェースを実装する
interface Exporter {
public void export(Data data);
}
関数型プログラミングではこのような宣言的な実装を自然と実装することができます。
複雑な手順を隠蔽し、「やること」を中心に集中して実装できるのが強みです。
また、Javaでの関数型プログラミングではラムダ式を導入することにより、コードの全体量の削減も叶えています。これもやはり「やること」への集中(ノイズの除去)に一役買っていると思います。
小さな関数に分ける
「小さく」することで理解容易性をあげます。これはバグの防止にも役立ちます。
また、「関数」にすることで副作用のない安全な実装ができます。
「小さな関数」は再利用性に優れています。うまく実装すれば、いつでもどこでも使い回すことが可能になります。
また、「小さな関数」はテストが書きやすいです。より安全な実装にすることができます。
おわりに
「副作用を回避できない場合」には適さないと書きましたが、これに当てはまらない場合は関数型プログラミングが実装の一つの選択肢なんだろうと思います。
古典的な書き方と比べて、一番異なるのはコードの見た目だと思います。
どちらの書き方でも試してみて、「今見て読みやすいか」「1ヶ月後にみて読みやすいか」「3年後にみて読みやすいか」を比べる(まあ関数型プログラミングに限らずいつもやっていることですね)のが大事かと思います。