はじめに
前の記事でtry-except型の例外処理は適切に処理されなかった場合、バグを引き起こす元となることを説明した。今回の記事では、try-exceptの例外処理のデメリットについてさらに詳しく述べたあと、Good Code, Bad Codeで紹介されていたResult型を内包したPythonライブラリであるreturnsの使い方について紹介する。
try-exceptで何が問題となりうるのか?
例外処理を追うのが難しい
例えば、以下のコードがあったとする。
def fetch_user_profile(user_id: int) -> 'UserProfile':
"""Fetches UserProfile dict from foreign API."""
response = requests.get('/api/users/{0}'.format(user_id))
response.raise_for_status()
return response.json()
考えられるエラーとしては
- ネットワークエラー
- そもそもリソースが存在しなかった
- 入力値が不正だった
- サーバーが混雑している、もしくはダウンしていた
- ここで利用している
print_pretty
が別のエラーを吐いた
想定しうる限りのエラーでもこれだけあり、また想定していないエラーが発生する可能性もある。このように例外はいつどこで発生するのかを追いかけるのが難しく、またどの例外がどの時点で処理されるのかを、例外が起こる前にコードを読んだだけで見つけ出すことはデバッグツールなどを使う必要が出てくる。その結果、後日リファクタリングやメンテナンスの際、コードの変更が既存のエラー処理にどのような影響を与えるのか判断することが非常に困難となる。
except Error as e: log(e) と書いてしまいがち
except Errorは全てのエラーを無差別に捕捉してしまうので、特に何のエラーが起こるのかも理解しないまま、とりあえずログに残しがち。エラーが起きても握りつぶされてしまうので、ログを見ていない限りエラーの存在にも気づかれにくい。最悪。
Result型の利点
サイレンスエラーを防止する
エラーが起こる可能性を、呼び出し元に強制的に意識させ、処理を促すことができる。Result型は、成功と失敗の両方のケースを明示的に処理することを強制する。エラーを例外ではなく値として表現することで、開発者が明示的にエラーを処理し、その影響を考慮するように促すため、意図せずエラーを黙殺してしまう可能性が低くなります。
具体的なエラー処理の推奨
Result型では、関数が返すことができるエラーの種類を明示的に指定することができる。これにより、処理すべき具体的なエラーの認識が促進され、except Error:と一括でエラーハンドリングすることを防ぎ、個別の適切なエラー処理が行われる可能性が高くなる。
コードの読みやすさとメンテナンス性の向上
Result型では、プログラムの流れがわかりやすく、エラーの処理が明示されているため、異なる状況でコードがどのように動作することが期待されるかが明確になる。
Result型のデメリット
エラーを見落とす可能性が非常に低くなった代わりに、必要な処理が多くなり、コードが冗長になる。また今のところPythonでは主流ではなく、対応するライブラリやフレームワークがあまりない。
Result型のコード例
Result型は基本的には以下のようにResult[value, Exception]という成功時と失敗時の二つの型を持つ。成功時にはSuccessを返し、失敗が起きた場合にはFailureを返す。
from returns.result import Result, Failure, Success,
def get_square_root(value)-> Result[float, Exception]:
if value < 0:
return Failure(value)
return Success(value**(1/2))
Result型を返す関数は以下のように扱える。
is_successful()
で成功したかどうかを確認し、成功時と失敗時に分けた処理を記述できる。成功した値のみを取り出すには.unwrap()
を実行する。ただしFailureに対するunwrap()
はUnwrapFailedError
をraiseするため、.failure()
で取り出す必要がある。
from returns.pipeline import is_successful
def display_square_root(value: float)-> None:
result = get_square_root(value)
if is_successful(result):
print(result)
print(result.unwrap())
else:
print(result)
#print(result.unwrap()) UnwrapFailedError
print(result.failure())
display_square_root(4.0)
# <Success: 2.0>
# 2.0
display_square_root(-5.0)
# <Failure: -5.0>
# -5.0
また、returnsには@safe
と@attempt
デコレータがあり、こちらを使うと以下のように書ける。
from returns.result import safe, attempt
@safe #次の形に型変換を行う: Callable[[int], Result[float, Exception]]
def safe_divide(first: float, second: float) -> float:
return first / second
@attempt #attemptは一つの引数しか持てないことに注意
def attempt_divide(num: float) -> float:
return num / num
print(safe_divide(6, 0))
# <Failure: division by zero>
print(safe_divide(6, 2))
# <Success: 3.0>
print(attempt_divide(2))
# <Success: 1.0>
print(attempt_divide(0))
# <Failure: 0> attemptはエラーではなく、エラー時のargumentをラップする
result型を続けて処理したい場合には、.bind()
もしくは.map()
を利用する。これは通常のmapに同等し、.bind()
であればResult型を返す関数を.map()
であればそれ以外を返す関数を引数にとって、Resultに内包された値をその関数に渡し、値をResultにラップして返す。もしFailureが渡された場合には、処理を行わずそのまま返す。
>>>print(safe_divide(6, 2).map(lambda x: -x))
# <Success: -3.0>
>>>print(safe_divide(4, 2).bind(lambda x: Success(-x)))
# <Success: -2.0>
>>>print(safe_divide(3, 0).bind(lambda x: Success(-x)))
# <Failure: division by zero>
最後にこれらを組み合わせて処理を実行できるflow
を使ってみる。以下コードは公式ドキュメントの例から。
from returns.result import Result, Success, Failure
from returns.pointfree import bind
from returns.pipeline import flow
def regular_function(arg: int) -> float:
return float(arg)
def returns_container(arg: float) -> Result[str, ValueError]:
if arg != 0:
return Success(str(arg))
return Failure(ValueError('Wrong arg'))
def also_returns_container(arg: str) -> Result[str, ValueError]:
return Success(arg + '!')
assert flow(
1, # initial value
regular_function,
returns_container, # Resultでラップされたコンテナを返す
# Resultコンテナを扱うため、`bind`を使う
bind(also_returns_container),
) == Success('1.0!')
# Failureを返す場合
assert flow(
0, # initial value
regular_function,
returns_container, #既にここでFailureが返されているので、
bind(also_returns_container), #この行をコメントアウトしてもassertは成立する
).failure().args == ('Wrong arg', )
bind()
やmap()
を利用して次のように書いてもいいのだけれど、flow
を利用した方が見た目がスッキリする。
value = regular_function(1)
assert returns_container(value).bind(
also_returns_container
) == Success('1.0!')
以上のようにResult型を利用することで、どこでどのエラーが処理されるのかを明確に記述することができる。
さいごに
そもそも型付き言語でのプログラミング自体が慣れていないので、とっつきにくく感じた。しかし関数型プログラミングについては以前本(関数型プログラミングの基礎)で触れたことがあったので、モナドの取り扱いやRailway Programming自体については軽く知っていたため、このように利用することもできるとわかって嬉しかった。RustにはResultが標準ライブラリで実装されていたり、TypescriptでもResult型を利用している記事も多く見かけるので、それらについても使ってみたい。
参考
returns
Python exceptions considered an anti-pattern
TypeScriptでResult型でのエラーハンドリングを通してモナドの世界を覗いてみる
Using Results in TypeScript