見出し画像

Retro-gaming and so on

Pythonのall()、any()、そして高階関数

ゴメン、正直言うと、前回の記事を書いた時、かなり腹が立っていた(笑)。
どうやらPythonicと言う(クソ)概念は関数型プログラミングと対立してるらしい(笑)。
グイドは去ったとは言え、じゃあ、現在のPython開発者グループはどう思ってんだろうか。関数型プログラミングを忌み嫌ってるのかね?
とてもそうとは思えない、ってのが正直なトコだ。
と言うのも、Python3.10以降登場して、僕も既に愛用者となっているパターンマッチ構文は関数型言語の機能だ。マジで関数型言語要素を追い出そう、としてるならまたもやHaskell/OCaml/Scheme辺りの機能を借りてくるのはおかしいだろう。
関数型言語の機能を追い出そうとしてるのに関数型言語の機能を追加する?
ホントにそんな事をやってるのなら間違いなく開発者達は分裂症気味だ(笑)。自分が何を追加しようとしてんだか分かってない、って事になる。
もちろん、「返り値を持たない」match文は完全に関数型プログラミングをサポートしているわけではない。明らかにRustのmatchよか非力で設計が良くない。
しかしそれでも便利なんだ。ハッキリ言おう、関数型言語は手続き型言語、そしてオブジェクト指向言語より遥かに便利な機能で溢れている。
関数型言語は遥か先に進んでるんだ。
しかし、今後の恐怖の展開は、Pythonicな奴らやC言語脳の奴らはこれを

C言語のswitch文と同じもの

と捉え、Pythonのニューカマーにそういう観点で色々と言う事、だ。
冗談じゃねぇ。
悪い事言わねぇから、F#のmatch式の使い方でも見て、学んで欲しい。
マジだわ。

さて、ところでだ。
前回の記事で紹介したが、Pythonにはこれらの関数型言語から借りてきた機能がある。
  • 第一級関数
  • 再帰
  • ラムダ式による無名関数
  • イテレータとジェネレータ
  • functoolsとitertoolsと言う標準モジュール
  • map(), filter(), reduce(), sum(), len(), any(), all(), min(), max()のようなツール
ここでまた、ポール・プレスコッドの言を思い出そう。

Lispに直接影響を受けたPythonの機能はPythonの歴史の非常に初期の段階で追加された。 Guidoは当時、言語設計者として始めたばかりで、 だから「NO」という習慣をまだ身に付けていなかったんだ。 人々が、他の言語からこんなクールな機能を持って来れるよと言ったら、 彼はそのもたらす結果をあまり考えずに取り込んでいた。 最初の数年間、それらの機能は非常に浮いていた。

いや、ポール・プレスコッドが言いたかった事自体は良く分かるんだけど、実の事を言うとこの部分はかなりおかしい
どこがおかしいのか、と言うと、浮いていると言う意見だ。
前回見た通り、Python3.xに於いてグイドは次のように計画してた。

グイドはPython 3からmap(), filter(), reduce(), そして lambda さえ除く事を計画していた。

これは別に「そんなバカな!」な計画ではないと思う。なんせリスト内包表記があるから、な。Haskell由来の最強構文の一つだからな。
ところが、じゃあ、map(), filter(), reduce(), そして lambdaを削除した、としよう。そうすると、実は上に挙げた「関数型言語由来の機能」のうち、こいつも必要なくなるんだ。

  • 第一級関数
このブログでも何度か言ってる「Pythonでは関数はファーストクラスオブジェクト」だ。こいつが必要なくなる。
何故なら、Pythonのリスト内包表記の構文では、全く「第一級関数」と言う機能は使ってないから、なんだよ。
例えば、

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

の場合、計算に使われてる「式」はx**2の部分で、ここは別に「第一級関数」である必要性がない、って事だ。
仮にdef foo(x): return x ** 2だとしてみよう。

