Day 044 — reflectパッケージ(コードレビュー: 書き込み系reflectの3つのバグ)

2026-09-10 🟡 上級者 / Phase 3 デバッグ・コードレビュー reflectパッケージ

📚 背景知識(読んでから問題へ)

Day 043では、reflectを使って構造体の必須フィールドを検証するValidateRequired関数を実装しました。今回はその発展として「読む(検証する)」だけでなく「書く(値を書き換える)」リフレクションを扱います。

reflectで値を読むだけなら、reflect.ValueOf(v)に構造体そのものを渡しても特に問題は起きません。しかし値を書き換える(Set系メソッドを呼ぶ)には、追加の条件が必要です。Goの値渡しの原則(関数に渡された値はコピーである)はreflectの世界でも変わらないため、reflect.ValueOf(v)が保持しているのは「元の変数のコピー」であり、そのコピーを書き換えても呼び出し元の変数には反映されません。さらにreflectパッケージは、このコピーへの書き込みそのものを実行時パニックで拒否します(field.SetString(...)のようなメソッドを、書き込み不可能なreflect.Valueに対して呼ぶとpanic: reflect: reflect.Value.SetString using unaddressable valueのようなエラーになる)。

この「書き込み可能かどうか」をreflectはCanSet()というメソッドで明示的に問い合わせられるようにしています。これはGoの設計思想である「暗黙の失敗より、明示的なチェックと分かりやすいパニックメッセージ」の表れです。書き込み可能にするための唯一の方法は、構造体そのものではなく構造体へのポインタをreflect.ValueOfに渡し、.Elem()でポインタが指す実体(アドレス可能な値)を取り出すことです。

📌
Day 043のValidateRequiredは値渡し(コピー)でも動作しました。それは「読むだけ」だったからです。今回の「書き込みを伴う」問題では、この違いが直接バグの原因になります。

📝 問題

以下は、構造体のゼロ値フィールドに、フィールドタグdefaultで指定したデフォルト値を自動的にセットするApplyDefaults関数のつもりで書かれたコードです。このコードには複数のバグがあります。 go run main.goで実際に実行して挙動を確認し、バグを最低2つ指摘した上で、修正した完全なコードを提示してください。

package main

import (
	"fmt"
	"reflect"
)

// Config は設定値を保持する構造体。
// フィールドタグ default で指定した値を、ゼロ値のフィールドに自動的に埋めたい。
type Config struct {
	Host  string `default:"localhost"`
	Port  int    `default:"9090"`
	Debug bool   `default:"true"`
}

// ApplyDefaults は cfg のゼロ値フィールドに、タグで指定したデフォルト値をセットする(つもり)。
func ApplyDefaults(cfg interface{}) {
	v := reflect.ValueOf(cfg)
	t := reflect.TypeOf(cfg)

	for i := 0; i < v.NumField(); i++ {
		field := v.Field(i)
		tag := t.Field(i).Tag.Get("default")
		if tag == "" {
			continue
		}
		switch field.Kind() {
		case reflect.String:
			if field.String() == "" {
				field.SetString(tag)
			}
		case reflect.Int:
			if field.Int() == 0 {
				field.SetInt(8080) // Port専用の値を仮置きしている(本来はtagの値を使うべき)
			}
		}
	}
}

func main() {
	cfg := Config{}
	ApplyDefaults(cfg)
	fmt.Printf("%+v\n", cfg)
}

指摘・修正すべき観点(最低2つ選んで直すこと):

  1. このコードを実際に実行すると何が起きるか(go run main.goの出力を予測してから確認する)
  2. ApplyDefaults(cfg)の呼び出し方と、関数内部のv.NumField()の呼び出しに矛盾がないか
  3. Intのケースで8080が決め打ちされている点は、Config.Portのタグが"9090"であることと整合しているか
  4. Debug boolフィールドのデフォルト値は、このコードで正しく処理されるか(switch文のcase一覧を確認する)

🔍 ヒント(段階的開示)

ヒント1 — 方向性

