(Coconut) Pythonプログラムの機能分解と個別検証
背景と概要
背景
私は探索的・対話的なデータ分析や理論の基礎検証にPythonを用いることが多い。その場合プログラミングそのものの試行錯誤だけではなく分析や理論検証の試行錯誤が必要である。後者のより本質的な試行錯誤に注力するためにはプログラムが適切に機能分解され、個別に検証されていることが望ましい。そのうえで有用なのが関数型プログラミングだが、イデオロギー的な臭いを排しプラグマティックな視点で臨むために「機能プログラミング」と書くことにした。英語にすればいずれもfunctional programmingである。この記事はPythonによる「機能プログラミング」の考え方や手法を整理したものである。
概要
内容はおおむね関数型プログラミング HOWTOをくだいたものになる。また関数型プログラミング HOWTOの内容を簡潔に運用するうえでCoconutが役立つので適時用いる。CoconutはPythonにコンパイルして実行するが、Jupyter上はコンパイルの作業を意識せずに使うこともできる。末尾にはCoconutの(私がよく使う)記法を付しておく。
機能プログラミングの考え方
ファンクションを分割せよ
次のようなプログラムは典型的と思われる。ここでは file.txt
から一行ずつ読み取り、それを加工することで key
と value
を作って辞書 result
に格納している。
result = {}
with open('file.txt','r') as f:
while line := f.readline():
... # 何らかの処理1
key = ... # 処理1の結果
... # 何らかの処理2
result[key] = ... # 処理2の結果
一度限りのやっつけプログラムならばこれでもよいが、機能プログラミングの立場からリファクタリングしてよう。まずはとうぜん次のように機能を分割するのが望ましい。
def make_key(a):
... # 何らかの処理1
key = ... # 処理1の結果
return key
def make_value(a):
... # 何らかの処理2
value = ... # 処理2の結果
return value
result = {}
with open('file.txt','r') as f:
while line := f.readline():
key = make_key(a)
value = make_value(a)
result[key] = value
ループの対象を分割せよ
上のプログラムはPythonのジェネレータを用いることで何についてのループかをさらに分離できる。
def read_lines(filename):
with open('file.txt','r') as f:
while line := f.readline():
yield line
するとメイン処理は次のようになる。
result = {}
for line in read_lines('file.txt'):
key = make_key(a)
value = make_value(a)
result[key] = value
定義と定義内容を分割せよ
はじめのプログラムは result={}
からループの終端までが result
変数の定義である。すなわち「result
を定義すること」と「result
の定義内容」とが混在している。このようなプログラムはコピペなどに伴ってバグを生みやすい。リスト内包表記を用いるとこれらを分割できる。
result = dict([(make_key(line), make_value(line)) for line in read_lines('file.txt')])
「result
を定義すること」がresult=
に、「result
の定義内容」がその左辺に分離されている。
ここで可読性を挙げるため次の補助関数を用い、またファイル名を変数に代入しよう。
def make_key_value(line):
return make_key(line), make_value(line)
filename = 'file.txt'
result = dict([make_key_value(line) for line in read_lines(filename)])
ループと処理は分割せよ
ここでCoconutを用いると上のプログラムは次のように書ける。
make_key_value = make_key `lift(,)` make_value
filename = 'file.txt'
result = read_lines(filename) |> map$ make_key_value |> dict
リスト内包表記ではループすることと何についてループするか、そしてループ内部の処理とが分離されていない。上のプログラムでは視覚上も構造上もそれぞれmap$
, read_lines(filename)
, make_key_value
に分離されていることがわかる。map$
の意味については次で述べる。
docstringとassertを書け
Coconutを用いプログラムは簡潔になったが、分かりにくいと感じるかもしれない。docstringは不可欠な記載だが、それ以上に分かりやすいのがassert
である。ここで登場した記法をassert
で例示すると次のようになる。
assert (lambda x:x+1)(1) == 1 |> x=>x+1
assert 1 |> x=>x+1 == 1 |> (.+1)
assert map((.+1), [1,2,3]) |> list == [2,3,4]
assert [1,2,3] |> map$ (.+1) |> list == [2,3,4]
assert
はFalse
の場合に例外を返すので、Pythonバージョンの変更などに伴う動作検証をするうえでも書いておくと重宝する。make_key_value
はCoconutで次のように書ける。
make_key_value = make_key `lift(,)` make_value
make_key_value.__doc__ = """make_keyとmake_valueの結果のタプルを返す"""
assert make_key_value("hoge") == (make_key("hoge"),make_value("hoge"))
再利用しない変数は引数に置き換えよ
ここでfilename
という変数を定義したが、後に再利用しないのであれば、このような個別性の薄い名の変数は混乱の基となる。関数の引数とすることで手軽にローカル化することができる。Coconutであればfilename=>
を加えるだけで済む。
make_result = filename => read_lines(filename) |> map$(make_key_value) |> dict
make_result.__doc__="""
ファイルを読み取り、一行ずつmake_key_valueを適用して、辞書を返す
"""
result = 'file.txt' |> make_result
いまmake_result
のdocstringがほとんどプログラムそのままになっていることに注目されたい。したがって慣れると可読性の向上をも享受することができる。
処理は貫徹せよ
ところで、処理が次のように続いていたとしよう。
result = ... # resultの定義
... # resultに対する何らかの集計や結合の処理
result = # 処理の結果でresultを再定義
これはresult
変数を上書きしていくことで最終成果を得る、やっつけプログラミングではありがちなスタイルである。上までのプログラムはその過程にすぎなかったわけである。もちろん手早くも試行錯誤を伴ってバグの温床となる。make_result
はmake_dict
とでも改名し、次のように整理しておくのが望ましい。
def collect(result_dict):
... # 何らかの処理
result = ... # 処理の結果
return result
make_result = make_dict ..> collect
make_result.__doc__="""
引数にmake_dictを適用し、さらにcollectを適用する
"""
result = 'file.txt' |> make_result
再利用性についてのコメント
このようにプログラムを書くことで機能が自然と分割されていく。すると次のような再利用が容易である。
files = glob.glob('*.txt')
make_results = map$(make_result)
make_results.__doc__="""
ファイルパスのリストを受け取りぞれぞれにmake_resultを適用する
"""
results = files |> make_results
ループは一発で仕留めろ
もしresults
の中身について再びループ処理する予定であり、
results = files |> make_results |> map$(postprocessing)
のように書きたくなったなら、
make_results = map$(make_result ..> postprocessing)
results = files |> make_results
のようにするべきである。
make_result_post = make_result ..> postprocessing
make_result_post.__doc__="""ファイル名を受け取ってmake_resultに加えてpost処理する"""
make_results = map$(make_result_post)
results = files |> make_results
とすればより機能分解され丁寧である。
テクニカルなコメント
map
の結果を確認するには最後にlist
を適用する必要がある。一方でlist
の結果を再利用せず、ふたたびイテレータとして用いるならばlist
は不要である。現実にはlist
を付けて結果を確認しながらプログラムすることになることが多いと思われる。
Coconutの記法
以上のようにプログラムを書くことで個別検証を伴う機能分離が自然となされ、より本質的な思考錯誤に注力しつつ、再利用性と可読性を高めることができる。
このような機能プログラミングを進めていく際に便利な省略記法を提供してくれるのがCoconutである。ここではCoconut v3.0.3のドキュメンテーションに基づき(私がよく使う)記法を整理しておく。基本的なものについてはトップページに記載されている。代数データ型や型アノテーションについては触れていない。気になる方はぜひドキュメンテーションをご一読ください。
関数適用関係
関数を柔軟に定義・変形・連結できる。
f = (a,b) => a+b # 下の式はすべて等価
assert 3 == f(1,2)
assert 3 == f 1 2
assert 3 == (1,2) |*> f # f(*(1,2))と同じ
assert 3 == (a=1,b=2) |**> f # f(**dict(a=1,b=2))と同じ
assert 3 == f$(1) 2
assert 3 == f$(?,2) 1
assert 3 == 2 |> f$(1)
assert 3 == (.`f`2) 1
assert 3 == (1`f`.) 2
assert 3 == (+)(1,2)
g = c => (c+1, c+2) # これは下2つと等価
g = =>(_+1,_+2)
g = (.+1) `lift(,)` (.+2)
assert (1,2) == g(0)
assert 3 == 0 |> g |*> f
assert 3 == 0 |> (g ..*> f) # ..>, ..**> も同様に定義されている
h = (.+1) `lift(f)` (.+2)
assert h(0) == (0+1) `f` (0+2)
リストやイテレータ、メソッド関連
イテレータをインデキシングしたり、インデキシングを関数として扱える。
assert 1 == range(3)$[1]
assert 1 == range(3) |> .$[1]
assert 'b' == "abc" |> .[1]
なおメソッドも同様に関数として実行できる。
assert ['a','b','c'] == "a,b,c" |> .split(',')
イテレータやリストの結合を容易にできる。
assert [0,1,2,3,4] == range(3) :: range(3,5) |> list
assert [[0,1],[2,3]] == [0,1;;2,3]
None-Awareにする
?をつけることでNone-aware にできる
assert (None |?> f) is None
assert (None |?> g |?*> f) is None
assert None?.split(',') is None
お役立ち関数
お役立ち関数がいくつか定義されている。
# ident は何もしない
assert (0,1) == 0 |> ident `lift(,)` (.+1)
# starmapは|*>しながらmapする
assert [1,3,5] == [(0,1),(1,2),(2,3)] |> starmap$ (+) |> list
# callは関数を適用する
assert 3 == call((.+1),2)
assert 3 == call((+),1,2)
# flattenは入れ子のリストを一階層にする
assert [1,2,3,4] == [1,2;;3,4] |> flatten |> list
パターンマッチとオペレーターの定義
パターンマッチで関数を定義できる。
def f((a,b)) = a+b
addpattern def f((c)) = c
assert 3 == f((1,2))
assert 3 == f((3))
# これは次と同じ
def f(arg):
match (a,b) in arg:
return a+b
else: match (c) in arg:
return c
パターンマッチを使って自前のオペレーターを定義できる。
operator |M>
def xs |M> f = xs |> map$ f
assert [2,3,4] == [1,2,3] |M> (.+1) |> list
Discussion