squares = [foo(x) for x in range(10)]

fooにxが与えられている以上、ここでは「第一級関数」としての役目じゃない。
これは明らかに、mapに与えるコールバック関数とは形式的には変わっている。

squares = list(map(foo, range(10)))

この場合、fooは「第一級関数」としての機能で使われている。いわゆるクロージャだ。fooは何らかの引数を与えられて無くても、mapの中でその機能を全う出来るわけだ。
グイドが仮にmap(), filter(), reduce(), そして lambdaを捨てるとすれば、Pythonでは関数はファーストクラスオブジェクトである必要がない、って意味になるんだ。

ちょっとここで、「Pythonでは関数はファーストクラスオブジェクトだ」と言う意味が分からない人もいるかもしれんので、軽く解説しておく。
これは、正直言うと「カッコ付けた」言い方なんだけど、「関数がファーストクラスオブジェクトだ」と言う事は、関数自体がデータだ、と言う意味なんだ。
言い換えると、

  1. 関数は変数に代入出来る。
  2. 関数は別の関数の引数にする事が出来る(いわゆるコールバック関数)。
  3. 関数は別の関数の返り値にする事が出来る(クロージャ等)。
と言う性質だ。
つまり、プログラミング言語には「関数がファーストクラスオブジェクト」の言語と「関数がファーストクラスオブジェクトじゃない」言語の二種類がある。
ここではちと1を見てみよう。
例えばPythonで次のような関数を書く。

def square(x): return x * x

引数xを二乗して返す関数だよな。当然次のようにして使うわけだが。

>>> square(3)
9

まぁ、これはフツーなんだけど、ところが「関数がファーストクラスオブジェクトである」プログラミング言語だと、この関数「自体」を変数に代入可能なんだ。

s = square

今、変数ssquareと言う「関数」を代入した。じゃあ、sは関数なのか、変数なのか。
少なくとも、sは「変数」を想定してた筈だが、一方実はこういう事が出来る。

>>> s(3)
9

なんと、変数sは「関数として機能している」。s自体は「関数として定義されたわけでもない」のに。
また、ラムダ式を使うと次のような事も出来る。

>>> square = lambda x: x * x

今度は変数squarelambda x: x * xを代入してる。さて、squareは変数なのか関数なのか。
表面的には変数だろう、が「機能」は実はやっぱり関数になってんだ。

>>> square(3)
9

あれ?って思うだろ?じゃあdefってキーワードを使った「関数定義」って一体何なんだ、と。必要なくね?と。
そう、だからいつぞやも書いたが、Schemeと言う言語では名前空間が一つで、関数がファーストクラスオブジェクトの場合、関数定義と変数定義は全く同じで構わない、と言う結論を出した、んだ。
理論的にはそうなる、んだけど、あくまでPythonとして考えた時には、だ。問題はこんな事してぇのか、と(笑)。
関数を変数に代入出来て、その変数を「あたかも関数のように使う事が出来る」・・・嬉しいか(笑)?
そう、結論から言うと、Pythonの関数がファーストクラスオブジェクトな理由の半分以上は、map(), filter(), reduce(), なんかの高階関数のコールバック関数にする為、なんだよ。
言い方を変えると、map(), filter(), reduce(), が無ければPythonで関数がファーストクラスオブジェクトにしている意味がない、んだ。
つまり、だ。ここで恐るべき事実が浮き上がる。map(), filter(), reduce()を削除しろ、って言ってる人達はそもそも「関数がファーストクラスオブジェクトだ」と言う意味が分かってない。そして高階関数も分かってない、って事になる。
人によっては、

「いや、高階関数なんて知らんし。C言語使ってるし。そんな関数型言語の機能なんざ知らんし。」

