goでreflectを使ってunexported fieldの値を見る

テストなどのhelperを作るときに、特定のstructのunexported fieldにアクセスしたくなることがある。 その方法のメモ(あとでまじめに書くかもしれない書かないかもしれない)。

with exported field

その前にreflect経由でのアクセスで考えてみる。以下の様なstructでとりあえずexported fieldにアクセスしてみる。

type S struct {
    Exported   int
    unexported int
}

reflect.ValueFieldByName()が使える。今回はintでやったが、通常利用するときは任意のstructということを考えてinterface{}として取り出す。

s := &S{Exported: 20, unexported: 10}
rv := reflect.ValueOf(s).Elem()

fmt.Println(rv.FieldByName("Exported").Interface())

これは上手く取り出せる。

20

with unexported field

同様のことをunexported fieldに対してやってみる。

s := &S{Exported: 20, unexported: 10}
rv := reflect.ValueOf(s).Elem()

fmt.Println(rv.FieldByName("unexported").Interface())

どうもpanicになるようだ。

panic: reflect.Value.Interface: cannot return value obtained from unexported field or method

unexported fieldなどへのアクセスは禁止されている。

with hack

いろいろ頑張ったところ、以下の様な感じでNewAt()を経由することで取り出せる。

s := &S{Exported: 20, unexported: 10}
rv := reflect.ValueOf(s).Elem()

rf := rv.FieldByName("unexported")
rf = reflect.NewAt(rf.Type(), unsafe.Pointer(rf.UnsafeAddr())).Elem()
fmt.Println(rf.Interface())

今度は大丈夫。

10

このhackが動く理由を調べるために、もう少し詳しく中を覗いてみることにする。

詳細

まず元々のpanicがどこで発生しているかと言うと、reflectパッケージのこの辺り。

value.go

func (v Value) Interface() (i interface{}) {
    return valueInterface(v, true)
}

