(Coconut) Pythonプログラムの機能分解と個別検証

2023/11/04に公開

背景と概要

背景

私は探索的・対話的なデータ分析や理論の基礎検証にPythonを用いることが多い。その場合プログラミングそのものの試行錯誤だけではなく分析や理論検証の試行錯誤が必要である。後者のより本質的な試行錯誤に注力するためにはプログラムが適切に機能分解され、個別に検証されていることが望ましい。そのうえで有用なのが関数型プログラミングだが、イデオロギー的な臭いを排しプラグマティックな視点で臨むために「機能プログラミング」と書くことにした。英語にすればいずれもfunctional programmingである。この記事はPythonによる「機能プログラミング」の考え方や手法を整理したものである。

概要

内容はおおむね関数型プログラミング HOWTOをくだいたものになる。また関数型プログラミング HOWTOの内容を簡潔に運用するうえでCoconutが役立つので適時用いる。CoconutはPythonにコンパイルして実行するが、Jupyter上はコンパイルの作業を意識せずに使うこともできる。末尾にはCoconutの(私がよく使う)記法を付しておく。

機能プログラミングの考え方

ファンクションを分割せよ

次のようなプログラムは典型的と思われる。ここでは file.txt から一行ずつ読み取り、それを加工することで keyvalue を作って辞書 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] 

assertFalseの場合に例外を返すので、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_resultmake_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