とか言うかもしんない。
いや、多分そういう人・・・いや、C言語脳は言うだろ。そもそもC言語脳は「C言語を使う」けど、別にC言語のエキスパートではないんだ。
だからこういう事を言う。
しかしC言語のエキスパートは高階関数の意味を良く知ってるんだよ。何故なら彼らは常に関数ポインタを使って似たような事をしてるから、だ。
つまり、「C言語を使ってる」人にも関数ポインタを全く使わない人と徹底して関数ポインタを使ってる人がいて、後者は如何にC言語の関数ポインタがクソメンド臭いか良く知ってるわけだ(笑)。だからこそ「関数がファーストクラスオブジェクトである」利点も良く分かる。
なんせCハッカーってGLIB書いてるような連中だぞ?アレ見ると、如何に関数がファーストクラスオブジェクトじゃない言語(つまりCだ・笑)でそれっぽいライブラリを書くのに苦労してんだか良く分かるんだよ(笑)。
そんな連中がPythonの高階関数を見て浮いているなんて思うわけねーだろ、ってな話だ(笑)。
ポール・プレスコッドやPythonicな連中はどっちなんだろ。Lisp臭いから浮いている、なんて思うのなら浅薄で、C言語であんま関数ポインタを使ってないのか。あるいは、「C言語では関数ポインタは使わないようにしよう」宗派でもあるのか(笑)。
あるいは単に、高階関数、と言う存在が理解できずに受け入れられないのか。
色々マジで考えると不可思議な意見、なんだわ。
謎だ。

ここで「文系プログラマーのためのPythonで学び直す高校数学」でも取り上げられてた数値積分の区分求積法をちと見てみよう。

 
ここではWikipediaの記事を元として話を進めていくが、「区分求積法」自体の発想は簡単だ。ある関数(ここでは√x)とx軸が境界線を成す範囲で、なおかつ(Wikipedia上の例では)0 ≦ x ≦ 1の範囲の面積を求めるには、なるたけ多くの(高さが違う)長方形
を敷き詰めて、その総和を取れば「近似出来る」っつー話だ。

ここでは、Wikipediaに書かれているように、y = √xを0≦x≦1の範疇で5等分して計算する事を考える。


これはPythonだとこうなる(括弧の中は全部1/5になる事に留意)。

>>> from math import sqrt
>>> from fractions import Fraction
>>> sum([sqrt(Fraction(i, 5)) * Fraction(1, 5) for i in range(1, 6)])
0.7497385975550066

当然、5分割より多く分割すれば精度が良くなるわけだが、このように分割数を5と固定せずにn、と外部から与えたい場合は関数化した方が良い。

def calc_area(n):
 from math import sqrt
 from fractions import Fraction
 return sum([sqrt(Fraction(i, n)) * Fraction(1, n) for i in range(1, n)])

例えば10分割や12分割を狙うと次のようになる。

>>> calc_area(10)
0.6105093417068175
>>> calc_area(12)
0.6202883614393754

分割数を多くすればするほど、ここの定積分の理論値の解、2/3(0.6666666666666666...)に近づいていくわけだ。
しかし上記の「文系プログラマーのためのPythonで学び直す高校数学」と言う本では、ここでプログラムを書く事自体は終了しちまって、あとはscipyにぶん投げるわけだ(笑)。
上の関数は今の時点次の問題がある。

  1. 求積の範囲が0 ≦ x ≦ 1と固まっている。
  2. 求積に使う関数が y = √x固定だ。
要は汎用性がサッパリない、わけだよな。
特に、「文系プログラマーのためのPythonで学び直す高校数学」と言う本ではy = xと言う関数とx軸、あとは0 ≦ x ≦ 1と言う範囲での求積をしてるだけで、全く発展性がない。つまり「別の関数を対象にしたい場合」はプログラムを1から書き直さないとならない。
それじゃ困るだろ、と。

