Pythonで関数型プログラミングのエッセンスに触れてみよう

関数型プログラミングって何?
Pythonでもできるのだろうか?

結論から書くと、Pythonで関数型プログラミングを行うのは大変です。

ですが、Pythonにも関数型プログラミングでよく使う機能や関数を提供してくれるライブラリがあるので、雰囲気を楽しむことはできます。

こんにちは、ライターのフクロウです。Pythonのインストラクターをしています。

関数型プログラミングが流行っている昨今、Pythonの勉強をしている方々の中にも、使ってみたいと思っている人は多いと思います。この記事でPythonから使える関数型プログラミングライブラリtoolzを通して、関数型のエッセンスを学びましょう。

この記事はこんな人のために書きました。

  • Pythonのif文、for文、関数などの使い方が分かる人
  • Pythonを使って関数型プログラミングを学びたい人

なお、Pythonの記事については、こちらにまとめています。

目次

関数型プログラミングのエッセンス

関数型プログラミング、最近よく聞く言葉ですが中々定義が分かりづらいです。関数型を紹介する文書でよく言われるものとして「状態を扱わない」とか「副作用を持たない」とかというものがあげられます。

関数の中で変数のもつ値の書き換えが起こらないような関数を純粋関数と良いますが、このような関数を使うことのメリットとは何でしょうか。

ある入力に対して返ってくる値が決まっている事(=参照透明性)ことで、バグが発生しづらく再利用がしやすい関数が作れます。また、一つの変数がもつ値が変わらないので「今この変数が持っている値はこれかな?」のように考える必要がなくなりますね。

このような強力な制約がある代わりに、プログラムは見通しがよいものになります。関数型が万能だとは思いませんが、考える必要がある事、覚えておく必要がある事が減ることで、必然的にバグの少ないプログラミングができそうです。

関数型プログラミングでよく使われる機能

関数型プログラミングでよく使われる機能として、以下が思い浮かびます。

  • 高階関数:関数を受け取ったり関数を返したりする関数
  • カリー化:関数に部分適用ができるようにする
  • 合成関数:関数同士を合体させて新しい関数を作る

これ以外にもたくさんあるのですが、Pythonで手軽に使えるものと言ったらこのあたりでは無いでしょうか。

さて、これらをPythonで手軽に使えるライブラリ「toolz」を使って、これらの機能がどういうものなのか見ていきましょう。

ライブラリのインストールとインポート

toolzのインストールはpipで行います。また、toolzをCythonで実装して高速にしたものに「CyToolz」があります。こちらも同様にインストールできます。

!pip install toolz
!pip install cytoolz

インストールが終わったら、pythonのモジュール内やJupyter上でimportしてみましょう。

import toolz

実際に使うときは、toolz内の関数を個別にimportした方が使いやすいはずです。今回はtoolzの機能がどの関数なのかについても見てもらいたいのでこのようにしています。

合成関数

a(b(x))のように、複数の関数を重ねたものを合成関数と呼びます。この例ではaとbは関数で、xはbの入力値です。b(x)の返り値がaに入って、aの返り値が最終的に返ってくる形ですね。

toolzで試してみましょう。

## In[]:
a = lambda x: x *2
b = lambda x: [x]

f = toolz.compose(a,b) # a(b(5))
f(5)


## Out[]:
[5, 5]

compose関数は受け取った関数を合成関数にしてくれます。注意してほしいのは引数の順番です。(a,b)という順番で渡したらb(a(x))となりそうですが、そうはなっていません。

5を無名関数のbを適用したあとにaを適用した値が返ってきています。

また、近い機能を実装した機能にpipeがあります。こちらは先程の期待した順番で関数を適用します。

## In[]:
toolz.pipe(5,a, b,) # b(a(5))


## Out[]:
[10]

まず引数を二倍にしてから、これをリスト型にして返していますね。pipeはscikit-learnを始めとした色々なPythonライブラリでも近い機能が同じ名前で実装されています。

それぞれのライブラリのpipeには、引数に取れる関数に制約があります。これを守ることで入力値に対して一連の関数を連続で適用する事が可能です。

この機能は、あとからコードを見たときに処理の流れが一目瞭然になる点が素晴らしいですね。

pipeに与える関数がちゃんと分かりやすくてシンプルな機能を持っていれば(そして分かりやすい関数名になっていれば)、コードの一行一行がそのまま処理の流れと対応していて理解しやすいはずです。

map

関数型プログラミングでは、for文を使うような部分でmapやfilterと言った関数が用いられることがあります。

mapはリストのすべての要素になにかの関数を適用する機能です。

実際にコードを見てみましょう。

## In[]:
toolz.pipe(
    map(lambda x: x*2, range(10)),
    list,
)

## Out[]:
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]

mapはmap objectというイテレータオブジェクトを返す関数です。そのまま結果を表示してみても<map object at 0x10f8d2c88>のような表示になってわけがわからないので、listに直してから表示してみました。

mapの第一引数には、第二引数の要素一つに適用したい関数を渡します。そうすると第二引数のすべての要素に対して適用した答えを返してくれるのがmapです。

これをPythonic(Pythonっぽく)書くと以下のようになります。

[x*2 for x in range(10)]

これはリスト内包表記という書き方で、こちらも慣れると分かりやすいと思います。

filter

filterはリストのすべての要素から条件にあった要素だけを取り出す機能です。

これもよく使われる関数で、関数型プログラミング言語を意識していない場面でも使ってほしい機能です。

## In[]:
toolz.pipe(
    filter(lambda x: x%2==0, range(10)),
    list
)


## Out[]:
[0, 2, 4, 6, 8]

