アミフィアブル株式会社AIチームのエンジニアの野村です。
はじめに
私が所属するPython/AIチームでは、PythonによるREST APIの開発を行っています。その開発には、静的型チェックツールのmypyとlintツールruffの使用が前提となっており、Pythonコードに厳密な型付けが義務付けられています。(以降のPythonコードは型付けが前提となります)。
厳密に型付けを行うことで本来であれば型無しの動的オブジェクト指向言語という関数型言語とは対称的なPython1であっても関数型のライブラリを導入しやすくなります。そこで、私のチームではPythonの関数型プログラミングライブラリreturns
(公式サイト)が提供するResultモナドを導入しています。
returnsのResultは関数型言語のResultモナドないしはEitherモナドを模倣して実装されていますが、returnsのドキュメントでは、モナドという単語は使用せずコンテナ(container)
という言い回しを使っています。また、関数型ドメイン設計や鉄道指向プログラミング解説でもモナド
という用語は避けられてResult型
と呼ばれることが多いようです。実際のところ関数に副作用を含みうるPythonでは定義上モナドとは言えません。この点に関しては別の記事で解説したいと思います。ただ、この記事ではモナドを恐れず「モナド」という用語で統一します。
Pythonでモナドといったガチガチの関数型スタイルを導入するメリットはなんでしょうか。結論を先に言いますと、Resultモナド導入は次の2つが大きな動機になります:
- オニオンアーキテクチャのレイヤー構造との親和性
- エラーハンドリングの厳密化と明晰化
これらの主題の前に下ごしらえとしてモナドを簡単に説明します(Part1)。また、チーム開発でのResult導入においてdo記法の役割が大きい点にも触れます(Part2)。そして、モナドを扱う上で必要不可欠となりまたオニオンアーキテクチャでのレイヤー区別に関係してくる純粋関数/不純な関数の区別を説明します(Part3)。その後、Resultを導入したオニオンアーキテクチャ(Part4)とエラーハンドリング(Part5)を解説します。
- Part1 Resultモナド(←いまここ)
- Part2 do記法
- Part3 純粋関数
- Part4 オニオンアーキテクチャ
- Part5 エラーハンドリング
なお、対象読者としては、関数型プログラミング、および、関数型ドメインモデリングに関心があるPythonエンジニアを想定しています。
Listから始めるモナド
モナドは、「モナド則」と呼ばれる特定の数学的な条件を満たすモノイド構造を意味しますが、細かい定義や証明はここでは省略します。重要なのはたった一つ、bind
です。bind(Haskellでは>>=
、Rustだとand_then
、ScalaだとflatMap
)がモナドクラスの主要メソッドでありモナドの中核要素となります。
モナドを理解する上で、最も身近でとっつきやすい例はList
でしょう。Listはモナドでもあります。Listモナドにおけるbind
は各要素に対して関数を適用し、その結果得られたリストを平坦化して結合する処理を行います。リスト内包表記で考えればすぐにイメージできると思います。
[
x * y |
x <- [1,2],
y <- [10,20]
]
[
x * y
for x in [1,2]
for y in [10,20]
]
このリストの結果は[[10, 20], [20, 40]]
ではなく [10, 20, 20, 40]
ですね。各要素をmapしてその結果をflatにしている、というのが容易に想像できると思います。bindもこれと同じです。上の例をbindで書いてみます。(pythonのreturnsではlistに対してbindが定義されていないので擬似コードになります)
[1,2] >>= (\x -> [10,20] >>= (\y -> return (x * y)))
[1,2].bind(lambda x: [10,20].bind(lambda y: x * y))
いきなり読みにくくなったと思いますが、次のように改行したものを見比べるとわかりやすくなります。bindのチェーンをラムダ式の途中で改行します。
[1,2] >>= (\x ->
[10,20] >>= (\y ->
return (x * y)))
[1,2].bind(lambda x: \
[10,20].bind(lambda y: \
x * y))
[例1.1]
と[例2.1(改行)]
、また、[例1.2]
と[例2.2(改行)]
のふたつを並べると、bind記法と内包表記との類似性が見えますよね。このようにListモナドにおいて、リスト内包表記とbindは表記の違いでしかありません。では、他のモナドはどうでしょうか。どのようなモナドであってもモナドのクラスメソッドのbindが使えます。
Maybeモナド
基本的なモナドの例として、Maybe(ないしOptional)モナドがあります。Maybeは「最大長が1のリスト」と考えると分かりやすいでしょう。実際、Maybeモナドの本質は「値がある(Some a)か、ない(Nothing)か」のどちらかだけを持つ構造です。
失敗の可能性がある関数をMaybeで表現してみます。以下のコードを例に取ります。
from returns.maybe import Maybe, Nothing, Some
def division(x: int, y: int) -> Maybe[float]:
try:
return Some(x / y)
except ZeroDivisionError:
return Nothing
yが0の場合、0での割り算は禁止されていますのでエラー(ZeroDivisionError)となります。その際にNothing(≒空リスト)を返し、成功時は計算結果をSomeでラップした要素を返す(≒返り値をリストでラップしたシングルトンリスト)。Maybe モナドは最大長が1のリストを使って行えることとほぼ同等です。
bind で失敗の可能性がある関数を連結する
もうひとつ、文字列を整数に変換する関数を用意します。
from returns.maybe import Maybe, Nothing, Some
def convert_int(value_str: str) -> Maybe[int]:
try:
return Some(int(value_str))
except ValueError:
return Nothing
これは整数に変換できない文字が入力されたらエラー処理としNothingを返します。division
とconvert_int
という失敗の可能性がある2つの関数をbind
でつなげたらどうなるでしょうか?
from returns.maybe import Maybe
a = Maybe.from_value("3").bind(convert_int).bind(lambda y: division(10, y))
# a = <Some: 0.3>
先のリスト内包表記とbindを併記した例([例2.2(改行)]
)と同じですね。次のように改行してみましょう。
from returns.maybe import Maybe
a = Maybe("3").bind(lambda x: \
convert_int(x)).bind(lambda y: \
division(10, y)))
# a = <Some: 0.3>
Maybeの中身が取り出されてそれぞれ後続の関数に渡されてチェーンしているのがなんとなく想像できるかと思います。
Resultモナド
リストモナドがMaybeモナド理解の橋渡しになりました。そして、Maybeモナドが使いこなせるようなればその他のモナドはこれの応用にすぎないので、容易に頭に入るようになってきます。特にResultモナドはMaybeを少しだけ拡張したものになります。
Resultモナドは、Maybeモナドに失敗の文脈を与えるものです。具体例は、上の例3.3
で、Maybe.from_value("0")
を入力した場合と、Maybe.from_value("a")
を入力した2つの場合を試してみてください。どちらもNothing
が返ってくるのが確認できます。
from returns.maybe import Maybe
a = Maybe.from_value("0").bind(convert_int).bind(lambda x: division(10, x))
# a = Nothing
b = Maybe.from_value("hoge").bind(convert_int).bind(lambda x: division(10, x))
# b = Nothing
つまり、エラーが適切に処理されてはいるのですが、0を割ることによりZeroDivisionError
が原因で失敗したのか、"a"をintの変換に失敗したことによりValueError
でNothingが帰ってきたのか判断できません。このNothingに何かしらの文脈を与えNothingに理由付けを与えることができるのがResultモナドです。先の[例3.1]と[例3.2]の関数をResultで書き換えてみましょう。
from returns.result import Failure, Result, Success
def division(x: int, y: int) -> Result[float, str]:
try:
return Success(x / y)
except ZeroDivisionError:
return Failure("0で割るのは禁止です!")
def convert_int(value_str: str) -> Result[int, str]:
try:
return Success(int(value_str))
except ValueError:
return Failure(f"{value_str}はintに変換できないよ!")
そして、先の[例4.1]をResultに置き換えて実行してみます。
a = Result.from_value("0").bind(convert_int).bind(lambda y: division(10, y))
# a = <Failure: 0で割るのは禁止です!>
b = Result.from_value("hoge").bind(convert_int).bind(lambda y: division(10, y))
# b = <Failure: hogeはintに変換できないよ!>
このように失敗した場合であっても、Maybeのように失敗を適切に扱いつつも、それの理由や原因を文脈として与えることができますのでエラーハンドリングに適しているモナドであると言えます。
次の話題
もともとのテーマは、オニオンアーキテクチャにおけるResultモナドの導入ということでしたが、長くなってしまうので今回はResultモナド、モナド一般の簡単な導入で終わりになります。次の記事では、モナドとこれを実際にチームで運用するにあって重要になるdo記法
を解説していきます。
-
Pythonは関数型言語を出発点としておらず、Pythonのクリエイター自身もPython当初における関数型言語の影響を否定しています。そして、末尾再帰などの関数型の特徴が最適化されていないことから関数型言語とみなすのには否定的です。[参考]Origins of Python's "Functional" Features ↩