関数型プログラミング超入門

Scala Advent Calendar 2022 - Qiita 3日目の記事です

Scalaオブジェクト指向プログラミング、関数型プログラミングのどちらもできるプログラミング言語ですが、ここでは関数型プログラミングについての基本的なところを説明します。

この記事のサンプルコードは、sbt console を使って実行しています。

1. Referential Transparency (参照透過性)

参照透過性とは、「プログラムの構成要素が同じもの同士は等しい」ということです。

例えば:

  • 1 と 1 は等しい
  • 文字列 abc と文字列 abc は等しい
  • 関数 f(1) と 関数 f(1) は等しい

つまり、変数 a と b に同じ値 (1 や abc や f(1)) を代入しているなら、a と b は等しいということになります。

println(1 == 1)
println("abc" == "abc")
def fn(a: Int) = a * 2;
println(fn(1) == fn(1))

Java になれている場合 "abc" == "abc" に違和感があるかもしれません。しかし Scala の場合には、== は値の等しさを比較するため、"abc".equals("abc") のような書き方は必要ありません。

参照透過性の利点 (An advantage of referential transparency)

参照透過性の利点は、数学的推論が利用できることです。f(1) の計算結果は常に f(1) の計算結果と等しいため、その時の状況によって結果が変わらないため、テストが容易になります。

2. Immutable (不変性)

構造化/手続き型/ 命令型プログラミングでは、次の例のように、変数をミュータブルに使用するのが一般的です。

var sum = 0 // var で定義されるミュータブルな変数
val list = [1, 2, 3]
for (x <- list) sum = sum + x
println(sum)

変数 sum、x の値はプログラムの実行中に値を変化させます。これは、参照透過性の破壊を意味します。

関数型プログラミングでは、イミュータブルな変数を利用することが一般的です。

3. Side Effect (副作用)

関数がファイルやデータベース、ネットワーク等外部との入出力に依存している場合、外部環境の変化により関数の結果が変化することがあります。このような作用を「副作用」と呼びます。副作用もまた参照透過性の破壊に繋がります。

この副作用を伴わずに参照透過性を持つ関数を「純粋な関数」と呼びます。

関数型プログラミングで、副作用を伴う関数を定義する場合、副作用を特定の関数内に隔離することがベストプラクティスになります。

4. Higher-order Function (高階関数)

高階関数とは、簡単にいうと引数に関数を、あるいは関数を返す関数です。

次の例は、高階関数の例です。Option.map() は引数が関数 (Function) です。:

def fn(x: Int) = x * 2
val maybe = Option(3)
println(maybe.map(fn(_)))

5. Map、Filter、Reduce

ここで説明する Map、Filter、Reduce はコレクションやストリームを扱う高階関数を使うパターンです。

  • map(f) は、要素の型を変換する関数が引数になります。元の型 E を変換後の型 F にします。
  • filter(f) は、要素の値を評価して通過させる場合は true、破棄する場合は false を返す関数が引数になります。
  • reduce(f) は、コレクションやストリームの要素の合計値を求めるというような計算に使用します。引数は要素の値を使って集計する関数になります。
val seq = Seq("1", "2", "3", "2", "2", "4")
seq.map(_.toInt).filter(_ == 2).reduce((x, y) => x + y)

6. カリー化

次のような関数があったとします。

def oldSum(x: Int, y: Int) = x + y

この関数をカリー化すると、次のようになります。

def sum(x: Int)(y: Int) = x + y

これによって何が違うかですが、次のようにするとわかります。

sum(1)_
res1: Int => Int = $Lambda$4598/185771598@5c88fc39

つまり sum(1)_ は、fn(y: Int) を返しています。これを利用して、与えられて引数に 2 を足す関数を生み出すことができます。

val twoPlus: Int => Int = sum(2)_
twoPlus(5)
res2: Int = 7

カリー化によって引数を1つとすることで、後で述べる関数合成がしやすくなるという利点もあります。

7. モジュール性

ここでのモジュールは、jar 形式などのライブラリモジュールのことではありません。

さまざまな機能を、小さな扱いやすい単位にソフトウェアを分割することをモジュール化といいます。

モジュールに必要な性質は、個々のモジュールの独立性です。モジュールが独立していれば、他のモジュールの変更による影響を受けにくくなります。関数が参照透過性を持っていれば、関数の結果は引数にのみ依存し、計算結果は他のコードの影響を受けない予測可能な値を返します。

8. 関数合成

複数の関数を引数として、合成した関数を返すことができます。

def sum: List[Int] => Int = _.reduce(_ + _)
def toOne: List[Any] => List[Int] = _.map(_ => 1)
def compose(f: List[Int] => Int, g: List[Any] => List[Int]) = (arg: List[Any]) => f(g(arg))

val list = List("abc", "def", "ghi")
compose(sum, toOne)(list)
res5: Int = 3

9. 遅延評価

遅延評価とは、計算を後回しにして評価する方法です。

遅延評価は無限に続く Stream などを表現することができます。

例えば、正格評価の List(1, 2, 3) では、評価時点でメモリに有限個の要素が確保されます。メモリは有限のリソースですので、無限個の要素からなる List を表現できないことは明確です。

遅延評価を用いる場合、処理時点で要素を評価することになります。つまりメモリにも評価時点で必要な個数の要素があれば良いことになります。

10. クロージャ

def makeCounter = {
    var count = 0
    () => {
        count = count + 1
        count
    }
}

val c = makeCounter
c()
res0: Int = 1
c()
res1: Int = 2

クロージャを使用することで、関数外から参照できない変数を定義することができます。

11. 畳み込み (Fold)

val list = List(1, 2, 3)

List の foldLeft(z)(op) によるリストの畳み込みを描くと下の図のようになります。

      op
     /  \
    op   3
   /  \ 
  op   2
 /  \
z    1
list.foldLeft(0)((x, y) => x + y)
res1: Int = 6

foldLeft は次の順序で計算されます。

  1. 初期値 (z) とリストの先頭の要素を op: つまり、x に 0 がバインドされ、y に 1 が設定されて計算された 1 を返す
  2. 計算結果の 1 とリストの2番目の要素を op: つまり、x に 1 がバインドされ、y に 2 が設定され、3 を返す
  3. 計算結果の 3 とリストの3番目の要素を op: つまり、x に 3 がバインドされ、y に 3 が設定され、6 を返す

したがって、最後の計算結果である 6 が返されます。

foldLeft はリストの先頭から計算しますが、リストの末尾から計算する foldRight もあります。