Javascriptは関数型の書き方もできると知り(主に[1]より)、自分の手持ちのコードを関数型の書き方でリファクタリングしてみました。その過程で学んだことをまとめておきます。また、タイトルはJavascriptですが、コードの例では型を明示できるTypescriptを使います。
#関数を関数として扱う(参照透過性)
プログラミングにおける関数型言語のキーコンセプトは、それにおける「関数」が「参照透過性」を保持していることです。では関数とは、参照透過性とは一体何でしょうか。
###関数の数学的定義
プログラミング言語、特に手続き型の言語において「関数(function)」という用語は、数学(集合論)で定義されてるものと異なっています。集合論における関数の定義は次です。
任意の集合$X,Y$について、関数 $f:X\to Y$とは、 $X$と$Y$間の二項関係 ($f\subseteq X\times Y$)で、次のような条件を満たす
- (全域性) $X$ のどの要素も出力を持つ
$\quad \forall x \in X\exists y \in Y ((x,y)\in f)$ - (唯一性)出力が存在する場合、その出力$(f(x)\in Y)$は唯一である
$\quad\forall x\in X\forall y,z\in Y ((x,y)\in f\wedge (x,z)\in f\Rightarrow y=z)$
2つの条件を合わせたもの「任意の入力$x\in X$に対して、唯一の出力$y\in Y$が存在する」($\forall x\in X\exists !y\in Y((x,y)\in f)$)が、プログラミング界隈で「参照透過性(referential transparency)」と呼ばれているものです。つまり、「○○は参照透過である」、と「○○は(数学的な)関数である」、はほぼ同じ意味です。歴史的背景をもう少し掘り下げました。
"ほぼ"と書いたのは、参照透過性の解説(Wikipediaなど)では唯一性条件は強調されていますが、全域性条件は特に触れられていないからです。 そのため、「唯一性条件=参照透過性」と考えたほうがいいかもしれません。実際、Haskellのcase文で場合分けが網羅されてなくてもコンパイルエラーになりません。例えば、
hoge:: Int -> String
hoge x = case x of
0 -> "hoge"
これはInt型の入力が0の場合しか定義されておらず全域性を満たしません。従って、これは関数ではありません。"純粋関数型言語"と呼ばれるHaskellでさえこのようなことがありますので、Javascriptで純粋関数を作る場合は関数の条件をしっかり把握し細心の注意を払う必要があります。
###全域性に反する・出力を持たない
以下で、集合を配列に置き換えて考えて(数学的な)関数ではない(Typescriptの)関数の例を見てみましょう。
Typescriptのトランスパイラーもブラウザのコンソール画面でもエラーになりませんが、次は純粋な関数ではありません。
function foo($x: number): void {
console.log($x)
}
let NUM: number = 0;
function bar(): void {
NUM = NUM + 1
}
ひとつ目は、fooに数字を入力した場合の出力がconsole.logという関数外部への出力であり、関数の出力(returnの引数)ではないため、条件に反します。ふたつ目のグローバル変数への代入も同様にreturnを持たないので、純粋な関数ではないと言えます。
###唯一性に反する・外部依存性
次のexampleFuncを実行するたび出力される数字は増えていきます。つまり、これは実行ごとに出力が違うため数学的な意味での関数ではありません。
let NUM: number = 0;
function exampleFunc(): void {
NUM = NUM + 1
NUM = NUM * 2
console.log(NUM)
}
tsc test.ts // test.tsをトランスパイル
node // REPL起動
> .load test.js // test.tsをトランスパイルしたものをREPL上にロード
> exampleFunc() // 2
> exampleFunc() // 6
> exampleFunc() // 14
出力が一意でないのは、外部の変数に出力が依存しているからであり、このような関数を「副作用」を持つ関数と言います。
副作用
関数の条件に反する要因を副作用と呼びます。
プログラミングにおいて、「状態」を参照し、あるいは「状態」に変化を当てることで、次回以降の結果にまで影響を与える効果のことを副作用(side effect)と呼びます。([p.7, 2])
状態とは、関数外部の要因のことで、つまり副作用は次のようにまとめることができるでしょう
- (外部からの入力)グローバル変数やdocument.getElementByIdなどの関数の引数以外の入力に出力が依存している
- (外部への出力)console.logなどの関数外部への出力
- (外部の操作)グローバル変数に対する代入などの操作
以下において数学の関数の条件に従っているものを「純粋関数」そうでない副作用を持つものを「不純な関数」と呼びます。
##用語まとめ
あらためて、参照透過性、副作用、純粋/不純、という言葉の意味を調べてみると、正直、これらの用語は対応する内容を直感的に連想させるものではない、要するに分かりにくい用語だと感じました。個人的に、これらの用語は次のように置き換えると分かりやすいかったです。
- 参照透過性 => 関数の唯一性条件
- 副作用 => 出力の外部依存性、関数外部への出力、グローバル変数の操作
- 純粋関数/不純な関数 => 数学的な関数/そうでない関数
##純粋と不純を分離する
言うまでもないことですが、純粋関数型言語を除いて世の中にある多くのプログラミング言語では、不純な関数は当たり前のように使われます。Javascriptを関数型の記法で書く最初のステップは、この関数の条件を極力保守することだと思います。言い換えれば、純粋な関数と不純な関数を分離することでしょう。
次は、先の関数exampleFuncを、3つの不純な関数と2つの純粋関数に分解しexampleFunc2にまとめたものです。
let NUM: number = 0;
function pureFunc1($x: number): number { // 純粋関数
return $x + 1
}
function pureFunc2($x: number): number { // 純粋関数
return $x * 2
}
function impureFuncUseGlobalVal(): number { // 不純な関数:出力の外部依存性
return NUM
}
function impureFuncChangeGlobalVal($x: number): void { // 不純な関数:関数の出力を持たない
NUM = $x
}
function impureFuncOutput($x: number): void { // 不純な関数:関数の出力を持たない
console.log($x)
}
function exampleFunc2(): void { // 不純な関数:純粋・不純をまとめる
const _num = impureFuncUseGlobalVal()
const _result = pureFunc2(pureFunc1(_num))
impureFuncChangeGlobalVal(_result)
impureFuncOutput(_result)
}
exampleFunc2はhaskellのdo構文の部分に当たると言えます。もともとのexampleFunc自体が単純なものなのでわざわざ冗長な書き方にしているだけに見えるかもしれませんが、このように純粋・不純を意識することでexampleFuncが複雑であった場合、これの構造の見通しが良くなります。
実際、私が既存のコードをリファクタリングした時に行ったことも、ここまで極端ではありませんが、このように関数を分解して純粋・不純の関数を分けました。そうすることで、関数同士をチェーンでつなげて可読性を上げたり、さらにモナドを導入できるようになります(外部依存性が残ったままでモナドを導入してもmonadic isolationが担保されない)。
関数型の恩恵
このように関数を純粋と不純分離して、純粋な関数を取り出すことで、さまざまな恩恵があります。例えば、とりあえず思いつくのまとめておきます。
- 関数の型が一致していれば他の場面でも再利用できる
- 関数合成や関数チェーンでコードの可読性が上がる
- モナドでエラーを制御できる
- ファンクショナル・リアクティブ・プログラミングへの足がかりになる(たぶん)
#その他
その他、個人的に意識していることです。
- グローバル変数の扱いには特に注意すべきなのでこれを変更したり参照する場合は専用の関数(上のimpureFuncChangeGlobalVal、impureFuncUseGlobalVal)を設けておくと混乱が減る。
- if文はelseを省略しない。undefinedであったら明記するかMaybeモナドなどで制御する。
- for文、while文は使用しない。代わりにmapやfilterを使う。
- 変数の宣言は基本的にconstを使う。グローバル変数の場合のみletを使う。
- 定数、グローバル変数、ローカル変数、関数の引数など自分なりに命名規則を作って区別する。以下、筆者のルール。
- グローバル変数: TEST_EXAMPLE
- ローカル変数: _testExample
- 定数: $TEST_EXAMPLE
- 関数の引数: $testExample
つづき(予定)
その2: lodash.jsとramda.jsの導入
その3: モナド(Maybeモナド、Eitherモナド、Writerモナド)の導入
#参考文献・サイト
[1] JavaScript関数型プログラミング 複雑性を抑える発想と実践法を学ぶ
[2] 関数プログラミング実践入門 ──簡潔で、正しいコードを書くため
[3] JavaScriptで関数型プログラミングの入門
[4] 「参照透過である」とは、何から何への参照がどういう条件を満たすことを言うのか