Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

Goのreflectパッケージを理解する

はじめに

こんにちは、サーバーサイドエンジニアの中川(@tkdev0728)です。
最近片っ端から試せる花粉対策を順番に試しており、最近は「強さひきだす乳酸菌」のキャッチコピーでお馴染みのあのヨーグルトのドリンクを飲み続けています。効果があるのかないのかわかりませんが、試せるものは片っ端から試す毎日です。
さて、今回はGoの標準パッケージの1つであるreflectパッケージについてまとめてみます。
自分は恥ずかしながらあまり使ったことがなかったので、いつか自分のように知りたいと思った人の助けになればと思っています。すでにご存知だという方は復習がてら読んでいただけるといただけると幸いです。

きっかけ

実は私は 生産性を高めるために行なっているVSCodeのカスタマイズ設定 という記事も書いているのですが、本記事の執筆時点ではVSCodeユーザーです。 ある日そんなVSCodeでGoを書いていて、ある関数に対するテストコードを書きたかったのでコマンドパレットを開き、Go: Generate Unit Tests For Functionと入力してテストコードの雛形を生成しました。 実際に生成した雛形の抜粋が下記です。

if got := HogeMethod(); !reflect.DeepEqual(got, tt.want) {
  t.Errorf("HogeMethod() return  %v, want %v", got, tt.want)
}

テストで期待値と実際の返り値を比較することはよくあると思いますが、そういう場合に使うのは assertパッケージのEqualメソッド だと思っており、reflect.DeepEqual()という関数は馴染みがなかったのですが、PHPでいうasssertSameみたいなものかと盛大に勘違いしており、この時は自分が知らないだけでそういう便利な関数があるんだなくらいに思っていました。
そんな感じで特に疑問を抱かずテストを書いてチームメンバーにコードレビューを依頼したところ、以下のようにコメントをいただきました。

恥ずかしながら自分はてっきりassert.Equalよりも厳密にみてくれる便利な関数なんだくらいにしか思ってなかったですが、どうやら気をつけて使うべきパッケージのようです。何に気を付けるべきなのかこの時にはまだわかってなかったので調べてみました。

reflectパッケージとは

概要

以下 reflect package - reflect - Go Packages より

Package reflect implements run-time reflection, allowing a program to manipulate objects with arbitrary types. The typical use is to take a value with static type interface{} and extract its dynamic type information by calling TypeOf, which returns a Type.

A call to ValueOf returns a Value representing the run-time data. Zero takes a Type and returns a Value representing a zero value for that type.

See "The Laws of Reflection" for an introduction to reflection in Go: https://golang.org/doc/articles/laws_of_reflection.html

上記を日本語に翻訳すると以下です。

reflectパッケージはランタイム・リフレクションを実装しており、プログラムが任意の型を持つオブジェクトを操作できるようにする。典型的な使い方は、静的な型interface{}を持つ値を受け取り、その動的な型情報をTypeOfを呼び出して取り出すことである。

ValueOfを呼び出すと、実行時のデータを表すValueが返される。Zeroは型を取り、その型のゼロ値を表すValueを返す。

Goでのリフレクション入門については「リフレクションの法則」を参照: https://golang.org/doc/articles/laws_of_reflection.html

要は動的に型の情報を取得して、その型情報を使って例えば分岐処理をさせたり、値を取り出したり値を書き換えたりすることができるそうです。
なんとなくは理解しましたが、正直言ってあまり使いどころの具体例がわからなかったのでもう少し調べました。

使いどころ

reflectパッケージが使われているパッケージで一番わかりやすかったのが、jsonパッケージでした。例えば構造体をJSONに変換するjson.Marshalから呼ばれる以下の関数では型によって別々のエンコード処理がよばれていることがわかります。
考えてみれば独自の構造体をJSONに変換したいケースはよくあると思いますが、フィールドの型は文字列だったり数値だったりはたまた別の構造体だったりします。タグをつけてJSONのキー名を指定することもありますし、場合によっては非表示なフィールドにしたいケースもあるかもしれません。そういった場合にreflectパッケージが有効であると理解できました。
とはいえ最近ではやはりパフォーマンスに対する懸念からreflectパッケージを使わずにエンコード処理を行えるパッケージもあるようです。