そもそも、これも何度も言ってるが、数値計算と言うプログラミングは、別段何か工夫するような必要はなく、「(数式に)言われたままに」プログラミングすれば済むジャンルだ。何も考える必要はない。必要なのは、敢えて言えば「数式の読解力」なんだけど、一方、「数式の読解力」と言うのは数学力と実はそれほど関係はないんだ。
言っちゃえば、「英語」の理解力と同様の「言語の読解力」が必要なだけ、なんだ。数式を粛々とプログラミング言語の「表現」に置き換える力があれば、取り敢えずは充分だ。
それでも「難しい」って思うかもしれないけど、言っちゃえば、「暗号解読力」、もっと言えば「パズルを解く能力」の方が重要だ。
上の区分求積法を数式で敢えて書くと次のようになるだろう。



今、単純に言うと関数Fnを書け、と言う問題になってるわけだが、右辺に注目して欲しい。そこにはまた別の関数f(xi)がある。数式をそのまま読むと、実は区分求積法は高階関数として実装せよ、と言ってるわけだ。数学はそう言ってる。
従って「何も考えずに」プログラムをPythonで書くと次のようになるだろう。

def integ(a, b, n, f):
 delta_x = (b - a) / n
 return sum([f(a + i * delta_x) * delta_x for i in range(n)])

たった3行で区分求積法のプログラムは書けるわけだ(※1)。
しかも積分対象の数学関数fは任意、だ。つまりコールバック関数として与えられるfはどんな数学的関数でも構わない。

>>> integ(0, 1, 10, sqrt)
0.6105093417068175
>>> integ(0, 1, 12, sqrt)
0.6202883614393754
>>> from math import pi, sin
>>> integ(0, pi, 100, sin) # サインを0〜piの範囲で100分割で面積計算
1.9998355038874436

区分求積法の関数を高階関数として実装したお陰で、どんな数学的関数対象でも区分求積法が実行出来る。
これは何もPythonみたいに「関数がファーストクラスオブジェクト」で、「高階関数が実装出来るから」ではない。C言語脳なら毎回毎回違う数学的関数fに従って「それ専用の」区分求積法の関数を書くかもしんない。しかし、C言語エキスパートなら、間違いなく「関数ポインタ」を使って「汎用的な」区分求積法プログラムを書くだろう。C言語では「関数がファーストクラスオブジェクト」ではないとしても、だ。
ただし、このテの問題を解くのなら「関数がファーストクラスオブジェクト」で、結果、高階関数を扱える言語で書いた方がラクなんだ。そしてC言語脳な「文系プログラマーのためのPythonで学び直す高校数学」と言う本では、そこに読者を誘導しない。

ここで理解して欲しいのは、高階関数が苦手だ、って人もいるかもしれない。使えても書けない、とかな。
でもPythonicやC言語脳の奴らみたいになる必要はなく(もちろん、「なるべきじゃない」)、とにかく練習してみる事。そして、何度も繰り返すけど、「練習する」なら数値計算は結構良い題材だ。数学的だからムズい、って事はない。むしろプログラミングの中では一番簡単な部類、だ。上にも書いただろ?考える必要はない、ただ、数式で書かれてる通りに実装すれば充分、だからだ。
そしてこれも繰り返すが、今回の区分求積法のプログラムも、別に僕が高階関数を使う、って判断をしたわけじゃないんだ。数式がそうしろ、って書いてんだよな。一般的な数学的関数fを利用する以上そうならざるを得ない。逆に、その判断に逆らってプログラムに落とし込めば、数式が要求してるような汎用性がない、って事になるわけだ。
いずれにせよ、このテの数値計算は良い遊び場だ。言語の馴染みのない機能を試すにはもってこいの遊び場なんだよ。何故ならプログラム自体は「何か考えたり工夫する程」難しくとも何ともないから、だ。

