非関数型プログラマでも分かる関数型プログラミング入門

  • 40
    いいね
  • 0
    コメント

最近、無数に関数型記事が上がっています。入門記事は、これでもかと言うくらい。私も関数型を浸透させたいので、めげずに上げます。多くの記事は、文字のみで語っていてソースコードで見なければ、良くメリットがわからないという指摘を見かけた気がしたので、コード中心の記事を上げたいと思います。

TL;DR

関数型プログラミング、関数型言語を使うことによって、以下の利点を得ることが出来ます。

  • 副作用があるコードと無いコードを分けて、テストのしやすさを確保します。
  • 関数型プログラマは、ミスの発見の多くをコンパイラに任せます。
  • 抽象的な汎用関数を使い、具体的な関数を記述します。結果ソースコード全体が短くなります。

はじめに

用語説明

用語の定義から入ります。関数型プログラミング自体に厳密な定義は無いと思います。しかし、自分が思っている関数型プログラミングを以下に項目を列挙したいと思います。

  • 関数がファーストクラスであること。
  • 副作用が無い関数と副作用がある関数を分けること。

結論から言ってしまうと、これだけです。その為、関数型(とプッシュしている)言語を使わなくても、関数型プログラミングをすることは出来ます!

次に関数型言語について定義します。関数型言語は、関数型プログラミングが出来ることが絶対条件です。ただ、この条件だけ満たしている言語は、最近の言語であればほとんです。なので、追加条件として、関数型プログラミングの利点を多く享受するるための機能が豊富である。これが、関数型言語である条件だと思っています。どのような機能かを以下に列挙します。

  • パターンマッチ
  • カリー化
  • 型クラス
  • 遅延評価

他にも様々あるかと思いますが、大体メジャーな機能は、これぐらいかと思います(抜けがあればご指摘ください)。今回紹介するのは、パターンマッチのみです。関数型プログラミング+パターンマッチのみの考え方で、どれほど強力な利点を得れるかをお楽しみください。

使用言語

今回の記事では、Scalaを使用します。Scalaは、Javaから派生した言語で、Javaの機能は全て使えます。そのため関数型脳にいきなり切り替えずとも、手続き的な処理・オブジェクト指向(むしろScalaでは、オブジェクト指向の考えは重視します。)は、問題なく使えます。関数型の実践投入を考えてる方は、是非Scalaをオススメします。従来のオブジェクト指向から徐々にオブジェクト指向 + 関数型にシフトしていけば良いので、安心です。手続き、オブジェクト指向の考えがウェルカムなScalaですが、関数型プログラミングに対するサポートもハンパではありません。Scalaの全ての機能を把握してバリバリ使いこなせてる方は、そう多くないと思います(それぐらい、あらゆる面に関して機能が豊富です)。別な言語を使わざるを得ないという状況でも、Scalaで学んだ知識は十分に活かせると思います。騙されたと思って入門してみてください。Scalaで学ぶ上でバイブル的な書籍を以下に紹介しておきます。ぶっちゃけてしまうと、今回の記事は書籍のほぼ抜粋です。

特に、「Scala関数型デザイン&プログラミング ―Scalazコントリビューターによる関数型徹底ガイド」の内容を多く使用します。

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

それでは、入門を開始してきます。非関数型プログラマの向けに順を追って説明しますが、Scalaの文法を事細かに説明はしません。最低でもJavaを記述したことがあれば、基礎部分は理解出来ると思っているからです。ただし、わかりづらい部分があれば、コメントでご指摘お願いします。

ファーストクラス関数

関数型プログラミングが出来る絶対条件である、ファーストクラス関数を説明します。ファーストクラス関数とは、関数をリテラルで表現できる。所謂ラムダ式ですね。引数に関数を渡すことが出来る。戻り値に関数を使うことが出来る。この3つの性質を持つ関数をファーストクラス関数と呼びます。それでは、コードを見てみましょう。

関数のリテラル表現

val add = (x: Int, y: Int) => x + y // 式が複数の場合、{}で囲んでもOK!
println(add(1, 2))
// => 3

関数を引数に渡す

// 整数xと関数fを受け取り、fにxを適用した値とxを足し、計算結果を返す関数。
def fadd(x: Int, f: (Int) => Int): Int = f(x) + x
println(fadd(3, (x: Int) => x * 2)) 
// => (3 * 2) + 3 => 9

戻り値として関数を返す

