はじめに
Pythonを使っている皆さん、@デコレータというのはご存知でしょうか。Djangoのようなwebフレームワークに触れたことがある方であれば「ログインが必要な動作を行う関数の前に@login_required
をつける」というようなことを「おまじない」として覚えた方もいるかもしれません。この@デコレータの意味を学び、さまざまなプログラムに応用してみよう、というのが今回の趣旨です。
なお、説明には誤りが含まれている可能性があります。もし誤りを見つけた場合にはご指摘いただけると助かります。
想定する読者像
python初級者~中級者。「引数」、「返り値(戻り値)」の概念や例外処理try-except
は理解できていることを前提とします。
最もシンプルな例
@funcA
def funcB():
print("funcB")
funcB()
この時、このプログラムはfuncA(funcB)()
という意味になります。さて、この式の意味を正しく読み取ることができるでしょうか。
- なぜ
funcA
の引数に関数であるfuncB
が入っているのか? - なぜ引数を表す
()
がfuncAの後ろに2つ並んでいるのか?
上のような疑問を持った方もいるかもしれません。ではここで冷静になって考えてみましょう。funcA
の引数と返り値はいったいどんなものでしょうか?
funcA
の直後の()
に関数であるfuncBが入っているのですから、今までのプログラミングのルールに従えば、funcA
は「関数を引数に取る関数」といえるでしょう。そしてfuncA(funcB)
はその直後に()
があるため、funcA(funcB)
それ自体も関数になっているということができます。すなわち、funcA
は「関数を引数にとり関数を返す関数」になっていることが分かります。
関数型プログラミングの経験がある方であればすぐにピンとくるでしょうが、funcAはいわゆる高階関数となっているのです。
ではここで一度、高階関数について確認しておきましょう。
高階関数について
高階関数、などと大層な名前がついていますが、要するに引数や返り値が関数になっているというだけで難しいことはありません。ここでは分かりやすい例を2つ示します。
高階関数の例1:総和計算Σ
文系の方でも高校数学で触れたことはあるでしょう、総和を表すΣです。これをpythonの関数として表してみましょう。例えばその計算は以下のようになります。
\sum_{n=1}^{3} (2n+1) = (2×1+1)+(2×2+1)+(2×3+1) = 15 \\
\sum_{n=1}^{5} n^2 = 1^2+2^2+3^2+4^2+5^2 = 55
例を見ればわかるように、Σの計算には「1から5まで」のような範囲の指定の他に、$n^2$のような、どのような演算を行うかについての指定も必要になります。すなわちΣの計算を行う関数には、下に示すように整数a
、b
の他に、関数f
も必要になるのです。
\sum_{n=a}^{b} f(n)\\
では、実際の実装を見てみましょう。
def sigma(a,b,f):
answer=0
for i in range(a,b+1):
answer+=f(i)
return answer
def square(n):
return n**2
print(sigma(1,5,square))
これを実行すると、55
が出力されます。
ただ、この方式だとsigmaの計算をするときに毎回fに入れるための関数を作らなければなりません。fが複雑な処理を行う関数ならば仕方ないのですが、「2乗する」とか「2倍して1を足す」くらいはもう少し簡潔に書きたいですね。
そこで使えるのが「匿名関数」という仕組みです。話がそれるため詳細は省略しますが、ラムダ式と呼ばれるものを用いて以下のように書くことができます。
def sigma(a,b,f):
answer=0
for i in range(a,b+1):
answer+=f(i)
return answer
print(sigma(1,5,lambda n:n**2))
さて、次は関数を返す高階関数の例を挙げてみます。
高階関数の例2:関数の反復実行
次は簡単な高階関数として、関数f
と正の整数n
を引数として、fをn回繰り返す関数を返す関数を実装します。
def repeat(f,n):
def wrapper(*args, **kwargs):
for i in range(n):
f(*args, **kwargs)
return wrapper
#使用例
def func1(text):
print(text)
func2=repeat(func1,3)
func2("Hello")
さきほどよりも難しそうな雰囲気がすると思いますが、一つ一つ確認していきましょう。
まず、f
には何らかの関数が、n
には繰り返し回数を表す正の整数が与えられます。
そして、関数の中で定義した関数wrapperが返り値として返されています。
使用例においてはこの返り値としての関数がfunc2に格納され、引数"Hello"
で呼び出されています。この呼び出し時の引数はfunc1
の引数と同じになります。すなわち、関数repeatを定義する時点では、関数wrapperをどのような引数を取る関数として定義するべきかは分かりません。
そこで*args
や**kwargs
を使用します。これは可変長引数と呼ばれるもので、引数の個数が可変である関数を定義できます。
このwrapper関数を、関数fに受け取った引数をそのまま与えることをn回繰り返すものとして定義すれば完了です。冷静に考えればそこまで難しくはないでしょう。
可変長引数について(詳細)
def func3(*args, **kwargs):
print(args)
print(kwargs)
func3("Hello", 1, a=2, b=3, c="World")
このときの出力は以下のようになります。
('Hello', 1)
{'a': 2, 'b': 3, 'c': 'World'}
すなわち、"Hello"
や1
のような位置引数は*args
にタプルの形で、a=2
やc="World"
のようなキーワード引数は**kwargs
に辞書の形でそれぞれ格納されます。
なおarg
やkwargs
というのは単なる慣用名であり、アスタリスクの数さえあっていれば他の名前でも構いません。
@デコレータの使用
では@デコレータを実際に使用する例を見てみましょう。
@デコレータの例1:例外処理を付加する
まずは任意の関数をエラーハンドリングで修飾して返す関数を見てみましょう。
def tryanderror(func):
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as e:
print("エラーが発生しました。")
print(f'type:{type(e)}')
print(f'args:{str(e.args)}')
return wrapper
@tryanderror
def plus(value):
value = value + 1
print(value)
return value
plus("Hello")
さて、高階関数の例2が理解できていれば関数tryanderrorを理解するのはそれほど難しくないはずです。もしtry-except
構文の意味が理解できていない場合はそちらを先に学習することをお勧めします。
さて、関数funcは受け取った値に1を加えて返す関数ですが、もし受け取った値が文字列であればエラーとなってしまいます。そのときにエラーによる異常終了を引き起こさず、エラー内容を出力に通知するために@デコレータが使われています。
初めに説明したように、このプログラムは以下と等価です。
def tryanderror(func):
def wrapper(*args, **kwargs):
try:
func(*args, **kwargs)
except Exception as e:
print("エラーが発生しました。")
print(f'type:{type(e)}')
print(f'args:{str(e.args)}')
return wrapper
def plus(value):
value = value + 1
print(value)
return value
tryanderror(plus)("Hello")
さて順番に解釈していきましょう。まずtryanderror(plus)
の返り値は関数wrapperです。tryanderror
引数func
にplus
が代入されているので、具体的な関数の定義は以下のようになります。
def wrapper(*args, **kwargs):
try:
plus(*args, **kwargs)
except Exception as e:
print("エラーが発生しました。")
print(f'type:{type(e)}')
print(f'args:{str(e.args)}')
この関数に対して引数"Hello"
を与えている、と考えればもうそれほど難しくないでしょう。
plus("Hello")
が実行され、途中で型エラーが発生しexcept
の中でそれが出力される、というだけです。
以上のようにして、関数を受け取り関数を返す関数tryanderror
と@デコレータ
を用いることで任意の関数に手軽に例外処理を付加できるようになったことが分かるでしょう。
ですがこの方法だけだと1つ問題があります。それは、高階関数の章で述べたような関数の反復実行を@デコレータで実装しようとした場合、関数の反復回数n
はどこで与えればよいのか、という問題です。これには、引数を受け取る@デコレータを用いる必要があります。
@デコレータの例2:関数の反復実行
まずはサンプルコードを見てみましょう。
def repeat(times):
def repeat_f(func):
def wrapper(*args, **keywords):
for i in range(times):
func(*args, **keywords)
return wrapper
return repeat_f
@repeat(10)
def sample(text):
print(text)
sample("Hello")
@デコレータを使用している部分は、先ほどまで関数名のみが書かれていた部分が@repeat(10)
のように引数を含む形に変わっています。それでも本質は変わりません。一番最初の例におけるfuncA
の部分をrepeat(10)
に置き換えるだけにすぎません。すなわち、sample("Hello")
は以下と等価になります。
repeat(10)(sample)("Hello")
順番に意味を理解していきましょう。まず、repeat(10)
の返り値は、関数repeat
の定義を見ると関数repeat_f
であることが分かります。times
に10
が代入されているので、返り値repeat_f
の定義は以下のようになります。
def repeat_f(func):
def wrapper(*args, **keywords):
for i in range(10):
func(*args, **keywords)
return wrapper
さて続いてこの関数に対して引数としてsample
を与えたときの返り値はwrapper
です。この定義は、func
にsample
を代入しているため以下のようになります。
def wrapper(*args, **keywords):
for i in range(10):
sample(*args, **keywords)
この関数wrapper
がrepeat(10)(sample)
の返り値です。あとはこの関数に引数"Hello"
が与えられるため、結果として関数sample
が引数を"Hello"
として10回実行される形となります。
ちなみに、この@デコレータの引数を関数定義時ではなく関数実行時に与えたい場合は、例えば以下のようにして関数を定義することで実現できます。
def sampletimes(times,text):
@repeat(times)
def sample(text):
print(text)
sample(text)
sampletimes(10,'hello')
最後に(+練習問題)
無事に理解できたでしょうか? @デコレータは理解してしまえばそれほど難しいものでもありません。自分のプログラムで使う機会があまりなかったとしても、これをきちんと理解しておくことで他人の書いたコードで不意に出くわしたときに面食らわなくて済むようになるでしょう。うまく使いこなせるようになれば便利な機能でもありますから、中級プログラマを目指す方はぜひ理解しておきましょう。
練習問題
整数型の変数interval
とtimes
を受け取り、interval
秒間隔でtimes
回関数を定期実行できるように関数を修飾できるデコレータを定義し、実際に使ってみましょう。ただし、デコレータの関数名はsync
とすること。
可能ならば、対象となる関数の実行時間がinterval
よりも小さい場合、実行間隔時間とinterval
の誤差が0.1秒以内になるように工夫してください。正しく実装できているか確認したい場合は、sync
関数の下に以下のプログラムを貼り付けて実行してみましょう。Assertion Error
が出ていなければ誤差上限をクリアできています。
ヒント:関数の実行自体にも時間がかかるので、単純に関数の実行が終わってからinterval
秒待つのでは誤差がうまれてしまいます。
import time
nowtime_ = time.time()
already_ = False
@sync(interval = 2,times = 5)
def test():
global nowtime_,already_
s = 0
for i in range(10000000):
s += i
if already_:
elasped = time.time()-nowtime_
assert (1.9 < elasped and elasped < 2.1)
nowtime_ = time.time()
already_ = True
test()
解答例
解答はあくまで一例です。また、実行環境によってはこのプログラムでも誤差上限がクリアできない場合があるかもしれません。(おそらく大丈夫なはずですが)
import time
def sync(interval, times):
def _sync(f):
def repeat_f(*args, **keywords):
print("START")
cnt = 0
start = time.time()
while cnt < times:
cnt += 1
while time.time()-start < cnt * interval:
time.sleep(0.05)
v = f(*args, **keywords)
print("END")
return v
return repeat_f
return _sync