はじめに
はじめまして、わんこ(@Wanko_IT)です。
実はわたし、2026年5月に転職をして「株式会社ユーザベース」で働いております。
転職後ジョインしたチームでは「Clojure」という関数型の言語を使っており、開発をしていく中で関数型プログラミングのメリットについて興味を持ったので調べたことをまとめたいと思います。
関数型プログラミングって何?
そもそも論として、関数型プログラミングとは何でしょうか?
軽く調べたところ以下のような特徴がありそうです。
- 参照透過性を持つ関数を利用すること
- 値の不変性(immutability)があること
- 第一級関数の性質を持つこと
- 高階関数を利用するスタイルであること
しかしながら、私の知る限り関数型プログラミングの定義において、広く認められた共通認識は確立されていないようです。
そのため今回は関数型デザイン(Robert C. Martin著)に記載されている以下の定義を採用します。
関数型プログラミングとは、変数のないプログラミングと言えるだろう。
関数型プログラムの値は変化しないのである。
つまり、関数型プログラミングの本質は不変性にあると言えるでしょう。
値の不変性ってなんだろう
値の不変性があるというのは、端的に言えばあらゆる値が定数であるということです。
Java・C・PHP・Rubyなどの非関数型の言語では変数を扱って値を表現します。
変数は名前の通り変更可能な値であり、変数の値はシステムの状態に応じて変化します。
// 財布を表現するJavaコード
class Wallet {
private int money;
public Wallet(int money) {
this.money = money;
}
public void addMoney(int amount) {
money = money + amount; // 中身を書き換える
}
public int getMoney() {
return money;
}
}
Wallet wallet = new Wallet(1000);
System.out.println(wallet.getMoney()); // 1000
wallet.addMoney(500);
System.out.println(wallet.getMoney()); // 1500 (最初と状態が変わっている)
一方でClojure・Scala・Erlangといった関数型言語では、定数を使って値を表現します。
定数は固定化された値であり、変化することがありません。
システムの状態に応じた値の変化は既存の値をもとに作られた新たな定数として表現されます。
// 財布を表現するScalaコード
case class Wallet(money: Int) {
def addMoney(amount: Int): Wallet = Wallet(money + amount) // 新しい財布を作る
}
val wallet1 = Wallet(1000)
val wallet2 = wallet1.addMoney(500)
println(wallet1.money) // 1000(変わってない!)
println(wallet2.money) // 1500(新しくできた財布)
値が不変だと何が嬉しい
値が可変であることに伴う問題は2点あります。
1. 共有ミュータブル状態
非関数型(Java)の場合
非関数型言語において、状態の変化はオブジェクトの持つプロパティやグローバルな変数の変更によって表現されます。
こういった特性は一部のユースケースにおいて、バグの原因となる可能性があります。
例えば、財布の金額を保存しておくために、このようなクラスを作ったとします。
class BalanceHistory {
// 特定地点での財布の状態を保持するリスト
private List<Wallet> snapshots = new ArrayList<>();
// 「今この瞬間の状態」を記録するためのメソッド
public void recordSnapshot(Wallet wallet) {
snapshots.add(wallet);
}
public List<Wallet> getSnapshots() {
return snapshots;
}
}
開発者はこのクラスを利用して、以下のように財布の金額記録のユースケースを表現するでしょう。
Wallet wallet = new Wallet(1000);
BalanceHistory history = new BalanceHistory();
history.recordSnapshot(wallet); // 1月末: 1000円を記録
wallet.addMoney(500);
history.recordSnapshot(wallet); // 2月末: 1500円を記録
wallet.addMoney(-1500);
history.recordSnapshot(wallet); // 3月末: 0円を記録
for (Wallet snap : history.getSnapshots()) {
System.out.println(snap.getMoney());
}
この時、直感的に予測される出力は以下のような形だと思います。
1000
1500
0
しかしながら実際には以下の出力となります。
0
0
0
これはrecordSnapshot(Wallet wallet)の引数として渡されているのがWalletオブジェクトへの参照であり、history.snapshotsに含まれる3つの要素が実際には同じインスタンスを指していることによって発生しています。
3つの要素が指すWalletインスタンスは同一のものであり、その値は3月末の状態となっています。
実際にインスタンスの等価性を確認してみると以下のように同一のインスタンスを指していることが確認できます。
List<Wallet> snapshots = history.getSnapshots();
System.out.println(snapshots.get(0) == snapshots.get(1));
System.out.println(snapshots.get(0) == snapshots.get(2));
System.out.println(snapshots.get(1) == snapshots.get(2));
true
true
true
このような共有ミュータブル状態では、処理による影響が予期せぬ形で波及してしまい、アプリケーション全体として正常な状態を保つために考えるべきことが増大してしまいます。
関数型(Scala)の場合
同様の処理を関数型で書くと以下のような形になります。
case class BalanceHistory(snapshots: List[Wallet] = Nil) {
def recordSnapshot(wallet: Wallet): BalanceHistory =
BalanceHistory(snapshots :+ wallet)
}
val wallet1 = Wallet(1000)
val history1 = BalanceHistory().recordSnapshot(wallet1) // 1月末: 1000円
val wallet2 = wallet1.addMoney(500)
val history2 = history1.recordSnapshot(wallet2) // 2月末: 1500円
val wallet3 = wallet2.addMoney(-1500)
val history3 = history2.recordSnapshot(wallet3) // 3月末: 0円
history3.snapshots.foreach(w => println(w.money))
関数型では、プログラムの中に表現される全ての値が変更不能であることが保証されます。
値が不変であるため、異なる状態のWalletインスタンスはそれぞれ独立した値として扱われ、後から変更される心配がありません。
2. 時間的結合性
非関数型(Java)の場合
非関数型言語においては、処理の中間状態としてアプリケーションが例外状態となってしまう可能性があります。
例えば以下のような、送金プログラムを考えてみましょう。
class Wallet {
private int balance = 1000000; // 財布の残高(100万円)
private int count = 0; // 送金回数(トータル)
// 送金を行うメソッド
public void transfer(int amount) {
balance -= amount;
count += 1;
}
public int getBalance() { return balance; }
public int getCount() { return count; }
}
Wallet wallet = new Wallet();
wallet.transfer(10000); // 1万円の送金
本来であればbalance -= amountとcount += 1はセットで原子性をもって変更されるべきですが、それぞれ状態として持つことにより、個々に変更をすることが可能になっています。
そのため以下の箇所で何かしらの処理を行うと不正状態のオブジェクトに対して操作を行うことが可能になります。
これはつまり、コードに表現されない暗黙的な処理の時間的依存性が存在すると言うことができるでしょう。
// 送金を行うメソッド
public void transfer(int amount) {
balance -= amount;
// この間の状態で行われる操作はビジネス要件としては異常な操作!!
count += 1;
}
今回のような直列処理では問題が起こる可能性は低いですが、並列プログラミングで他スレッドが状態を参照する可能性があるなどの要件が出てくると、こういった特性がバグの原因となる可能性が出てきます。
関数型(Scala)の場合
関数型プログラミングでは、オブジェクトの変更は新たな不変の値としてしか表現することができません。
そのため、同一オブジェクトの中間状態が外部から観測されることがなく、一部フィールドだけが変更された不正な状態のオブジェクトが見えてしまう問題が起きません。
case class Wallet(balance: Int, count: Int) {
def transfer(amount: Int): Wallet =
Wallet(balance - amount, count + 1) // balanceとcountが同時に確定する
}
// 実行例
val w0 = Wallet(1000000, 0)
val w1 = w0.transfer(10000)
まとめ
ここまでの話をまとめると関数型プログラミングとは、不正な状態を発生させにくくするプログラミングスタイルであるということが言えそうです。
関数型プログラミングが防いでくれるのは、複数のオブジェクト・関数やスレッドが相互に作用し合った際の不整合な状態です。もし仮にこういった状態によるバグが起きてしまった場合は、再現させることが難しく、またテストコードをすり抜ける可能性も非常に高いでしょう。
A Philosophy of Software Design, 2ND Edition (John Ousterhout 著)では、以下のような記述があります。
Of the three manifestations of complexity, unknown unknowns are the worst. An unknown unknown means that there is something you need to know, but there is no way for you to find out what it is, or even whether there is an issue. You won’t find out about it until bugs appear after you make a change.
(Omitted)
With unknown unknowns, it is unclear what to do or whether a proposed solution will even work. The only way to be certain is to read every line of code in the system, which is impossible
本書で紹介されている3つの複雑性の中で、問題自体の認知が困難な状態である「未知の未知」は最悪のものであり、本質的には全てのコードを読まない限り変更が上手くいくかを知る術はありません。
上記で紹介したようなバグや問題は特定の手順・条件で処理を踏んできた場合に起きる等、再現の難しい場合が大半であり、まさしく「未知の未知」が言うところの問題があることに気づけない、あるいは広範囲のコードを読む以外に問題を特定する方法が無い状態を生み出します。
関数型プログラミングも銀の弾丸ではありませんが、認知できないバグや不具合を埋め込む可能性を下げるためにも関数型プログラミングを採用することは合理的な判断となりそうです。