アミフィアブル株式会社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)。その後、Resultを導入したオニオンアーキテクチャ(Part4)とエラーハンドリング(Part5)を解説します。
- Part1 Resultモナド
- Part2 do記法
- Part3 純粋関数(←いまここ)
- Part4 オニオンアーキテクチャ
- Part5 エラーハンドリング
なお、対象読者としては、関数型プログラミング、および、関数型ドメインモデリングに関心があるPythonエンジニアを想定しています。
純粋関数の定義
関数型言語のスタイルを導入するということは、関数が純粋であるか不純であるかを区別するのが非常に重要になってきます。また、この区別がオニオンアーキテクチャの設計に活きてきます。そもそも、純粋関数
という用語はプログラミング業界での言い回しで、なんら特殊な関数ではなく数学における普通の関数のことです。なぜプログラミング業界では純粋という枕詞をつけるというと純粋ではない不純な関数
もごくごく一般的に扱うからです。
まずは、純粋関数、つまり数学における一般的な関数を定義しましょう。集合論の教科書を引用します。1
$X$と$Y$が集合であるとき、$X$から$Y$への(あるいは、$X$上での$Y$の中への)関数(funciton)とはつぎのような関係$f$のことである。すなわち、[1]$dom f=X$であり、[2]$X$内のそれぞれの$x$に対して、$(x,y)\in f$となるただ1つの要素$y$が$Y$内にあるような関係$f$である。この一意性の条件はつぎのようにして陽表的に定式化できる。つまり、$(x,y)\in f$で$かつ(x,z)\in f$ならば$y=z$である。([1],[2]筆者追加)
すなわち関数$f$は$X$と$Y$上の2項関係$f\subseteq X\times Y$であり($X$を関数の定義域、$Y$を値域と呼びます)、次の2つの条件を満たします。
- [1]全域性: $X$のどの要素も出力$f(x)\in Y$を持つ2。
- $\forall x \in X.\exists y\in Y. (x,y)\in f$
- [2]唯一性: $f$の出力は唯一である
- $\forall x\in X. \forall y\in Y. (x,y)\in f \wedge (x,z)\in f \implies y=z$
一般に、関係$f$が関数であるならば、$(x,y)\in f$を$f(x)=y$と書きます。また、2つ目の条件は、プログラミング業界では参照透過性
と呼ばれる条件になります3。
純粋関数というと2つの条件を満たす関係である必要がありますが、ほとんどの場合2つ目の唯一性条件、参照透過性、が強調されます。
関数型プログラミング言語の内で、全ての関数が参照透過性を持つようなものを純粋関数型プログラミング言語という(関数型プログラミング(Wikipedia))
不純な関数
先のように純粋関数は数学の言語で厳密に定義できます。では、不純な関数とはなんでしょうか。プログラミング業界では、数学的に定義された関数以外の多くの対象も関数と呼びます。これらの数学的な定義を逸脱する関数が不純な関数
と呼ばれます。また、副作用
を持つ関数といったいいかもされます。具体例を列挙してみましょう。
エラーケース
Pythonでは、関数内部で例外(Exception)を発生させることができますが、このような例外は関数の型シグネチャには現れないため、関数の出力が常に定義域と値域で明示されるという関数の前提に反しています。たとえば、次のような割り算の関数を考えてみます。
def divide(a: float, b: float) -> float:
return a / b
この関数は一見シンプルに見えますが、bに0が渡された場合にZeroDivisionError
が発生します。つまり、全ての入力に対して値を返すという関数の基本的な定義(=全域性)を満たしていません。数学的には、0で割る操作は定義されていないため、b=0を想定していないこの関数は、本来の意味での関数ですらないと言えます。さらに、例外が発生するかどうかは実行時までわからないため、この関数は参照透過性も持たたないため関数とは言えません。
(Resultモナドを使うことでエラーケースも純粋関数として扱うことができます→Part5エラーハンドリングへ)
グローバル変数参照
関数の内部でグローバル変数を読み書きする場合も、出力が変化する可能性があるため不純な関数とみなします。グローバル変数はプログラムのどこからでも参照・更新される可能性があるため、同じ引数を渡してもグローバル変数の状態により結果が変化することがあります。これも参照透過性が崩れる典型的なパターンです。
COUNTER: int = 0
def increment_counter() -> int:
"""
呼び出すたびにcounterの値が変わる副作用がある。
"""
global COUNTER
COUNTER += 1
return COUNTER
外部ファイルの入出力
プログラムがファイルの読み込みや書き込みを行う場合、呼び出すたびにファイルの内容や外部環境の状態が変化している可能性があります。そのため、同じ引数を与えても常に同じ結果を返すとは限らず、外部環境によって結果が左右される、つまり参照透過性が保たれないことが多いです。このような外部I/O操作を行う関数は不純な関数の典型例と言えます。
def read_username_from_file(filename: str) -> str:
"""
指定ファイルからテキストを読み込んで返す不純な関数。ファイルの内容が変更されれば結果も変わる。
"""
with open(filename, 'r', encoding='utf-8') as f:
text = f.read()
return text
API参照
外部のWeb APIやシステムAPIを呼び出す関数は、外部サービスの状態や通信状況に依存します。例えば、現在の天気を取得するAPIを内部で呼び出す関数は、同じ引数として渡しても呼び出すタイミングによって返ってくる天気が異なります。外的状態や時刻に依存し、結果が一定ではないため不純な関数に分類されます。
不純な関数の外部ライブラリ呼び出し(例:date, time, random)
現在時刻を返すtime()関数や乱数を返すrandom()関数などは、呼び出すたびに返却値が変わります。こちらも同じ入力(そもそも入力を取らないことが多い)でも呼ぶたびに違う結果が得られるため、数学的な関数としては成立しません。このようにプログラム外部または環境の状態に依存する処理はすべて不純な関数とみなすことができます。
ただ、外部ライブラリの場合どれが純粋でどれが不純かの判断はグレーになってきますので慎重な判断が必要です(ライブラリの中身を全て見れば判断可能ですが現実的ではありません)。例えば、numpyなどの数値計算ライブラリにある関数であれば純粋な関数として見なせますが、seleniumなど外部情報を取得しにいくライブラリはそれと見なすことはできません。
データベース参照
データベースの状態(レコードの追加・更新・削除)が変化すると、同じクエリでも結果が変わるため参照透過性は成立しません。さらに、DB接続時やクエリ実行時に例外が発生するなど、副作用も多分に考えられます。
Pythonのおける純粋/不純の区別の難点
Haskellは純粋関数が言語仕様上強制されるため副作用の分離が可能ですが、pythonは不純な関数と純粋関数が言語仕様で区別されていないため、それを区別するのは最終的には人間の判断ということになります。PythonでResultモナドなど関数型のスタイルを取り入れるうえで、この純粋/不純の区別と判断が一番の難点であると考えています。もし副作用を含んだまま最初に紹介したResultモナドを導入したとしても副作用が適切に処理できないままコードに内包してしまいます。それが、最初の記事の冒頭で申しましたように、pythonではresultモナドが本来の意味でのモナドになっていない理由です。
PythonでResultモナドを導入する懸念点
Python におけるResultモナド(正確には、モナドのようなもの)は、Success や Failureで値をラップし、エラーを包んだり展開したりするための仕組みとしては機能します。しかしながら、関数がすでに副作用を意図せず内包している場合は、これがどこで起きているのかを、mypyなどの外部ツールを導入したとしても、把握できません。結果として、純粋関数として扱うべき部分と副作用を持ち込む部分が混在してしまい、厳密な意味での「モナドによる副作用管理」にはなりえないのです。しかしながら、Success や Failureでラップしているだけであたかもモナドを扱えている気になってしまう危険性があります。そのため、本当にモナドを本来の意味でのモナドとして扱うには不純な関数とはなにかそれを引き起こす副作用とはなにかを十分理解しておく必要があります。
オニオンアーキテクチャへ
このような懸念点はあるものの、依然としてResultモナドをオニオンアーキテクチャに導入するメリットは大きいと考えています。その点についていよいよ次の記事で解説していきたいと思います!
-
P.R.ハルモス著、富川滋訳『素朴集合論』、1975年、ミネルヴァ書房、p.53 ↩
-
$dom$の定義($domR = \{x : (xRy)となるyがある\}$)より (ibid., p.49)。 ↩
-
参照透過性(referential transparency)の語源を遡ると分析哲学、および、哲学者クワインの『ことばと対象』(p.237, 勁草書房)に行き着きます[参考]What is referential transparency?。筆者は大学院でクワインを研究をしていたこともあり、分野を超えた邂逅に感動した覚えがあります。この言葉の意味合いをクワインの言語哲学(指示論)の文脈を踏まえて捉えると
参照透過性
という訳語よりも、哲学業界での訳語である指示的透明性
と訳すほうが正確だと個人的には考えています。 ↩