まず本当にgo run main.goを実行してみてください。パニックする場合、パニックメッセージのどの単語が手がかりになるかを読み取ってください。パニックしない場合でも、出力された%+vの値が期待通りかどうかを1フィールドずつ確認してください。

ヒント2 — アプローチ

reflect.Valueが「書き込み可能(addressable)」であるための条件をCanSet()のドキュメントで確認してください。書き込み可能にするには、reflect.ValueOfに何を渡し、その後どのメソッドを呼んで「指し示す先の実体」を取得する必要があるか考えてください。また、タグの文字列("9090"のような数値の文字列表現)をint64に変換する標準ライブラリの関数(strconvパッケージ)も確認してください。

ヒント3 — コード骨格
func ApplyDefaults(cfg interface{}) error {
	v := reflect.ValueOf(cfg)
	if v.Kind() != reflect.Ptr || v.IsNil() {
		return fmt.Errorf("cfgは構造体へのポインタである必要があります")
	}
	v = v.Elem() // ポインタが指す実体を取得する(ここでCanSetがtrueになる)
	t := v.Type()

	for i := 0; i < v.NumField(); i++ {
		field := v.Field(i)
		tag := t.Field(i).Tag.Get("default")
		if tag == "" || !field.CanSet() {
			continue
		}
		switch field.Kind() {
		case reflect.String:
			// ...
		case reflect.Int:
			n, err := strconv.ParseInt(tag, 10, 64)
			// ...
		case reflect.Bool:
			// ...
		}
	}
	return nil
}

✅ 模範解答

package main

import (
	"errors"
	"fmt"
	"reflect"
	"strconv"
)

// Config は設定値を保持する構造体。
// フィールドタグ default で指定した値を、ゼロ値のフィールドに自動的に埋める対象。
type Config struct {
	Host  string `default:"localhost"`
	Port  int    `default:"9090"`
	Debug bool   `default:"true"`
}

// ApplyDefaults は cfg(構造体へのポインタ)のゼロ値フィールドに、
// タグ default で指定した値をセットする。
//
// バグ1: 元のコードは ApplyDefaults(cfg) のように構造体を値渡ししていたため、
//        関数内で書き換えても呼び出し元の変数には反映されない(コピーへの書き込み)。
//        さらに t := reflect.TypeOf(cfg) と v := reflect.ValueOf(cfg) はどちらも
//        構造体そのものの Value/Type であり Kind() は Struct になるためパニックはしないが、
//        「読める」だけで「書き込めない」状態になっている。
// バグ2: Int のケースで tag の値を無視して 8080 を決め打ちしていたため、
//        Port のタグ "9090" が反映されない(Host のタグが正しく使われているのと非対称)。
// バグ3: switch 文に reflect.Bool の case がなく、Debug フィールドのデフォルト値が
//        まったく処理されない(無言でスキップされ続ける)。
func ApplyDefaults(cfg interface{}) error {
	v := reflect.ValueOf(cfg)
	if v.Kind() != reflect.Ptr || v.IsNil() {
		return errors.New("cfgは構造体へのポインタである必要があります")
	}
	v = v.Elem() // ポインタが指す実体を取得して初めて書き込み可能になる
	if v.Kind() != reflect.Struct {
		return fmt.Errorf("構造体へのポインタが必要です(got *%s)", v.Kind())
	}
	t := v.Type()

	for i := 0; i < v.NumField(); i++ {
		field := v.Field(i)
		tag := t.Field(i).Tag.Get("default")
		if tag == "" {
			continue
		}
		if !field.CanSet() {
			// 非公開フィールド(小文字始まり)などは書き込めないため安全にスキップする
			continue
		}

		switch field.Kind() {
		case reflect.String:
			if field.String() == "" {
				field.SetString(tag)
			}
		case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
			if field.Int() == 0 {
				n, err := strconv.ParseInt(tag, 10, 64)
				if err != nil {
					return fmt.Errorf("フィールド %s のdefaultタグ %q はint変換できません: %w", t.Field(i).Name, tag, err)
				}
				field.SetInt(n)
			}
		case reflect.Bool:
			// bool のゼロ値は false なので、フィールドが false のときだけ埋める
			if !field.Bool() {
				b, err := strconv.ParseBool(tag)
				if err != nil {
					return fmt.Errorf("フィールド %s のdefaultタグ %q はbool変換できません: %w", t.Field(i).Name, tag, err)
				}
				field.SetBool(b)
			}
		}
	}
	return nil
}

