検索ガイド -Search Guide-

単語と単語を空白で区切ることで AND 検索になります。
例: python デコレータ ('python' と 'デコレータ' 両方を含む記事を検索します)
単語の前に '-' を付けることで NOT 検索になります。
例: python -デコレータ ('python' は含むが 'デコレータ' は含まない記事を検索します)
" (ダブルクオート) で語句を囲むことで 完全一致検索になります。
例: "python data" 実装 ('python data' と '実装' 両方を含む記事を検索します。'python data 実装' の検索とは異なります。)
当サイトのドメイン名は " getwebtips.net " です。
トップレベルドメインは .net であり、他の .com / .shop といったトップレベルドメインのサイトとは一切関係ありません。
practical_python_design_patterns

Practical Python Design Patterns - Python で学ぶデザインパターン: The Decorator Pattern - Part. 2 「デコレーターパターン」の巻 投稿一覧へ戻る

Published 2022年6月6日12:50 by T.Tsuyoshi

SUPPORT UKRAINE

- Your indifference to the act of cruelty can thrive rogue nations like Russia -

Practical Python Design Patterns - The Decorator Pattern 編

The Decorator Pattern
(デコレーターパターン)

ある関数をデコレート (修飾) するためには、デコレートされる関数をラップしてそれ自身が関数として利用できるオブジェクトを返す必要があります。Python においては「全て」のものはオブジェクトです。勿論「関数」もオブジェクトです。そして、そのオブジェクトが「関数」であるためには、 __call__() 特殊関数を実装している必要があります。つまり、デコレータが __call__() ダンダーメソッドを実装したオブジェクトを返すのであれbは、それは「関数」として利用できる、というわけです。
このような観点からみると、従来のデコレータパターンの実装はあまり「Python的 (Pythonic)」であるとは言えません。なぜなら、デコレータが返すのは「純粋な手続き型関数 (regular procedural functions)」 だからです。そこでこの章における「デコレータパターン」の説明では、より Pythonic な実装方法を取り上げます。そして、デコレータを利用するために Python で提供されている組み込みの @ シンタックスを活用します:
class_decorated_profiled_fib.py
import time
from typing import Callable, Any


class ProfilingDecorator:
"""
デコレータを関数ではなくクラスとして実装します。
__call__() 特殊関数を実装しているため、このクラスを関数として扱うことができます。
"""

def __init__(self, f: Callable[[Any], Any]):
print('実行時間計測デコレータが初期化されました')
self.f = f # ①

def __call__(self, *args) -> Any: # ②
start_time = time.perf_counter()
result = self.f(*args) # ③
end_time = time.perf_counter()
print(f'{n} 項のフィボナッチ数の算出時間 = {end_time - start_time}s') # ④

return result

@ProfilingDecorator # ⑤
def fib(n: int) -> int:
print('フィボナッチ関数を実行します')
if n < 2:
return 0

fibPrev = 1
fib = 1

for num in range(2, n):
fibPrev, fib = fib, fib + fibPrev

return fib

if __name__ == '__main__':
n = 77
print(f'{n} 項のフィボナッチ数 = {fib(n)}')
fib() 関数の先頭で記述した print() 文はデコレータとの実行順序を確認するためのものです。一度確認したら削除してください。そうすれば計測する実行時間にこの print 文が影響を与えなくなります。実行結果は以下のような出力になるはずです:
実行時間計測デコレータが初期化されました
フィボナッチ関数を実行します
77 項のフィボナッチ数の算出時間 = 0.00039219995960593224s
77 項のフィボナッチ数 = 5527939700884757
デコレータをクラスとして定義しています。このクラスで修飾された関数 (fib) は、クラスインスタンスの初期化中にインスタンス変数 (self.f) に保存されます。
実際の計測を行う関数です。
デコレータでラップされた (修飾された) オリジナル関数が実行されます。パフォーマンスカウンターで囲み実行時間を計測しています。
オリジナル関数 (デコレータ修飾された関数) の実行時間をコンソール出力しています。
Python インタプリタは、ProfilingDecorator クラスをインスタンス化し、コンストラクタへの引数として修飾している関数 (fib) を渡します。その結果返された ProfilingDecorator クラスのオブジェクトは __call__() 特殊関数を実装していますから、「__call__() を実装しているオブジェクトだから [関数] に違いない」というダックタイピング (duck typing) による判断のもと、オリジナル関数である fib() の実行は ProfilingDecorator「関数」の実行へと入れ替えられます。
__call__() メソッドのパラメータとして *args を指定していることにも注目してください。これにより不特定多数の位置引数を取得することができるようになっています。このパラメータの受け取り方は packing と言われているもので、渡されてきたすべての引数は tuple 型として args 変数にセットされます。そして、このデコレータで修飾されたオリジナル関数 (インスタンス属性である f に保存されています) を実行する際に再度 *args という形式で引数として渡しています。これは unpacking (アンパック) と呼ばれている機能で、args タプルの各要素がそれぞれ独立した位置引数として self.f() 関数に渡されることになります。
デコレータ (decorator) は unary function (単項関数: 引数を1つだけ取る関数) で、自分が修飾している関数を引数として受け取ります。そして結果として、受け取った関数を「ラップして」実行する関数を返します。つまり、ラップした関数の機能はそのままに、ある機能をプラスアルファすることを可能にするものです。ラップされた元の関数の機能は一切変化せずそのままですから、この性質を利用して「何重にも」デコレータを記述することが可能です。
ちょっとバカげた例ではありますが、計測した実行時間を HTML 形式の文字列として出力するデコレータも実装してみましょう:
stacked_fib.py
import time
from typing import Callable, Any


