Ruby の関数型プログラミングの特徴

Enigmo Advent Calendar 2018 の7日目の記事です。

概要

Enigmo の Steven です。 プログラミング言語に対して興味を持ってますので、今日は Ruby について話したいと思います。

Rubyオブジェクト指向だと言ったら、反対する人は多分いないと思いますが、関数型言語の特徴も持ってると言ったら、ピンとこない人はそれほど少なくはないかと思います。 それでも、Ruby プログラマーでしたら、関数型言語から受け継がれたそういう機能はおそらく毎日使っています。 そういう機能がなかったら、Ruby は世界中で使われてる現在の言語にならなかったかもしれません。

この記事ではその機能を説明して、Ruby の理解と関数型言語に関する知識が少し深まる機会になれればと思います。

Ruby の特徴について

Wikipedia によると、Ruby は 10個以上のプログラミング言語から影響を受けて作られました。 その中には PythonC++Smalltalk などのオブジェクト指向プログラミング言語は複数ありますが、その中には Lisp もあります。

Lisp というのは現在、一つの言語というより、言語のファミリーですが、そのファミリーの言語は特徴的な文法を用いてることで有名でしょう。 ただし、Ruby では括弧は Lisp と比べて極めて少ないですし、文法も全然違うので、最初は関係を疑うかと思いますが、言語の根本的なところで Lisp の一部の特徴を確認できます。

Lisp から引き継がれたもの

条件式の場合、Ruby の世界では nil と false は偽として解釈されます。それ以外の値はすべて真になります。 PHP などの言語と比べて、極めて簡単でわかりやすいルールですが、その特徴は Lisp からそのまま引き継がれました。 それに関しては Clojurejvm 上の Lisp)は Ruby と全く一緒です。Common Lisp では false という値はないですが、それ以外は一緒です。

もう一つの特徴としては、Lisp と同じく Ruby には文はなく、すべては式です。 C などの言語では if文、関数の定義などは文であって、値として扱えないのですが、Ruby ではどんなものも式であって、値として扱えます。 if then else endは該当のブランチの値を返しますし、多くの場合はそんなに役に立たないと思いますが、def foo; endはメソッド名をシンボルとして返します。

以上は Lisp 由来の特徴の一部ですが、引き継がれたものの中で一番影響が大きかったのは、関数型プログラミングだと言えます。

Ruby の関数の扱い

どんな段階で特定の言語が関数型言語になるかというのは定かではなくて、判断しにくい時がありますが、Ruby ではやはりオブジェクト指向の面が一番強いので、関数型言語だと言えません。 ただし、Ruby ではある程度関数型プログラミングが可能だと主張しても、誤りではないでしょう。

Ruby関数型プログラミングができるのは Ruby の関数の扱いのおかげです。 厳密に言うと、Ruby には関数はなく、すべてはメソッドですが、説明がより簡潔になるよう、以下の説明では両方が同じだと一旦みなしてください。

第一級関数(first-class functions)

第一級関数を提供する言語では関数を単なる値として扱えます。他の関数に引数として渡すこともできれば、関数から返すのはもちろん、変数に保存することもできます。 Ruby では既存のメソッドを値として扱うにはまずメソッドをMethodProcオブジェクトとして抽出する必要がありますが、それができれば、そのオブジェクトを他のオブジェクトと何の違いもなく自由に扱えます。

def add(a, b)
  a + b
end

def apply(fn, *args)
  fn.call(*args)
end

add = method(:add)

apply(add, 22, 44)
# => 66

無名関数(anonymous functions)

無名関数はその名前の通り、名前のない関数を表しています。それだけです。 名前を与える必要がなくなると、整数と文字列と同じく、関数はただのリテラルになります。 Ruby ではproclambdaで無名な関数オブジェクトをもちろん生成できますが、ブロックも無名関数の一種だと言えます。

my_proc = proc { puts 1 }
my_lambda = lambda { puts 2 }

def with_my_block
  yield 3
end

with_my_block { |x| puts x }

クロージャ(closures)

関数を値として使える言語では、クロージャは、関数の内側から、関数定義時に関数の外側にしか存在しなかった変数名(グローバル以外にも)を参照することを可能にします。 Ruby でもクロージャのサポートを確認できます。

def foo(n)
  o = n * 2
  lambda { |p| o + p } # o は lambda の引数から受けられず、foo のローカル変数を指しています
end

bar = foo(10)
bar.call(5)
# => 25

無名関数とクロージャは時々一緒くたにされることがありますが、違うものです。 無名関数がないが、クロージャがある言語と、その逆の言語を想像できます。

高階関数(higher-order functions)

高階関数は以上のものと違って、機能ではなく関数の一種類の名前です。 高階関数というのは、他の関数を引数として受けるか、関数を返り値として返すか、それともその両方を行う関数を表しています。 どちらかが欠けてると、多少不便になるので、以上で紹介した機能をベースに、高階関数を可能にするプログラミング言語は多いです。 Ruby では高階関数は多いですが、一番始めに頭に浮かぶのはEnumerableのメソッドです。

multiplier = 5
# map という関数はブロックを関数として受け取ってます
[1, 2, 3].map { |n| n * multiplier }
# => [5, 10, 15]

したがって、Ruby でプログラミングをしているのであれば、おそらく毎日、ある程度の関数型プログラミングをしているということになります。

関数型プログラミングのいいところ

Enumerableのメソッドを好む人は Ruby プログラマーのほぼ全員だと思いますが、そのようなプログラミングがなぜいいのかをもっと具体的に説明しましょう。

  • 関数を組み合わせることで量の少ないコードでもかなりな処理を行えます
  • 高階関数になると、再利用できる関数は多く、アルゴリズムをそれぞれの関数の組み合わせとして実装できます
  • 命令型プログラミングとオブジェクト指向プログラミングと比べて、ステートを扱うことが少ないので、ステートによるバグがより少ない