https://cs.opensource.google/go/go/+/refs/tags/go1.22.1:src/encoding/json/encode.go;l=376-424

// newTypeEncoder constructs an encoderFunc for a type.
// The returned encoder only checks CanAddr when allowAddr is true.
func newTypeEncoder(t reflect.Type, allowAddr bool) encoderFunc {
    // If we have a non-pointer value whose type implements
    // Marshaler with a value receiver, then we're better off taking
    // the address of the value - otherwise we end up with an
    // allocation as we cast the value to an interface.
    if t.Kind() != reflect.Pointer && allowAddr && reflect.PointerTo(t).Implements(marshalerType) {
        return newCondAddrEncoder(addrMarshalerEncoder, newTypeEncoder(t, false))
    }
    if t.Implements(marshalerType) {
        return marshalerEncoder
    }
    if t.Kind() != reflect.Pointer && allowAddr && reflect.PointerTo(t).Implements(textMarshalerType) {
        return newCondAddrEncoder(addrTextMarshalerEncoder, newTypeEncoder(t, false))
    }
    if t.Implements(textMarshalerType) {
        return textMarshalerEncoder
    }

    switch t.Kind() {
    case reflect.Bool:
        return boolEncoder
    case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
        return intEncoder
    case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
        return uintEncoder
    case reflect.Float32:
        return float32Encoder
    case reflect.Float64:
        return float64Encoder
    case reflect.String:
        return stringEncoder
    case reflect.Interface:
        return interfaceEncoder
    case reflect.Struct:
        return newStructEncoder(t)
    case reflect.Map:
        return newMapEncoder(t)
    case reflect.Slice:
        return newSliceEncoder(t)
    case reflect.Array:
        return newArrayEncoder(t)
    case reflect.Pointer:
        return newPtrEncoder(t)
    default:
        return unsupportedTypeEncoder
    }
}

懸念点

さて、標準パッケージでも使われていることがわかったreflectパッケージですが、今後は逆に具体的にどのような点が懸念点として上がるのか気になります。色々調べる中でなんとなくパフォーマンス面で懸念がありそうということと、レビューでいただいたコメントでpanicを引き起こしやすいことは分かりましたが、実際どのくらい差分が出るのか、なぜpanicを引き起こしやすいのかを簡単なコードで検証してみます。

検証

パフォーマンス

以下のようなサンプルコードを用意しました。
0~9999までを順番に加算するだけのシンプルなコードです。normalSum()では引数をint型で受け取ってそのまま加算して返します。reflectSum()では一度interface{}で受け取り値をreflect.ValueOf()で受け取ってint型に変換して加算して返します。確かにreflectSum()では型変換が入って少し遅くなりそうですが、実際どれくらいの差が出るのか動かしてみます。

package main

import (
    "fmt"
    "reflect"
    "time"
)

func normalSum(nums []int) int {
    sum := 0
    for i := 0; i < len(nums); i++ {
        sum += nums[i]
    }
    return sum
}

func reflectSum(nums interface{}) int {
    val := reflect.ValueOf(nums)
    sum := 0
    for i := 0; i < val.Len(); i++ {
        sum += val.Index(i).Interface().(int)
    }
    return sum
}

func main() {
    nums := make([]int, 10000)
    for i := range nums {
        nums[i] = i
    }

    start := time.Now()
    normalSum(nums)
    fmt.Println("Normal:", time.Since(start))

    start = time.Now()
    reflectSum(nums)
    fmt.Println("Reflect:", time.Since(start))
}

