LoginSignup
28
18

More than 3 years have passed since last update.

関数型プログラミング入門者向け。Scalaと関数型プログラミングで一番重要な概念: Part 1 関数合成

Last updated at Posted at 2019-08-16

目的

私は普段仕事ではC#を使います(95%以上。時々JavaScript)、ですがデータ処理のためにScala を使う案件が出てきそうな雰囲気になってきたので、ここはひとつ「ScalaのもっともScalaらしい、しかも最重要な機能を学ぼう」ということになり、その学習というか調査の結果を、主に関数型プログラミングにあまり縁の無い方や、すこしかじったけどよくその良さが分からない(つまり自分!)を対象にまとめました。元々は英語で書いたんですが、頑張って日本語にしました。

おことわり

関数型プログラミングに関しては、興味レベルでHaskellについてちょっと本を読んだり、F#でごく簡単なツールを実装した、ぐらいの経験で、実務経験はほぼ0です。ですが、関数型プログラミングの方法論やベストプラクティスはじわじわと非関数型プログラミング言語・環境でも見るようになってきました(例えばASP.NETのミドルウェアなどは一種の関数合成でしょう)。また、関数型プログラミング言語でなくても、関数型プログラミングが推奨するコーディング(例えば副作用を減らす、参照透過性を目指すなど)はベストプラクティスとして知っておくべきだと思うようになりました。(本音を言うと、関数型プログラミング、例えばf#を使ってプロジェクトをやりたいんですよね・・・)

内容

今回は、Scala と関数型プログラミングの「ど真ん中」を攻めたいと思いました。Scalaと関数型プログラミング言語を取り巻く様々な概念の中から、特に重要で「それがあるからこそ関数型プログラミング!」といえるような概念に集中することにしました。ですから、lazy evaluation, pure function, pattern matchin, referential transparency, といった概念は全く扱わないか、副次的な位置づけにしました。

自分なりに調査して、もっとも重要だと感じたのが、重要な順から並べて、以下の項目になります(あくまでも個人の意見です)。

  • 関数合成(パート1としてこの投稿の下にあります)
  • 部分適用とカリー化(パート2として別の投稿にあります)
  • 高階関数(まだ書きかけです。map, reduce, fold, などに付いて)

こんな人にオススメ(たぶん)

  • 普段、関数型プログラミングを使わないひと
  • 関数型プログラミングっていったい何なのって疑問に思ってるひと
  • Javaを普段使いだけどScalaが気になってる人(環境的には一番楽な人)
  • 関数型プログラミングのいったいどこがどのよういいいのか知りたい人
  • 関数型プログラミングの「カリー化」がいまいちよく分からない人
  • 手で触りながら勉強したい人(全ての例はScalaのコンソールに打ち込めます)

Part 1 関数合成

準備

ScalaをREPLできる環境が必要です。

  • JDKをインストールします (Open JDK でもオッケー)。http://jdk.java.net/12/ にあるZipファイルがありますから、適当な場所に解凍しましょう (例によって環境変数をちょっといじることになるでしょう。詳しくはJDKインストール手順を解説した記事をみてください)
  • Scalaをインストール。 リンクは https://www.scala-lang.org/download/ です。僕はウィンドウズユーザーなので、Windowsインストーラー (msi)を使いました。

インストールが完了したら、コマンドラインから scala.exe を実行します。Scala REPL環境が出来ます。この記事のエクササイズはすべてScala REPL環境でやりました(もちろんJavaScriptのサンプルを除いて)

ネットで調べたところ、関数型プログラミングに詳しい人たちが、関数合成 がこのプログラミングパラダイムにおいて、もっとも重要な概念のひとつだという点である程度意見がまとまっているように思えました。

このセクションでは、ごく簡単な複数のステップからなる処理を、ふたつのパラダイムで実装して比べてみます。そのパラダイムとは: 命令型プログラミングと関数型プログラミングです。

処理の流れはこんな感じです。
1. ある整数型を表す文字列を入力として受け取り、整数型の値に変換します。この整数はマイナス記号が付いているかもしれません(ネガティブな値かもしれません)
1. 変換された整数型の値を絶対値に変換します。
1. 絶対値に変換された値に1を足します。

例えば、"-20" を受け取ったとしたら、この一連の処理は、まず-20という整数型の値に変換し、絶対値 20に変換し, 最後に1を足して 21に変換します。

命令型プログラミングでやった場合

まずはそれぞれのステップが Scala のライブラリと式でどのように表せるか確認しましょう。

scala> Integer.parseInt("-20")
scala> Math.abs(-20)
scala> 20 + 1