func main() {
	cfg := Config{}
	if err := ApplyDefaults(&cfg); err != nil {
		fmt.Println("エラー:", err)
		return
	}
	fmt.Printf("%+v\n", cfg)

	fmt.Println("\n--- 一部だけ既に値が入っている場合(上書きされないことを確認) ---")
	cfg2 := Config{Host: "example.com"}
	if err := ApplyDefaults(&cfg2); err != nil {
		fmt.Println("エラー:", err)
		return
	}
	fmt.Printf("%+v\n", cfg2)

	fmt.Println("\n--- ポインタでなく値を渡した場合(意図的なエラーハンドリングの確認) ---")
	cfg3 := Config{}
	if err := ApplyDefaults(cfg3); err != nil {
		fmt.Println("エラー:", err)
	}
}

実行結果(予測):

{Host:localhost Port:9090 Debug:true}

--- 一部だけ既に値が入っている場合(上書きされないことを確認) ---
{Host:example.com Port:9090 Debug:true}

--- ポインタでなく値を渡した場合(意図的なエラーハンドリングの確認) ---
エラー: cfgは構造体へのポインタである必要があります
📌
元のバグありコードを実際に実行した場合の挙動: ApplyDefaults(cfg)は構造体を値渡ししており、v := reflect.ValueOf(cfg)のKindはStruct(ポインタではない)なのでv.NumField()はパニックしません。しかしfield.SetString(tag)・field.SetInt(...)を呼び出した瞬間に、vが呼び出し元のコピーであり書き込み不可能(unaddressable)であるため、panic: reflect: reflect.Value.SetString using unaddressable valueでクラッシュします。

🪜 Step-by-Step 解説

1
ポインタを受け取り、Elem()で実体を取得する
reflect.ValueOf(cfg)に構造体そのものを渡すと、得られるreflect.Valueは「呼び出し時にコピーされた値」を指します。このコピーはCanSet() == falseであり、Set系メソッドはすべてパニックします。&cfg(ポインタ)を渡し、v.Elem()で「ポインタが指す元の変数の実体」を取得することで、初めてCanSet() == trueになります。これはGoの値渡しの原則がreflectにもそのまま適用されているだけで、特別なルールではありません。
2
CanSet()で書き込み可能性を確認する
非公開フィールド(フィールド名が小文字始まり)は、たとえポインタ経由で構造体を取得していてもCanSet()がfalseになります。unsafeパッケージ(Day 045で扱う予定)を使わない限り、外部パッケージから非公開フィールドを書き換えることはできません。CanSet()によるチェックを入れることで、そのようなフィールドに遭遇してもパニックせず安全にスキップできます。
3
タグの値を型に応じて変換する
元のコードはIntのケースで8080を決め打ちしており、Stringのケースだけがタグの値を正しく使っていました。tagは常に文字列(reflect.StructTagから取り出した生の文字列)なので、intに書き込むにはstrconv.ParseInt、boolに書き込むにはstrconv.ParseBoolで明示的に変換する必要があります。「文字列から目的の型への変換」はreflectパッケージ自身は面倒を見てくれない領域です。
4
switch文のcase漏れをテストで検出する
reflect.Boolのcaseが欠けていたバグは、パニックしないため気づきにくいという点が重要です。Debugフィールドは何も処理されずfalseのまま残りますが、エラーも出ません。このような「静かに何もしない」バグは、期待する出力とのdiff(この問題では%+vの出力を目で確認すること)や、後述するテーブル駆動テストで全フィールドの結果を検証することでしか発見できません。

💡 設計思想・なぜこう書くのか

