3.3 デコレーターの威力

投稿者: | 2022-11-26
  • 『Pythonトリック』より

目次

3.3 デコレーターの威力

  • Pythonのデコレーターを利用すれば、基本的には、呼び出し可能オブジェクト(関数、メソッド、クラス)自体を恒久的に書き換えなくても、それらのオブジェクトの振る舞いを拡張したり変更したりできる
  • 既存のクラスや関数の振る舞いに追加しておかしくないほど十分に汎用的な機能は、デコレートするのにもってこいです
  • これには、次のような機能が含まれます
    • ログ機能
    • アクセス制御と認証の適用
    • 計測関数やタイミング関数
    • レート制限
    • キャッシュなど
  • では、Pythonのデコレーターの使用方法をマスターしなければならないのでしょうか
  • 日々の作業にデコレーターがどのように役立つかが見えてきません

  • もう少し現実的な例をみてみよう
  • レポート生成プログラムで30個の関数を使用しているとする
  • 上司が「監査用に必要なため、各ステップに入出力のログ機能を追加してもらいたい」と言ってきた
  • この場合、デコレーターを知らないと、30個の関数を大急ぎで変更し、ログ機能を呼び出すコードを書きなぐることになる
  • しかし、デコレーターを知っている場合は、余裕の笑みを浮かべることができる

  • @audit_logというわずか10行ほどの汎用デコレーターのコードを入力し、そのデコレーターを各関数の先頭行にペーストすればよい
  • つまり、デコレーターはそれほど強力
  • デコレーターを理解することが、Pythonに本気で取り込むかどうかの節目であるとも言える
  • そのため、ファーストクラス関数の特性を含め、この言語の高度な概念をしっかり理解しておく必要がある

デコレータの仕組みを理解することは計り知れない価値がある

  • 最初はなかなか理解できないが、デコレーターはサードパーティのフレームワークやPythonの標準ライブラリでよく目にする非常に有益な機能です
  • デコレーターの説明はPythonチュートリアルの成否の分かれ目でもある
  • ここではステップ形式で説明してみる
  • その前に、Pythonのファーストクラス関数の特性をもう一度確認しておくとよい

  • デコレーターを理解するにあたり最も重要な「ファーストクラス関数」のポイントをまとめる
  • 関数はオブジェクトである
    • 変数に代入したり、他の関数に引数として渡したり、関数から戻り値として返したりできる
  • 関数は他の関数の中で定義できる
    • 内側の関数は外側の関数のローカル状態を取得できる(レキシカルクロージャ)

デコレーターの基礎

  • デコレータとはいったい何?
  • デコレータは、別の関数をデコレート(ラッピング)することで、デコレートされた関数を実行する前後にコードを実行できるようにする機能
  • デコレートされた関数を書き換えなくてもよい
  • 基本、デコレータは呼び出し可能オブジェクトです
  • この呼び出し可能オブジェクトは、入力として呼び出し可能オブジェクトを受け取り、別の読み出し可能オブジェクトを返す
# - もっとも単純なデコレータ
def null_decorator(func):
    return func
  • null_decoratorは呼び出し可能オブジェクト(関数)であり、入力として別の呼び出し可能オブジェクトを受け取り、その呼び出し可能オブジェクトをそのまま返す
  • このデコレータを使って別の関数(greet)をデコレートしてみる
def greet():
    return 'Hello!'

greet = null_decorator(greet)
greet()
'Hello!'
  • この例では、greet関数を定義したあと、null_decorator関数を経由して実行することで、greet関数をデコレートしている
  • もっと便利な書き方にすると、Pythonの@構文を使って関数をデコレートする
@null_decorator
def greet():
    return 'Hello!'

greet()
'Hello!'
  • @構文は単なる糖衣構文(syntactic sugar)であり、このごく一部に使用されるパターンのショートカットです
  • @構文を使用すると、関数が定義されるとすぐにデコレートされることに注意してください
  • 通常、デコレートされていない元の関数にアクセスするのは難しくなります
  • そこで、デコレートされていない関数も呼び出せるようにするために、一部の関数を明示的にデコレートするという手もあります