次に、これらのライブラリ関数呼び出しおよび算術式を利用して、新たに関数うを定義しましょう。

scala> def f(s:String):Int = {
     | val i = Integer.parseInt(s)
     | val a = Math.abs(i)
     | val r = a + 1
     | r
     | }

シンタックスは C言語由来のプログラミング言語(Java, C#, JavaScriptなど)でおなじみの雰囲気なので、それらの言語を知ってる人ならすんなりと理解できる実装ですね。

ところで、Scala REPL は、ステートメントが未完の場合は改行の後に自動的にパイプ文字 | を付けて、続けて記述できるようにしてくれます。ステートメントを完了させるには、記述を完成させたあとEnterキーを打ち続けるとそのうち終わります。

関数の呼び出しもごく普通です。

 scala> f("-20")    

別のやり方として、同じ関数をインラインを多用して1行でやる方法もあります。この場合、ひとつの関数の出力が、直接別の関数の入力になっています。

scala> def f = (s:String) => Math.abs(Integer.parseInt(s)) + 1

ここまでで、Scala を使ってはいますが、関数型プログラミングの特徴を使ってコーディングしているとは言えません。ラムダ式(JavaScriptで言う「アロー関数」)を使っている点でそのようにも見えますが、ラムダ式は無名関数だというだけです。ところで Scala は純粋な関数型プログラミング言語ではありません。関数ファーストな言語であるというだけで命令型言語のように使うことも可能です。(この戦略はf#と似ていますし、Haskellとは異なります)

それぞれのステップを関数として見る

関数型プログラミングのやり方をみる前に、それぞれのステップを、a + 1 という算術演算も含めて、「独立した関数として扱えること」を確認しておきましょう。これは関数合成をする際に重要になります。

Integer.parseInt の確認の前にまず Math.abs 関数についてみてみましょう (Integer.parseInt はオーバーロードされている関数なのでちょっとだけ事情が異なります。この次にみていきます。)

scala> Math.abs _
res46: Int => Int = $$Lambda$1088/0x0000000801835040@5d5db92b

このコードで注目したいのがアンダースコア文字 _ が、この関数が実行されること防いでいるという点です。代わりに、, instead it will 関数の型を返しています。アンダースコアを指定しないとScalaのインタープリターが関数の呼び出しを試みて、足りない引数を要求するという事態になってしまうのです。

Int => Int の部分の読み方は "Int型の引数ひとつを取り、Int型の値を返す関数"となります。

= 記号の右辺は関数の本体にあたります。この実行例では、僕の実行環境での関数本体の在りかがIDとして表示されていますが、各自の実行環境で値はことなります。

では、Integer.parseInt 関数についてみていきましょう。

scala> Integer.parseInt _
               ^
       error: ambiguous reference to overloaded definition,
       both method parseInt in class Integer of type (x$1: String)Int
       and  method parseInt in class Integer of type (x$1: CharSequence, x$2: Int, x$3: Int, x$4: Int)Int
       match expected type ?

エラーが出てしまいましたね。コンパイラーは、引数のバリエーションで関数の実装を特定するオーバーロードされた関数について、なんら引数情報を与えていないので、解決できないと文句をいっています。

曖昧さを解決するには、引数の配列を指定します。(今回のケースでは、引数はひとつだけでした).

scala> Integer.parseInt(_: String)
res47: String => Int = $$Lambda$1089/0x0000000801834840@4fca434c

String => Int の部分は "String型の引数を1つ取り、Int型の値を返す関数"と読むことが出来ます。

最後に a + 1 という式を見てみましょう。 a は未知の値なので未知のままにするために、ラムダ式として表します。

scala> (a: Int) => a + 1
res48: Int => Int = $$Lambda$1090/0x0000000801840840@5d8f3a36

ラムダ式は名前の無い関数なので、Scala REPL はその関数型を返すことが出来るのです。

というわけで、3つのステップの全てを関数として表せることが確認できました。

このセクションの主な目的は、それぞれのステップを独立した関数として表すことが出来ることを確認することでした。これが意味するのは、それらのステップを他の関数に渡す、もしくは他の関数から返す、異なる関数と交換する、などといった操作が出来るということです。あるプログラミング言語がそのようなことを可能にさせる時、その言語では、関数はファーストクラスのオブジェクトだと表現されます。

関数型プログラミングでのやり方

では関数型プログラミングを使って同じ関数を実装してみましょう。

ではまず3つの関数に名前をつけることから始めましょう。
これは技術的には必要というわけではないのですが、関数合成のコードを理解する際に、読みやすくするために行います。のちほど、もっと直接的にコーディングした例を示します。

scala> def s1 = Integer.parseInt(_: String)
scala> def s2 = Math.abs _
scala> def s3 = (a: Int) => a + 1

こうすることで3つのステップにs1, s2, そして s3 という名前をつけることができました。

次にこの3つの関数を使って f2 という新しい関数を合成します。

scala> def f2 = s1.andThen(s2).andThen(s3)

andThen は Scala の関数オブジェクトのメソッドの一つです。下に示すような関係になるように、ひとつの関数の出力を、別の関数の入力にパイプ風に繋げています。

s3(s2(s1(x)))

一番内側にある関数 s1xを受け取るところから始まり、その結果をすぐ外側の s2 に渡す。そのように外側に向かって値を渡していく様子が想像できるでしょう。

さらに、composeというメソッドを使った他のやり方もあります。下は2つの異なる書式の例ですが、効果は同じです。

// pattern 1
scala> def f2 = s3.compose(s2).compose(s1)
// patter n2
scala> def f2 = s3 compose s2 compose s1

この書式の場合、左から読み進めて3つ目の関数 s1 が、最初の2つ関数(s3s2)のペア(合成関数)にどう関わっているかという点で、すこし理解しにくいかもしれません。混乱した場合、以下のように読み解くと理解しやすいでしょう。

まず合成関数 s3.compose(s2) を見たとき 「どちらの関数が先に呼び出されるだろうか?」と考えてみると、その答えは s2 です。ですから、この合成関数は呼び出される前までは s2 を表していると理解できます。ということは、2つ目のcomposes2に対して、あたかも s2.compose(s1) と記述したような効果を持ちます。よって ss1 の戻り値を受け取る、s3(s2(s1(x))) のように構成されたことと同じになります。

合成関数 f2 の話に戻りますが、ここで重要なのは f2 まだ関数の定義にすぎないということです。ですから、関数の呼び出しは一切おこなっていません。

ではようやく下に示すように関数を呼び出してみましょう。"-20"引数の値として渡すので、結果は 21 が返ってくるはずです。

scala> f2("-20")

前述したように s1, s2, そして s3 はライブラリ関数(および式)の別名でしかなかったので、別名を使わず関数合成すると以下のようになります。

// 1行でやる
scala> def f3 = (Integer.parseInt(_:String)).andThen(Math.abs _).andThen((x:Int) => x + 1)

// 複数の行にまたがる
scala> def f3 = (Integer.parseInt(_:String)).
     | andThen(Math.abs _).
     | andThen((x:Int) => x + 1)

このようにそれぞれのステップを実際の関数と交換するだけです。

関数型プログラミングの関数合成を使う利点は?

利点1: 変更が簡単で安全

例を見ていきましょう。処理の要求が変わったとして、命令型プログラミングと関数型プログラミングの実装を変更して、それらを比較してみましょう。

既存の処理:

  1. ある整数型を表す文字列を入力として受け取り、整数型の値に変換します。この整数はマイナス記号が付いているかもしれません(ネガティブな値かもしれません)
  2. 変換された整数型の値を絶対値に変換します。
  3. 絶対値に変換された値に1を足します。

ここで、新たに「デバッグ目的として、それぞれのステップごとの途中結果をログに出力する」という要求が発生したとします。

新たな要求を加えた後の処理:

  1. ある整数型を表す文字列を入力として受け取り、整数型の値に変換します。この整数はマイナス記号が付いているかもしれません(ネガティブな値かもしれません)
  2. 整数の値をログに出力する
  3. 変換された整数型の値を絶対値に変換します。
  4. 絶対値に変換された整数の値をログに出力する
  5. 絶対値に変換された値に1を足します。
  6. 1を加えた後の整数の値をログに出力する

注:実際にはI/O処理は"副作用"として考えられるので Haskell のような純粋な関数型プログラミング言語では特別な取り扱いをするようです。ここでは理解を助ける目的でI/Oステップを取り入れますが、ベストプラクティスではないというのが私の理解です。

では、命令型プログラミングと関数型プログラミングの実装を比べてみましょう。

命令型プログラミングの場合

scala> def f(s:String):Int = {
     | val i = Integer.parseInt(s)
     | println(i)
     | val a = Math.abs(i)
     | println(a)
     | val r = a + 1
     | println(r)
     | r
     | }

 scala> f("-20")
-20
20
21
res55: Int = 21  

お分かりのように、println を呼び出す命令文を関数 f の本体の処理の流れに書き加えています。

さらに、新たに加えた命令文が iaといった r ローカル変数を参照しています。今回はたまたま運よく、それらの変数がすでに定義されていましたが、もしローカル変数を使わずにインライン形式で暗黙的に値が関数から関数へ渡されていたなら、出力のためだけに既存のコードをいじる必要があったでしょう。これは既存のコードのレファクタリングを余儀なくさせ、テストの結果を無効にさせ、レグレッションの可能性を生むことになります。

当然ながら、この変更が f に悪影響を及ぼしていないかを確認するために、単体テスト等を再度実行する必要が生じます。

関数型プログラミングの場合

関数型プログラミング言語の関数合成を使って変更をする前に、コンソールに文字列を表示できる新しい関数を定義しなくてはなりません。なぜなら、ビルトインの println 関数は戻り値を返さないので、関数どうしで数珠つなぎにすることが出来ないからです。

ということで、ごく単純な関数で printInt をアダプトできるようにしましょう。

scala> def printInt(x:Int):Int = {
     | println(x)
     | x
     | }

printInt という名前のラッパー(wrapper 包み)関数を定義し終えたら、合成された関数のパイプラインに挿入することが出来るようになります。printIntreflexive な関数で、入力した値はそのまま出力されるので、パイプラインのどこに挟み込んでも安全です。

scala> def f3 = (Integer.parseInt(_:String)).andThen(printInt).andThen(Math.abs _).andThen(printInt).andThen(_ + 1).andThen(printInt)

scala> f3("-20")
-20
20
21
res31: Int = 21

同じ関数を、別名を使って、改行も加えて書くと分かりやすいかもしれません。

scala> def f = s1.andThen(printInt).
     | andThen(s2).andThen(printInt).
     | andThen(s3).andThen(printInt)

元々あった関数はそれぞれすでにテストされていて正しいと仮定した場合、ここで必要とされるのは、挿入した関数が"純粋である(ある値に対してかならずいつも同じ値が返され、なおかつ副作用がない)"ということです。

printInt は identity function (日本語では「恒等写像」という難しい言葉なんですね。要は「まったく同じものを返す」という意味)です。副作用に関しては、厳密にはI/Oがあるのでそうとは言えないかもしれませんが、少なくともプログラムデータの状態には干渉しないので、ここは便宜上副作用が無いと考えたとすると、合成された関数は正しく動作すると言えます。

利点2: 再利用しやすい

関数合成を意識して定義された関数は、合成関数も含めて再利用がしやすいと言えます。

例えば、まず下のように関数をリストとして保存します。

scala> val func = List(s2, s3)

ここで func は関数を要素に持つただのリストなので、もっと要素を増やしたりもしくは減らしたり、要素の順番を変えたりと自由に構成できます。

次に、Scala の 高階関数 (higher-order functions) のひとつを使って合成関数を作ります。 (高階関数は、関数を引きするにとったり、戻り値として返す関数のことです)。

scala> def fc = func.foldLeft(s1)((ac, cu) => ac.andThen(cu))
scala> fc("-20")
res64: Int = 21

foldLeft 関数を使っています。この関数は 初期値 をとります。この場合、String型を引数にとり、Int型を戻り値として返す関数ならなんでもいいです。初期値の関数がリスト内の1番目の関数と繋げられ、その結果がリスト内の2番目(最後)の関数と繋げられます。

最終的に、関数が鎖のようにリンクされ、関数が合成されます。

さて、このように関数を合成するやり方を使うと、ほかの状況でもその大部分を再利用しやすくなる点をデモしていきます。あらたな要求は、途中結果をログを出力する、でしたから、先ほど定義した printInt を以下のように合成関数に組み入れることが出来ます。

scala> val funs = List(printInt _, s2, printInt _, s3, printInt _)
scala> def fc = funs.foldLeft(s1)((c, n) => c.andThen(n))

scala> fc("-20")
-20
20
21
res66: Int = 21

途中段階の値がコンソールに出力されているのが分かります。

さらには、printInt _prn という別名を与えて仕様することで、もっと読みやすいコードになります。

scala> def prn = printInt _
scala> def func2 = List(prn, s2, prn, s3, prn)
scala> def fc = func2.foldLeft(s1)((ac, cu) => ac.andThen(cu))

利点: 抽象・具体の程度を柔軟に指定できる

foldLeft といった高階関数は Scala などの関数型プログラミング言語で特によく利用されます。このタイプの関数はコレクションの要素に対して処理を行う関数を引数として受け取り、すべての要素にそれを適用します。関数合成は、この処理を行う関数を定義するのに使えます。また、複数の高階関数を合成して別の関数を作ることも出来ます。
map, reduce, fold などが良く知られた例です。(C# を知っている人なら LINQ の SelectAggregate と言えばピンとくるでしょう)

例えば以下のような2つの、コレクションに対する処理あったとします。

ある文字列型のデータを要素とするコレクションがあったとして (例:"-20", "32", "-100")

  • 処理A:入力コレクションに対し、それぞれの要素をInt型の絶対値に変換し、それぞれの要素に1を足し、結果のコレクションを返す
  • 処理B: 入力コレクションに対し、それぞれの要素をInt型の絶対値に変換し、それぞれの要素に2を掛け、結果のコレクションを返す

両方とも入力と出力は、個別のデータアイテムではなく、データアイテムのコレクションなので、以下のように変換されることになります。

Stringのコレクション => Intのコレクション => Intのコレクション => Intのコレクション

ところで、処理Aと処理Bは「1を足す」か「2を掛ける」かという演算が異なるだけだという点に注目しましょう。それ以外は、入力はStringのコレクションで、各要素のStringの値はInt型に変換し、そのInt型の値を絶対値に変換し、異なる処理を経て出力はIntのコレクションになる、という点で全く同じです。ですから、プログラミングのベストプラクティスとして、共通する部分はひとつのコードを再利用して冗長をさけたいところです。

さて、これを実装するのに、指定された関数を使って各要素を処理する、map を利用できます。処理Aと処理Bを命令型プログラミングと関数型プログラミング(合成関数機能を利用する)で実装した場合を比較してきます。

命令型プログラミングの場合

一般的には、異なる処理部分を含む、名前の付いた2つの関数を定義します。(NOTE: もちろん、共通部分をサブルーチン化したり、オブジェクト指向プログラミングのパターンを使うなどして、冗長化を最小限に抑えるテクニックもありますが、ここでは単純に同じコードを書くやり方にします)

// 入力データ
scala> def ary = List("-20", "32", "-100")

// 処理Aのための関数
scala> def add1(s:String):Int = {
     | val i = Integer.parseInt(s)
     | val a = Math.abs(i)
     | val r = a + 1
     | r
     | }

// 処理Bのための関数
scala> def mulBy2(s:String):Int = {
     | val i = Integer.parseInt(s)
     | val a = Math.abs(i)
     | val r = a * 2
     | r
     | }

// 処理A
scala> ary.map(add1)
res71: List[Int] = List(21, 33, 101)

// 処理B
scala> ary.map(mulBy2)
res72: List[Int] = List(40, 64, 200)

この場合、add1mulBy2という関数を定義しました。

名前を付けた関数の代わりにラムダ式(無名関数)を使うやり方もあります。その場合は add1mulBy2 の代わりにラムダ式を指定します。ただ、ラムダ式を使ったとしても全く同じビジネスロジックをそれぞれの式の中で記述することになります。リファクタリングして同じサブルーチンを呼び出せばいいですが、そうなるとまた名前付きの関数が増える問題に戻ってしまいます。

関数型プログラミングの場合

関数合成を使うと、specialize (特殊化)はインラインで行えますし、その実装は柔軟に変更を加えることが出来きます。高階関数に対して直接、合成した関数を渡します。(この点はラムダ式を使う時と原則同じです)

scala> def ary = List("-20", "32", "-100")

// 処理A
scala> ary.map((Integer.parseInt(_:String)).andThen(Math.abs _).andThen(_ + 1))
res73: List[Int] = List(21, 33, 101)

// 処理B
scala> ary.map((Integer.parseInt(_:String)).andThen(Math.abs _).andThen(_ * 2))
res74: List[Int] = List(40, 64, 200)

これ以外にもいくつか特殊化の例を見ていきます。以下のように柔軟に特殊化できるのが関数合成の利点です。

// 処理の順番を変える
scala> ary.map((Integer.parseInt(_:String)).andThen(_ + 1).andThen(Math.abs _))

// 「1を足す」と「2を掛ける」の両方をする処理
scala> ary.map((Integer.parseInt(_:String)).andThen(Math.abs _).andThen(_ + 1).andThen(_ * 2))

// 共通部分をいったん合成関数にする
scala> def p = (Integer.parseInt(_:String)).andThen(Math.abs _)
p: String => Int

// 共通の関数と特殊化した処理を合わせる
scala> ary.map(p.andThen(_ + 1))
res76: List[Int] = List(21, 33, 101)

scala> ary.map(p.andThen(_ * 2))
res77: List[Int] = List(40, 64, 200)

このように、実験が容易になるのが分かるでしょう。開発者は Scala のREPL (Read-Evaluate-Print-Loop) 環境などを利用して生産性を高めることが出来るようになります。

ところで、上の例では「部分適用」というとても強力な関数型プログラミングの機能を使っています。次回は「部分適用」と「カリー化」について解説します。(Part 2 に続く)

28
18
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
28
18