📌
パニックメッセージを「読めば直せる」ように設計する: reflect.Value.SetString using unaddressable valueというパニックメッセージは、単に落ちるだけでなく「何が原因か(addressableでない)」まで教えてくれます。Goの標準ライブラリは、実行時エラーであっても原因を特定しやすい文言を返すことを重視しており、reflectパッケージもその例外ではありません。
📌
「書き込み不可能」をコンパイルエラーではなく実行時パニックにする代償: Goは通常、書き込めない変数(例: 別パッケージの非公開フィールド)への代入をコンパイル時に弾きます。しかしreflect経由の操作では、コンパイラは対象の型を静的に把握できないため、このチェックを実行時に先送りするしかありません。これが「reflectは型安全性をコンパイル時から実行時に後退させる」というDay 043の設計思想の具体例です。CanSet()による事前チェックは、この後退した安全性を部分的に取り戻すための手段です。

🌐 他言語との比較

言語同等の概念Goとの違い
JavaField.setAccessible(true) + Field.set(obj, value)JavaはsetAccessible(true)を呼べば非公開フィールドへの書き込みも(セキュリティマネージャの制約がなければ)可能。Goはunsafeパッケージを使わない限り非公開フィールドへの書き込みをreflectだけでは絶対に許さない、より保守的な設計
Pythonsetattr(obj, name, value)Pythonはそもそも「書き込み可能かどうか」を明示的に問い合わせるAPI(CanSet相当)がなく、書き込めない場合はAttributeErrorなどの例外で気づく。Goは書く前にCanSet()で確認できる点が対照的
C++なし(標準のリフレクションは存在しない)C++にはGoのreflectに相当する標準機能がなく、同種の動的操作をするにはRTTI(typeid)や外部ライブラリ、コード生成に頼る必要がある

🏆 実務での使いどころ

  • 設定ファイルのデフォルト値埋め込み: 環境変数や設定ファイルから読み込んだ構造体に対し、未設定項目にデフォルト値を適用する処理(kelseyhightower/envconfigのようなライブラリが同種のアプローチを取る)
  • ORM/マッパーでの書き込み処理: データベースの行データを構造体のフィールドに書き込む処理は、今回学んだ「ポインタを受け取りElem()で実体を取得しSetする」パターンをそのまま使う
  • テストのフィクスチャ生成: テストデータ構造体のゼロ値フィールドにランダム値やデフォルト値を自動生成して埋めるヘルパー関数

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
reflect.ValueOf(cfg)に構造体を値渡しし、フィールドの読み取りは動くため「動いている」と誤解するKind()やField(i).String()のような読み取り専用の操作は値渡しでもパニックしないため、書き込みを試みるまでバグに気づかない「読める」と「書ける」はreflectでは別の条件。書き込みが必要な関数は、設計段階でポインタ受け取りを前提にする
switch field.Kind()で扱う型のcaseを一部書き忘れても、コンパイルも実行も正常に終わるため見逃すGoのswitch文は(defaultがなければ)該当しないcaseは単に無視されるだけで、コンパイラも実行時エラーも出さないreflectベースの汎用関数は、対象になりうる型(Bool, Float64, Sliceなど)を網羅的にリストアップし、テーブル駆動テストで全パターンの出力を検証する習慣をつける
field.SetInt(n)に渡すnをint型のまま渡そうとしてコンパイルエラーになるSetIntのシグネチャはfunc (v Value) SetInt(x int64)であり、strconv.Atoiが返すintとは型が異なるint64が必要な場面ではstrconv.ParseInt(s, 10, 64)を使うか、int64(n)で明示的に変換する

🚀 次のステップ

  • 発展: 今回のApplyDefaultsに、reflect.Slice・reflect.Float64のcaseを追加し、テーブル駆動テスト(Day 023の応用)で全フィールド型の組み合わせを検証してみましょう
  • 次回予告: unsafeパッケージの基礎 — reflectがCanSet()で守っていた「非公開フィールドへの書き込み禁止」を、あえて破る場面とその代償を学びます

🎯 自己評価

自分の回答

気づき・メモ