ここでは第二引数(0〜9の整数を持ったリストです)から、偶数だけを取り出すfilterを作りました。

filterもmap同様にイテレータオブジェクトを返すので(これはメモリ効率を考えての実装だと思います)、listに直してから表示します。

これについてもリスト内包表記で書いてみましょう。

[x for x in range(10) if x%2==0]

リスト内包表記では、if文はitem for item in containerの後ろに書きます。これで先程のfilterと同じ動作になります。先程も書きましたが、この書き方は慣れると非常に分かりやすいはずです。ただ普通に考えると、filterを使ったほうが何がやりたいのか分かりやすいでしょう。(filterという関数の名前からやりたいことがすぐに分かりますよね)

この様に、map/filterと言った関数を使うことで、for文を使って書いていたような処理を一行にまとめることが出来ます。また、これらの関数を見ただけで何がしたいかが分かるので、読みやすい(可読性が高い)コードが書けそうです。

畳み込み関数

  1. リストから2つずつ要素を取り出して、何かの関数を適用する。
  2. 先程の戻り値とリストの次の要素を同じ関数に適用する。
  3. 上の処理をリストの要素がなくなるまで繰り返す。

このような処理を畳み込みといいます。リストの要素を左から取り出すか、右から取り出すかでfoldl、foldrと言った関数に別れている事が多いです。

toolzではfoldlに相当する機能がtoolz.reduceで実装されています。

## In[]:
toolz.reduce(lambda a,b: a+b, range(10))


## Out[]:
45

ここでは、すべての要素を足し合わせる機能を実装しました。つまりsum(range(10))と同じです。

この機能もmap/filterと同様によく使われるもので、いくらでも応用が効く基本的なものです。

これをPythonらしく書くと以下のようになります。

[https://docs.python.org/ja/3/library/functools.html#functools.reduce より]

def reduce(function, iterable, initializer=None):
    it = iter(iterable)
    if initializer is None:
        value = next(it)
    else:
        value = initializer
    for element in it:
        value = function(value, element)
    return value

reduce(lambda a,b:a+b, range(10))

もう少しわかりやすく実装してみましょう。(簡単のために、第二引数のリストの中身は整数型だけだと仮定します)

def _reduce(f, seq, init=0):
    result = f(init, seq[0])
    
    for item in seq[1:]:
        result = f(result, item)
    return result
        
_reduce(lambda a,b:a+b, range(10))

reduceの中身がわかったでしょうか。

このようにfor文を使えば、ここまでの関数を再現することはできます。しかしmap/filter/reduceを使うことで明らかにシンプルに書くことが出来ていることが分かると思います。

累積関数

次に紹介するのは、先程のreduceに近い機能です。

reduceでは計算の最終的な結果のみを返していましたが、今回は途中の計算結果もすべてまとめてリストとして返すようにします。

この様に累積した計算結果をすべて返す機能は、toolzではaccumulateとして実装されています。

## In[]:
toolz.pipe(
    toolz.accumulate(add, range(10)),
    list)


## Out[]:
[0, 1, 3, 6, 10, 15, 21, 28, 36, 45]

先ほどの_reduce関数で、for文の中で作っていた途中計算の結果がすべて返ってきていますね。これも応用が広いので使ってみてください。

カリー化と部分適用

最後に、関数の部分適用というものを試してみましょう。これには関数をカリー化する必要があります。

例えば2つ引数を取る関数があったときに、これを引数が一つだけの関数2つの合成関数だと考えましょう。このとき、1つ目の関数にだけ値を渡した状態を変数に束縛しておけば、部分的に引数を渡した状態の関数を作る事ができます。これが部分適応です。また、このように複数の引数をとる関数を一つの引数を取る複数の関数に分割することをカリー化といいます。

## In[]:
# 引き算をする関数をカリー化
sub_ = toolz.curry(lambda a,b: a-b)

# 10-bをするsub_10関数を作成(部分適用)
sub_10 = sub_(10)

# bとして3を渡してみる
sub_10(3)

## Out[]:
7

引き算関数の第一引数に10を渡した状態の関数をsub_10としました。この関数に3を渡したので、実際には10-3をやっていることになりますね。

出力値を見てみると7になっていることがわかるので、部分適用が出来ていることになります。もう少し例を見てみましょう。

## In[]:
# 10-4
sub_10(4)


## Out[]:
6

同様にちゃんと期待した動作ができています。

また、カリー化した関数を一行で実行してみるならば、引数を示す括弧は2つ使う必要があります。

## In[]:
# 3-9
sub_(3)(9)


## Out[]:
-6

関数の入力を一つにできるというのは、先ほど紹介したpipeやcomposeのような一連の関数をつなげる機能を使うときに特に力を発揮します。

複数の関数のパイプラインを作るには、ある関数の出力が次の関数の入力として使えなければいけません。こんなとき入力が1つだけだとわかっていれば、簡単に関数を組み合わせる事ができそうですね。

まとめ

この記事では、toolzライブラリを通してPythonで関数型プログラミングをするための機能を紹介しました。関数型プログラミングをするために使えそうな個々の機能を紹介しただけですが、中々に奥深いとは思いませんでしたか?

さて、Pythonで完璧に関数型プログラミングを行うのは難しいですが、部分的にならこの考え方を活用することは簡単です。

この記事で関数型プログラミングに興味を持ったら、HaskellやClojureなどの言語に挑戦してみてはいかがでしょうか。きっとあなたのプログラミング人生に大きな影響を与えてくれるでしょう。

この記事を書いた人

第一言語はPythonです。
皆さんRustやりましょう。

目次