Python で Clojure っぽい関数チェーン。

■便利なメソッドチェーンが欲しい。

Rubyならこんなに直感的なコード、

# ruby
a = [1,4,3,2]
p a.sort.reverse.map{|x| x.to_s}.join('-')

Pythonだとイケてない。

# python
a = [1,4,3,2]
print '-'.join(map(str, reversed(sorted(a))))

操作対象の a を包み込むように括弧がネストするのは、書きにくく読みにくい。

Pythonista は言語の思想的にはこう書くのがよいだろうと言う。

# python
a = [1,4,3,2]
a.sort()
a.reverse()
a = map(str, a)
print '-'.join(a)

なにか釈然としない。

■正攻法なアプローチ

それならばメソッドが self を返す list のラッパーを作ればいい。
というのがこちらの記事 → PythonでもRubyみたいに配列をメソッドチェーンでつなげたい

■別のアプローチ(本題)

この不満解決のための別のアプローチ。一言で言えば、Clojure のマクロ「 ->> 」のリスペクトです。
Clojure ではとても Lisp らしい関数の連鎖構文を用意しています。

;;clojure
(use '[clojure.string :only (join)])

(def a [1 4 3 2])
(->> a sort reverse (map str) (join "-") println)

これはオブジェクトのメソッドを呼び出しているわけではないので「メソッドチェーン」というより「関数チェーン」ですが、関数の記述順など Ruby との類似性が見えます。
この Clojure のやりたかを Python で実現すると。

# python
from __future__ import print_function
from functools import partial

def f_chain(*args):
    return reduce(lambda x,f:f(x), args)

a = [1,4,3,2]
f_chain(a, sorted, reversed,
        partial(map, str),
        lambda ls: '-'.join(ls),
        print)

Clojure と比べて、

  • map は partial で部分適用する必要がある。(clojure の ->> はマクロのため、S式をそのまま書ける)。
  • join は lambda式で包む必要がある。

という二点により若干冗長ですが、最初に書いた二つの Python コードと比べて一貫性があり流れを追いやすいと思います。どうでしょうか?
周知のPythonライブラリに対する「改変度」が低いのも良い点だと思います。
既存の Python 関数をそのまま並べて書ける汎用性、必要なのは小さな f_chain 関数ひとつだけという手軽さ・見通しのよさがよいんじゃないかと。

【訂正】'-'.join に lambda は不要。

Python では、関数を期待する文脈でメソッドを渡すことが出来てしまうんですね。C++Clojure では関数とメソッドは同じ構文が使えない(ことがある)ため、Python でもそんな風に思い込んでいました。
つまり、上のコードはもっとシンプルに、

f_chain(a, sorted, reversed, partial(map, str), '-'.join, print)

これでいいんです。"-".join は、lambda で包む必要ありません。

Pythonのメソッドチェーンは改行が不自由

Python はインデントでブロックを作るという性質上、

a.sort()
.reverse()

a.sort().
reverse()

は syntax エラー になります。チェーンが長くなっても改行がしにくいというのはちょっと厳しいですね。
エスケープすれば一応、自由改行ができますが、これは Python では非推奨とされています。

a.sort()\
.reverse()

この点、f_chain はカンマで引数を並べるだけなので改行の自由度が高いです。
適度に改行を入れながら、好きなだけチェーンを繋ぐことができます。

問題点

いいところばかり書きましたが問題もあります。
高階関数 f_chain がとることが出来るのはメソッドではなく、あくまでも関数です。
従って、メソッドでしか用意されていない処理はどうしてもやりずらい(lambda でいちいちラップする必要がある)。
また、Clojure等のLispでは「演算子も関数」ですので、高階関数に渡しやすいのですが、Python ではそうもいきません。
やはり、lambda で包むか、「+ に対する add関数」のように「演算を行う関数」を準備しなければなりません。

■おまけ:FizzBuzz

# -*- coding: utf-8 -*-
# pyhon
from __future__ import print_function
from functools import partial
from itertools import count
from itertools import cycle
from itertools import izip
from itertools import imap
from itertools import islice

def f_chain(*args):
    return reduce(lambda x,f:f(x), args)

# FizzBuzz無限リスト
fizzbuzz_list = f_chain(
    izip(cycle(['']*2+['Fizz']),
         cycle(['']*4+['Buzz']),
         count(1)),
    partial(imap, lambda a: (a[0]+a[1],a[2])),
    partial(imap, lambda a: a[0] if a[0] else a[1]))

# 2行追加で最初の100項切出し & 表示
f_chain(
    izip(cycle(['']*2+['Fizz']),
         cycle(['']*4+['Buzz']),
         count(1)),
    partial(imap, lambda a: (a[0]+a[1],a[2])),
    partial(imap, lambda a: a[0] if a[0] else a[1]),
    lambda ls: islice(ls, 0, 100),
    tuple, print)