記事の内容に一区切りついたので、演習に加えて本コラムを挟むこととしました。今まではPythonを、順番に命令を並べて制御する「手続き型言語」として使ってきましたが、以後は「オブジェクト指向言語」として使いはじめます。
オブジェクト指向型言語とはなんぞやという話は次回以降に譲り、今回は手続き型言語、オブジェクト型指向言語に並んでよく使われる「関数型言語」について取り扱いたいと思います。
Pythonも関数型言語の思想を一部取り込んでいるので、関数型がどのようなものか学ぶことで、新しい「関数型に近いPythonの文法」を理解しやすくなるでしょう。また、Pythonに限らずさまざまな言語で「関数型のメリット」を強く意識して自分のコーディングにルールを課すことで、コードがより頑丈なものとなるかもしれません。
いずれにせよ、関数型を知って損することはないと思いますので、気軽に読んでいただけたら幸いです。
プログラミング言語のパラダイム
何度もお伝えしているように、機械が「01」しか理解できない一方で、人間は「01」を理解しづらいため、機械と人間の間にプログラミング言語を挟むことで、機械を制御しやすくしているのでした。そして、そのプログラミング言語にはさまざまな種類が存在し、今まで取り扱ったC、Java、Pythonといった言語はそのうちの一握りです。
このさまざまなプログラミング言語は、いくつかの種類に分類することができます。人間が話す言語は、そのルーツによりいくつかに分類でき、同じルーツの言語同士はそれほど大きな違いがありません。たとえば、ラテン語をルーツとするヨーロッパ系の言語はそれぞれ似通っており、その文法の差異も、英語と日本語といった成り立ちが違う言語の差異に比べると、はるかに小さいです。
プログラミング言語もこれと同じで、「その言語がどのようにして開発されたか」といったことによって、いくつかの種類に分類できます。その大まかな種類は、
- 手続き型言語
- オブジェクト指向言語
- 関数型言語
であると一般的にいわれています(これ以外にも学術的な側面が強いマイナーな言語がいくつかあります)。
手続き型言語は、今までPythonでやってきたような「上から下にコードをベタ書きしていく」というスタイルで記述します。オブジェクト型指向言語はこの手続き型言語の進化系といえるため、両者は似通っています。
ただ、関数型言語は手続き型言語と異なる思想のものとで開発されているので、手続き型ともオブジェクト指向型とも、本来は似ているものではありません。実際には「完全に純粋」な関数型言語はあまりなく、強く手続き型言語の影響を受けていることが多いのですけど(笑)。
このプログラミング言語の分類関係を以下の図に記載します。
上記図では、オブジェクト指向型言語と関数型言語の間に「ハイブリット型言語」というものがありますが、これは便宜的にそう呼んでいるだけであり、実際はオブジェクト指向型言語に関数型の特徴を加えたものにあたります。副作用が少なく理論上並列化しやすい関数型のメリットをオブジェクト指向に持ち込むというのは、昨今のブームかもしれません。
手続き型言語からオブジェクト指向型言語への進化については次回以降に扱いますが、簡単にいってしまうと「コードを整理して大規模な開発を容易にするための改良」がされています。ただ、なぜオブジェクト指向型言語が、この期に及んで関数型の特徴を取り込むかというと、関数型には関数型ならではの強みがあるからです。
ボヤッとした話はこれぐらいにして、実際に今回のテーマのひとつである関数型の言語がどのようなものか見ていきましょう。
関数型言語の特徴
関数型言語にもいくつか種類があり、最古の最も有名なものはLispです。それ以外にもHaskelやOcaml、そのほかさまざまな言語がありますが、今回はErlangと呼ばれている言語を使って説明します。Erlang以外は、サンプル程度しか組んだことがないのでよくわかりません(笑)。
Erlangは関数型言語であるものの、比較的手続き型言語の文法に似ている言語です。関数型言語としての側面よりも、並列性や頑健性に優れている言語として有名かもしれません。具体的には、Actorモデルと呼ばれている「高速で資源競合が発生しにくいマルチスレッド」の仕組みをもっており、それを使って大量のスレッドを使うようなプログラムを作る場合に選ばれることが多いです。
たとえば、Twitterサーバー実装のコア部分に使われているという話を聞きます。ほかにも分散システムやメッセージング系の実装に使われることが比較的多いと思います。先ほどのHybrid型言語に分類されるScalaという言語のAkkaと似ています。
実際、私も大学の卒論でP2Pのノード間の接続トポロジの有効性を検証するためのシミュレータを書いたのですが、それにErlangを使いました。ノードに見立てた数千、数万のプロセスが、Network通信に見立てたメッセージングを大量に行うというコードを書きましたが、同じようなことをJavaの組み込みのスレッドライブラリでやる場合に比べると非常に簡単なアルゴリズムで実装できたことを覚えています。
さて、関数型の言語というよりも、Erlangへ話が脱線してきているので話題を戻しましょうか。関数型の特徴を一言でいってしまうと以下のようなものになります。
- 副作用が発生しにくいコード
- 関数になんでもやらせる
それぞれ簡単に解説してみます。
副作用を減らす
言語によって変わってくるかと思いますが、副作用を減らすための実装で一番有名なのは「変数に再代入できない」というものではないかと思います。
たとえばPythonだと、
>>> num = 5
>>> num = 10
というコードに問題はありませんが、Erlangだとこれと同等のことをするコードは以下のものとなります(Erlangのシェルを使っています)。
Eshell V6.3 (abort with ^G)
1> Num = 5.
5
2> Num = 10.
** exception error: no match of right hand side value 10
補足:
- 変数は大文字から始まる
- 文末の . は式の終わりを示す(最初はCやJavaの;と同じという認識でOK)
- 変数の型はPythonと同じく動的に決まる
再代入をしようとしてエラーが出ていますね。
正確には代入というよりもパターンマッチの機構に起因してエラーが発生しているのですが、「Erlangの変数には値を1度しか代入できない」と思っていただければいいと思います。変数Aの中身を変更したい場合は変数Bを新しく用意してあげる必要があります。
変数を使いまわせないというデメリットもあるのですが、これは変数の中身が常に一定であることを保証するというメリットがあります。たとえば「意図しない箇所から内容を書き換えられる(バグ)」は発生しないので、自分が一度設定した値は最後まで残り続けます。そのため、デバッグなどもしやすい場合が多いかと思います。
関数になんでもやらせる
「関数型」という名前からわかるように、関数型言語ではとにかく関数になんでもやらせます。たとえば、関数を変数に代入したり、高階関数やクロージャーといった概念を多用します。
CやJava(最近のものは別ですが)では、そもそもこういった概念は使えないか、非常に使いにくいものとなっています。PythonやJavaScriptといった「スクリプト系」の言語はこれらの概念も利用できますが、あくまでもオマケ的な側面が強いです。関数型はこれらの機能を前面に押し出してきているように感じます。
たとえば、数列から偶数を見つけるプログラムを書くとしましょう。Pythonだと次のようになると思います。
even_list = []
for i in range(10):
if(i % 2 == 0):
even_list.append(i)
print(even_list)
これがErlangだと、たとえば以下のように書けます。
1> Is_even = fun(X) -> X rem 2 =:= 0 end.
#Fun<erl_eval.6.90072148>
2> Even_list = lists:filter(Is_even, lists:seq(0, 9)).
[0,2,4,6,8]
このErlangのコードがなにをやっているかというと、1行目で引数Xが偶数か奇数かを判定する関数を作り、2行目でそれを[0,1,2..,8,9]というリストに適用して判定がTrueとなった要素だけを取り出しています。なんというか、Pythonの手続き型のコードと全然違いますね。関数でリストをフィルタしてあげています。
長々とErlangの説明をするのも連載の趣旨から外れるのでこのあたりで切り上げたいと思いますが、要するにfor文などでデータを制御するのではなく、データ構造に対して制御を適用するような処理をします。個人的な見解ですが、手続き型言語は「制御ありき。データは二の次」というような思想があり、関数型は「まずデータがあり、それに処理を適用」というような思想があるように思えます。
もしErlangに興味があれば、私が学生の頃に作ったサイトでも一読していただけると幸いです。更新は見事に止まっていますが(笑)。
Pythonの関数型に近い側面
さて、長々と関数型言語について書いてきましたが、話題をPythonに戻しましょう。実は先ほどもさらっと伝えましたが、Pythonなどのスクリプト系言語は、関数型に近いことがある程度できます。ずいぶん前の関数の回で「高階関数」と「クロージャ」についてお話しましたが、それも本来は関数型の概念です。今回はその2つ以外の関数型に近い機能を紹介してみたいと思います。
ここでは、
- lambda式
- 関数を適用するリスト処理(map, filter, reduce)
- リスト内包表記
について扱います。
なお、これらは初心者が必ずしも使いこなせる必要がある機能ではありません。関数型では標準的な機能ですが、手続き型やオブジェクト指向型では実装されていない場合もあります。
lambda式
今までの関数は主にdefを使って宣言してきましたが、使い捨ての関数に関しては特に名前を付けずに「無名関数」として利用すればよいです。lambda(ラムダ)式は無名関数を作る記法のひとつです。
lambda式は「lambda 引数: 式」という形で関数を書きます。defによる関数の宣言と異なり、関数名がないことがわかりますね。だから「無名関数」なのです。
実際に利用してみます。
adder = lambda x,y : x + y
print(adder(5, 10))
xとyの2つの引数をとり、その合計値を返す関数を作っています。そしてそれを変数adderに代入しています。
lamdaが生成する関数も「値」ですので、リストなどに格納することもできます。それほど使い道はないかもしれませんが、以下のように複数処理を連続で実施したい場合にときどき使います。
funcs = [(lambda x,y:x+y), (lambda x,y:x-y), (lambda x,y:x*y),(lambda x,y:x**y)]
for fun in funcs:
print(fun(5,10))
個人的にはPythonではlambda式はそれほど多く使いません。上記のような簡単な関数をその場限りで使うのにおいては便利ですが、そうでない限りはdefで宣言し、それを必要に応じて呼び出すことのほうがわかりやすいかもしれません。
たとえば上記の2番目のlambdaのサンプルは以下のように書き直せます。
def fun1(x,y):
return x + y
def fun2(x,y):
return x * y
def fun3(x,y):
return x ** y
funcs = [fun1, fun2, fun3]
for fun in funcs:
print(fun(5,10))
defを使うと不必要に宣言数を増やしてしまいますし、lambdaで複雑な処理を書くと非常に見づらいです。それぞれのメリット、デメリットを考えて適切に利用する必要があります。一般的には処理の内容が複雑であればdefで宣言し、簡単な場合はlambdaでその場で利用する形になるかと思います。
ちなみに、defで宣言した関数もlambda式も、関数型と判定されます。
def check_even(x):
return x%2==0
fun = lambda x:x%2==0
print(type(check_even))
print(type(fun))
# <type 'function'>
# <type 'function'>
lambda式はこれからお話するfilter、map、reduceや自作の高階関数、もしくはGUIのライブラリなどで使うことが多いです。
filter
Erlangでリストに対してfilterをかける処理を紹介しました。同じことをPythonで実行することもできます。
まず最初にfilterを扱います。これはErlangの例と同じように、ある特定のリストに対して関数を適用すると、関数がTrueを返す要素のみのリストを返すというものです。
さっそくコードを示します。
fun = lambda x:x%2 == 0
num_list = range(10)
even_list = filter(fun, num_list)
print(even_list)
# [0, 2, 4, 6, 8]
filter関数の第一引数に、
- 引数をひとつ受け取る
- Boolの返り値を返す
lamda関数を渡し、第二引数にリストを渡します。filterは第二引数のリストの各要素に対して関数を適用し、Trueとなるものだけで構成されるリストを返します。
なお、上記サンプルは本来であれば以下のように一行で書くのが普通です。一行で書ききれないならlambdaは使わないほうがいいかもしれません。
even_list = filter(lambda x:x%2 == 0, range(10))
filterを適用してももとのリストには影響はありません。そのため、副作用のないコードとなります。
map
次にmap関数です。こちらはリストの要素に関数を適用していくというものです。やっていること自体は難しくないので、こちらも例を示します。
num_list1 = range(10)
num_list2 = map(lambda x:x*2, num_list1)
print(num_list1)
print(num_list2)
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
第二引数のリストの要素に対して、第一引数に渡された関数を適用し、関数の返り値をリストにして返しています。
reduce
最後にreduceです。これは「たたみ込み」と呼ばれる処理で、別言語だとfoldなどという呼び方をしているかもしれません。mapやfilterに比べるとちょっと複雑で、それほど利用場面は多くないと思います。
foldの処理概念を以下の図に記します。
図を見てもらうとわかるように、リストの「要素N番目の処理結果をN + 1番目で利用」ということをリストの先頭から末尾まで繰り返していき、最後の処理結果を返すというものです。
書き下すと、
- 1番目と2番目の要素を使いAを得る
- 3番目とAを使いBを得る
- 4番目とBを使いCを得る
- 最後の要素である5番目とCを使いDを得る
- Dを返す
というような動きになります。
プログラムもfilterやmapと同じように、関数の定義をリストに適用します。ただ、関数の引数が2つになっているのが今までと異なる点です。
a = reduce((lambda x,y:x+y), range(1,7))
print(a)
# 21
リストの中から何かひとつの要素を選ぶというような使い方にも便利で、そのようなときは以下のようなコードとなります。今回は「2値を比較して大きい方を返す処理」を繰り返して最大値を選ぶという処理をしています。なお、ランダムな数値のリストを作成するためにmapを使っています。こういう使い方も便利かもしれません。
import random
def get_bigger(x,y):
if(x>y):
return x
else:
return y
random_list = map(lambda x:random.randint(0,100),range(9))
a = reduce(get_bigger, random_list)
print(random_list)
print(a)
# [43, 12, 70, 45, 24, 11, 16, 92, 59]
# 92
mapもfilterと同じく、もとのリストには変更が加えられていません。そのため副作用の少ない関数です。
リスト内包表記
最後にリスト内包表記を扱います。リスト内包表記はリストを生成するための特別な書式です。これもmapやfilterと似た使い方をします。
まずmapに近い使い方です。先ほどの2倍にするmap処理をリスト内包表記で書くと、以下のようになります。
list1 = range(10)
list2 = [x*2 for x in list1]
print(list1)
print(list2)
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
2行目がリスト内包表記ですが、よく見るとfor文の使い方に似ていますね。"for x in リスト"とすると、xにリストの要素が入ってループを回します。その各要素xに対して"x * 2"という処理をしてリストを作成します。
リスト内包表記が優れているところは先程のmap処理に加えて、同時にfilter処理もできることです。たとえば偶数だけ抜き出し、それを2倍にするということもできます。
list1 = range(10)
list2 = [x*2 for x in list1 if x%2==0]
print(list1)
print(list2)
# [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# [0, 4, 8, 12, 16]
先ほどの例とほとんど同じですが、"for x in list1"の後にif文が追加されているのがわかりますね。このif文がTrueとなった要素だけ、リスト作成の対象となります。
パッと見でリスト内包表記は難しく見えるかもしれませんが、以下のような構造になっていると思えば、理解しやすいかもしれません。
ちなみにリスト内包表記は、通常のループ文よりも高速に動作する場合が多いといわれています。これはfor文のようにScriptとして一行一行処理されるのではなく、Python内にあるバイナリでリストに対して処理を施すためです。興味があればベンチマークを取ってみてもいいかもしれませんね。
次回からオブジェクト指向について取り扱います。オブジェクト指向は、C言語プログラマがC++やJavaを学習する際の最大の難所だと昔からいわれています。思想としてのオブジェクト指向と文法としてのオブジェクト指向を両方マスターしないと使いこなすことは難しいからだと思います。
何週にもわたってオブジェクト指向の話が続くので大変かもしれませんが、オブジェクト指向を表面的にではなく本質から理解できると世界が変わります。ぜひ、頑張って最後まで完遂してください。
執筆者紹介伊藤裕一(ITO Yuichi)シスコシステムズでの業務と大学での研究活動でコンピュータネットワークに6年関わる。専門はL2/L3 Switching とデータセンター関連技術およびSDN。TACとしてシスコ顧客のテクニカルサポート業務に従事。社内向けのソフトウェア関連のトレーニングおよびデータセンタとSDN関係の外部講演なども行う。 もともと仮想ネットワーク関連技術の研究開発に従事していたこともあり、ネットワークだけでなくプログラミングやLinux関連技術にも精通。Cisco社内外向けのトラブルシューティングツールの開発や、趣味で音声合成処理のアプリケーションやサービスを開発。 Cisco CCIE R&S, Red Hat Certified Engineer, Oracle Java Gold,2009年度 IPA 未踏プロジェクト採択 詳細(英語)はこちら |
---|