Ruby
関数型プログラミング

関数合成Composeが初めて役に立った話

More than 1 year has passed since last update.

高階関数の例としてよく出てくるわりに使い道のわからなかった関数合成(compose)が、初めて実用的な役に立ったので、参考のために記録に残しておく。


たくさんあるAPIにたらい回しされた

あるとき、Aに問い合わせた情報をもとにBに問い合わせて、さらにその答えをもとにCに問い合わせて、さらに……

みたいな組み合わせがたくさん出てきて大変なことになった。

説明のために簡略化したコードを例として挙げる。

API呼び出しがこんな関数だとしたら、

def query(site, api, key)

...
end

たらい回しされるところのコードはこんな感じになってて

def fetch_foo()

foo = query("site1", "api1", key)
return foo
rescue => e
logger.error(e)
return FetchError.new(key, e)
end

def fetch_bar(key)
value1 = query("site1", "api1", key)
bar = query("site1", "api2", value1)
return bar
rescue => e
logger.error(e)
return FetchError.new(key, e)
end

def fetch_baz(key)
value1 = query("site1", "api3", key)
value2 = query("site2", "api4", value1)
baz = query("site3", "api5", value2)
return baz
rescue => e
logger.error(e)
return FetchError.new(key, e)
end

以下略みたいなfetch_xxx()っぽい関数がたくさん増えた。


問題点


  • 同じようなコードがいっぱい増えてカオスになってきた。

  • 情報がどこにあるのかという知識が、どうやって取得するかという手続きに埋め込まれてしまい、容易に読み取れない。


解決策

コードに法則性がありそうだったので、気になる部分を取り出して並べてみた。

# 1段でとれるパターン

foo = query("site1", "api1", key)

# 2段必要なパターン
value1 = query("site1", "api1", key)
bar = query("site2", "api2", value1)

# 3段必要なパターン
value1 = query("site1", "api3", key)
value2 = query("site2", "api4", value1)
baz = query("site3", "api5", value2)

これは単純化するとこういう形なので、

a = f(x)

b = g(a)
c = h(b)

一時変数への代入を省くとこうなり、

h(g(f(x)))

関数合成(compose)の例そのままの形になっている。

つまりfetch_xxx()を共通化するには、任意の数の関数のリストを受け取ってすべて合成する関数(concat)があればいいということが分かる。

concat を実装するにあたっては以下の性質が使える。


引数と返り値の型が等しい関数は、恒等関数idを単位元としcomposeを演算子とした Monoid とみなせるので、foldl/foldrによる畳み込みができる。


静的型のないRubyでは引数の数が1であればとりあえずいい感じに合成して適用できる。

(ただし引数と返り値の型が合わなければ実行時エラーになる)

Rubyでのfoldlinjectなので下記のように書ける。

def compose(f, g)

->(x) { f.call(g.call(x))) }
end

def concat(list)
id = ->(x) { x }
list.inject(id) { |memo, f| compose(memo, f) }
end

これを使えばquery()の呼び出しの連鎖をひとつの関数にまとめられる。

def refer(*access_path)

queries = access_path.map do |pair|
site, api = pair
->(key) { query(site, api, key) }
end
concat(queries.reverse)
end

# 1段でとれるパターン
query_foo = refer(["site1", "api1"])
# 2段必要なパターン
query_bar = refer(["site1", "api1"], ["site2", "api2"])
# 3段必要なパターン
query_baz = refer(["site1", "api3"], ["site2", "api4"], ["site3", "api5"])

単なる1引数関数にまとまっているので、これを実行するのは.call()するだけでいい。

def fetch(query, key)

query.call(key)
rescue => e
logger.error(e)
return FetchError.new(key, e)
end


まとめ

複数のAPI呼び出しのたらい回しを関数合成とみなすことで、


  • 似たようなたくさんの関数をひとつに共通化できた

  • 呼び出しの連鎖を文字列のリストとして表現することができた