// 整数xを受け取り xより1多い整数とnを掛け合わせる関数を返す関数。
def someFun(x: Int): (Int) => Int = (n: Int) => (x + 1) * n
val f = someFun(5)
// (n: Int) => 6 * n
println(f(3))
// 6 * 3 => 18

以上がファーストクラスな関数の例になっています。最近のほとんどの言語だったら問題なく付いている機能だと思います。あくまで関数型プログラミングをするための道具の一つですが、これがとても重要になってきます。ちなみに、関数に引数を渡せる、戻り値として関数を返せる(どちらか一方を満たせば大丈夫です。)関数のことを高階関数と呼びます。

再帰関数とパターンマッチ

関数型プログラミングでは、副作用がある関数と無い関数を分けます。これは副作用によるバグを避けたり、テストを書きやすくするため、という意味合いが強いです。そのため、手続き型・オブジェクト指向言語をする場合でも、このプラクティスは、非常に有効です。関数型プログラミングにおいては、他の関数と組み合わせやすくする(関数合成)ためという点でも重要です。

例えば、ファイルの行数を数える関数を書いたとしましょう。(例外処理は、省いています。)

import scala.io._

def getFileLines(name: String): Int = {
    val source = Source.fromFile(name, "UTF-8")
    source.getLines.length
}

println(getFileLines("memo"))
=> Number of rows in memo

しかし、このような関数は、テスト時に冪等性を確保出来ませんし、テストディレクトリにテストファイルも配置しなくては、なりません。以下のように、副作用を分けるべきです。

import scala.io._

def getLineLength(lines: List[String]): Int = 
    lines.length

println(getLineLength(Source.fromFile("memo", "UTF-8").getLines.toList))
=> Number of rows in memo
println(getLineLength(List("aaa", "bbb", "ccc")))
=> 3

それでは、以下のような場合はどうするのでしょうか? 一時的にJavaの構文を使います。

int arr[] = {18, 29, 36, 12, 15};

int sum = 0;
for(int i = 0; i < arr.length(); i++){
    sum += arr[i];
}

この例では、合計値である変数sumとカウンタ変数iが、ループの中で書き換えられるので副作用を含んでいます。この程度の例では、間違いはまず起こさないと考えるか、そもそもイテレータ・拡張for文を使うと思います。ただ、それらを使用したとしても、副作用を含んでることは変わりません。関数型プログラミングの原始的な考えでは、再帰関数を使います。皆さんは、再帰にはどのようなイメージを持っているでしょうか?私は再帰を学んだ当初は、ひどく理解しづらく、ループのほうが直感的に感じました。しかし、関数型言語の力を使うと、その考えは一変します。

def sum(list: List[Int]): Int = 
  list match {
    // 空のとき
    case Nil     => 0 // Nil == List()
    // リストの先頭(x) :: 残りの要素
    case x :: xs => x + sum(xs)
  }

println(sum(List(18, 29, 36, 12, 15)))
// => 110

この例では、パターンマッチを利用して、データ構造を分解して、直接値に集中したコードが書けています。パターンマッチの役割は、リッチなif, switch ではなく、データ構造に着目してコードが書けることです。そのため、indexと配列の長さが一致した時、などと間違えの起きやすい条件ではなく、空のリスト(Nil)のときと、それ以外の場合と明確に分けて記述することが出来ます。さらに、関数型言語の力を見てみましょう。
わざとNilの条件を書き忘れてみます。

def sum(list: List[Int]): Int = 
  list match {
    case x :: xs => x + sum(xs)
  }

すると、以下のような警告が起きます。

