Python3 で 関数の内部で自分自身の引数を取得する方法

シェアする

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は標準ライブラリがシンプルに実装されているものが多いので疑問に思ったらドキュメントをさらっと眺めたらコードを確認するのをお勧めします.具体的な処理が分かるので自分の求める処理のお手本が得られる可能性があります.

こんなことをしなくても標準機能で自分の引数を確認する方法がどこかにありそうなのですが,練習ということで気にしないようにします.

参考文献

スポンサーリンク
レクタングル(大)広告

シェアする

フォローする

スポンサーリンク
レクタングル(大)広告