class ProfilingDecorator:
def __init__(self, f: Callable[[Any], Any]): # ②
print('実行時間計測デコレータが初期化されました')
self.f = f

def __call__(self, *args) -> Any: # ⑤
print('ProfilingDecorator が呼び出されました')
start_time = time.perf_counter()
result = self.f(*args) # ⑥
end_time = time.perf_counter()
print(f'{n} 項のフィボナッチ数の算出時間 = {end_time - start_time}s') # ⑦

return result # ⑧


class ToHTMLDecorator:
def __init__(self, f: Callable[[Any], Any]): # ③
print('HTML文字列出力デコレータが初期化されました')
self.f = f

def __call__(self, *args) -> str: # ④
print('ToHTMLDecorator が呼び出されました')
return f'<html><body>{self.f(*args)}</body></html>' # ⑧

@ToHTMLDecorator # ③
@ProfilingDecorator # ②
def fib(n: int) -> int: # ⑥
print('フィボナッチ関数を実行します')
if n < 2:
return 0

fibPrev = 1
fib = 1

for num in range(2, n):
fibPrev, fib = fib, fib + fibPrev

return fib

if __name__ == '__main__':
n = 77
print(f'{n} 項のフィボナッチ数 = {fib(n)}') # ① ⑨
このコードを実行した結果は以下のようになります。通常、デコレータで修飾された (ラップされた) 関数の返り値の型とデコレータ自体が返す型は一致しているのが普通です。しかしこのちょっと特殊な例では、ラップされている関数 (fib) が返す型 (int) とデコレータから最終的に返ってくる型 (str) が異なっています。あまりうまい例ではありませんね...
実行時間計測デコレータが初期化されました # ②
HTML文字列出力デコレータが初期化されました # ③
ToHTMLDecorator が呼び出されました # ④
ProfilingDecorator が呼び出されました # ⑤
フィボナッチ関数を実行します # ⑥
77 項のフィボナッチ数の算出時間 = 0.00033489998895674944s # ⑦
77 項のフィボナッチ数 = <html><body>5527939700884757</body></html> # ⑨
ただいい機会ですからこのように2つのデコレータで修飾されている場合の処理の流れを追ってみたいと思います:
デコレート修飾されている関数 (fib) を実行します。
まず最も内側のデコレータが呼び出されます。この時 ProfilingDecorator クラスのオブジェクトが作られコンストラクタへの引数として fib() が渡されます。ここで fib() の実行は ProfilingDecorator「関数」の実行へと変換されます。
続いて ToHTMLDecorator デコレータが呼び出されます。この時 ToHTMLDecorator クラスのオブジェクトが作られコンストラクタへの引数として fib() をラップしている ProfilingDecorator 「関数」が渡されます。ここで大元の fib() の実行は ToHTMLDecorator「関数」の実行へと変換されます。
もう修飾しているデコレータはありませんから、全てをラップしている ToHTMLDecorator「関数」が実行されます; すなわち ToHTMLDecorator(n) です。この中では自分がラップしている ProfilingDecorator(n) を実行しています。
ProfilingDecorator(n) が実行されます。この中では自分がラップしている fin(n) を実行すると同時に、その実行をタイマーで挟んで実行時間を計測しています。
ここでやっと大元の fib(n) が実行され n 項のフィボナッチ数を求めて返します。この値は呼び出し元である ProfilingDecorator「関数」の result 変数にセットされます。
計測時間をコンソールに出力します。
算出されたフィボナッチ数を呼び出し元である ToHTMLDecorator「関数」へ返します。ToHTMLDecorator「関数」ではその値を元に HTML 文字列を作成し返します。
呼び出し元では返り値から文字列を作成しコンソール出力、プログラムを終了します。
さて、ここまでの例では、「全てはオブジェクトであり、そのオブジェクトが __call__() を実装していれば [関数] として利用することができる」という Python の特徴を活かして、デコレータとしてクラスを利用してきました。しかしここで、デコレータとしてクラスではなく「通常の関数」を利用する実装を見てみましょう:
function_decorated_fib.py
import time
from typing import Callable, Any

