Python で関数(や呼び出し可能なオブジェクト)のバリデーションをする際に自分自身の引数を辞書として取得したい要件がありました.Python の豊富な標準ライブラリにありそうなものですが,ざっと探した限り見つからなかったので自分で作成しました.
動作環境
- MacOS HighSierra 10.13.6
- Python 3.7.0
TL;DR
読むのが面倒な方のために結論だけ先にまとめておきます.get_args_of_current_function()
として実装しました.下記を見ていただければ使い方がわかるかと思います.
実装:
import inspect def get_args_of_current_function(offset=None): parent_frame = inspect.currentframe().f_back info = inspect.getargvalues(parent_frame) return {key: info.locals[key] for key in info.args[offset:]}
最後の辞書を生成している部分ですが条件式を使うのとどちらがよいのか判別付きませんでした.確かリストの条件比較よりも辞書値を参照する方がハッシュマップなので速いという認識(Fluent Pythonを読んだだけ)なのでこのような実装をしたのですが,誰かPythonコアに強い方は教えてくださると嬉しいです.代替記法は次のようになります.
return {key: value for key, value in info.locals.items() if key in info.args[offset:]}
関数のときはそのまま使う:
def f(a, b, c, d=1): e = 'e' args = get_arguments_of_current_function() print(args) f(a=1, b=None, c=[1, 2, 3]) # => {'a': 1, 'b': None, 'c': [1, 2, 3], 'd': 1}
クラスのときはself
を除外する:
class MyClass: def __init__(self, a, b, c, d=99): e = 'e' args = get_args_of_current_function(offset=1) print(args) MyClass('a', 2, c=[1, 2, 3]) # => {'a': 'a', 'b': 2, 'c': [1, 2, 3], 'd': 99}
解決への道のり
以下では,私がこの実装にたどり着くまでの経緯をまとめていきます.話の流れのなかで上記コードがどのように動いているのか掴めると思います.
方法1: locals()
Python3 にはローカル変数を辞書として取得できるlocals()
という関数があります.これを使うと一見大丈夫なように見えます.
def f(a, b, c, d=1): args = locals() print(args) f(a=1, b=None, c=[1, 2, 3]) # => {'a': 1, 'b': None, 'c': [1, 2, 3], 'd': 1}
ところが,これは現在のスコープ内の変数をすべて拾ってきてしまうので,取得前になにか定義していると引数以外のフィールドが辞書に含まれてしまいます.
def f(a, b, c, d=1): e = 'e' args = locals() print(args) f(a=1, b=None, c=[1, 2, 3]) # => {'a': 1, 'b': None, 'c': [1, 2, 3], 'd': 1, 'e': 'e'}
そもそも引数のバリデーションしたいという目的があるので余計なことをする前にさっさとlocals()
で取得しろと言われたらそれまでなのですが,関数の最初に定義されているローカル変数は引数だけという前提でローカル変数を使う運用をしているだけなので仕様変更(今回の組み込み関数の性質上あるとは思いませんが)があったりしたら怖いということと,また,locals()
本来の関数の意味とは違う使い方をしている感があったのでこの方法は不採用になりました.
候補2: inspect を素直に使う
確か,Pythonの細かい情報はinspect
モジュールで取得できるという記憶がおぼろげながらあったので,関数を探していたら,inspect.getargvalues
というのを見付けました.どうやらこれは,別で生成したframe
を受け取り,その生成された場所での引数を取得するようです.ふむふむなるほどということで次のようなコードを書いて試してみました.
import inspect def f(a, b=None): c = 'c' frame = inspect.currentframe() info = inspect.getargvalues(frame) print(info) f(1) # => ArgInfo(args=['a', 'b'], varargs=None, keywords=None, locals={'a': 1, 'b': None, 'c': 'c', 'frame': ', line 5, code f>})
以降冗長なのでimport inspect
は省略します.ここで重要なのはinfo.args
に引数が過不足なく含まれている(c
は含まれていない)ということと,info.locals
はすべてのローカル変数が格納されているということです(一時変数のframe
も格納されていることが分かると思います).ここまでくれば後は簡単で引数のキーでローカル変数を浚ってくれば辞書を作ることができます.
def f(a, b=None): c = 'c' frame = inspect.currentframe() info = inspect.getargvalues(frame) print({key: info.locals[key] for key in info.args}) f(1) # => {'a': 1, 'b': None}
これを関数化したいわけですが,frame
は欲しい引数の直下で生成する必要があるので次のようになりました.
def get_args_of_current_function(frame): info = inspect.getargvalues(frame) return {key: info.locals[key] for key in info.args} def f(a, b=None): c = 'c' frame = inspect.currentframe() args = get_args_of_current_function(frame) print(args) f(1) # => {'a': 1, 'b': None}
流石に呼び出し元がimport inspect
してframe
を生成するのは美しくないと思ったので次に移ります.
候補3: stackoverflow をパクる
要するに親のframe
が得られればよいわけです.雑にググっているとstackoverflow: get parent function という投稿を見付けました.ここでは呼び出し元の関数を次のようにして取得できるという tips が共有されていました.
import inspect def first(): return second() def second(): return inspect.getouterframes( inspect.currentframe() )[1] first()[3] # 'first'
どうやら外側のフレームを取得する関数inspect.getouterframes
があったようです.たぶん全部がリストに格納されていて一つ上が要素[1]
に格納されているのでしょう.早速試してみます.
def get_args_of_current_function(): current_frame = inspect.currentframe() parent_frame = inspect.getouterframes(current_frame)[1].frame info = inspect.getargvalues(parent_frame) return {key: info.locals[key] for key in info.args} def f(a, b=None): c = 'c' frame = inspect.currentframe() args = get_args_of_current_function() print(args) f(1) # => {'a': 1, 'b': None}
各リスト成分はFrameInfo
というオブジェクトらしく,実際のframe
はアクセスして取得する必要があるようでしたが,これで求める要件は満たされた気がします.
本当でしょうか…? inspect.getouterframes
が余計なことをしている気がします.ちょっと実装を見てみましょう.ipythonのヘルプ機能で見てみます(上記のFrameInfoの構成もこのようにして確認しました).
In [1]: import inspect In [2]: inspect.getouterframes?? Signature: inspect.getouterframes(frame, context=1) Source: def getouterframes(frame, context=1): """Get a list of records for a frame and all higher (calling) frames. Each record contains a frame object, filename, line number, function name, a list of lines of context, and index within the context.""" framelist = [] while frame: frameinfo = (frame,) + getframeinfo(frame, context) framelist.append(FrameInfo(*frameinfo)) frame = frame.f_back return framelist
調べてみるとframe = frame.f_back
で一つ親(外側)へとイテレートして終わるまですべてのframe
をより高級なオブジェクトに格納してすべてを生成しています.今回欲しいのは一つ上のframe
なので余分な処理が多すぎます.この辺を手動で実装してあげればより効率的になるはずです.
最終版
要するに,候補3でparent_frame
を生成している部分
current_frame = inspect.currentframe() parent_frame = inspect.getouterframes(current_frame)[1].frame
を次のように変更すればよいわけです.
current_frame = inspect.currentframe() parent_frame = current_frame.f_back
まとめると,最初の結論の実装が得られます.
def get_args_of_current_function(): current_frame = inspect.currentframe() parent_frame = current_frame.f_back info = inspect.getargvalues(parent_frame) return {key: info.locals[key] for key in info.args} def f(a, b=None): c = 'c' args = get_args_of_current_function() print(args) f(1) # => {'a': 1, 'b': None}
TL;DRではわざわざ一時変数に入れる必要のない部分を省略していますが実質は上と等価です.また,クラスに属するメソッド内で用いる際には所謂self
, cls
を除外したい場合があるのでそのためのoffset(最初から何個の引数を無視するか)を指定できるようにしました.
番外編: もっと頑張る
一応ですがもっと余計な処理を省くことが出来ます.inspect
モジュールの実装を追っていくと分かるのですが,inspect.getargvalues
の具体的な実装はinspect._getfullargs
で定義されています.これはCPythonで利用されるコードオブジェクトという関数の情報にアクセスするインスタンスを渡すことで実行途中の関数の状態を解析してくれる関数です.コードオブジェクト自体はPythonの関数f
があったとしてf.__code__
でアクセスできるようなのですが,今回はフレームインスタンスからアクセスしたいのでframe.f_code
の形式で取得できます.
では,実装を見てみましょう.
In [1]: import inspect In [2]: inspect._getfullargs?? Signature: inspect._getfullargs(co) Source: def _getfullargs(co): """Get information about the arguments accepted by a code object. Four things are returned: (args, varargs, kwonlyargs, varkw), where 'args' and 'kwonlyargs' are lists of argument names, and 'varargs' and 'varkw' are the names of the * and ** arguments or None.""" if not iscode(co): raise TypeError('{!r} is not a code object'.format(co)) nargs = co.co_argcount names = co.co_varnames nkwargs = co.co_kwonlyargcount args = list(names[:nargs]) kwonlyargs = list(names[nargs:nargs+nkwargs]) step = 0 nargs += nkwargs varargs = None if co.co_flags & CO_VARARGS: varargs = co.co_varnames[nargs] nargs = nargs + 1 varkw = None if co.co_flags & CO_VARKEYWORDS: varkw = co.co_varnames[nargs] return args, varargs, kwonlyargs, varkw
ここで引数のキーを取得しています.
nargs = co.co_argcount names = co.co_varnames nkwargs = co.co_kwonlyargcount args = list(names[:nargs])
以上を受けて引数のキーを取得してみます.
def f(a=1, b=2, c=3): D = 'D' frame = inspect.currentframe() print(frame.f_code.co_argcount) print(frame.f_code.co_varnames) print(frame.f_locals) f() # => 3 # => ('a', 'b', 'c', 'D', 'frame') # => {'a': 1, 'b': 2, 'c': 3, 'D': 'D', 'frame': ', line 6, code f>}
完全に理解しました.
そうです.強力なラッパー関数を使わなくても次のようにかけることが分かりました.
import inspect def get_args_of_current_function(offset=None): frame = inspect.currentframe().f_back names = frame.f_code.co_varnames nargs = frame.f_code.co_argcount arg_keys = names[offset:nargs] return {key: frame.f_locals[key] for key in arg_keys} def f(a, b=None): c = 'c' args = get_args_of_current_function() print(args) f(1) # => {'a': 1, 'b': None}
行数は増えましたが無駄な処理は減りました.ここまで頑張る意味があるかと言われたら微妙です.一応パブリックなメソッドとプロパティしか使っていないのでCPythonとinspect
モジュールに破壊的な変更が加わらなければ動き続けるはずです.
まとめ
今回は問題解決する際の試行錯誤を全てトレースする形で実装の解説をしてみました.Pythonは標準ライブラリがシンプルに実装されているものが多いので疑問に思ったらドキュメントをさらっと眺めたらコードを確認するのをお勧めします.具体的な処理が分かるので自分の求める処理のお手本が得られる可能性があります.
こんなことをしなくても標準機能で自分の引数を確認する方法がどこかにありそうなのですが,練習ということで気にしないようにします.