さて、以前、Pythonでfunctools.reduceは非推奨なんかじゃない、と言う話を書いた。
もし、非推奨化したら、ハッカーはPythonを去るだろう、と予言した。
しかし実際問題、仮にfunctools.reduceが非推奨になってもハッカーは動じないだろうとは思う。と言うのも自分で書けるから、だ。
プログラミング言語の内部には色んなレベルがあって、正直言うと、ユーティリティレベルで何がどう変更されようと、一般にはユーザーにはそれほど影響はないんだ。関数は書ける。ユーティリティと言うのは、そのユーザーから関数を書く手間を開放する為、に存在してるんだ。でも無かったら無かったで書けるんで問題はない。
一方、プログラミング言語組み込みの機能はそうはいかない。例えばPythonが第一級関数を放棄したとすると、それはユーザーレベルではどうにもしようがない。何故なら第一級関数自体はユーザーがプログラミング出来るレベルには存在しないから、だ。いわゆる構文も同じだ。Python 3.10でmatch文が追加されたが、3.10以前でユーザーがmatch文を追加したい、と思ってもそれは出来ない。それは関数ではなく構文だから、だ。
「構文を追加したい」と思ってもそれを許すプログラミング言語、となると一般的にはLispくらいしか存在しない。「ユーザー定義構文」が存在するのがLispの強みだが、Pythonはもとより、一般的にプログラミング言語は、ハッキリ言っちゃえばユーザーが構文を追加するのを「禁止している」(※1)。
と言うわけで、functools.reduceが無くなったら無くなったで、第一級関数がある限り僕らはそれをプログラミング可能なんで問題はない。
そして、reduceの類に関数型言語ユーザーがコーフンするのは、「簡単な実装のクセに効果が絶大」だから、だ。効果が絶大なプログラムが必ずしも複雑な実装を要するわけではない、と言う好例なんだ。ハッキリ言っちゃえば「誰でも書ける」範疇のプログラムであり、そのクセ汎用性がデカい、って辺りにreduceの特徴がある。
また、reduceの類は、関数型言語に於いては「再帰の例題」として良く使われる。しかしながら、本質的な意味に於いては、別に繰り返し構文forを使って書いても構わない関数だ。
第一に、末尾再帰を最適化するプログラミング言語、あるいはその処理系を用いてる立場だと、実は感覚的には「繰り返し構文」と「末尾再帰最適化」には差がない。「形式の差」程度しか認識していないんだ。従って、Pythonのような「再帰が苦手」な言語だと、再帰で実装するよかfor文なんかで実装した方がメリットがデカいし、それに対しての苦痛は特に感じない。
僕なんかもPythonでは滅多にfor文は使わないんだけど、reduceなんかの基礎的ユーティリティを作成する場合には、for文辺りを使うのを厭わない。いや、何度も言ってるが、for文が低レベルなのは間違いないんだが、それを使った基礎的なユーティリティで得られるメリットを考えると、そういう「基礎的な部分の実装」で低レベル機能導入もやむなし、とか思うんだよな。基礎的ユーティリティでforのような低レベル機能を使っても、ユーティリティを得た事による抽象化のメリットは果てしなくデカい。言い換えるとPythonの「再帰が苦手」だと言う弱点をカヴァー出来る利点はトレードオフを考えても非常に大きい、んだ。そして、そういうトコじゃないと「ここで低レベルな機能を使わないでいつどこで使うんだ?」となる(※2)。
第二に、以前指摘した通り、reduceと言う関数の本質は非破壊的なfor文だ。reduceが分からん、黒魔術っぽい、と思ってる人たちにとって、for文でのreduce実装の方が分かりやすいだろう。再帰は魔術で、だからreduceは黒魔術だ、ってぇんじゃないんだ。forに慣れた人にはforで定義されたreduceの方が、読んで分かるし、使う際にも不安を覚えづらいだろう。このテの人には「大した実装じゃないんだ」と証明するにはforを使った方がいい。繰り返すが、再帰で実装するか反復で実装するか、ってのは本質的には大した問題じゃないし、違いはない。
仮にPythonからfunctools.reduceが消えたとしよう。僕なら嬉々として実装して個人的なユーティリティ・ライブラリにしちまうね(笑)。名前もfoldに変えちまう(笑)。
Pythonのfunctools.reduceの欠点は、平たく言うと、中途半端にANSI Common Lispの影響を受けたのか、初期値をオプショナル引数にした事だ。そのせいで明解性が欠けちまったんだ。
そもそもANSI Common Lispは「プロのエキスパートなエンジニア向け」に設計された言語、としての側面があって、「良く分かってる人向けに」色々とオプショナル引数を採用して隠してる事が多い。ただ、そういう実装方針は、元々「プログラミングのビギナー向け」を意識したPythonの実装には「合わない」んだ。初期値を必ず引数として入力するようにしてたら、今のように「reduceの使い方が分からない」と混乱するような事は・・・無かった、とは言い難いが、減ってはいたんじゃなかろうか。
ところで、Pythonでfoldを実装したとしよう。しかし、いつぞや見た通り、実の事を言うと、関数型言語界隈ではfold系関数は3種類程存在する。「畳み込み」と言っても「どう畳み込むか」が違うんだ。
1. fold(これが一般的にはフツーのreduceと同じ):
fold(function, z, [a, b, c, d]) の実行順序:
2. fold-right(リストを右側から走査する):
fold_right(function, z, [a, b, c, d]) の実行順序:
この木は下から実行されていく事を現していて、「演算順序」は変わらないが、リストを左から走査していくか、右から走査していくか、の違いを現している。
しかし、一般的に、関数型言語ではこれ以外にfold-leftと呼ばれる実装があり、それはfold-rightと木で見た時に完全に対称になっている関数で、演算順序さえ変わっている。
3. fold-left(fold-rightと対称な関数):
fold_left(function, z, [a, b, c, d]) の実行順序:
繰り返すが、fold-rightとリスト走査も逆、演算順序も逆、と言うのが関数型言語でのfold-leftだ。
fold系関数は図解すると上のダイアグラムのようになるが、Pythonではforを使って実装するのは極めて簡単だ。
1. fold:
def fold(function, value, iterable):
it = iter(iterable)
for element in it:
value = function(element, value)
return value
2. fold_right:
def fold_right(function, value, iterable):
it = reversed(iterable)
for element in it:
value = function(element, value)
return value
3. fold_left:
def fold_left(function, value, iterable):
it = iter(iterable)
for element in it:
value = function(value, element)
return value
現在のPythonのfunctools.reduceはリストではなくイテラブルを引数に取る、と言う事になってるが、実装上、リスト等のシーケンスを引数に取った時、一旦それをiterでイテラブルへと変換している。
まぁ、そこだけが「Pythonらしい」実装となるが、プログラム本体を見ると要は殆どがfor文の「作用」だけなんだ、ってのが分かるだろう。だから「非破壊的な」forが正体、だと言ったわけだ。
そして形式的な話をすると、この3つの実装は殆ど同じ、となる。
細かいトコを見ると、関数fold_right「だけ」が引数として与えられたリスト等のシーケンスをイテラブルへと変換する際に、reversedを使ってreversed iteratorにしてるトコ、あとは、fold_leftに於いて、for文内に於ける関数functionの引数の適用順序が逆だ、と言うトコだけが違う。
しかしながら繰り返すけど、形式的にはこの3つの関数は同じなんだ。
そうすると、「3つの関数を一つに纏められないか・・・?」とムラムラしてくるんだよな(笑)。
さぁ、どうする(笑)?
Pythonにはsorted関数があるが、昇順に並び替えるか、降順に並び替えるか、はキーワード引数reverseにTrueを与えるか否か、で切り替える事が出来る。これを真似しよう。
キーワード引数reverseにTrueを与えると引数iterableが逆順になる、とする。またキーワード引数reverseにFalseを与えるとfunctionに適用する2つの引数の順序が変わる、とする。デフォルト値はNoneとする。
これで、関数foldの書き始めは次のようになる。
def fold(function, value, iterable, /, *, reverse = None):
/とか*ってのはPythonで最近(多分?)出てきた表現だ。/と*の間では、フツーの引数とキーワード引数が混在出来、*以降にはキーワード引数しか置けない。関数foldは位置引数(function、value、iterable)以外のreverseはキーワード引数なんで、*以降に置く。
いずれにせよ、reverseがデフォルトのNoneだった場合、通常で言うreduceと同様になり、Trueだった場合iterableが逆順のイテラブルになる。
これを次のように表現する。
def fold(function, value, iterable, /, *, reverse = None):
it = (reversed if reverse else iter)(iterable)
何度も言うが、「Pythonでは関数はファーストクラスオブジェクトだ」と言う意味をキチンとつかもう。上の変数itの定義式で引数になってるのはiterableだ。そして前半の部分はキーワード引数reverseの値によって「iterableに適用する関数が切り替わってる」。reverseがTrueの場合、関数はreversedになり、そうじゃなければiterになる。
C言語的発想だと、この部分は
if reverse == True:
it = reversed(iterable)
else:
it = iter(iterable)
と書きかねないが、長ぇ(笑)。reverseが真偽値だ、って分かってる以上、Trueを使って等価判定する必要もないし、itに関数適用結果を代入する、なんつーのはダサい。
基本的に「同じ事をやるなら短い方が正義」なんだ。条件式と「関数がファーストクラスオブジェクトだ」と言う事を徹底して活用しよう。
次はこう書く。
def fold(function, value, iterable, /, *, reverse = None):
it = (reversed if reverse else iter)(iterable)
args = lambda value, element: (value, element) if reverse == False else \
(element, value)
変数argsの目的はfunctionを適用する2引数の順序を入れ替える事、だ。具体的にはreverseがFalseの時引数順序を入れ替える。
ただし、ここで気をつけないとならない事がある。デフォルト引数になっているNoneの扱い、だ。
Pythonのマニュアルには次のような記述がある。
None の真値 (truth value) は偽 (false) です。
確かにインタプリタで試してみると次のようになる。
>>> not(None)
...
True
そうすると、reverseをそのまま使うと、fold_leftと挙動を合わせる場合のFalseとデフォルト引数のNoneの差が分からない、と言う事になる。
しかし、等価判定を使うとNoneとFalseに差がある事が分かる。
>>> None == False
...
False
これを利用する。
argsはラムダ式を利用してるので、実際はこれは関数となる。
あとは、forで回せばいい。
def fold(function, value, iterable, /, *, reverse = None):
it = (reversed if reverse else iter)(iterable)
args = lambda value, element: (value, element) if reverse == False else \
(element, value)
for element in it:
value = function(*args(value, element))
これも何度か書いてるが、argsの先頭の*(アスタリスク)は「カッコを外す」アンパック演算子だ。これにより、argsのラムダ式が返すタプルのカッコを外し、functionへ与える2引数になる。
あとはvalueを返せば良い。完成形はこのようになる。
>>> from operator import add
...
>>> z = 'z'
...
>>> fold(add, z, ['a','b','c','d'])
...
'dcbaz'
>>> fold(add, z, ['a','b','c','d'], reverse = True)
...
'abcdz'
>>> fold(add, z, ['a','b','c','d'], reverse = False)...
'zabcd'
と言うわけで、仮にPythonでfunctools.reduceが非推奨になってもちっとも構わない。
ある意味、ここで見せたようなfoldの方が、functools.reduceより遥かに強力なユーティリティとなるだろう。
※1: PythonにはPythonの構文木を表示するASTモジュールと言う機能があるにはある。
これは、Pythonが式を評価する際に、どういう構文木を生成するか教えてくれる機能だ。
>>> import ast
>>> print(ast.dump(ast.parse("2 + 2"), indent = 4))
Module(
body=[
Expr(
value=BinOp(
left=Constant(value=2),
op=Add(),
right=Constant(value=2)))],
type_ignores=[])
上の例だと、2 + 2と言う計算式がPython内部でどういった抽象構文木(AST: Abstract Syntax Tree)に変換され、evalに手渡される形式になってるのかを表示する。
言わば、Lispのマクロはこの部分を自在にユーザーが弄りまくって構文を作れる機能なんだが、一方、Pythonで「文字列として表現した式を」ASTに変換した上、それを弄りまくってPythonのevalに手渡すように設計する、なんつーのは至難の業となる。
一般的にはそれは「不可能」だろう。
また、実はここで言うASTはLispでは例に従うとそのまま(+ 2 2)であり、Lispプログラミングとは、ASTを直接ユーザーがプログラミングしてる事に他ならない・・・Pythonだと上で表示されたような「ややこしい」ASTを内部的に使ってるのに、LispのS式はシンプルにそのままASTなんだ。
言い換えると、Lispではフツー、プログラミング言語では隠されているASTがそのまま剥き出しになっている。
※2: こういう「判断」はLispのような「真に高級言語」だと滅多に出てこないが、他の言語だとたまに顔を出す考え方だ。
例えば悪名高いgotoでも、基礎的ユーティリティを書く際に、「使わざるを得ない」場合使った方が良い、ってのはあるだろう。単にフツーにプログラムを書いてる場合に、あっちこっちで濫用するべきじゃない、って事なんだ。プログラムの表層部分では高度に抽象化した関数を使うべきだが、「根幹の部分」で、例えば速度が必要だ、なんて場合は原始的かつ低レベルな「機能」を使う事は悪い事じゃない。なおかつ、そういった「基礎的」ユーティリティはそれこそ「アチコチから呼び出される」可能性がいっつも高いんで、最適化を考慮した場合、「使うべきではない」と言われるgotoを「敢えて使う」と言うのは悪い作戦ではなくなる。