LoginSignup
2
3

More than 1 year has passed since last update.

pythonにおける高階関数と@デコレータについて(Python中級)

Last updated at Posted at 2023-02-11

はじめに

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$のような、どのような演算を行うかについての指定も必要になります。すなわちΣの計算を行う関数には、下に示すように整数abの他に、関数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=2c="World"のようなキーワード引数は**kwargsに辞書の形でそれぞれ格納されます。
なおargkwargsというのは単なる慣用名であり、アスタリスクの数さえあっていれば他の名前でも構いません。

@デコレータの使用

では@デコレータを実際に使用する例を見てみましょう。

@デコレータの例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引数funcplusが代入されているので、具体的な関数の定義は以下のようになります。

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であることが分かります。times10が代入されているので、返り値repeat_fの定義は以下のようになります。

def repeat_f(func):
    def wrapper(*args, **keywords):
        for i in range(10):
            func(*args, **keywords)
    return wrapper

さて続いてこの関数に対して引数としてsampleを与えたときの返り値はwrapperです。この定義は、funcsampleを代入しているため以下のようになります。

def wrapper(*args, **keywords):
    for i in range(10):
        sample(*args, **keywords)

この関数wrapperrepeat(10)(sample)の返り値です。あとはこの関数に引数"Hello"が与えられるため、結果として関数sampleが引数を"Hello"として10回実行される形となります。

ちなみに、この@デコレータの引数を関数定義時ではなく関数実行時に与えたい場合は、例えば以下のようにして関数を定義することで実現できます。

def sampletimes(times,text):
    @repeat(times)
    def sample(text):
        print(text)
    sample(text)

sampletimes(10,'hello')

最後に(+練習問題)

無事に理解できたでしょうか? @デコレータは理解してしまえばそれほど難しいものでもありません。自分のプログラムで使う機会があまりなかったとしても、これをきちんと理解しておくことで他人の書いたコードで不意に出くわしたときに面食らわなくて済むようになるでしょう。うまく使いこなせるようになれば便利な機能でもありますから、中級プログラマを目指す方はぜひ理解しておきましょう。

練習問題

整数型の変数intervaltimesを受け取り、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
2
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
3