func valueInterface(v Value, safe bool) interface{} {
    if v.flag == 0 {
        panic(&ValueError{"reflect.Value.Interface", Invalid})
    }
    if safe && v.flag&flagRO != 0 {
        // Do not allow access to unexported values via Interface,
        // because they might be pointers that should not be
        // writable or methods or function that should not be callable.
        panic("reflect.Value.Interface: cannot return value obtained from unexported field or method")
    }
    if v.flag&flagMethod != 0 {
        v = makeMethodValue("Interface", v)
    }
// ...

Value.Interface()valueInterface()safe=trueで実行していた。 そんなわけで、条件を迂回するには safe=false で実行するか v.flagがReadOnlyになっていなければ良い。

ここでflagの定義はこう。ちょっと分かりづらいので、aからeまでの番号を振って見た。

type flag uintptr

const (
    flagKindWidth        = 5 // there are 27 kinds
    flagKindMask    flag = 1<<flagKindWidth - 1
    flagStickyRO    flag = 1 << 5 // a
    flagEmbedRO     flag = 1 << 6 // b
    flagIndir       flag = 1 << 7 // c
    flagAddr        flag = 1 << 8 // d
    flagMethod      flag = 1 << 9 // e
    flagMethodShift      = 10
    flagRO          flag = flagStickyRO | flagEmbedRO
)

reflect.Valueの中を以下の様な形で無理やり覗いてみると、以下の様に変わっていた。たしかにNewAt()を経由することでflagStickyROが取り除かれている。

fmt.Printf("%b\n", reflect.ValueOf(rf).FieldByName("flag").Uint())

// 元の値
// dcba*****
// 110000010

// NewAt後の値
// dcba*****
// 110100010

NewAt()で新たなstructでwrapすることになる。そしてこのときに使われるreflect.Ptrというconstからflagが計算される。このPtrから作られたflagは先程の通りroではないから、Interface()が使える様になる。というわけ。

// NewAt returns a Value representing a pointer to a value of the
// specified type, using p as that pointer.
func NewAt(typ Type, p unsafe.Pointer) Value {
    fl := flag(Ptr)
    t := typ.(*rtype)
    return Value{t.ptrTo(), p, fl}
}

gist

参考

PEP593のAnnotatedでwrapされた型ヒントからmetadataを取り出す

Annotated

PEP593で型ヒントにmetadataを付けられる様になった。

例えば、関数の型ヒントを見てCLIのコマンドを生成するようなフレームワークがあったとして、コマンドラインオプションのヘルプメッセージ用の文字列も保持したい。以下のコード上での --name オプションに対するヘルプメッセージをどうにか保持できないか悩んだりしたが、PEP593以前はできなかった。

def hello(name: str) -> None:
    print(f"hello {name}")

# このような形で呼び出せる
# $ python main.py --name=world"
# hello world

PEP593で提案されたAnnotatedを利用すると以下の様に書ける様になる(3.9から)。

from typing_extensions import Annotated


class Description:
    def __init__(self, description: str) -> None:
        self.description = description


def hello(*, name: Annotated[str, Description("the name of person")]) -> None:
    print(f"hello {name}")

typingモジュールではなくtyping_extensionsモジュールのものを使うことで3.9以前のpythonでも利用できるようになる。

mypyの解釈

mypyによる型チェックのときに、Annotatedが使われた引数がどのように解釈されているのかを見てみる。reveal_typeを使うと、その時に解釈された型がどのようなものであったかを確認できる。

--- 01hello-annotated.py 2020-07-29 04:59:29.000000000 +0900
+++ 02reveal-type.py  2020-07-29 04:59:06.000000000 +0900
@@ -7,4 +7,5 @@
 
 
 def hello(*, name: Annotated[str, Description("the name of person")]) -> None:
+    reveal_type(name)
     print(f"hello {name}")

nameはstrとして認識されていた。と、まぁそんなわけで、型チェック時には無視されるような値を型ヒントに埋め込むことができる。

$ mypy --strict 02*.py
02reveal-type.py:10: note: Revealed type is 'builtins.str'

metadataの抽出

mypyでは無視されるmetadata部分を取り出して利用したい。Annotatedを使って定義された関数からmetadataを取り出そうとしてみる。

pepの文章を見ると、typing.get_type_hints()がinclude_extrasというオプションを持つらしい。これを利用することでmetadataでwrapされた値が返ってくる(はず)。

from typing_extensions import get_type_hints

print(get_type_hints(hello))
# {'name': <class 'str'>, 'return': <class 'NoneType'>}

print(get_type_hints(hello, include_extras=True))
# {'name': typing_extensions.Annotated[str, <__main__.Description object at 0x10cd1e730>], 'return': <class 'NoneType'>}

なるほど。これで関数からmetadata付きの辞書が取れる様になった。通常genericsなどの型パラメーターを持った型の値を取り出すにはget_args()を使う。また、get_originを使うことで、特殊化した型の基底として使われている値を取る事ができる1

from typing_extensions import get_args

hints = get_type_hints(hello, include_extras=True)
print(get_args(hints["name"]))
# (<class 'str'>, <__main__.Description object at 0x106427730>)

print(get_origin(hints["name"]))
# <class 'typing_extensions.Annotated'>

これで、metadataを取り出してよしなにできるようになった。やりましたね。

3.8以前のpythonでは?

ところが話はもう少し面倒なことになる。

実は、get_args()などが追加されたのは3.8からで、それ以前のpythonはtypingモジュールがこれらの関数を持っていない。backportとしてtyping-inspectパッケージが用意されているが、まだこちらはAnnotatedに対応していない模様。

import typing_inspect

print(typing_inspect.get_args(hints["name"]))
# (<class 'str'>,)

仕方がないので、直接実装を覗いて無理やり取り出す。どうやら__metadata__というフィールドに情報を格納しているらしい。

print(hasattr(hints["name"], "__metadata__"))
# True

print(hints["name"].__metadata__)
# (<__main__.Description object at 0x104b9b730>,)

はい。

gist


  1. 正しくない表現かもしれない。Dict[str,str]に対して get_origin()を使うと dict が返るというような意味合いで使いたかった。