Edited at

Pythonのビット和演算子( | ) を unixシェルなどでお馴染みのパイプ処理(ストリーム処理)にしてみる実験

@suzuki-hoge さんの「ruby 2.6 が出たので触ってみて、python と比較してみた」にコメントしたのですが、面白いことができそうな気がしたので記事にしておきます。


実験結果

まずは、実験結果をご覧ください。


実験その1

>>> ['ruby', '2.6'] | join('-') | cap | surround('|')

'| Ruby-2.6 |'

リストデータの要素を、ハイフン(-)で繋いで(join)、先頭を大文字にし(cap)、縦棒(|)で囲む(surround) という処理の流れ(ストリーム)を パイプ(|)接続してデータを左から右に流しています。

メソッドチェーンにする方法もありますが、処理メソッドを増やすにはクラス修正が必要です。関数をコマンドに見立ててパイプ接続できる方が柔軟ですよね。


実験その2

シェルスクリプトのようにパイプ処理をバッチ処理化(関数合成)しておいてからデータ処理する実験です。

>>> batch = join('-') | cap | surround('|')

>>> ['python', '3.7.1'] | batch
'| Python-3.7.1 |'
>>> [['ruby', '2.6'], ['python', '3.7.1']] | collect(batch)
['| Ruby-2.6 |', '| Python-3.7.1 |']


実験その3

print関数をパイプ出力にしてみる実験です。

>>> ['python', '3.7.1'] | batch | out

| Python-3.7.1 |
>>> ("hello", "world") | join(", ") | cap | out("log:", end="!\n")
log: Hello, world!


実験その4

カリー化の実験です。

>>> f = Pipe(lambda name, version: f"{name}-{version}")

>>> python = f("python")
>>> ['2.7', '3.7.2'] | collect(python)
['python-2.7', 'python-3.7.2']


実装


各種関数

join, cap, surround, collect, out 関数の実装を以下に示します。

Pipeクラスは次に説明しますが、各処理をlambda関数定義しています。簡単ですよね?

join = Pipe(lambda joiner, items: joiner.join(items))

cap = Pipe(lambda text: text.capitalize())
surround = Pipe(lambda decoration, text: f"{decoration} {text} {decoration}")
collect = Pipe(lambda pipe, items: [item | pipe for item in items])
out = Pipe(lambda *args, **kwargs: print(*args, **kwargs))


Pipeクラス

要となるPipeクラスです。簡単な仕組みです。

class Pipe:

def __init__(self, func, *args, **kwargs):
self.func, self.args, self.kwargs = func, args, kwargs

def __call__(self, *args, **kwargs):
return Pipe(self.func, *self.args, *args, **self.kwargs, **kwargs)

def __or__(self, pipe):
if not isinstance(pipe, Pipe):
raise TypeError("unsupported operand type(s) for |"
f": '{type(self).__name__}' and '{type(pipe).__name__}'")
return Pipe(lambda value: value | self | pipe)

def __ror__(self, value):
return self.func(*self.args, value, **self.kwargs)


__init__(self, func, *args, **kwargs):メソッド

__init__メソッドは、インスタンス生成したときに呼ばれる初期処理メソッドです。

各lambda関数はfunc引数に渡されます。

関数以外の引数があれば*args引数(位置引数)と**kwargs引数(キーワード引数)に渡たされます。

それぞれをインスタンス変数として保持しておきます。


__call__(self, *args, **kwargs)メソッド

__call__メソッドは、join('-')surround('|')のように、インスタンス化した後に関数呼出しされたときに呼び出されるメソッドです。

このメソッドでは、渡された引数を追加引数として取り込み、新たなPipeインスタンスを生成しています。

インスタンス生成せずにself.argsを置き換える手もありますが、バッチ処理化した後に引数を置き換えられると困るので、引数を指定した時点で新たなインスタンスを生成しています。

join_hyphen = join('-') としておいて "abc" | join_hyphen のように使うこともできます。


__or__(self, pipe)メソッド

__or__ メソッドは、0x60 | 0x12 のように論理和演算したときに呼ばれます。

整数同士であればintクラスの__or__メソッドが呼ばれます。

join('-') | cap と書いたとき、Pipeクラスの__or__メソッドが呼ばれ、pipe引数にはcapインスタンスが渡されます。

このメソッドでは、パイプ処理をバッチ処理化(関数合成)した新たなPipeインスタンスを生成しています。


__ror__(self, value)メソッド

["Python", "3.7.1"] | join('-') と書いたとき、listクラスの__or__メソッドが呼ばれます。

しかし、listクラスではPipeクラスと論理和する方法が分からずTypeErrorの例外を発生させます。

例えば、リスト(list)と文字列(str)を論理和すると以下のように例外が発生します。

>>> [1, 2, 3] | "abc"

Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: unsupported operand type(s) for |: 'list' and 'str'

このとき、「左項の__or__は失敗したけど、右項に頼めばできるのではないか?」と考えてPythonインタープリタは右項オブジェクトの__ror__メソッド呼び出しも試みます。右項(right)に対するorということで__ror__というメソッド名になっているようです。右項処理メソッドは他にもいろいろあります。

value引数には左項オブジェクトが渡ります。この例であればリストデータが渡されます。

このメソッドでは、初期化時に渡された関数を呼び出してデータ処理しています。

引数は、関数呼出し/カリー化引数(self.args)、データ(value)、最後にキーワード引数(self.kwargs)の順です。


まとめ

演算子オーバーライドの一例としてパイプ処理を実装してみました。

データが渡されれば __ror__メソッドでデータ処理し、Pipe同士の接続であれば__or__メソッドで関数合成するようにしました。

同様なライブラリが既に公開されているかもしれません。面白いネタだったので調べずに自分の手を動かしてみました。

pathlibのように /演算子をパス区切りに見立てて書けるライブラリもあります。

面白い使用例や実装例などがあれば是非コメントください。