本記事は『Pythonトリック』の「CHAPTER 2 よりクリーンなPythonのためのパターン」から抜粋したものです。掲載にあたり一部を編集しています。
2.1 アサーションによる安全対策
本当に役立つ言語の機能が案外に注目されないことがあります。どういうわけか、これに該当するのがPythonの組み込みのassert文です。
ここでは、Pythonでのアサーションの使い方をざっと紹介します。アサーションを使ってPythonプログラムでエラーを自動的に検出する方法がわかるでしょう。このようにすると、プログラムの信頼性が高まり、デバッグも容易になります。
この時点で、「アサーションとは何だろう、何の役に立つのだろう」と考えているかもしれません。その答えを教えましょう。
Pythonのassert文は、基本的には、条件をテストするデバッグ支援ツールです。アサーションの条件がTrueの場合は何も起きず、プログラムは何事もなく動作し続けます。しかし、アサーションの条件がFalseと評価された場合はAssertionError例外が送出され、必要に応じてエラーメッセージが生成されます。
Pythonのアサーション:例
アサーションがどのような状況で役立つのかがわかるよう、簡単な例を見てみましょう。次の例は、各自のプログラムで実際に遭遇するかもしれない現実的な問題に似たものにしてあります。
Pythonでオンラインストアを構築しているとしましょう。あなたはこのシステムに割引クーポン機能を追加しようとしており、最終的に次のapply_discount関数を記述します。
def apply_discount(product, discount): price = int(product['price'] * (1.0 - discount)) assert 0 <= price <= product['price'] return price
assert文が含まれているのがわかるでしょうか。この文により、たとえ何があろうと、この関数によって計算される割引価格が0ドルを下回らないことと、商品の元の値段を上回らないことが保証されます。
この関数を呼び出して有効な割引を適用した場合に、この機能が実際にうまくいくことを確認してみましょう。この例では、オンラインストアの商品を単純なディクショナリとして表すことにします。実際のアプリケーションではおそらくそのようにはしませんが、アサーションのデモならこれで問題ないでしょう。ここでは商品の例として、149.00ドルの靴を作成します。
>>> shoes = {'name': 'Fancy Shoes', 'price': 14900}
ところで、整数を使って金額をセントで表すことで、通貨の端数処理問題を回避していることに気づいたでしょうか。一般的にはよい考えですが、本題からそれてしまうので詳しい説明は省略します。この靴に25%の割引を適用した場合は、111.75ドルの特別価格になるはずです。
>>> apply_discount(shoes, 0.25) 11175
うまくいったようです。今度は、無効な割引を適用してみましょう。たとえば、200%の「割引」を適用すると、顧客にお金をあげることになってしまいます。
>>> apply_discount(shoes, 2.0) Traceback (most recent call last): File "", line 1, in File " ", line 3, in apply discount assert 0 <= price <= product['price'] AssertionError
このように、無効な割引を適用しようとすると、プログラムがAssertionErrorで停止します。こうなるのは、200%の割引がapply_discount関数に配置したアサーションの条件に違反するためです。
また、失敗したアサーションを含んでいるコード行が例外のスタックトレースによって正確に特定されることもわかります。あなたや同じチームの別の開発者がオンラインストアのテスト中にこのようなエラーの1つに遭遇した場合は、例外のトレースバックを調べるだけで、何が起きたのかをすぐに把握することができます。
このようにすると、デバッグ作業がかなり効率化され、長期的に見て、プログラムがよりメンテナンスしやすくなります。そしてそれこそが、アサーションの威力なのです。
なぜ通常の例外ではだめなのか
おそらく先の例を見て、単にif文と例外を使用しなかったのはなぜだろうと思っていることでしょう。
アサーションの正しい使い方は、プログラム内の回復不可能なエラーについての情報を開発者に知らせることです。File-Not-Foundエラーのように、想定内のエラー状態を知らせることはアサーションの目的ではありません。そうした想定内のエラー状態では、ユーザーが修正措置を施すか、単にリトライすればよいわけです。
アサーションはプログラムの内部セルフチェックと位置付けられており、コード内で何らかの状態をあり得ないものとして宣言します。こうした状態が1つでも発生すれば、プログラムにバグがあることになります。
プログラムにバグがなければ、こうした状態になることは決してありません。しかし、そうした状態が実際に発生した場合、プログラムはアサーションエラーでクラッシュし、どの「あり得ない」状態が発生したのかを正確に知らせます。これにより、プログラムのバグを追跡して修正するのがはるかに容易になります。そして、筆者の作業を楽にしてくれるものは何だって歓迎します。あなただってそうでしょう? さしあたり、次のように覚えておいてください。Pythonのassert文はデバッグ支援ツールであり、ランタイムエラーに対処するためのメカニズムではありません。アサーションを使用する目的は、バグの根本原因と推定されるものを開発者がすばやく見つけ出せるようにすることです。プログラムにバグがなければ、アサーションエラーが発生することはないはずです。
アサーションを使って他に何ができるか詳しく見ていきましょう。ここでは、現実的なシナリオでアサーションを使用するときによくある落とし穴を2つ紹介します。
Pythonのアサーション構文
言語の機能を使い始める前に、その機能がPythonでどのように実装されているのかを詳しく調べてみるのは常によい考えです。そこで、Pythonドキュメントに照らしてassert文の構文をざっと見ておきましょう。
assert stmt ::= "assert" expression1 ["," expression2]
この場合、expression1はテストする条件です。オプションのexpression2はアサーションが失敗した場合に表示されるエラーメッセージです。各assert文は、実行時にPythonインタープリタによって次のような一連の文に変換されます。
if debug : if not expression1: raise AssertionError(expression2)
このコードには興味深い点が2つあります。
1つは、アサーションの条件をチェックする前に、debugグローバル変数もチェックすることです。この変数は組み込みのBooleanフラグであり、通常の状況ではTrue、最適化が要求される場合はFalseになります。この点については、「注意点1:データの検証にアサーションを使用しない」でもう少し説明します。
また、expression2を使ってオプションのエラーメッセージを渡すこともできます。このエラーメッセージはトレースバックにおいてAssertionErrorとともに表示されます。このようにすると、デバッグがさらに単純になることがあります。たとえば、筆者は次のようなコードを見たことがあります。
>>> if cond == 'x': ... do x() ... elif cond == 'y': ... do y() ... else: ... assert False, ( ... 'This should never happen, but it does occasionally. ' ... 'We are currently trying to figure out why. ' ... 'Email dbader if you encounter this in the wild. Thanks!')
みっともないコードかと言われれば、そのとおりでしょう。しかし、アプリケーションの1つでハイゼンバグ(調査しようとすると消えたり、振る舞いを変化させたりするバグ。ハイゼンベルグの不確定性原理をもじったもの)に直面しているとしたら、これが有効かつ有益な手法であることは間違いありません。
Pythonでアサーションを使用するときによくある落とし穴
先へ進む前に、Pythonでのアサーションの使用に関して注意しておきたい重要な点が2つあります。
1つ目は、アプリケーションにセキュリティリスクやバグをもたらすことに関連しており、2つ目は、無意味なアサーションを書いてしまいやすい構文の癖についてです。
かなりぞっとする話なので(そしておそらく実際にそうであるため)、次の2つの注意点に少なくともざっと目を通しておいてください。
注意点1:データの検証にアサーションを使用しない
Pythonでのアサーションの使用に関する最大の注意点は、アサーションをグローバルに無効化できることです。アサーションを無効にするには、コマンドラインスイッチ-Oおよび-OOを使用するか、CPythonの環境変数PYTHONOPTIMIZEを使用します。
そうすると、すべてのassert文がnull操作になります。つまり、アサーションはコンパイル時に取り除かれ、評価されなくなります。このため、条件式は1つも実行されなくなります。
これは他の多くのプログラミング言語でも採用されている設計上の決定です。このため、入力データを手っ取り早く検証する手段としてassert文を使用するのはきわめて危険な行為となります。
どういうことか説明しましょう。関数の引数に「間違った値」や想定外の値が含まれているかどうかをチェックするためにプログラムでassert文を使用するとしましょう。それはすぐさま逆効果となり、バグやセキュリティホールの原因になることがあります。
この問題を具体的に示す簡単な例を見てみましょう。この場合もPythonでオンラインストアを構築しているものとします。このアプリケーションのどこかに、ユーザーのリクエストに応じて商品を削除するための関数が含まれています。
アサーションを覚えたばかりのあなたは、それらをコードで使ってみたくてたまりません(同じ立場なら、筆者だってそうでしょう)。そこで、この関数を次のように実装します。
def delete product(prod id, user): assert user.is admin(), 'Must be admin' assert store.has_product(prod id), 'Unknown product' store.get product(prod id).delete()
このdelete product関数を詳しく見てみましょう。アサーションが無効化された場合はどうなるでしょうか。
この3行の関数には深刻な問題が2つあります。これらの問題の原因は、assert文の誤った使い方にあります。
-
assert文で管理者特権をチェックするのは危険な行為である
アサーションがPythonインタープリタで無効化されている場合、これはnull操作になります。このため、誰でも商品を削除できる状態になります。特権のチェックは実行すらされません。これはセキュリティ問題の原因になる可能性があり、オンラインストアのデータを破壊したり、深刻なダメージを与えたりする機会を攻撃者に与えることになります。
-
アサーションが無効化されているとhas_product()チェックが省略される
つまり、get product関数を無効な商品IDで呼び出せるようになります。プログラムがどのように書かれているかによっては、さらに深刻なバグにつながるかもしれません。最悪の場合は、このオンラインストアにDoS(Denial of Service)攻撃を仕掛ける手段になったとしてもおかしくありません。たとえば、誰かが不明な商品を削除しようとするとストアアプリがクラッシュするとしたら、攻撃者が無効な削除リクエストを使ってストアアプリを攻撃し、機能停止に陥れるおそれがあります。
このような問題を回避するにはどうすればよいのでしょう。その答えは、データの検証にアサーションを決して使用しないことです。代わりに、通常のif文で検証を行い、必要に応じて検証例外を送出するとよいでしょう。
def delete product(product id, user): if not user.is admin(): raise AuthError('Must be admin to delete') if not store.has_product(product id): raise ValueError('Unknown product id') store.get product(product id).delete()
このように書き換えることには、あいまいなAssertionError例外が発生する代わりに、ValueErrorやAuthErrorといった意味的に正しい例外が発生するようになる、というメリットもあります(これらの例外は明示的に定義する必要があります)。
注意点2:決して失敗しないアサーション
常にTrueと評価されるasset文をうっかり書いてしまうのは意外によくあることです。筆者も過去に痛い目を見ました。かいつまんで言うと、これは次のような問題です。
assert文の第1引数としてタプルを渡すと、アサーションは常にTrueと評価されるため、決して失敗しません。
たとえば次のアサーションは決して失敗しません。
assert(1 == 2, 'This should fail')
この問題は、空ではないタプルがPythonでは常に真と評価されることと関係しています。タプルをasset文に渡すと、アサーションの条件は常にTrueになります。上記のasset文は、失敗して例外を発生させることが決してないため、無意味になってしまいます。
この非直観的な振る舞いのせいで、不適切なasset文を複数行にわたって書いてしまう、ということが比較的容易に起こります。たとえば筆者は、テストスイートの1つで偽りの安心感をもたらす無意味なテストケースを嬉々として量産したことがあります。次のアサーションが筆者のユニットテストの1つに含まれていたと想像してください。
assert ( counter == 10, 'It should have counted all the items' )
一見すると、このテストケースはまったく問題なさそうですが、不正な結果を決してキャッチしません。counter変数の状態に関係なく、このアサーションは常にTrueと評価されます。なぜそうなるのでしょうか。タプルオブジェクトの真偽値をアサートするか らです。
先に述べたように、これで墓穴を掘るのは意外によくあることです(筆者はそのときのことをまだ引きずっているほどです)。この構文上の癖がトラブルに発展するのを防ぐために、コードリンターを使用するとよいでしょう。なお、Python 3の最近のバージョンでは、こうしたあやしいアサーションに対してSyntaxWarningが生成されます。
なお、こうした理由もあるので、ユニットテストケースでは常に簡単なスモークテストを行ってください。テストが実際に失敗するのを確認してから、次のテストを記述するようにしてください。
Pythonのアサーション:まとめ
これらの注意点があるにせよ、Pythonのアサーションは強力なデバッグツールですが、開発者によって十分に活用されないことが多いようです。
アサーションの仕組みと、それらをどのような状況で適用すればよいかを理解すれば、メンテナンスやデバッグや容易なPythonプログラムを記述するのに役立つはずです。
このすばらしいスキルを身につければ、Pythonの知識をレベルアップし、オールラウンドのPython使いになるのに役立つでしょう。おかげで筆者は何時間も続くデバッグから解放されました。
ここがポイント
- Pythonのassert文はプログラムの内部セルフチェックとして条件をテストするデバッグ支援ツールである。
- アサーションは開発者によるバグの特定を助けるために使用すべきものであり、ランタイムエラーに対処するためのメカニズムではない。
- アサーションはインタープリタの設定を使ってグローバルに無効化できる。