def profiling_decorator(f: Callable[[Any], Any]) -> Callable[[Any], Any]: # ②
def wrapped_f(n: Any) -> Any: # ④
start_time = time.perf_counter()
result = f(n) # ⑤
end_time = time.perf_counter()
print(f'{n} 項のフィボナッチ数の算出時間 = {end_time - start_time}s')

return result # ⑥

return wrapped_f # ③

@profiling_decorator # ②
def fib(n: int) -> int: # ③
print('フィボナッチ関数を実行します')
if n < 2:
return 0

fibPrev = 1
fib = 1

for num in range(2, n):
fibPrev, fib = fib, fib + fibPrev

return fib

if __name__ == '__main__':
n = 77
print(f'{n} 項のフィボナッチ数 = {fib(n)}') # ① ⑥
デコレータを通常の関数を利用して実装した場合、実行順序が少しややこしく感じられるかもしれません。順を追って見ていきましょう:
デコレータ修飾されている関数が呼び出されます。
デコレータ関数が呼び出されます。この際、デコレータ関数への引数として修飾されている関数 (fib) が渡されます。
デコレータ関数は高階関数 (higher-order function) ですから、引数として関数 (fib) を取り、返り値として関数 (wrapped_f) を返します。この段階で、fib(n) の実行は wrapped_f(n) の実行へと置き換えられます。
wrapped_f(n) が実行されます。この関数はオリジナル関数 (fib) をラップしていると同時に、自分自身はデコレータ関数 (profiling_decorator) の内部関数です。この構造は Python において「クロージャ (closure)」と呼ばれているもので、内部関数 (wrapped_f) は自分を囲っている外部関数 (profiling_decorator) の引数にもアクセスすることが可能です。
オリジナル関数を実行、その実行時間を計測します。
オリジナル関数の実行結果 (フィボナッチ数) が返され表示されます。
Closures
(クロージャ)
ここでもう少しクロージャ関数 (closure functions) について見てみましょう。wrapped_f() 内でアクセスしている f はこの関数のローカル変数でも何でもなく、自分を囲む関数 (profiling_decorator) に渡されてきた引数です。しかも、この wrapped_f() が実行される段階では、外側の profiling_decorator() は終了しています (return wrapped_f をしていますからね)。ではどうして wrapped_f() は既に終了してる関数のスコープに存在していたパラメータ f にアクセスできているのでしょうか?
実はこれが「クロージャ」の特徴なんです。ある関数でラップされている関数が実行される際、その関数には「自分をラップしている関数」の属性が渡され保存されます。ですからラップ関数 (profiling_decorator) が return して名前空間から消えてしまっても、その関数が保持していた変数の値にアクセスできる、というわけです。
つまり、クロージャを実装するには、ある関数 (a) を定義し、その関数で自分の中に定義した関数 (b) を返すようにすればいいんですね。そうすれば b において a の属性情報にアクセスできる、というわけです。
Retaining Function __name__ and __doc__ Attributes
(デコレータ修飾された関数における __name__ 属性と __doc__ 属性の値の保持)
先にも述べたように、デコレータを利用しても、オリジナル関数 (ラップされる関数) のいかなる機能にも影響が及ばないのが理想です。しかし、ここまでの実装では、オリジナル関数の __name__ と __doc__ 属性の値はラップしている wrapped_f() のものに書き換えられてしまっています:
func_attrs.py
import time
from typing import Callable, Any

