🌴

なぜ関数型言語には必ず引数/戻り値が必要か?(数学的な由来と副作用について)

2022/07/10に公開

この記事では「なぜ関数型言語には必ず引数/戻り値が必要か?」というテーマについて考えてみます。従来の命令型言語の関数と違って、関数型言語には必ず引数/戻り値を持つという特徴があります。ではなぜ引数/戻り値を必ず持つのでしょうか。「関数型言語はそのような決まりの言語だから」と言わればそれでオシマイなのですが、もう少し突っ込んで色々と考えてみたいと思います。

先に結論を言うと、引数/戻り値がないとしたらその挙動を正確に予測できなくなるからです。 プログラムの世界にその影響が留まらないとしたら、その影響範囲を正確に予測することは困難となります。そしてその裏には「プログラミングの世界に数学の抽象化を持ち込もう!」という壮大な意図があります。まずは基本的な引数/戻り値とは何か?からスタートして、その裏に隠された意図を学んでいきましょう。

引数/戻り値とは?

そもそも引数/戻り値とは何でしょうか?

引数:関数に外部から投入する値
(処理結果を変えるため利用される)
戻り値:処理の結果として返却される値
(別の場所で再利用できる)

わかりやすいのが数学におけるy=x^{2}の関数です。受け取った数値を2乗する関数ですが、xで投入する値によってyの結果は変わってきますよね。ここでいうxは外部から投入する引数であり、yは結果として返却される値となっています。

x:引数=関数に外部から投入する値
y:戻り値=処理の結果として返却される値

受け取った数値を2乗する..というのをプログラムで実現したのがこちらです。nizyou関数であり、同じく2という数値を投入すると4という結果が返却されます。

nizyou :: Int -> Int
nizyou num = num * 2
main = print $ nizyou 2

2:引数=関数に外部から投入する値
4:戻り値=処理の結果として返却される値

引数/戻り値を必ず持つ

そしてここからが重要なポイントなのですが、関数型言語における関数は必ず引数/戻り値を持つことが決まりとなっています。 従来の命令型言語の関数では引数/戻り値のない関数を作ることもできました。わかりやすいのが、呼び出すとHelloWorldを出力するhello関数です。

#include <stdio.h>

void hello(void){
  printf("Hello\n");
}

int main(void){
    hello();
    return 0;
}

外部から投入される値もありませんし、結果として返却される値もありません。(戻り値がないというのは、返却される結果を他の関数やif文などで再利用できないからです。)ただディスプレイへと文字列が出力されるだけで、引数も戻り値もない関数となっています。

これはHaskell的にはダメで、必ず引数/戻り値を指定する必要があります。なぜならHaskellの関数は数学的な関数(つまりy=x^{2}などの関数)を中心に計算を表現するプログラミングスタイルだからです。不純な部分はなるべくはいじょして、数学らしさを追求した言語となっています。

関数型言語の由来について

なぜ関数型言語では引数/戻り値を必ず持つのでしょうか。これは関数型言語の根本的な考え方/スタンスに関わる問題となっています。そもそもプログラミング言語のパラダイムには大きく3つあります。

1、命令型言語
2、関数型言語
3、論理型言語

コンピュータは計算を行うことで処理を行います。この計算機における計算の考え方によって上記の3つに分かれています。中でも関数型言語は(数学における)関数を中心にプログラムを構成する言語です。

数学の集合論を解くため

そして関数型言語は元はプログラミングとは関係ない分野から生まれました。1930年代、数学者アロンゾ・チャーチは数学の集合論の問題を解くために新たな論理システムを考案します。それは関数と変数(x,y)のみを使用する論理システムであり、ラムダ計算と呼ばれました。ラムダ計算では全てのモノが関数として表されます。 真と偽は関数であり、全ての整数さえも関数として表すことができます。しかし残念ながらラムダ計算では当初の目的を達成することはできませんでした。

「数学の集合論の問題を解く」という問題は解決できませんでしたが、思わぬ発見へとつながります。ラムダ計算は(命令型言語のベースの考え方となっている)チューリングマシンに匹敵する普遍的な計算モデルを可能にしました。この計算モデルは『チャーチ=チューリングのテーゼ』と呼ばれ、プログラミングへ数学の考えを持ち込むことがきっかけとなります。

異分野の知見が発展をもたらす

ここで注目したいのが、元々は数学の集合論を解くために生まれた点です。 それはまるで1800年代の数学者:ジョージ=ブールの論理学が「0と1によって全ての情報を表す」というコンピュータを支える最も重要な理論となったのに似ています。アロンゾ・チャーチと同様、ブールは元々コンピュータやプログラミングのためにブール代数を考案したワケではありませんでした。彼は元々「人間の思考を数学で表そう!」というアイディアからスタートしました。(合理的な人間なら論理を使って思考しているわけで、それなら人間の思考を数学で表すことも可能では..?と考えました。)そしてそのアイディアは1900年にクロード=シャノンによって再発見され、工学との出会いを経て、形になることになります。チャーチの場合も全く同様で「全てのモノを関数で表す」というラムダ計算のアイディアは、数学の集合論ではなく、遠く離れた情報工学・コンピュータサイエンスの分野で花開くことになります。

数学の豊かな知見をプログラミングへ