デコレータは振る舞いを変更できる

  • 別のデコレータを記述してみる
  • このデコレータは実際に何かを行い、デコレートされた関数の振る舞いを変更する
  • 少し複雑な例として、デコレートされた関数の結果を大文字に変換するデコレータを見てみよう
def uppercase(func):
    def wrapper():
        original_result = func()
        modified_result = original_result.upper()
        return modified_result
    return wrapper
  • このuppercaseデコレータは、前述のnull_decoratorのように入力として渡された関数(入力関数、greet)をそのまま返すのではなく、新しい関数(wrapper)をその場で定義し(クロージャ)、その関数を使って入力関数(func)をラッピングすることで、入力関数の呼び出し時にその振る舞いを変更する
  • このwrapperクロージャは、入力として渡されたデコレートされていない元の関数(たぶんfunc)にアクセスできる
  • そして、その関数の呼び出し前後に追加のコードを実行できる(厳密には、入力関数を呼び出す必要すらありません)
  • この時点では、デコレートされた関数はまだ実行されていない
  • 実際には、入力関数をこの時点で呼び出してもまったく意味がありません。
  • どうしたいかというと、入力関数(func)が最終的に呼び出されるときに、その振る舞いをデコレータが変更できればよいわけです
  • このことについて少し考えみてください
  • ややこしいのは承知ですが、うまくつじつまが合うはずです
# def greet():
#    return 'Hello!'

@uppercase
def greet():
    return 'Hello!'

greet()
'HELLO!'
  • これが期待どおりの結果だとよいのですが、ここで何が起きているか詳しく見てみよう
  • このuppercaseデコレータは、null_decoratorとは異なり、関数をデコレートするときに別の関数オブジェクトを返します
greet
>> <function __main__.greet()>
null_decorator(greet)
>> <function __main__.greet()>
uppercase(greet)
>> <function __main__.uppercase.<locals>.wrapper()>
  • さきほどと同様に、デコレートされた関数の振る舞いについては、その関数が最終的に呼び出されるときに変更する必要がある
  • uppercaseデコレータはそれ自体が関数です
  • そして、uppercaseがデコレートする入力関数(func=greet)の「将来の振る舞い」を変更するには、入力関数をクロージャ(wrapper)と置き換える(またはクロージャでラッピングする)以外に方法はありません
  • uppercaseが別の関数(クロージャ)を定義して返すのはそのためです
  • このようにすると、返された関数を後から呼び出すことで、元の入力関数を実行してその結果を変更できるようになる
  • デコレータはラッパークロージャを通じて呼び出し可能オブジェクトの振る舞いを変更するため、元の関数を恒久的に書き換える必要はありません
  • 元の呼び出し可能オブジェクトは永遠に書き換えられずに、デコレートされたときだけにその振る舞いが変化することになる
  • このようにして、ログ機能などの再利用可能な構成要素を既存の関数に付け足すことができる
  • デコレータが標準ライブラリやサードパーティのパッケージで頻繁に使用されるほど強力な機能であることがうなずける
  • クロージャとデコレータはPythonにおいて最も理解しにくい概念(らしい)

関数に複数のデコレータを適用する

  • 関数には複数のデコレータを適用できる
  • このようにすると、それらのデコレータの効果が倍増する
  • デコレータが再利用可能な構成要素としてかくも有益なのは、そのため
  • 例をみてみる。次の2つのデコレータは、デコレートされた関数の出力文字列をHTMLタグで囲みます
  • これらのタグがどのようにネストされるのかを調べれば、Pythonが複数のデコレータをどの順序で適用するのかがわかる
def strong(func):
    def wrapper():
        return '<strong>' + func() + '</strong>'
    return wrapper

def emphasis(func):
    def wrapper():
        return '<em>' + func() + '</em>'
    return wrapper
  • では、これら2つのデコレータをgreet関数に同時に適用してみる
  • 通常の@構文を使用し、1つの関数の上に複数のデコレータを積み重ねるだけです
@strong
@emphasis
def greet():
    return 'Hello!'