特徴は他にありますが、まとめて言いますと、関数型言語では数学により近い形でプログラムを実装すると言えます。

関数型プログラミング向けの gem

Ruby で以上のように関数型プログラミングができますが、Ruby はあくまでもオブジェクト指向の言語なので、それなりの限界があります。 高階関数をライブラリーに追加することは簡単ですが、HaskellOCaml のような関数型言語に近づかせるにはかなりの努力が要ります。 それも度を越えると、RubyRuby じゃなくなって、デメリットの方が大きくなることがあるかと思います。

それでも場合によってはほかの関数型言語にある機能を Ruby に持ってくるメリットがありますので、以下ではいくつかの機能とそれを提供する gem を紹介します。

永続データ構造

永続データ構造では既存の値を変更することは不可能で、もとの値から新しい値を生成することしかできません。 新しい値の生成時に既存のデータ構造と一部の構造が共有されるので、HashArrayなどをまるごとコピーするより早いです。

一見では追加の制限しかかけられてないと見えますが、新しい値をしか生成できないというのは逆に保障でもあって、変更してはならないデータ構造を気づかずに変更してしまうというバグが完全になくなります。 並行計算ではスレッドの間でデータ交換を行う時に伴う心配もなくなるほか、たまに見かける#dup#freezeのメソッド呼び出しも不要になります。

関数型言語に限る機能ではなく、それぞれのオブジェクト指向言語にも普及しつつある機能であって、Ruby でもこれからStringが完全にイミュータブルになることから見て、Ruby では将来的にそのデータ構造が公式的に導入されるかもしれません。

パターンマッチング

Ruby では値を比べるには==はもちろん、case when endで使われる===もあります。 ただし、そのメソッドで値が同じであるかどうかを簡単に判断できても、データの一部だけ(いわゆるデータのパターン)を簡単に比べるのはもっと難しいです。 その問題を解決するため、データの比較をより汎用的に行えるように、パターンマッチングという機能が存在します。

パターンマッチングでは、比較したいパターンを定義することで、データがパターンにマッチした場合、もしくはマッチしなかった場合の処理を指定できます。 パターンがいくら複雑でも定義可能なので、複雑なデータ構造の比較時に特に役に立ちます。 例のパターンとして以下のはどうでしょうか?

  • 値が配列で、最初の要素がハッシュである必要があるが、ほかの要素は気にしません
  • ハッシュに:foo:barのキーが必須ですが、追加で他のキーがあって問題にしません
  • :fooに対する値は 10 の整数である必要があります

Ruby でパターンマッチングを可能にする gem の中には以下のがあります。

モナド

Arrayに対して使える#map#flat_mapメソッドは人気でしょう。 ただし、Arrayでの使い方が言語を問わず一番普及しているとはいえ、#map#flat_mapは配列限定のメソッドではありません。

#mapはコレクションの各要素に対して任意な関数を適用して、その結果をもとの要素に置き換える処理を行います。 Arrayの場合、ここでいうコレクションは配列ですが、#mapを提供できるコレクションは他にもいろいろあります。 配列は 0 以上の要素を含むとして、Maybeは 0 か一つの要素を含むコレクションです。 フューチャーでしたら、将来的に一つの要素を含むことになりますが、現在はまだないかもしれません。 全然違うものに見えるかもしれませんが、以上のものはすべて要素を含むコレクションとして一緒です。 そのコレクションでも#mapは含まれてる要素に対して任意の関数を適用する処理を行うことになります。

#flat_mapはコレクションの各要素に、その要素を引数にしながら、同じ種類のコレクションを返す関数を、適用する処理を行います。 関数から返された各コレクションを、コレクションの種類によって、連結したり、そのまま返したりします。 #flat_mapと一緒に使われる時、最終的の結果を変更しない関数も使用可能な場合は(以下は#pureと呼ぶ)、#map#pure#flat_mapだけを使って実装可能です(配列の場合はpure = ->(x) { [x] })。 #pure#flat_mapを提供するデータタイプ(コレクション)はモナドと呼ばれています。

#map#flat_mapのいいところの中には以下のがあります。

  • 高階関数なので、再利用性は高いですが、その中でも#map#flat_mapはかなり根本的な関数なので、それをベースに、何のコレクションにも対応する関数を、実装できます。
  • #map#flat_mapは通常の実装で全域写像(total function)なので、渡された関数も全域写像であれば、どんなコレクションに対して適用されても、エラーになりません(Maybeのコレクションで#map#flat_mapを使えば、nil のチェックは完全に不要になります)。

モナドを本格的に使うには協力な型を提供する静的型付けな言語が大体必要になりますが(Haskell など)、簡単なユースケースなら、Ruby でも以下のライブラリーなどでモナドを使えます。

言語とライブラリーによって#map#flat_mapは違う名前になったりします。他によく使われる名前としては fmap(map)と bind(flat_map)はあります。

終わりに

プログラミング言語の世界では、最近の一つの流れとして、今まで関数型言語にしかなかった機能が少しずつ他の言語でも採用されるようになってます。 Ruby ではかなり以前から高階関数を使えますが、もっと最近な例として Java 8 があります。新しい言語なら、Swift と Rust もあります。 当分はその流れはおそらく止まらないので、これからも関数型プログラミングの機能がどんどん普及して、もしかすると Ruby の方にも新しい機能が現れるかもしれません。 そうなれば、今以上にも Ruby関数型プログラミングのいいところを活かせるのでしょう。