でも数学だから、じゃないの?って思う人もいるだろう。高階関数は数学だから役に立ってるのか、と。
でも言っちゃうと、数学で書けないヤツはフツーのプログラムでも書けないよ(笑)。まずは、数値計算で高階関数を「使えるようになってから」出直して来い、とは正直思う(笑)。
でもそう言う意地悪を言わないとすると、いや、フツーのプログラミングでも高階関数は充分役立つ。
一般的には。もし、貴方が同じパターンの関数を二度書いたとする。それだけでフツーは「ユーティリティ化が必要だ」ってサインなの。昔から言うだろ?「二度あることは三度ある」とか「三度目の正直」とか。
結局、人類の歴史的には二度ある事は三度あり、三度あった事は四度あり・・・なんだよな。そのテの「繰り返しは絶対起こる」って警告なんだよ。つまり関数Aを書いてて構造的に似通った関数Bを書かざるを得ない・・・これは確実に「自分専用のユーティリティ」を書く機会だ、って事だ。そしてその「パターン」のうち、その関数内で使われてる関数を取っかえれば済む、って場合、それはそのユーティリティを「高階関数で書け」ってこったな。そうすれば色んな問題が即座に解決出来る、って事だ。「三度目の正直」では一から関数を書く必要がない。
また、これも繰り返すけど、以前、基礎的なユーティリティから派生的なユーティリティへの流れに付いて書いた。上流へ行けば行く程高階関数の比率が高くなる。
いや、もちろん、下流の下流っつーか傍流になると高階関数の必然性は落ちるよ。高階関数自体が抽象度が高い、そして汎用性が高い、からね。でも貴方自身がそういう「汎用性が高い関数を書く」と言う可能性を潰す必要はないでしょ?貴方がソフトウェア工学上のとんでもない発見をするかもしんない。そんな時には貴方は間違いなく高階関数を書いてるだろう。
「可能性は可能性」だ。でもそういう可能性をわざわざ自分で潰す必要性はないと思うんだよな。

とまぁ、ここまでは前フリで、だ(笑)。長ぇな(笑)。
Pythonicな奴らがこういう「汎用性を無視した」ユーティリティを書けばどうなるんだ、って話だ。
Pythonにはall()any()と言う関数がある。





これ、同種の関数に比べるとある意味凄い非力なんだよな。何故なら高階関数じゃないから、だ。
これらは、要は前提として、引数に与えるイテラブルは次のようになっているのを仮定している。

>>> all([True, True, True, True])
...
True
>>> all([False, True, False, False])
...
False
>>> any([False, True, False, False])
...
True

いや、ホンマ、単純に言うと、「真偽値だけのイテラブル」なんざフツーはあり得ないだろ、って事なんだよな。
つまり、これらは次のようにリスト内包表記やジェネレータと組み合わせて使われるのを想定してるんだわ。

>>> any([isinstance(i, int) for i in ['a', 3, 'b', 2.7]]) # リストに整数が一つでも含まれる?
...
True
>>> any(isinstance(i, int) for i in ['a', 3, 'b', 2.7]) # ジェネレータ式
...
True
>>> any([isinstance(i, int) for i in ['a', 3.1, 'b', 2.7]]) # リストに整数が一つでも含まれる?
...
False
>>> any(isinstance(i, int) for i in ['a', 3.1, 'b', 2.7]) # ジェネレータ式
...
False
>>> from operator import lt
...
>>> any([lt(*i) for i in zip([3, 1, 4, 1, 5], [2, 7, 1, 8, 2])]) # 一つでも不等式を満たす数字のペアがある?
...
True
>>> any(lt(*i) for i in zip([3, 1, 4, 1, 5], [2, 7, 1, 8, 2])) # ジェネレータ式
...
True
>>> all([isinstance(i, str) for i in 'abc']) # 文字列 "abc" の中身は皆文字列?
...
True
>>> all(isinstance(i, str) for i in 'abc') # ジェネレータ式
...
True
>>> from operator import 
eq
...
>>> any([eq(*i) for i in zip([1, 2, 3, 4, 5], [5, 4, 3, 2, 1])]) # 2つのリスト要素を順に見ていって同値がある?
...
True
>>> any(eq(*i) for i in zip([1, 2, 3, 4, 5], [5, 4, 3, 2, 1])) # ジェネレータ式
...
True