従来の命令型言語がベースとするのは工学の考えでした。要するにメモリ・レジスタを直接操作してコンピュータを動かす..という考えの元に作られています。しかしチャーチの考案した計算モデルを使えば、工学に由来するメモリ・レジスタの操作を離れ、数学的な考えをベースにプログラムを組み立てることができます。最先端のプログラミング研究では、プログラムが期待通りに動作することを数学的に証明する実験が進められています。またほとんどのプログラミング言語では、その設計の非数学的な設計が原因で、プログラマが利用できる抽象化が工学的な理由により制限されることもあります。数学をプログラムすることが可能であると知ったら、コードについて色々なことを証明できるようになり、数学で許可されている抽象化をほぼ無制限に利用できるようになります。2000年以上も豊かに蓄積されてきた数学の知見をプログラミングの世界へ持ち込むことが、関数型プログラミングの1番の目的となっています。

関数型言語の3つのルール

このプログラミングの数学モデルは、実用に際して様々な影響をもたらします。Haskellの関数は全て以下の3つのルールに従います。これらのルールに従うことによってHaskellの関数は数学の関数らしく動作することができます。(特に3つ目のルールは数学における関数の基本定義の一部となっています。同じ引数が常に同じ結果となる..というルールをプログラミング言語に適用することを参照透過性と呼びます。)

・全ての関数が引数を受け取らなければいけない
・全ての関数が値を返さなければいけない
・関数が同じ引数で呼び出された時は常に同じ値を返さなけばいけない

上記3つのルールに従っているため、Haskellは安全なプログラミング言語です。安全というのは、プログラムの振る舞いが常に期待通りで、カンタンに論証できることを意味します。つまりその原因・影響はプログラムの世界のみに限られており、ディスプレイ・メモリ/レジスタ・ディレクトリなど予期せぬ動作をすることがなくなります。

安全なプログラムとは?

安全なプログラムとは具体的には何でしょうか。それは挙動をプログラムの世界内で正確に予測できることです。引数/戻り値がプログラムの世界にないとすれば、それはプログラムの世界の外から取ってくる必要があります。するとプログラムの挙動が外の世界に及んでしまし、その挙動を予測することが困難となります。ここからはなぜ引数/戻り値が必要か?と言う問題を1つ1つ例を挙げながら考えてみます。

なぜ戻り値が必要か?

例えばここではreverse関数を考えてみます。リストを逆順にする関数ですが、その挙動は命令型言語で関数型言語で異なります。関数型言語では戻り値がプログラムの世界へ必ず返却されるのでその挙動を予測できます。しかし命令型言語ではプログラムの外の世界へ影響が出る可能性もあるので、必ずその挙動を予測できなくなっています。HaskellとPythonでreverse関数を使ったのがこちらです。

Haskellでrevrese関数を使うと期待していた通りにリストを逆順にすることができます。[1,2,3]というリストが[3,2,1]となって出力されています。

>>> [1,3,8,7,4].reverse()
None

しかしPythonでreverse関数を使うとNoneとなります。なぜでしょうか。なぜならリストの値を書き換えるだけで、それを戻り値として返却するわけではないからです。これは副作用として破壊的代入が行われたことをユーザーに気づかせる目的がある(らしい)ためです。公式ドキュメントには以下のように書かれています。

reverse() メソッドは、大きなシーケンスを反転するときの容量の節約のため、シーケンスをインプレースに変化させます。副作用としてこの演算が行われることをユーザに気づかせるために、これは反転したシーケンスを返しません
http://docs.python.jp/3/library/stdtypes.html#mutable-sequence-types

ここで注目したいのが処理結果がプログラムの世界の外で反映されているため、その挙動を正確に予測することが難しい点です。プログラムの中で行なった処理は必ずしもプログラムの中へ結果が反映されるわけではありません。今回で言えばメモリ・レジスタの値が変更されただけでしたが、このように外部の世界へ影響が出る可能性が出てきます。そうするとプログラムの挙動を予測することが困難になりますし、論証をすることも難しくなってきます。

そんなワケでHaskellの関数には必ず戻り値を返却する決まりとなっています。それは「外部世界への影響」という予測できない動作を除いて、プログラムの世界だけでその挙動をコントロールするためです。

なぜ引数が必要か?

では次に「なぜHaskellの関数には必ず引数があるのか?」という問題について考えてみます。これは関数が引数を取らないとしたら、隠れた状態にアクセスしないといけないからです。関数の処理結果に影響を与える引数はプログラムの世界にはなく、その結果を予測することができなくなってしまいます。「引数を取らない関数」としてわかりやすいのがgetLine関数です。データ型を調べるとこのようになります。

Prelude> :t getLine
getLine :: IO String

getLineは引数を取りませんが、IO String型を返す関数です。引数としてどんな値を受け取るか?というのはプログラムの中だけではわからないですよね。文字列がきちんと入力されるかもしれませんし、めちゃくちゃなデータを突っ込まれるかもしれません。成功するかどうかわからないので、その挙動は予測できませんし、論証もできません。「引数を取らない関数」というのは「どんなデータが入ってくるのかわからない関数」であり、その挙動を予測できません。

Haskellの関数が戻り値を返却しないとしたら、その影響は外部世界のどこかに出てきます。その挙動はコードを見るだけでは予測できません。また引数を受け取らないとしたら、コードの世界の外から受け取る必要があります。そうするとこの部分でもコードを見るだけではその挙動を予測できなくなります。「プログラムの世界へ影響を留める」という理由から必ず引数/戻り値を持つ決まりとなっているのです。

実用的なプログラムを作るため

とはいえ安全性だけを追求しても、実用的なプログラムを作れません。副作用を禁止すると以下のことができなくなってしまいます。

データベース
現在時刻
乱数
HTTPレスポンス

そこで関数型言語ではなるべくこれら不順(?)な部分を最小限に留めるための仕組みが用意されるようになっています。

Discussion