信頼度ランク
| S | 公式ソース確認済み |
| A | 成功実績多数・失敗例少数 |
| B | 賛否両論 |
| C | 動作未確認・セキュリティリスク高 |
| Z | 個人所感 |
関数型プログラミングの思想 ── なぜ Java にラムダと Stream が入ってきたのか
純粋関数・副作用なし・イミュータブルデータとはどういう考え方か。オブジェクト指向と何が違うのか。Java 8 でラムダ・Stream が導入された背景と、関数型の考え方がコードをどう変えるかを解説。
一言結論
関数型の本質は「状態を変えない・副作用を持たない関数を組み合わせる」発想。Java はオブジェクト指向言語だが、マルチコア時代の並列処理の需要でラムダ・Stream として関数型を取り込んだ。
Java 8(2014 年)でラムダ式と Stream API が導入された。これは Java の設計思想に関数型プログラミングの考え方が入ってきた転換点だ。
なぜ Java にそれが必要になったのか。関数型とは何を大事にする思想なのか。
1. オブジェクト指向の「状態」が引き起こす問題
オブジェクト指向は「状態(データ)とふるまい(メソッド)をオブジェクトにまとめる」発想だ。
class Counter {
private int count = 0; // 状態
public void increment() { count++; }
public int getCount() { return count; }
}
オブジェクトは「状態を持ち、メソッドでそれを変化させる」。これは直感的だが、マルチスレッドで問題が起きやすい。
Counter c = new Counter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) c.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) c.increment();
});
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(c.getCount()); // 2000 になるはずが 1873 などになる
count++ は「読む→加算→書く」の 3 ステップなので、スレッドが同時にやると競合する。状態(count)が複数スレッドから変更されるのが根本原因だ。
2. 関数型プログラミングの核心 ── 状態を変えない
関数型プログラミング(Functional Programming)の考え方は:
「関数は入力を受け取って出力を返す。副作用を持たない。状態を変えない。」
純粋関数とは
// 純粋関数: 同じ入力 → 必ず同じ出力。外部の状態を変えない
static int add(int a, int b) {
return a + b;
}
// 純粋でない関数: 外部の状態に依存する / 変える
static int counter = 0;
static int impureAdd(int a, int b) {
counter++; // ← 副作用(外部の状態を変えている)
return a + b;
}
純粋関数は:
- テストが簡単: 入力と出力だけ確認すればいい。DB や時刻やグローバル変数を考慮しなくていい
- 並列安全: 状態を変えないのでスレッド競合が起きない
- 理解しやすい: 関数の中だけ見れば動作がわかる
イミュータブルデータ
// ❌ ミュータブル: 変更できるリスト
List<String> mutable = new ArrayList<>(List.of("a","b","c"));
mutable.add("d"); // 状態が変わる
// ✅ イミュータブル: 変更できないリスト(変更は新しいリストを作る)
List<String> immutable = List.of("a","b","c");
// immutable.add("d"); → UnsupportedOperationException
List<String> newList = Stream.concat(immutable.stream(), Stream.of("d"))
.collect(Collectors.toList());
3. Java 8 がラムダを取り込んだ理由
2010 年代にマルチコア CPU が普及し「並列処理を活用したい」という需要が高まった。
しかし従来の Java(for ループ・可変リスト)は並列化が難しい:
// 従来のやり方: 順番にループ → 並列化しにくい
List<String> result = new ArrayList<>();
for (String s : names) {
if (s.length() > 3) {
result.add(s.toUpperCase());
}
}
Stream API の登場で、「何をする処理か」を宣言的に書き、実行は Stream に任せられるようになった:
// Stream: 処理の「意図」を書く。並列化は parallelStream() に変えるだけ
List<String> result = names.stream()
.filter(s -> s.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
// 並列版(コレクションの変更なし)
List<String> result = names.parallelStream()
.filter(s -> s.length() > 3)
.map(String::toUpperCase)
.collect(Collectors.toList());
各ラムダが純粋関数であれば(外部の状態を変えない)、Stream は安全に並列実行できる。
4. 命令型 vs 宣言型 ── 考え方の違い
// 命令型(How を書く): 「どうやってやるか」を書く
List<Integer> evens = new ArrayList<>();
for (int n : numbers) {
if (n % 2 == 0) {
evens.add(n);
}
}
// 宣言型(What を書く): 「何をしたいか」を書く
List<Integer> evens = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
命令型は「for ループを使って、条件を確認して、リストに追加する」という手順を書く。宣言型は「偶数だけのリストが欲しい」という意図を書く。
どちらが優れているかではなく、宣言型は「意図」が読みやすい、命令型は「処理」が追いやすいというトレードオフだ。
5. Java の関数型と純粋な関数型言語の違い
Java は「オブジェクト指向に関数型を加えた」言語であり、Haskell・Scala・Clojure のような純粋な関数型言語ではない。
// Java のラムダ: 外部の可変状態を使うことができてしまう(副作用を持てる)
List<String> sideEffect = new ArrayList<>();
names.stream()
.filter(s -> {
sideEffect.add(s); // ← 副作用(やってはいけない)
return s.length() > 3;
})
.collect(Collectors.toList());
Java は副作用を持つラムダを文法上は許している。「関数型の考え方を使う」かどうかはプログラマの判断に委ねられている。
Stream を正しく使うための原則:
- ラムダ内で外部の可変状態を変更しない
- ラムダはできるだけ純粋関数にする
- 共有される可変オブジェクトをラムダ内で操作しない
6. 関数型の発想が役に立つ場面
Optional でnull安全に
// ❌ null チェックが煩雑
User user = findUser(id);
if (user != null) {
Address addr = user.getAddress();
if (addr != null) {
String city = addr.getCity();
if (city != null) {
System.out.println(city.toUpperCase());
}
}
}
// ✅ Optional でチェーン
findUserOptional(id)
.map(User::getAddress)
.map(Address::getCity)
.map(String::toUpperCase)
.ifPresent(System.out::println);
コレクション操作
// 売上トップ3の商品名を取得
List<String> top3 = products.stream()
.sorted(Comparator.comparingInt(Product::getSales).reversed())
.limit(3)
.map(Product::getName)
.collect(Collectors.toList());
これを命令型で書くと sort・subList・ループが必要になる。Stream で書くと「何をしているか」が 1 箇所で読める。
まとめ
| 概念 | 意味 |
|---|---|
| 純粋関数 | 同じ入力→同じ出力。副作用なし |
| 副作用 | 関数の外の状態を変えること(DBへの書き込み・グローバル変数の変更など) |
| イミュータブル | 変更しない。変更が必要なら新しい値を作る |
| 宣言型 | 「何をするか」を書く。「どうやるか」は実行エンジンに任せる |
| Java での活用 | Stream・Optional・ラムダで関数型の考え方を部分的に取り込む |
Java はオブジェクト指向が主体だが、「状態を変えない」「副作用を持たない」という関数型の発想を適切に取り入れることで、テストしやすく並列化しやすいコードが書ける。for ループが悪いわけではないが、「Stream で書けるか?」を考える習慣が視野を広げる。