アミフィアブル株式会社AIチームのエンジニアの野村です。
はじめに
私が所属するPython/AIチームでは、PythonによるREST APIの開発を行っています。その開発には、静的型チェックツールのmypyとlintツールruffの使用が前提となっており、Pythonコードに厳密な型付けが義務付けられています。(以降のPythonコードは型付けが前提となります)。
厳密に型付けを行うことで本来であれば型無しのオブジェクト指向言語という関数型言語とは対照的なPythonであっても関数型のライブラリを導入しやすくなります。そこで、私のチームではPythonの関数型プログラミングライブラリreturns
(公式サイト)が提供するResultモナドを導入しています。
ちなみに、returnsのResultは関数型言語のResultモナドないしはEitherモナドを模倣して実装されていますが、returnsのドキュメントでは、モナドという単語は使用せずコンテナ(container)
という言い回しを使っています。また、関数型ドメイン設計や鉄道指向プログラミング解説でもモナド
という用語は避けられてResult型
と呼ばれることが多いようです。実際のところ関数に副作用を含みうるPythonでは定義上モナドとは言えません。この点に関しては別の記事で解説したいと思います。ただ、この記事ではモナドを恐れず「モナド」という用語で統一します。
動的型付けのオブジェクト指向言語であるPythonでモナドといったガチガチの関数型スタイルを導入するメリットはなんでしょうか。結論を先に言いますと、Resultモナド導入は次の2つが大きな動機になります:
- オニオンアーキテクチャのレイヤー構造との親和性
- エラーハンドリングの厳密化と明晰化
これらの主題の前に下ごしらえとしてモナドを簡単に説明します(Part1)。また、チーム開発でのResult導入においてdo記法の役割が大きい点にも触れます(Part2)。そして、モナドを扱う上で必要不可欠となりまたオニオンアーキテクチャでのレイヤー区別に関係してくる純粋関数/不純な関数の区別を説明します(Part3)。
- Part1 Resultモナド
- Part2 do記法(←いまここ)
- Part3 純粋関数
- Part4 オニオンアーキテクチャ
- Part5 エラーハンドリング
do記法で可読性を改善
前回の記事でPythonのライブラリreturnsでResultモナド(ライブラリの名称だとResultコンテナ)を導入しました。そして、モナドでラップされた値はbindという結合子でつなげることができるのも確認しました。このbindによるモナドの連携がモナディックなプログラミングの肝になるのですが、同時に無計画にbindやlambdaを多用すると統一性が失われ可読性が落ちます。このような問題を解決するために重要なのがdo記法
です。そして、私のチームでは、bindによるモナドの連携はdo記法を用いるように限定しています。
returns のdo記法
returnsでは、Haskellのdoに近い構文が提供されています。これを使うと下記のように書けます。前回の記事でも使ったdivision
とconvert_int
をResultモナドの出力で再定義します。また、do記法の本家のHaskellのdo記法と併記しておきます。
from returns.result import Result, Success, Failure
def division(x: int, y: int) -> Result[float, ZeroDivisionError]:
try:
return Success(x / y)
except ZeroDivisionError as e:
return Failure(e)
def convert_int(value_str: str) -> Result[int, ValueError]]:
try:
return Success(int(value_str))
except ValueError as e:
return Failure(e)
a = Result.do(
z
for x in Result.from_value("3")
for y in convert_int(x)
for z in division(10, y)
)
# a = <Success: 0.3>
上から下に読んでいけば、「"3" をResultでラップ → convert_int(x) に渡し → division(10, y)に渡し → 最終的な z を返す」という流れが理解しやすくなります。Haskellで次のようにより手続き型ライクな見た目になります。
a = do
x <- pure 3
y <- convert_int x
z <- division y 10
return z
returnsの場合は、最終的な返り値をdoの真下(処理の上)に書く必要がありますが、それ以外の構文は同じような書き方ができます。
do記法がない場合の bindチェーン
do記法はなぜ重要なのでしょうか。do記法はbindのシンタックスシュガーに過ぎないのでなくてもbindで同じことが書けます。次の例を見てみましょう。
from returns.result import Result
def successor(x: int) -> Result[int, str]:
return Success(x + 1)
a = (
Result.from_value(0)
.bind(successor)
.bind(successor)
.bind(successor)
)
# a = <Success: 3>
一引数の関数のbindチェーンであればこのようにdo記法がなくても可読性を損なわずに目で追えると思われるかもしれません。しかし、これに二引数の関数が含まれてくるとbindの構造がネストすることになり、可読性が著しく低下します。次は、1
と5
にそれぞれ+1
したものを足し合わせるだけの非常に単純な処理ですが、それにもかかわらず何をしているのか一目で分かりづらくなります。
from returns.result import Result
def successor(x: int) -> Result[int, str]:
return Success(x + 1)
def addition(x: int, y: int) -> Result[int, str]:
return Success(x + y)
a = (
Result.from_value(1)
.bind(successor)
.bind(lambda y: Result.from_value(5)
.bind(successor)
.bind(lambda w: addition(y, w)))
)
# a = <Success: 8>
これを returns の do記法で書き換えると次のようにインデントを統一してスッキリと表現できます。(x、yなどの一時変数をその都度導入する必要はありますが必要なコストとみなしています)
a = Result.do(
u
for x in Result.from_value(1)
for y in successor(x)
for z in Result.from_value(5)
for w in successor(z)
for u in addition(y, w)
)
# a = <Success: 8>
これは最初にみたリスト内包表記を思い起こすとわかりやすいと思います。
業務ロジックを連結させる場合、bindのチェーンも多くなり複雑になってきますので、bindでそれらを記載すると表記も複雑になり書いた本人でさえ識別するのが困難になってきます。do記法を導入することでどこが入れ子になっていて、どこが Result なのかが直感的に追いやすくなります。そのため、Resultモナドをチームで運用するのであれば、do記法が重要な役目を担ってくるわけです。
クライスリ射とdo記法
実際の運用では、do記法で業務ロジックをまとめることを想定して、主要な関数のシグネチャはすべて、hoge(x: T) -> Result[T,str]
となるように運用します。Mを任意のモナドとしてこのような型 A -> M B
の関数のことを クライスリ射(Kleisli Arrow)
と圏論では呼びます。主要な業務ロジックシグネチャをクライスリ射に統一することでモナディックに一連の処理を組み立てる事ができます。
def some_bussine_logic1(x: str) -> Result[int, str]:
output = ...
return Success(output)
def some_bussine_logic2(x: int) -> Result[int, str]:
output = ...
return Success(output)
def some_bussine_logic3(x: int, y: int) -> Result[int, str]:
output = ...
return Success(output)
a = Result.do(
final_out
for int_input in Result.from_value(1)
for str_input in Result.from_value("piyo")
for int_out1 in some_bussine_logic1(str_input)
for int_out2 in some_bussine_logic2(int_input)
for final_out in some_bussine_logic3(int_input1, int_input2)
)
map、apply、bindメソッドの使用制限
モナドを用いたエラーハンドリングで重要になるのはbind
であるというのはすでに言及したとおりですが、モナドは関手、かつ、applicative関手でありますので(参考サイト、訳本)、それらの特徴を使うことができますモナドを模しているreturns
でも当然これらのメソッドは実装されています。すなわち、map
とapply
です。しかし、私のチームではこれらの使用は基本的に禁止しています。こういったメソッドを使えば多種多様なシグネチャを連結させることができるので、その場しのぎで無計画にモナドを連結させたりできコードの統一性が失われます。
また、haskellの場合、<*>
や<$>
といったmapとapplyに対応する中間演算子が用意されており、このような記号でモナドを連結させるコーディングスタイルをApplicativeスタイルといいます。ただこのスタイルのコードは非常に独特な見た目になります。ひとつ例を見てみましょう。
a = (+) <$> Just 3 <*> Just 5
独特ですよね?これをreturnsのメソッドで書いてみましょう。
from returns.curry import curry
from returns.maybe import Some, Maybe
@curry
def add(x: int, y: int) -> int:
return x + y
a = Some(3).apply(Some(5).map(add))
# a = <Success: 8>
mapを<$>
、applyを<*>
に置き換えてSome(3).apply(Some(5).map(add))
の左右をひっくり返して見てみると、haskellのApplicativeスタイルのコードと対応しているのが見えるのですが、しかし、ぱっと見では一般的なpythonicなコーディングスタイルとはかけ離れていてすぐに理解できません。mapとapplyの引数の型がhaskellと逆転していて1チェーンにならず入れ子になるのが致命的に読みにくい、かつ、書きにくいです。場当たり的にこのようなメソッドを使われてはコードの共有が困難になります。do記法であれば次の例のように無駄な複雑さを省略できます。
from returns.maybe import Some, Maybe
a = Maybe.do(
add(x, y)
for x in Some(3)
for y in Some(5)
)
# a = <Success: 8>
したがって、resultモナドを使う関数の型シグネチャはさきほど言及したクライスリ射に限定します。それにより、do記法で統一的にモナドの連携を扱うことができるのです。
デメリットとしてはmapで1行でかける箇所をdoで3行以上使って書く必要でてきたり冗長に感じる場面もあるかもしれませんが、関数型スタイルに慣れていないメンバー間であっても
- doという手続き型ライクな記法で少ない学習コストで関数型のエッセンスを取り入れられて
- 一貫したコードスタイルにできコードを共有しやすい
というメリットのほうが重要である考えています。
他にも関数型のメソッドが用意されていますが、基本的に自由な使用は禁止しています。一例として、returnsのcurry
デコレーターで関数をカリー化するといったことも可能ですが、同じ関数名で型シグネチャが変わるので混乱の原因になります。ラムダ式を使えばカリー化が必要になる場面は多くありませんので、無駄な使用を避けるために禁止しています。
次の話題
もともとのテーマは、オニオンアーキテクチャにおけるResultモナドの導入ですが、次に扱うテーマは、まだアーキの話しではなく、純粋関数と不純な関数の区別を説明したいと思います。モナドのあとにこれらを説明するのは順番的に変な感じもしますが、これら関数の区別によりオニオンアーキテクチャの各レイヤーを明確かつ厳密に定義するために使用しますので、この区別を明確に把握しておく必要あります。この純粋/不純の区別こそオニオンアーキテクチャにおけるResultモナド導入の鍵になると考えています。