Main.scala:5: warning: match may not be exhaustive.
It would fail on the following input: Nil
    list match {
    ^
one warning found

なんとコンパイラが、条件の漏れを検出して、警告を出してくれます。そのため、手続き型における再帰関数より安全にプログラミングすることが出来ます。

同じように、リストの長さを求める関数も書いてみましょう。

def length(list: List[Int]): Int = 
  list match {
    case Nil     => 0
    case x :: xs => 1 + length(xs)
  }

println(length(List(18, 29, 36, 12, 15)))
// => 5

無事先ほどと変わらぬアプローチで記述することが出来ました。

抽象的な汎用関数

先程の節の例ですが、改めて手続き型プログラミングと比べてみてどうでしょうか? 再帰関数の書きやすさや安全にプログラミング出来る機構は、納得だと思いますが、全体の行数を比べて見ると手続きとそう変わらない、むしろ手続き型の方が少ないように見えます。そうであれば、慣れた手続き型スタイルで行ったほうが良いと思われるかもしれません。そこで、抽象的な汎用関数の利用です。改めて、sumとlength関数を並べてみましょう。

def sum(list: List[Int]): Int = 
  list match {
    // 空のとき
    case Nil     => 0 // Nil == List()
    // リストの先頭(x) :: 残りの要素
    case x :: xs => x + sum(xs)
  }

def length(list: List[Int]): Int = 
  list match {
    case Nil     => 0
    case x :: xs => 1 + length(xs)
  }

すると、空のListのとき・値があるとき、そのような処理をするか、これだけに注意すれば、あとはパターンであることがわかります。関数型プログラミングでは、パターンを抽出して抽象的な関数として提供する場合が多いです。今回のような演算は畳み込み演算と呼ばれ、foldLeft(左畳み込み)という関数を使います。Scalaでは、foldLeftはListのメソッドとして用意され、初期値・要素が存在するときの処理を関数として受け取る高階関数になっています。

// list.(初期値){値があるときの、処理}
val list = List(18, 29, 36, 12, 15)
val sum    = list.foldLeft(0){(acc, x) => x + acc }
val length = list.foldLeft(0){(acc, x) => 1 + acc }

最初は戸惑うかもしれませんが、再帰関数が書けるようになっていれば、自然と高階関数に落とすことも出来ると思います。そして、劇的に短くなったことがわかります。関数型言語の利点として誤解されやすいのが、処理を短く書くための関数が豊富に用意されているわけではありません。それならば、昔からよく使われている言語のほうが圧倒的に有利だと思います。関数型言語の利点は、処理を短く書くためにパターンを抽出した、抽象的な汎用関数が豊富に用意されている。ということです。

さらに、いくつか汎用関数を見ていきましょう。おなじみ関数に値を適用して、新たな値として扱うmap関数です。

val list = List(18, 29, 36, 12, 15)
list.map(x => x * 2)
=> List(36, 58, 72, 24, 30)

mapして、flattenするflatMapです。

List(List(1,2,3), List(4), List(5, 6)).flatMap{ list => list.map(x => x * 2) }
// => List(2, 4, 6, 8, 10, 12)

この2つの高階関数は、型を超えて使用出来る抽象的な関数です(マジックではなく、多くの関数で実装されているという意味合いです)。値が存在するか、どうかの文脈を持つOptionを使ってみます。(nullの代わりに習慣として使われる型です)

def even(x: Int): Option[Int] =
if(x % 2 == 0) Some(x) // 値があるときは、Some
else None              // 値がないときは、Noneとなります。

even(2).map(x => x * 2)
// => Some(2) => Some(2 * 2) => Some(4)
even(1).map(x => x * 2)
// => None => None

// List同様パターンマッチすることも可能です。
even(10).map(x => x * 2) match{
    case Some(x) => println(s"even: $x")
    case None    => println(odd)
}
// => even: 20

Some(Some(1)).flatMap(opt => opt.map(v => v * 2))
// => Some(2)

並列に実行される値、Future

import scala.concurrent._
import ExecutionContext.Implicits.global

Future(2).map(x => x * 2)
// => Success(4)
// flatMapの例は省略

mapやflatMapが使えると、使用する側からは、分岐(Listが空か、Noneか)や反復・再帰を記述する必要がありません。これは、型を超えて使用することが出来るので、とても抽象化されていることがわかります。ものすごいテキトーなことを言いますが、モナドとは規則を守りながら実装されたmapとflatMapを持ち合わせた型の総称に過ぎません。他にも、モナドが使える事で得られる利点は多くありますが、今回は割愛させていただきます。

まとめ

どうでしょう?関数型の魅力が伝わったでしょうか?さらに言うと、これら関数型プログラミングの基礎概念は、言語を超えても姿形が変わる(型の名称が変わる、文法が変わる)だけで、共通です。これは、オブジェクト指向の考えを抑えて別言語を学ぶときの気軽さと同様です(むしろ、クラスベース・プロトタイプベースなどを考える必要がないので、もっと気楽です)。これを機に、関数型プログラミングをはじめて、安全なプログラミング・コンパイラの心強さ・テストの書きやすさ・モナドの便利さなどを実感してみては、いかがでしょうか?