はじめに:プログラミングパラダイム・シフトの歴史
※()の中身は流行、注目されたざっくりの時代である
パラダイム以前
機械語(0と1の羅列)でやっていた時代
データも命令もすべてメモリに書き込まれており、メモリ番号間でジャンプ(goto)しまくる
【例】
100: 30番に0を書きこむ
101: 7番と14番を比べる
102: 101番の結果を読む。14番の方が大きければ300番にジャンプする
103: (自分のターンの処理開始)
・
・
・
構造化プログラミング(1960年代~)
goto文を使っていたプログラミングに対して、あらゆるフローチャートは「順次」「選択」「反復」を用いることですべて表現できることが提唱された。
「Go To Statement Considered Harmful」(エドガー・ダイクストラ)
オブジェクト指向プログラミング(1990年代~)
現実世界を写実的にモデル化し、プログラムを構築するアプローチ。
データと命令を一つのまとまり(オブジェクト)にしておく。
Windows95の流行に伴う、GUIの普及によって爆発的流行したらしい。
関数型プログラミング(2010年代~)
「内部構造をもつことによる参照透過性の低下」というオブジェクト指向の弱点に対抗する形で、より堅牢なプログラムをかく書き方として流行。
※最近はオブジェクト指向プログラミングと関数型プログラミングを組み合わせて使う例も多いっぽい。後述します。
関数型プログラミングとは
イミュータブルな値を操作する純粋関数を使うプログラミングである。
また宣言型プログラミングの一種である。
純粋関数
関数は嘘をつけるか?
残念ながら嘘をつける。
以下に4つの関数を紹介するが、驚いたことにこの4つの関数のうち、3つは嘘をついている。
public static int add(int a, int b){
return a + b;
}
public static char getFirstCharacter(String s){
return s.charAt(0);
}
public static int divide(int a, int b){
return a / b;
}
public static void eatSoup(Soup soup){
// TODO: 「スープを飲む」アルゴリズム
}
- getFirstCharacter()はStringが渡されるとCharを返す。しかし、こっそり空のStringを渡すとなんの文字も返さず例外(StringIndexOutOfBoundsException)を投げる。
- divide()はbとして0が渡された場合、約束していたintを返さない。
- eatSoup()は渡されたスープを飲むと約束するが、スープを渡しても何もせずvoidを返す。
- これに対し、add()はaやbとして何が渡されても約束通りintを返す。こういう関数は信用できる!
嘘をつかない関数では、関数の本体についてシグネチャ(=どのような引数をとるか、どのような戻り値を返すか、といった関数の型)が全てを語っている。こうした関数は信頼できる。
つまり、コードを書くときに驚きが少ないほど、作成するアプリケーションのバグが少なくなる。嘘をつかない全て関数の中で最も信頼できるのが純粋関数である。
純粋関数の条件
以下の条件を満たす関数を、純粋関数と呼ぶ。
- 常に戻り値を返す。
- 引数にのみ基づいて、戻り値を計算する
- 既存の値を変更しない
練習のために、次の関数が純粋関数であるか考えてみよう。
【問題1】
static int increment(int x){
return x + 1;
}
- 常に戻り値を返す。→はい
- 引数にのみ基づいて、戻り値を計算する。→はい
- 既存の値を変更しない→はい
この関数は純粋関数である。
【問題2】
static double randomPart(double x){
return x * Math.random();
}
- 常に戻り値を返す。→はい
- 引数にのみ基づいて、戻り値を計算する。→いいえ
- 既存の値を変更しない→はい
引数だけでなく、Math.random()をつかってランダムデータを生成している(副作用)
この関数は純粋関数でない。
【問題3】
static int add(int a, int b){
return a + b;
}
- 常に戻り値を返す。→はい
- 引数にのみ基づいて、戻り値を計算する。→はい
- 既存の値を変更しない→はい
この関数は純粋関数である。
【問題4】
static int addItem(String item){
items.add(item);
return items.size() + 5;
}
- 常に戻り値を返す。→はい
- 引数にのみ基づいて、戻り値を計算する。→いいえ
- 既存の値を変更しない→いいえ
この関数は純粋関数でない。
【問題5】
static char getFirstCharacter(String s){
return s.charAt(0);
}
- 常に戻り値を返す。→いいえ
- 引数にのみ基づいて、戻り値を計算する。→はい
- 既存の値を変更しない→はい
空の文字列を渡した場合、戻り値を返さず例外を投げる。
この関数は純粋関数でない。
【問題6】
static void eatSoup(Soup soup){
// TODO: 「スープを飲む」アルゴリズム
}
- 常に戻り値を返す。→いいえ
- 引数にのみ基づいて、戻り値を計算する。→いいえ
- 既存の値を変更しない→はい
この関数は純粋関数でない。
純粋関数を適応していく例
純粋関数でない関数から、純粋関数に修正していく具体例を見ていくことで、純粋関数の何が嬉しいか理解を深める。
旅行プランを変更するreplan関数を考えていく。
フランスのパリからポーランドのクラクフまで、ヨーロッパの都市をめぐる旅行を計画する。最初のプランは次の通りである。
List<String> planA = new ArrayList<>();
planA.add("Paris");
planA.add("Berlin");
planA.add("Krakow");
System.out.println("Plan A: " + planA);
//→ Plan A: [Paris, Berlin, Krakow]
ところが計画変更でクラクフにいく前にウィーンにいくことになった
List<String> planB = replan(planA, "Vienna", "Krakow");
System.out.println("Plan B: " + planB);
//→ Plan B: [Paris, Berlin, Vienna, Krakow]
このように使われるreplan関数を考えていく。シグネチャは以下の通り。
static List<String> replan(List<String> plan, String newCity, String beforeCity)
パラメータ
- plan→変更したいプラン
- newCity→追加したい都市
- beforeCity→新しい都市をどの都市の前に追加するか
戻り値 - 新しいプラン
このようなreplan関数を以下のように実装した
static List<String> replan(List<String> plan, String newCity, String beforeCity){
int newCityIndex = plan.indexOf(beforeCity);
plan.add(newCityIndex, newCity);
return plan;
}
まず、この実装は純粋関数となっているか?
- 常に戻り値を返す。→はい
- 引数にのみ基づいて、戻り値を計算する。→はい
- 既存の値を変更しない→いいえ
この関数は以下のように引数でわたしたplanA(=既存の値)を変更してしまう
List<String> planA = new ArrayList<>();
planA.add("Paris");
planA.add("Berlin");
planA.add("Krakow");
List<String> planB = replan(planA, "Vienna", "Krakow");
System.out.println("Plan A: " + planA);
//→ Plan A: [Paris, Berlin, Vienna, Krakow]
このような関数は、かなり直感に反する動きとなり、ミスにつながりやすい。
replan関数を純粋関数に修正する。
static List<String> replan(List<String> plan, String newCity, String beforeCity){
int newCityIndex = plan.indexOf(beforeCity);
List<String> replanned = new ArrayList<>(plan); //コピーを作成
replanned.add(newCityIndex, newCity);
return replanned;
}
- 常に戻り値を返す。→はい
- 引数にのみ基づいて、戻り値を計算する。→はい
- 既存の値を変更しない→はい
純粋関数となったreplan関数は次のように既存の値を変更せず動く
System.out.println("Plan A: " + planA);
//→ Plan A: [Paris, Berlin, Krakow]
List<String> planB = replan(planA, "Vienna", "Krakow");
System.out.println("Plan B: " + planB);
//→ Plan B: [Paris, Berlin, Vienna, Krakow]
System.out.println("Plan A: " + planA);
//→ Plan A: [Paris, Berlin, Krakow]
純粋関数のメリット
あらためて純粋関数のメリットを整理する。
副作用がない
既存の値を変更しないので、副作用がない。副作用は上記の例でみたようにバグにつながりやすい。
※ちなみに副作用とはなにか?
「引数に基づいて戻り値を計算する」以外に関数がおこなうことのすべてを実は副作用という。つまり関数が「HTTP呼び出しを行う」「グローバル変数やインスタンスフィールドを変更する」「DBにデータを挿入する」「標準出力に書き込む」「例外を投げる」..などこれらはすべて副作用である。
参照透過性
ある入力に返しては、必ず一定の出力を返す特性のことを参照透過性という。
関数が引数だけを使って値を計算し、既存の値を変更しない場合、その関数は自動的に参照透過性を持つ。
テスト容易性
参照透過性が高いのでテストしやすい。(内部構造があると、インプットに対するアウトプットの原因がわかりづらい。)
単一責任
関数が返せる値がひとつだけで、既存の値を変更できない場合、その関数にできるのはひとつのことだけで、それ以上のことはできない。コンピューターサイエンスではそれを単一責任と呼ぶ。
宣言型プログラミング
命令型と宣言型
プログラミング言語は命令型と宣言型の二つの主なパラダイムに分類される。
簡単な例を通して、これら二つのパラダイムの違いを紹介する。
数字型の配列に対し、その配列の要素の和を出力するjavaScriptの実装を考える。
命令型プログラミング
命令型プログラミングは、具体的なステップと手順を指示するプログラムのスタイル
const numbers = [1, 2, 3, 4, 5];
let sum = 0;
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
console.log(sum); // 15
この例では、明示的なループと変数の使用が行われている。これは命令型プログラミングの特徴であり、手順を一つ一つ指定している。
宣言型プログラミング
宣言型プログラミングは、プログラムが何を行うかを先に宣言し、具体的な手順を指示しないスタイル。代わりに、望まれる結果を指定する。
const numbers = [1, 2, 3, 4, 5];
const sum = numbers.reduce((accumulator, currentValue) => accumulator + currentValue, 0);
console.log(sum); // 15
通常は宣言型コードの方が、命令型コードよりも簡潔でわかりやすい。
関数型プログラミングは通常宣言型プログラミングによって記述される。
【参考:AirbnbのjavaScript style Guideでも宣言型プログラミングが推奨されている】
https://github.com/airbnb/javascript/#iterators-and-generators
Javaでの関数型プログラミングの実装例
近年はJavaをはじめとしたメジャー言語も関数型プログラミングのパラダイムをサポートしている。
Java SE 8 (2014年3月~) より関数型インターフェース、ラムダ式、StreamAPIが導入された。
以下は整数のリストから偶数をフィルタリングし、それらの偶数の平均値を計算する実装を、関数型プログラミングで実装している例。
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 偶数をフィルタリング
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0)
.collect(Collectors.toList());
// 偶数の平均値を計算
OptionalDouble average = evenNumbers.stream()
.mapToDouble(Integer::doubleValue)
.average();
if (average.isPresent()) {
System.out.println("偶数の平均値: " + average.getAsDouble());
} else {
System.out.println("偶数が見つかりませんでした。");
}
StreamAPIのfilter関数、mapToDouble関数は純粋関数になっており、宣言型プログラミングで書かれている。
おまけ:オブジェクト指向プログラミングとの組み合わせ
近年はオブジェクト指向プログラミングと関数型プログラミングを組み合わせて使う例も多いっぽい。
Scala, Kotlin, Swiftなどもオブジェクト指向と関数型プログラミングの両方の特徴をサポートする、マルチパラダイムなプログラミング言語といわれている。
可変性にたいしてオブジェクト指向プログラミングではカプセル化を、関数型プログラミングでは純粋関数を使って対抗しているイメージ。
関数型プログラミングメリットまとめ
重複する部分も多いので、箇条書きで
- 副作用の制御
- 不変性
- 参照透過性
- テスト容易性
- 可読性
- 並列性
参考書籍
ゆるコンピューター科学ラジオ【プログラミングパラダイム・シフト】
https://www.youtube.com/watch?v=R9ob9fuoNi8