ん〜〜〜・・・Pythonic(笑)?
通常、こういう関数って、やっぱ高階関数として実装されると思うんだよな。
リスト内包表記やジェネレータ式と組み合わせる前提とか・・・個人的にはあまり美しくねぇ気がする(笑)。
この辺の機能だと、JavaのallMatch/anyMatchとかJavaScriptのevery/someとかC#のAll/Anyを使い慣れてる奴らでも混乱すると思う(笑)。この3つの言語では、形式が違っても「高階関数的」なんだよな(※2)。
Pythonはそうじゃない。まぁ、リスト内包表記やジェネレータ式があるから、って言い訳なんだろうけど、ある意味標準形式に反してるんだ。
効率性の問題もある。リスト内包表記だと、真偽値を算出する、って事はリスト的には最後まで計算される。一方、例えばanyなんかはシーケンスの一個目がTrueだったら即刻Trueを返していい。allなら一個でもFalseが見つかったらそれでFalseが確定だ。
要は「最後まで計算する必要がないのに」リスト内包表記で真偽値を判定する、ってことは「必ず最後まで計算する」事になる。
となると、引数でイテラブルを取る、って書いてるけど、実質上、ジェネレータ式を使え、って事なのかもしんない。高階関数なら気にせんでエエのに(笑)。
どうもこの辺がPythonの境界線、っつーか、ユーティリティとして見ると「ダメに片足ツッコんでる」気がするんだよなぁ(笑)。
それがPythonicって事かもしれない(笑)。
気をつけろ。

※1: 「たった3行で書ける」と言うのは、Pythonの抽象性の高さのお陰、ってのもあるけど、そもそも数式自体がシンプルだから、だ。
ここで分かって欲しいのは、「必要なのは数学力ではなく、暗号解読力なりパズルを解く能力だ」と言う意味だ。
例えば元の数式でΣと言う記号が出てくる。「やべ、俺、高校時代、数学のΣが苦手だった・・・」じゃないんだ。貴方に求められているのは計算力じゃなく、Σ、って記号が出てきた時点で「Pythonのsumを使えばいいんだな」と言う判断力だ。そしてΣの上やら下に付いてるモノに関してもPythonのrangeと比較すれば意図はすぐ分かる。
また、f(xi)xiに対してもxn = x0 + nΔxと書かれてるし、x0 = aだ、とまで書いてるので、従ってf(xi)は必然的にf(a + n * Δx)にならざるを得ない、ってのもすぐ分からなければならない。いや、「分からなければならない」と言うより「分かるように訓練する」と。それが読解力であって、繰り返すが、このテのプログラミングに必要なのは数学力じゃなくって読解力だ、ってのはそういう意味なんだ。むしろ貴方の言語解釈能力が問われている。
文系プログラマーのためのPythonで学び直す高校数学」をポンコツな本と評したのは、読者を、numpyやscipyの使用で誤魔化して、そういう読解力を身に付けると言うパースペクティヴが全く無かったから、に他ならない。
そしてそういう「数式の解読」を全く無視して、C言語的にforループをする、とか言う超バカな戦略(いや、戦略って言う程じゃねぇ・苦笑)を何の疑いも持たずに行った辺りを批判している。
だから、抽象度が高い、Pythonを使う必然性が全くない、って言ったんだ。

※2: Rustのall/anyも高階関数的だ。もちろん、リスト内包表記を借りてきてるHaskellのall/anyもそうだ。
  • Xでシェアする
  • Facebookでシェアする
  • はてなブックマークに追加する
  • LINEでシェアする

最近の「プログラミング」カテゴリーもっと見る

最近の記事
バックナンバー
人気記事