def profiling_decorator(f: Callable[[Any], Any]) -> Callable[[Any], Any]:
def wrapped_f(n: Any) -> Any:
"""
wrapped_f 関数の doc string です
"""

print(f'ラップされている関数名: {f.__name__}')
print(f'ラップされている関数のドキュメンテーション文字列: {f.__doc__}')
print(f'ラップしている関数名: {wrapped_f.__name__}')
print(f'ラップしている関数のドキュメンテーション文字列: {wrapped_f.__doc__}')
start_time = time.perf_counter()
result = f(n)
end_time = time.perf_counter()

return result

return wrapped_f

@profiling_decorator
def fib(n: int) -> int:
"""
fib 関数の doc string です
"""

if n < 2:
return 0

fibPrev = 1
fib = 1

for num in range(2, n):
fibPrev, fib = fib, fib + fibPrev

return fib

if __name__ == '__main__':
print(f'デコレータ修飾されている関数名: {fib.__name__}')
print(f'デコレータ修飾されている関数のドキュメンテーション文字列: {fib.__doc__}')
n = 77
fib_val = fib(n)
実行結果を見てください。デコレータ修飾されている関数は fib() にもかかわらず、取得できる関数名もドキュメンテーション文字列も wrapped_f のものになってしまっています:
デコレータ修飾されている関数名: wrapped_f
デコレータ修飾されている関数のドキュメンテーション文字列:
wrapped_f 関数の doc string です

ラップされている関数名: fib
ラップされている関数のドキュメンテーション文字列:
fib 関数の doc string です

ラップしている関数名: wrapped_f
ラップしている関数のドキュメンテーション文字列:
wrapped_f 関数の doc string です
デコレータ修飾されている関数の __name__ と __doc__ の値を間違いなく取得できるようにするには、デコレータ関数の「スコープを抜ける前」にラップされている関数の値で上書きしておく必要があります:
correct_func_attrs.py
import time
from typing import Callable, Any

def profiling_decorator(f: Callable[[Any], Any]) -> Callable[[Any], Any]:
def wrapped_f(n: Any) -> Any:
"""
wrapped_f 関数の doc string です
"""

print(f'ラップされている関数名: {f.__name__}')
print(f'ラップされている関数のドキュメンテーション文字列: {f.__doc__}')
print(f'ラップしている関数名: {wrapped_f.__name__}')
print(f'ラップしている関数のドキュメンテーション文字列: {wrapped_f.__doc__}')
start_time = time.perf_counter()
result = f(n)
end_time = time.perf_counter()

return result

wrapped_f.__name__ = f.__name__
wrapped_f.__doc__ = f.__doc__
return wrapped_f

@profiling_decorator
def fib(n: int) -> int:
"""
fib 関数の doc string です
"""

if n < 2:
return 0

fibPrev = 1
fib = 1

for num in range(2, n):
fibPrev, fib = fib, fib + fibPrev

return fib

if __name__ == '__main__':
print(f'デコレータ修飾されている関数名: {fib.__name__}')
print(f'デコレータ修飾されている関数のドキュメンテーション文字列: {fib.__doc__}')
n = 77
fib_val = fib(n)
こうすることで、ラップしている関数の情報は一切表には出なくなり、ラップされている関数の正しい情報のみが取得できるようになります:
デコレータ修飾されている関数名: fib
デコレータ修飾されている関数のドキュメンテーション文字列:
fib 関数の doc string です

ラップされている関数名: fib
ラップされている関数のドキュメンテーション文字列:
fib 関数の doc string です

ラップしている関数名: fib
ラップしている関数のドキュメンテーション文字列:
fib 関数の doc string です
実は、Python の標準ライブラリには、我々自身がこのような実装の手間をかけなくてもラップされる関数の __name__ と __doc__ 属性の値を提供するよう指示するために利用可能なモジュールが含まれています。更に都合の良いことには、このモジュールで提供されている各関数は higher-order function ですから、デコレータパターンにおける「ラップしている関数」をデコレートすることで機能を提供してくれる、という、正にこの章の学習にはベストマッチしているものです:
correct_func_attrs_with_functools.py
import time
from typing import Callable, Any
from functools import wraps

