数学計算のための関数型プログラミング
プログラミング言語は、問題に対する一般的な解決策を記述するためのものです。その結果がたまたま、機械で実行できる形になります。どのプログラミング言語にも、それぞれ異なる長所と短所があります。その理由の一つは、構文と意味論が、その言語で簡単に扱える問題の範囲に大きな影響を与えることです。
tl;dr: 一般的な命令型アプローチよりも、数学的な計算には関数型プログラミングのほうが適していると思います。
数学に組み込みの抽象化を使う
言語の背後にある考え方、つまり基盤となるプログラミングパラダイムは、その言語を中心に形成されるコミュニティの特徴を形づくります。開発者たちは、言語のコアを取り巻く形で、すぐに使える独自のライブラリやフレームワークのエコシステムを作り上げます。その結果、ビジネスアプリケーションに強い言語もあれば(たとえばCobolです)、システムプログラミングで優れた力を発揮する言語もあります(CやRustなど)。
コンピューターで数学的・数値的な問題を解くとなると、Fortranを思い浮かべる人もいるでしょう。Fortranは汎用言語ですが、科学技術計算で広く知られています。もちろん、この言語はその目的を念頭に置いて作られました。だからこそ名前もFormula Translationなのです。
この分野でFortranが人気を得た理由の一つは、性能を意識しつつ、数学的な概念を表現するためのドメイン固有のキーワードをいくつか組み込みで提供していることです。たとえば、複素数専用のデータ型COMPLEXや、数学用語とよく似たDIMENSIONというキーワードがあります。後者は配列やベクトルの作成に使えます。
命令型スタイルと関数型スタイル
組み込みキーワードは、特定の問題領域における言語の表現力を高めるのに役立ちます。しかし、この方法には大きな限界があります。言語のコアを無限に拡張していくのは現実的ではありません。保守が難しくなり、習得にも時間がかかるだけです。そのため、ほとんどの言語は、ルーチンをより小さく扱いやすい部分に分割するために、関数、サブルーチン、クラス、オブジェクトといった別の抽象化手段を用意しています。こうした仕組みはプログラムの複雑さを抑えるのに役立ちますが、特に数学的な問題を扱うときは、定型コードによって解決策が見えにくくならないよう注意が必要です。
例I ― 階乗
例として、正の数nの階乗を計算する次の式をプログラムコードに変換する、という問題を考えます。
上の式を命令型スタイルのJavaで実装すると、次のようになります。
public static long fact(final int n) {
if (n < 0) {
// Negative numbers not allowed
return 0;
}
long prod = 1;
for (int i = 1; i <= n; ++i) {
prod *= i;
}
return prod;
}これほど短い問題の定義に対しては、かなり長い解決策です。(1からnまでを明示的なループで書いたのは意図的です。再帰関数を使えばもっと短くできますが、数学の式には登場しない概念を使うことになります。)
また、このプログラムにはpublic、static、System.err.println()など、言語固有のキーワードが数多く含まれています。それだけではありません。プログラマーは、使う変数のデータ型をすべて明示的に指定しなければなりません。面倒な作業です。
こうした要素がすべて、数学的な定義を分かりにくくしています。
これを、Haskellのような関数型言語で書いた次のバージョンと比べてみてください。
fact n = product [1..n]これは、問題の定義をほぼそのままコードに変換したものです。明示的な型も、一時変数も、publicのようなアクセス修飾子も必要ありません。
例II ― 内積
先ほどのHaskellプログラムが短いのは、その作業にちょうど合った抽象化、つまりproductキーワードと[1..n]という範囲構文を言語が提供しているからだ、と考えることもできます。そこで次は、HaskellにもJavaにも用意されていない単純な関数を見てみましょう。2つのベクトルの内積です。数学的な定義は次のとおりです。
3次元のベクトルなら、次のように書けます。
まずはHaskellでの実装です。
type Scalar a = a
data Vector a = Vector a a a deriving (Show)
dot :: (Num a) => Vector a -> Vector a -> Scalar a
(Vector a1 a2 a3) `dot` (Vector b1 b2 b3) = a1*b1 + a2*b2 + a3*b3数学的な型を、それぞれ1行で定義できる点に注目してください。さらに、dot関数を中置記法で定義しています。つまり、dotの第1引数を関数名の前に置き、第2引数を後ろに置くのです。こうすると、コードが数学的な表記に近くなります。上の関数は、たとえば次のように呼び出せます。
(Vector 1 2 3) ’dot’ (Vector 3 2 1)短く、正確で、読みやすいコードです。
次に、Javaで同様の実装を見てみましょう。
public static class Vector<T extends Number> {
private T x, y, z;
public Vector(T x, T y, T z) {
this.x = x;
this.y = y;
this.z = z;
}
public double dot(Vector<?> v) {
return (x.doubleValue() * v.x.doubleValue() +
y.doubleValue() * v.y.doubleValue() +
z.doubleValue() * v.z.doubleValue());
}
}
public static void main(String[] args) {
Vector<Integer> a = new Vector<Integer>(3, 2, 1);
Vector<Integer> b = new Vector<Integer>(1, 2, 3);
System.out.println(a.dot(b));
}
}ベクトルを適切にテキスト表示するには、toString()メソッドもオーバーライドしなければなりません。Haskellなら、コードに示したようにShow型クラスから派生させるだけで済みます。
新しい抽象化を作る
関数や型だけでは簡潔なプログラムを書けない場合、Haskellには、言語のコアそのものを拡張する新しい演算子やキーワードを作るための単純な構文も用意されています。これにより、ドメイン固有言語の実現が可能になります。メモリ管理や配列の反復処理といったプログラミング言語自体の癖に回り道をさせられることなく、開発者は実際の問題により直接取り組めるのです。Haskellはこの考え方を積極的に取り入れていますが、Javaにはそのような機能がありません。
結論
ここでJavaをこき下ろしたり、Haskellを崇拝したりしたいわけではありません。どちらの言語にも、それぞれふさわしい居場所があります。Javaを選んだのは、多くのプログラマーが読めるからにすぎません。
比較しているのは、数値プログラミングや記号プログラミングにおける関数型と命令型のアプローチです。そしてその点では、私はいつでも関数型のアプローチを選びます。余計なものを取り除き、洗練された解決策を生み出してくれるからです。高い抽象度で扱い、数学の言葉で表現するための便利な方法を提供してくれるのに、こうした強みは多くのプログラマーに見過ごされています。
Abraham H. Maslowが1966年の著書『科学の心理学』(The Psychology of Science)で述べた次の言葉が、ここにはよく当てはまります。
「手にしている道具がハンマーだけなら、すべてを釘のように扱いたくなるものだと思います。」
記事をランダムに読む