検索ガイド -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. 3 「クラスを修飾するデコレーター & クロージャークラス」の巻 投稿一覧へ戻る

Published 2022年6月7日20:58 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 編
「クラスを修飾するデコレーター & クロージャークラス」の巻

Decorating Classes
(クラスを修飾するデコレーター & クロージャークラス)

もし、あるクラスで定義されているすべてのメソッドの実行時間を計測したいとしたら、次のようなコードを記述することになりますね:
class DoSomethingClass:
@profiling_decorator
def to_do_1(self):
...

@profiling_decorator
def to_do_2(self):
...

...
全てのクラスメソッドを同じデコレータで修飾する、というこの方法は期待通りに動作してくれます。ですが DRY (Don't Repeat Yourself) 原則に違反してしまっています。もしシステムのパフォーマンス確認が終了しデコレータを削除してもよい段階になった時、修飾している全てのメソッドからこのデコレータを取り除かなければなりません。もしこのクラスに新しいメソッドを追加しそのクラスをこのデコレータで修飾するのを忘れてしまったら、そのメソッドはこのパフォーマンスチェックから漏れてしまうことになります。もっと良い方法が必要です...
やりたいことは、Python に対して「このクラスのメソッドはすべてこのデコレータで修飾してね」ということを通知し実行してもらうために該当クラスに印をつけておく (デコレートしておく) ことです。コードで表せば次のようになるはずです:
@profile_all_class_methods
class DoSomethingClass:
def to_do_1(self):
...

def to_do_2(self):
...

...
つまり、DoSomethingClass を利用する側から見た場合はまさしく DoSomethingClass そのもので何ら違いはありませんが、内部的には全てのメソッドに対する呼び出しに指定されたデコレータが適用されるようにする、という付加機能を提供する DoSomethingClass をラップするクラスが必要だ、ということです。
実行時間計測デコレータのコードは前章実装したものを利用できます。しかし、ラッパー関数 (デコレータ内のクロージャ関数) はどのようなパラメータを持つ関数でも受け入れられるようによりジェネリックな実装にする必要があります:
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(*args, **kwargs) -> Any:
start_time = time.perf_counter()
result = f(*args, **kwargs)
end_time = time.perf_counter()
print(f'{f.__name__} の実行時間: {end_time - start_time}s')

return result

return wrapped_f
今回は、位置引数をまとめたもの (*args) と名前付き引数をまとめたもの (**kwargs) という2つの引数を取るようにしました。これによって、Python において関数に渡されてくる引数のあらゆるパターンに対応できるようになります。また、ラッパー関数内でオリジナル関数を実行する際にはこれらを「アンパック (unpack)」して渡しています。
さて、これでラッパー関数の対応は終了しましたから、いよいよ本題の「クラスデコレータ」の実装に入りましょう。先程も少し触れましたが、この「クラスデコレータ」は、ラップしたクラスに含まれる全てのメソッドをここで準備したラッパー関数でデコレートしたクラスを返すものです。勿論修飾したクラス自体の機能には一切手を加えません。これを達成するために利用するのが __getattribute__() 特殊関数です。
Python はあるオブジェクトの属性へのアクセスがある度にこの __getattribute__() を呼び出します。同じような特殊関数に __getattr__() がありますが、__getattr__() がアクセスがあった属性がインスタンスの属性辞書に存在しない場合にのみ呼び出されるのに対し、__getattribute__() は属性辞書にその属性が存在する、しないにかかわらずアクセスがある度に必ず呼び出されます (これについてもう少し詳しく知りたい方は、【 Effective Python, 2nd Edition 】__getattr__、__getattribute__、__setattr__ メソッドを利用して、事前に定義していないインスタンス属性を操作しよう! を参照してください)。
ですからこの __getattribute__() をオーバーライドして、ラップしたクラスのインスタンスメソッドに対するアクセスがあった場合には、そのメソッドを上記のデコレータ関数でラップして返すようにすれば我々の目的は達成できます。しかし、__getattribute__() 特殊関数は、インスタンス変数、インスタンスメソッド、どちらの属性アクセスに対しても呼び出されますから、アクセスされた属性が「メソッド」であることを内部でチェックする必要があります:
class_profiler.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(*args, **kwargs):
start_time = time.perf_counter()
result = f(*args, **kwargs)
end_time = time.perf_counter()
print(f'{f.__name__} の実行時間: {end_time - start_time}s')

return result

return wrapped_f

def profile_all_class_methods(wrapped_class): # ②
class ProfileClass:
def __init__(self, *args, **kwargs): # ④
self.inst = wrapped_class(*args, **kwargs)

def __getattribute__(self, attr): # ⑤
try:
value = super(ProfileClass, self).__getattribute__(attr)
if attr == 'inst': # ⑥
return value
except AttributeError: # ⑦
value = self.inst.__getattribute__(attr)
if callable(value): # ⑧
return profiling_decorator(value)
else: # ⑨
return value
return ProfileClass # ③


@profile_all_class_methods # ②
class DoSomethingClass:
def __init__(self, var1, var2):
self.var_a = var1
self.var_b = var2

def to_do_1(self, sum_num):
result = sum([i for i in range(sum_num)])

def to_do_2(self, row_num):
with open('result.txt', 'w', encoding='utf-8') as wf:
for i in range(row_num):
wf.write(f'現在 {i + 1} 行目\n')

if __name__ == '__main__':
obj = DoSomethingClass(2, 5) # ① ③

print(f'obj.var_a: {obj.var_a}') # ⑤
print(f'obj.var_b: {obj.var_b}')
obj.to_do_1(1000000)
obj.to_do_2(10000)
実行結果は以下のようになります:
obj.var_a: 2
obj.var_b: 5
to_do_1 の実行時間: 0.046336199971847236s
to_do_2 の実行時間: 0.004513900028541684s
また、obj.to_do_2(10000) を実行した結果として出力されたファイルの内容は以下のようなものです:
result.txt
現在 1 行目
現在 2 行目
現在 3 行目
...
現在 9998 行目
現在 9999 行目
現在 10000 行目
このコードの流れを少し追っておきたいと思います:
DoSomethingClass クラスのオブジェクトを作成します。
このクラスは profile_all_class_methods() デコレータ関数によって修飾されています。よって DoSomethingClass 自体が引数として渡され profile_all_class_methods() デコレータが実行されます。
profile_all_class_methods() からはラッパークラス (クロージャークラス) である ProfileClass が返されてきますから、DoSomethingClass クラスのオブジェクト作成は ProfileClass クラスのオブジェクト作成に書き換えられます。
ProfileClass クラスインスタンス作成時には、ラップするクラス (DoSomethingClass) のオブジェクトを作成し内部変数である inst に保存します。渡されてきた引数はそのまま引き継がれます。
属性に対するアクセスが発生します。この時点で __getattribute__() 特殊関数が呼び出されます。このコードでは __getattribute__() 特殊関数の動作が肝になりますので、あとはこの部分だけを抜き出して説明します。
今回のプログラムでやりたいことを思い出してください。ラップしたクラスのメソッドにアクセスがあった場合に、そのメソッドを計測関数でラップしたものに置き換えたいのでした。つまり対象としたい属性はオリジナルクラス (ラップされている DoSomethingClass) の属性だけです。しかし、ラッパークラスの属性の中で1つだけ無視してはいけないものがあります。それはラップしているクラスのオブジェクトを内部的に保存している self.inst インスタンス変数へのアクセスです。ですから try ブロックの中のラッパークラス属性の問い合わせ時に、この変数への問い合わせである場合に限り値を返すようにしています。それが ⑥ の部分です。もしラッパークラスのその他の属性への問い合わせがあっても無視します。
さて本題の「ラップされているクラス」の属性に対する問い合わせだった場合、try ブロック内では「ProfileClass にはそんな属性は存在しないよ」ということで AttributeError ⑦ が発生します。ここで初めてオリジナルクラスの属性を参照します。そしてもし問い合わせ属性が「呼び出し可能 == callable」であればメソッドですから実行時間計測関数でそのメソッドをラップした結果を属性値として返しています ⑧。呼び出し可能でなければインスタンス変数ですからそのままの値を返します ⑨。
流れはこのような感じです。ただ、気付かれた方はいるでしょうか?このコードはまだまだ完全ではありません。ラップしているクラスにもラップされているクラスにも含まれていない属性の問い合わせがあった場合は しっかりと AttributeError 例外が投げられてしまいこのプログラムは「停止」してしまいます。どうぞ対処をお願いします。

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

0 comments

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

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