def profiling_decorator(f: Callable[[Any], Any]) -> Callable[[Any], Any]:

@wraps(f)
def wrapped_f(n: Any) -> Any:
"""
wrapped_f 関数の doc string です
"""

print(f'ラップされている関数名: {f.__name__}')
print(f'ラップされている関数のドキュメンテーション文字列: {f.__doc__}')
print(f'ラップしている関数名: {wrapped_f.__name__}')
print(f'ラップしている関数のドキュメンテーション文字列: {wrapped_f.__doc__}')
start_time = time.perf_counter()
result = f(n)
end_time = time.perf_counter()

return result

return wrapped_f

@profiling_decorator
def fib(n: int) -> int:
"""
fib 関数の doc string です
"""

if n < 2:
return 0

fibPrev = 1
fib = 1

for num in range(2, n):
fibPrev, fib = fib, fib + fibPrev

return fib

if __name__ == '__main__':
print(f'デコレータ修飾されている関数名: {fib.__name__}')
print(f'デコレータ修飾されている関数のドキュメンテーション文字列: {fib.__doc__}')
n = 77
fib_val = fib(n)
出力結果に変化はありませんが、我々自身で「正しい」__name__ と __doc__ の値を割り当てるコードを書く必要はありません。
さて、現在の実行時間計測デコレータの計測表示単位は「秒」ですが、もし「ミリ秒」でも表示できるように選択可能にしたい場合はどうしたらよいのでしょうか?そのためにはデコレータ関数自身に引数を渡し、その値によってラップしている関数の実行内容を変える必要がありそうです。
これは decorator factory (デコレータファクトリー) とも呼ばれる手法です。まずデコレータ関数を定義し、そのデコレータを「ラップ」するもう1つ外側の関数を定義します。その一番外側の関数に対して計測表示単位を指定するための引数を渡すようにします。デコレータ内のクロージャ関数ではその値を見て実行内容を変更するわけです。
このシリーズをここまで学習してきた方であれば「ピン!」と来たと思います。これはまさに「ファクトリーパターン」です。文字列を引数として受け取り、それを判断材料として実行する関数を選択/変更するのがファクトリー関数でしたね:
import time
from typing import Callable, Any
from functools import wraps

def profiling_decorator_with_unit(unit: str = 'second') -> Callable[[Any], Any]: # ②
def profiling_decorator(f: Callable[[Any], Any]) -> Callable[[Any], Any]:
@wraps(f)
def wrapped_f(n: Any) -> Any:
start_time = time.perf_counter()
result = f(n)
end_time = time.perf_counter()

elapsed_time = end_time - start_time
elapsed_unit = 's'
if unit == 'millisecond':
elapsed_time = elapsed_time * 1000
elapsed_unit = 'ms'

print(f'{n} 項のフィボナッチ数の算出時間: = {elapsed_time}{elapsed_unit}')

return result
return wrapped_f
return profiling_decorator # ③

@profiling_decorator_with_unit('millisecond') # ② ③
def fib(n: int) -> int:
if n < 2:
return 0

fibPrev = 1
fib = 1

for num in range(2, n):
fibPrev, fib = fib, fib + fibPrev

return fib

if __name__ == '__main__':
n = 77
print(f'{n} 項のフィボナッチ数 = {fib(n)}') # ①
もう一段 def 定義のネストが深くなりました。新たに付け加わった部分だけちょっと流れを追ってみましょう。
fib() を実行します。
fib() は @ シンタックスで修飾されています。が、今回 @ の後ろの構文 profiling_decorator_with_unit('millisecond') は通常の関数の実行です。ですから、まずは普通にこの関数が実行されます。
profiling_decorator_with_unit() はデコレータである profiling_decorator を返します。よって、fib() を修飾している @profiling_decorator_with_unit('millisecond') は @profiling_decorator に置き換えられます。ここまでくれば以降は今までやってきたデコレータの処理の流れになります。
デコレータ定義の最深部に位置するラッパー関数 wrapped_f() は、今まで通りに、デコレータである profiling_decorator() のクロージャであると同時に、今回はその外側の profiling_decorator_with_unit() のクロージャでもあります。よって、この2つの関数に渡されてきたパラメータにアクセスできます。

この投稿をメールでシェアする

0 comments

コメントはまだありません。

コメントを追加する(不適切と思われるコメントは削除する場合があります)