モチベーション
Python の変数付きデコレータの変数を固定させて別名を与えたいことがある。
今回は以下のような汎用デコレータ tag
を対象にする。
import functools
def tag(*tags):
def _tag(f):
@functools.wraps(f)
def _wrapper(*args, **kwargs):
res = f(*args, **kwargs)
begins = ''.join([f'<{e}>' for e in tags])
ends = ''.join(reversed([f'</{e}>' for e in tags]))
return f'{begins}{res}{ends}'
return _wrapper
return _tag
@tag()
def test1():
return 'test1'
@tag('p')
def test2():
return 'test2'
@tag('p', 'span')
def test3():
return 'test3'
if __name__ == '__main__':
print(test1())
print(test2())
print(test3())
$ pipenv run python main.py
test1
<p>test2</p>
<p><span>test3</span></p>
逐次全てを書くこともできるが、コンテンツが長くなる場合や記述上の業務ロジックが入ってくる場合は別名を与えたい。 例えば、<h1>
で囲む部分を @title
と書きたい、といったことが考えられる。
これをどのように実現するかを説明する。
なお、Pythonのバージョンは 3.8.2
, Ubuntu 18.04 上で実行。
実現方法
以下の通り。 必要部分のみ抜粋
title = tag('h1') # 利用時に引数を追加で利用しない場合
def with_p(*tags): # 利用時に変数を追加で利用する場合
return tag('p', *tags)
@title
def test4():
return 'test4'
@with_p('span')
def test5():
return 'test5'
if __name__ == '__main__':
print(test4())
print(test5())
$ pipenv run python main.py
<h1>test4</h1>
<p><span>test5</span></p>
このように、固定の引数を渡した別名のデコレータを作成することができた。
なぜこのような方法になるのか
そもそもPythonの文脈におけるデコレータは、関数定義のための構文糖衣である。 公式ドキュメント の例を引用すると、
@f1(arg)
@f2
def func(): pass
は、だいたい次と等価です
def func(): pass
func = f1(arg)(f2(func))
ただし、前者のコードでは元々の関数を func という名前へ一時的に束縛することはない、というところを除きます。
デコレータとして利用できる項目は 関数を引数に取り、関数を返すcallable である必要がある。 このような callable を「デコレータとして利用可能」とここでは呼ぶこととする。
実は tag
関数そのものは「デコレータとして利用可能ではない」(そのため、@tag
とだけ書くと失敗する)。 なぜなら、この関数は「任意個数の引数を取り、「デコレータとして利用可能なcallable」を返す関数」であるためである。 この関数そのものはデコレータとして利用可能ではないが、この関数の戻り値はデコレータとして利用可能 である。 そのため、@tag
として利用するのではなく、@tag()
のように呼び出して、その結果をデコレータとして利用する。
このように「デコレータとして利用可能」な型を意識すれば、別名を付けたり、一部に固定変数を渡す方法も見えてくる。
tag('h1')
の評価結果はデコレータとして利用可能なので、title = tag('h1')
とすれば、@title
というデコレータを利用することができるようになる。
また、def with_p(*args): ...
は 「任意個数の引数を取り、「デコレータとして利用可能なcallable」を返す関数」であり、型としては tag
関数と等しい。 そのため、with_p()
で「デコレータとして利用可能なcallable」を得ることができるので、@with_p()
というデコレータを利用することができるようになる。
関数の部分適用 functools.partial を使う
実は最初は functools.partial
で部分適用すればいいのかと思っていたのだが、これをやると失敗した。
h2 = functools.partial(tag, 'h2')
@h2
def test6():
return 'test6'
if __name__ == '__main__':
print(test6())
$ pipenv run python main.py
Traceback (most recent call last):
File "main.py", line 59, in <module>
print(test6())
TypeError: _tag() missing 1 required positional argument: 'f'
これは functools.partial
の仕様に起因する。 例えば、以下のような Haskell の例では、add5
は add
に1引数を部分適用した Integer -> Integer
型の関数である。 一方、add 2 3
のように全ての引数を渡した時点で、その結果は Integer
となり、結果が得られる。 ( こちらを参考に一部引用 )
add :: Integer -> Integer -> Integer
add x y = x + y
add5 = add 5
しかし、functools.partial
は結果として callableなpartialオブジェクトを返す。 そのため、関数に全ての引数を適用したとしても結果が得られるのではなく、0引数で呼び出し可能なオブジェクトが返ってくる。
def add(x, y):
return x + y
add23 = functools.partial(add, 2, 3)
if __name__ == '__main__':
print(add23)
print(add23())
$ pipenv run python main.py
functools.partial(<function add at 0x7f2d3e5dfc10>, 2, 3)
5
つまり、functools.partial(tag, 'h2')
の戻り値の型は tag
と変わらないのである。@tag
として使えないため @tag()
のようにする必要があった通り、この場合は @h2
ではなく、@h2()
としてやらなければならない。 あるいは、h2 = functools.partial(tag, 'h2')()
のようにしておけば、@h2
として利用できる。
おまけ
Chalice や Flask では @app.route
のようにインスタンス内の関数を指定したデコレータがよく使われる。
これに固定値を含めた業務上の別名を付ける場合は、上記の通り型を意識して、
def post(route, **kwargs):
return app.route(route, methods=['POST'], **kwargs)
@post('/')
def index():
return {'hello': 'world'}
のようにすることで別名を定義できる。
まとめ
「デコレータとして利用可能な関数(callable)」を意識することで、より柔軟にコーディングができるようになった。