以下が実際に動かしてみた結果です。実行時間の確認なので都度キャッシュを削除しながらとりあえず3回動かしてみたのですが、信じられないくらいの差が出てました。実際のアプリケーションコードで10000回も加算処理を行うことなど早々ないと思いますし、他の処理によって処理時間は変わってくると思うので一概には言えませんが正直こんなにわかりやすい差になると思っていなかったので少し意外でした。

$ go run main.go 
Normal: 8.541µs
Reflect: 420.416µs

$ go clean -cache

$ go run main.go 
Normal: 7.333µs
Reflect: 326.375µs

$ go clean -cache

$ go run main.go 
Normal: 6.042µs
Reflect: 317.042µs
なぜこのような結果になるのか

reflectSum()の方が遅いんだろうなとは思っていたのですが、結果が想像以上でした。 遅くなる要因としては以下の2つかなと思うのですが、以下の処理をループさせることによってどんどん差が大きくなっていくのかなと推測しています。

  1. reflect.ValueOf()で値の型情報を取得し、新しいオブジェクトを作成している
  2. val.Index(i).Interface().(int)で要素を取得後にその値をintに変換している

panicを引き起こしやすいコード

パフォーマンスの問題はわかりましたが、次にレビューでのコメントでもいただきましたがどういう点に気をつけないとpanicを起こしてしまうのかについて検証します。
以下のようなコードを用意しました。

package main

import (
    "fmt"
    "reflect"
    "strconv"
    "time"
)

func normalSum(nums []int) string {
    sum := "0"
    for i := 0; i < len(nums); i++ {
        sum += strconv.Itoa(nums[i])
    }
    return sum
}

func reflectSum(nums []int) string {
    val := reflect.ValueOf(nums)
    sum := "0"
    for i := 0; i < val.Len(); i++ {
        sum += val.Index(i).Interface().(string)
    }
    return sum
}

func main() {
    nums := make([]int, 10000)
    for i := range nums {
        nums[i] = i
    }

    start := time.Now()
    normalSum(nums)
    fmt.Println("Normal:", time.Since(start))

    start = time.Now()
    reflectSum(nums)
    fmt.Println("Reflect:", time.Since(start))
}

わかりやすいようにさっきのパフォーマンスの検証に使ったコードをベースにして、normalSum()reflectSum() の返り値をintからstringに変更し、そのために加算時に型変換処理を追加しました。
このコードを実行してみます。

$ go run main.go  
Normal: 22.172667ms
panic: interface conversion: interface {} is int, not string

normalSum() は正常終了しましたが、 reflectSum() ではpanicが起きてしまいました。 interface型で受け取った値ですが元々はint型なのでこういう書き方はできません。正しくはval.Index(i).String() と書く必要があります。
このようにコンパイル時の型チェックが行われないので型や値の状態を正しく理解した上で、適切な操作を行う必要がありそうです。
上記はあくまで一例なので、実施にreflectパッケージを使いたいときは使いたいメソッドなどのドキュメントを一読してから使うことをおすすめします!

以上を踏まえた上で最初のコード

ここまでわかったので改めて最初のコードをみてみます。

if got := HogeMethod(); !reflect.DeepEqual(got, tt.want) {
  t.Errorf("HogeMethod() return  %v, want %v", got, tt.want)
}

HogeMethod()はある構造体を返し、wantには期待値が入るので同じく構造体が入ります。その2つの構造体をreflect.DeepEqual()で比較しています。最初はなんとなく厳密に比較してくれるのだろうという理解度 でしたが、いくつか気をつけるべき点があることが理解できました。
そのテストで何を検証したいのかを考えて、用法用量を守って正しく使うことが大事ですね!

学び

今回はチームメンバーのコメントがあったおかげでここまで詳しく調べましたが、自動生成されたコードについて疑問や関心を持つことの大切さを学びました。
特に最近ではChatGPTをはじめとしてAIをうまく活用しながらコードを書くようになってきていますが、自分で1から書いたときと同様に自分のコードに責任を持って意図を話せるようにしておくのが大事ですね!

参考