greet()
'<strong><em>Hello!</em></strong>'
  • この結果から、デコレータは下から順に適用されるのがわかる
  • この下からの順番を覚えておくために、この振る舞いをデコレータスタックと呼ぶことにする
  • この例を分解し、@構文を使用せずにデコレータを適用するとしたら、次のようになる
# def greet():
#     return 'Hello!'

decorated_greet = strong(emphasis(greet))

decorated_greet()
'<strong><em>Hello!</em></strong>'

引数をとる関数のデコレート

  • ここまでの例は、引数を取らない単純なgreet関数をデコレートしただけでした
  • これまでのデコレータは、入力関数に引数を転送することはなかったが、引数を取る関数にしたいときはどうすればよいか?
  • ここで役立つのが、可変数の引数に対処するPythonの*args**kwargsです
  • 次のproxyデコレータはこの機能を利用する
def proxy(func):
    def wapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper
  • このデコレータには、注目すべき点が2つある
  1. wrapperクロージャの定義において、演算子*と**を使って、位置パラメータとキーワードパラメータに対する引数をすべて取得し、それらを変数argsとkwargsに格納している
  2. 続いて、wrapperクロージャが取得した引数を元の入力関数に転送している。引数の転送には、「引数アンパック」演算子*と**を使用している
  • 演算子*と**の意味がオーバーロードされ、それらが使用されるコンテキストに応じて変化する、という点は少し残念だが、考え方は理解できたと思います
  • proxyデコレータで実装した手法をもっと実践的な例として膨らませてみよう
  • 次に示すtraceデコレータは、実行時に関数の引数と結果を記録します
def trace(func):
    def wrapper(*args, **kwargs):
        print(f'TRACE: calling {func.__name__}() '
            f'with {args},{kwargs}')
        original_result = func(*args, **kwargs)

        print(f'TRACE: {func.__name__}() '
              f'returned {original_result!r}')

        return original_result
    return wrapper
@trace
def say(name, line):
    return f'{name}: {line}'

say('Jane', 'Hello,World')
TRACE: calling say() with ('Jane', 'Hello,World'),{}
TRACE: say() returned 'Jane: Hello,World'
'Jane: Hello,World'

「デバック可能」なデコレータを書く方法

  • デコレータを利用するときは、実際にはある関数を別の関数に置き換えているだけ
  • このプロセスには、元の(デコレートされていない)関数に紐付けられたメタデータの一部が「覆い隠されてしまう」という欠点がある
  • たとえば、元の関数の名前、docstring、パラメータリストは、ラッパークロージャによって隠されてしまう
def greet():
    """Return a friendly greeting."""
    return 'Hello!'

decorated_greet = uppercase(greet)
  • その関数のメタデータにアクセスしようとすると、代わりにラッパークロージャのメタデータが返される
greet.__name__
# >>>'greet'

greet.__doc__
# >>>'Return a friendly greeting.'

decorated_greet.__name__
# >>>'wrapper'

decorated_greet.__doc__
# >>>何も表示されない
  • これでは、Pythonインタープリターでのデバッグや操作が難しくなってしまう
  • ありがたいことに手っ取り早い解決方法がある
  • Pythonの標準ライブラリに含まれているfunctools.wrapsデコレータです
  • functools.wrapsをカスタムデコレータで使用すると、次のように、失われたメタデータをデコレートされていない関数からデコレータクロージャにコピーできる
import functools

def uppercase(func):
    @functools.wraps(func)
    def wrapper():
        return func().upper()
    return wrapper
  • functools.warpsをデコレータから返されたラッパークロージャに適用すると、入力関数のdocstringやその他のメタデータが引き継がれる
@uppercase
def greet():
    """Return a friendly greeting."""
    return 'Hello!'

greet.__name__
# >>>'greet'

greet.__doc__
# >>>'Return a friendly greeting.'
  • ベストプラクティスとして、カスタムデコレータを作成するときには常にfunctools.wrapsを使用することをお勧めします
  • 将来、自分自身や他人がデバックの苦痛から解法されるでしょう