🙄

理解して覚えるpython ~デコレータ編~

2022/09/25に公開

はじめに

初学者がデコレータだけ学習しようとすると苦戦すると思います.書き方を覚えたつもりでも理解していないと,ほんの数日で忘れてしまいます(過去の僕がそうでした).
本記事では,デコレータを理解するのに必要な最低限の知識と,そのユースケースを見ていくことで理解して覚えることを目的としています.

前提知識

まず,関数内関数とクロージャから学びます.

関数内関数

簡単に言うと,関数のネストです.ユースケースとしては,クラスを書くほどじゃないけど,少し読みにくい処理等を書きたいときとかではないでしょうか(あまり書くことはないと思いますが).内部の関数を外に書かかないことで,他の関数からもアクセスできないという制限をかけており,単純に2つ関数を書くよりも可読性が向上します.

def outer(a):
    def inner(b):
        return 3*b
    return inner(a)    
print(outer(1))  # 出力は3

return 文でinner()が実行されている点がポイントです.この例では,何も関数内関数である必要はなく,単にイメージしやすいかなという思いで載せています.より具体的な使いどころとしては,この方の記事が参考になると思います.

クロージャ

クロージャは,内部で定義された変数と引数以外の変数(自由変数と呼ぶ)を覚えている関数のことを指し,ふつうの関数もこれにあたるようです.
関数内関数で示した例をまねると,以下のようになります.

def outer(a):
     def inner(b):
         return 2*a + b
     return inner

ここで注目すべきは,innerが実行されていないまま返されていることです.
このままでは意味がわからないと思いますので,実行してみます.

print(outer(1))
# 出力:<function outer.<locals>.inner at 0x7f568315a4d0>

はじめてこれを見たとき混乱しました.ここでは見た目通り,a に1が代入されていますが,inner 関数オブジェクトが返ってきているので,inner 関数は実行されていません.ここで重要なのが,inner関数が a の値を覚えているということです.a は inner 関数内部で定義されていませんし,inner 関数の引数でもありませんから,上述した「自由変数」に当たります.inner 関数はクロージャであるため,外側のスコープで定義された a を覚えているということです.
したがって,inner関数の a に値が保存されたまま,実行待ち状態になっているということがわかります
ここで,次に

tmp = outer(1)
tmp(2)  # 4(=2*1+2) 

# 以下でも同様
# outer(1)が,aに1が代入されたinnerとなっている
outer(1)(2)  # 4(=2*1+2)

を実行すると,inner(2)となり,ようやく関数が実行されたことになります,
ちなみにユースケースは,変数を保存しておいて後で実行したいときですが,そのようなケースがパッと浮かばないので他の記事で探してみてください.

デコレータ

いよいよ本題です.

def outer(f):
    def inner(b):
        print('Hello') # プラスの処理(デコレート)部分
        return f(b)  
    return inner

クロージャとの違いは,outer 関数の引数が関数 f であることです.「outerの引数を覚えておいて,inner を後から実行できる」というクロージャの性質を思い起こすと,「関数 f を覚えたまま保持し,あとから inner で実行できる」ことがわかります.
ここからがデコレータの本領なのですが,inner 関数の内部にプラスの処理を入れておけば関数 f に加えて実行する処理を増やすことが可能です.つまり,処理したい関数 f をデコレート(修飾)できるということですね.(上の例では関数の実行直前に'Hello'を出力します)
あとはどのように実行するかですが,クロージャと同様に

tmp = outer(f)  # 関数を記憶
tmp(3)  # 実行

としてもいいですが,偉い人たちがデコレートっぽい書き方を提供してくれており(syntax sugurという),デコレートしたい関数に

@outer
def f(arg)
    return arg

のように,「@関数名」と書くことで,あとは以下のように直接関数fを呼び出すことで実行できるようになっています.

f(3) 
# 出力:
# Hello
# 3

ちゃんと'Hello'が呼び出された後に,関数が実行されている様子がわかります.

少し応用

興味ある人以外は読み飛ばしてもいいかもしれません.

functools.wrapsの使用

先ほどの実装では細かい点ですが,問題点があります.f 関数の__name__属性を参照してみると,

print(f.__name__)
# 出力:inner

となっています.詳しいことがわからなくても,__name__属性を参照する機能に影響がでそうだとわかります.そこで,関数の名前を適切に参照できるようにしてくれるもの(functools.wraps)を使ってデコレートします.
これを使用して先ほどのデコレータを定義し直します.具体的には,__name__がおかしくなっている関数にデコレートします.

from functools import wraps
.
def outer(f):
    @wraps(f)  # __name__属性の参照先を f にする
    def inner(b):
        print('Hello')
        return f(b)
    return inner

となり,出力は以下のように正常になっています.

print(f.__name__)  
# 出力:f

引数を加える

先ほどの functools.wraps でも気になったかもしれませんが,デコレータには引数を加えることでより柔軟な処理が行えます.
例えば,bool 型のis_public変数がTrueのときとFalseのときで記事を公開するか否かを決定したい場合は,以下のようにします.

def outer(is_public=False):
    def middle(f):
        def inner(b):
	    if is_public:
	        # 記事を公開にする処理
	    return f(b)
	return inner
    return middle	

これには,クロージャの性質を利用しています.つまり,普通のデコレータの外側に引数を持った関数でラップすることで,引数として入っているis_public記憶したまま中の処理を行えるということです.

実際の例

組み込み関数

classmethod と staticmethod が有名ですが,これらは以下のように使用します.

class TmpClass:
    CLASS_ = 100  # クラス変数
    
    def __init__(self, instance_var):
        self.instance_ = instance_  # インスタンス変数
    
    @classmethod
    def tmp1(cls): # cls変数でクラス変数にアクセス可能
        print(cls.CLASS_)  # クラス変数なのでアクセス可能
	# print(self.instance_)  # これは不可
	
    @staticmethod
    def tmp2(prm):  # 適当なprmという変数を引数にとる
        print(prm)  # クラス変数,インスタンス変数のどちらでもないのでアクセス可能

classmethod でデコレートされたメソッドは,インスタンス変数のみにアクセスできず,staticmethod は,デコレートされたメソッドの引数やメソッド内で定義された変数,関数以外にアクセスできません,
これらを使用する利点として,名前空間やメモリの削減,何より可読性と保守性の向上が考えられます.

おわりに

割と丁寧に追っていったつもりです.私自身も忘備録として書いていますので,嘘があった場合にはご指摘いただけますと幸いです.

参考

Discussion