COLUMNコラム

  • HOME
  • コラム
  • Excelの計算エラーのような例外をPythonで作る

Excelの計算エラーのような例外をPythonで作る

  • プロダクト開発

2021.01.20

プロダクト開発部 菰田 洵

プロダクト開発部で開発しているものの中に、ファイルから文字列を取得し、数値に変換して比較/計算するというものがあります。
文字列から数値へ変換する場合、当然その文字列が数字であることを期待しています。
しかし、現実には未入力や数字でない値が入れられたりします。

Pythonでそのような文字列を数値型へ変換しようとすると、当然エラーが投げられます。

>>> int("a")
Traceback (most recent call last):
    ...
ValueError: invalid literal for int() with base 10: 'a'

エラーが投げられないようにキャスト/計算する関数へ渡される前に分岐を挟むと、計算対象が増えるごとに分岐を設定しなければならずとても面倒です。

そのような状況を解決するヒントとなったのが、Excelの“#VALUE!”で表示されるエラーです。

もしExcelで「文字列のセル+数値のセル」を計算して、そのたびにExcelが強制終了していたらとても使いづらいことになります。

Excelではそのようなエラーが発生するセルに”#VALUE!”を表示して、全体は停止しないようになっています。

xl_value_error

さらにそのエラーの原因を検証できる機能もあります。

xl_show_process

xl_error_detail

xl_trace_error

xl_trace_arrow

このような例外をPythonで実装できないか考えたところ、計算やキャストされようとして失敗した際にエラーを投げる代わりに「算術演算の実装」された例外を返すという手法を取ることにしました。

実装

リポジトリはこちらです。

デコレートされた関数内でArithmeticErrorTypeErrorValueErrorが投げられるならば、代わりに算術演算の実装されたCalcErrorという例外を返すデコレータcalc_error_trapを作りました。

CalcError__cause__属性には、デコレートされた関数内で投げられたエラーが設定されているので、その__traceback__属性を見ればどこでエラーが発生したかを確認できます。

>>> @calc_error_trap
... def cast_to_int(string: str) -> int:
...     return int(string)
>>> ret = cast_to_int("5")
>>> ret
5
>>> ret = cast_to_int("a")
>>> ret
CalcError("invalid literal for int() with base 10: 'a'")
>>> ret.__cause__
ValueError("invalid literal for int() with base 10: 'a'")
>>> @calc_error_trap
... def myadd(a, b):
...     return a + b
>>> ret = myadd(1, 1)
>>> ret
2
>>> ret = myadd(1, "1")
>>> ret
CalcError("unsupported operand type(s) for +: 'int' and 'str'")
>>> ret.__cause__
TypeError("unsupported operand type(s) for +: 'int' and 'str'")
>>> @calc_error_trap
... def myrshift(a, b):
...     return a >> b
>>> ret = myrshift(1, 1)
>>> ret
0
>>> ret = myrshift(1, -1)
>>> ret
CalcError('negative shift count')
>>> ret.__cause__
ValueError('negative shift count')
>>> @calc_error_trap
... def mytruediv(a, b):
...     return a / b
>>> ret = mytruediv(6, 2)
>>> ret
3.0
>>> ret = mytruediv(1, 0)
>>> ret
CalcError('division by zero')
>>> ret.__cause__
ZeroDivisionError('division by zero')

CalcErrorは数値などと計算してもエラーが発生しません。 その代わり、自身のコピー(各属性が等しい値である別インスタンス)を返します。

>>> try:
...     raise CalcError("e")
... except CalcError as ce:
...     e = ce
>>> ret = e + 1
>>> ret
CalcError('e')
>>> ret is e
False
>>> ret.__cause__ is e.__cause__
True
>>> ret.__context__ is e.__context__
True
>>> ret.__suppress_context__ == e.__suppress_context__
True
>>> ret.__traceback__ == e.__traceback__
True
>>> vars(ret) == vars(e)
True

例外同士が計算された場合は、コピーではなく新しいCalcErrorインスタンスを返します。 そのインスタンスに設定された__cause__属性は、例外同士を計算させたことによるTypeErrorが設定されます。 この__traceback__を見れば、どの例外同士が計算されたかを確認でき、さらにその__cause__属性を辿ることで例外の発生原因を確認可能です。

>>> e2 = Exception("e2")
>>> ret = e + e2
>>> ret
CalcError("+ was used to operate: 'CalcError' and 'Exception'")
>>> ret.__cause__
TypeError("+ was used to operate: 'CalcError' and 'Exception'")
>>> ret = e2 - e
>>> ret
CalcError("- was used to operate: 'Exception' and 'CalcError'")
>>> ret.__cause__
TypeError("- was used to operate: 'Exception' and 'CalcError'")

CalcErrorのbool評価は常にFalseで、大小比較も全てFalseを返し、==で比較した場合は同じインスタンスの場合のみTrueを返します。

>>> e > 1
False
>>> e <= -10
False
>>> bool(e)
False
>>> x = e
>>> e == x
True
>>> e != CalcError("e")
True

数値のようなフォーマットをすることも可能です。 その場合、#CALC!が表示されます。

>>> f"{4:8d}"
'       4'
>>> f"{e:8d}"
'  #CALC!'
>>> f"{5:010}"
'0000000005'
>>> f"{e:010}"
'    #CALC!'

これによって、変換/計算時に分岐を挟む必要がなくなり、コードをすっきりさせることができます。

これらは全てMITライセンスで公開していますので、誰でも自由に使用/改変が可能です。

ご意見、ご感想、issue、PRなど、ありましたらお寄せください。お待ちしております。

タグをクリックすると、そのジャンルに属するコラムの一覧をご覧いただけます。