Day 008 — 構造体の基礎(struct定義・フィールドタグ)

2026-08-05 🟢 初心者 / Phase 1 デバッグ・コードレビュー 構造体の基礎(struct定義・フィールドタグ)

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

構造体(struct)は、複数の異なる型のフィールドを1つにまとめた集合型です。Goにはクラスがないため、「データの入れ物」としての役割は構造体が担います。

type Config struct {
	Name    string
	Retries int
	Timeout int
}

構造体を宣言しただけでは値は作られません。Config{}のようにゼロ値で作ることも、Config{Name: "api", Retries: 3}のようにフィールド名を指定して初期化することもできます。フィールドを指定しなかった場合は、それぞれの型のゼロ値(stringなら""intなら0)が入ります。

構造体にはフィールドタグという文字列メタデータを付けられます。

type Config struct {
	Name    string `json:"name"`
	Retries int    `json:"retries"`
}

フィールドタグ自体はGoの言語仕様としては「フィールドに付随する単なる文字列」に過ぎず、コンパイラは特別扱いしません。しかしencoding/jsonのような標準ライブラリやサードパーティのライブラリがreflectパッケージ経由でこの文字列を読み取り、「JSONエンコード時にこのフィールドをnameというキー名にする」といった振る舞いを実現しています。つまりフィールドタグは「構造体とリフレクションを組み合わせることで、言語機能を拡張せずにメタデータ駆動の振る舞いを実現する」というGoらしい設計の一例です。

そして、今回のテーマで最も重要なのが構造体は値型であるという事実です。

c1 := Config{Name: "api"}
c2 := c1      // c2 は c1 の「コピー」。フィールドが全部複製される
c2.Name = "web"
fmt.Println(c1.Name) // "api" のまま。c2の変更はc1に影響しない

これは配列と同じ挙動で、スライスやマップとは対照的です(スライス・マップは内部にポインタを持つ参照的な型でした)。構造体は「代入」でも「関数への引数渡し」でも、値そのものがまるごとコピーされます。この挙動を理解していないと、「関数の中でフィールドを書き換えたつもりが、呼び出し元には反映されていない」という典型的なバグを踏みます。

📝 問題

以下のコードは、Config構造体に対してデフォルト値を設定するapplyDefaults関数を実装したつもりですが、期待通りに動作しません。バグを特定し、修正してください。

バグのあるコード:

package main

import "fmt"

type Config struct {
	Name    string `json:"name"`
	Retries int    `json:"retries"`
	Timeout int    `json:"timeout"`
}

// applyDefaults は Name が空文字列、Retries・Timeout が 0 の場合に
// デフォルト値を設定するつもりの関数。
func applyDefaults(c Config) {
	if c.Name == "" {
		c.Name = "default-service"
	}
	if c.Retries == 0 {
		c.Retries = 3
	}
	if c.Timeout == 0 {
		c.Timeout = 30
	}
}

func main() {
	cfg := Config{}
	applyDefaults(cfg)

	fmt.Printf("Name=%q Retries=%d Timeout=%d\n", cfg.Name, cfg.Retries, cfg.Timeout)
}
🐛
期待する出力: Name="default-service" Retries=3 Timeout=30
実際の出力: Name="" Retries=0 Timeout=0

要件:

  1. なぜapplyDefaultsを呼び出してもcfgのフィールドが変わらないのか、構造体の値型としての性質を踏まえて理由を説明してください
  2. applyDefaultsを修正し、実際にcfgへデフォルト値が反映されるようにしてください。修正方法は次のどちらか一方(または両方を実装して比較しても構いません):
    • (A) applyDefaultsConfigを受け取り、デフォルト値を適用した新しいConfigを返すようにし、呼び出し側でcfg = applyDefaults(cfg)のように再代入する
    • (B) applyDefaults*Config(ポインタ)を受け取り、ポインタ経由でフィールドを直接書き換える
  3. main関数を修正後の関数シグネチャに合わせて更新し、go run main.goを実行して期待する出力が得られることを確認してください

go run main.go でそのまま実行できる、完全な1つのプログラムとして提出してください。

🔍 ヒント(段階的開示)

ヒント1 — 方向性

c1 := Config{}c2 := c1 のように別の変数へ代入すると、c2c1の完全なコピーになります。関数の引数として構造体を渡すのも「代入」と同じ扱いです。applyDefaults(cfg)を呼び出したとき、関数の中のcと呼び出し側のcfgが「同じもの」なのか「別のコピー」なのか、まずそこを確認してください。

ヒント2 — アプローチ

方法(A)なら、関数の戻り値の型をConfigにしてreturn cを追加し、呼び出し側は戻り値を使ってcfgを更新します。方法(B)なら、引数の型を*Configにし、関数内ではc.Name = "..."のようにポインタ経由でフィールドへ直接アクセスします(Goでは(*c).Nameと書かなくてもc.Nameで自動的にポインタの指す先のフィールドにアクセスできます)。呼び出し側はapplyDefaults(&cfg)のようにアドレス演算子&でポインタを渡します。

ヒント3 — コード骨格
package main

import "fmt"

type Config struct {
	Name    string `json:"name"`
	Retries int    `json:"retries"`
	Timeout int    `json:"timeout"`
}

// 方法(A): 新しい Config を返す版
func applyDefaults(c Config) Config {
	if c.Name == "" {
		c.Name = "default-service"
	}
	// Retries, Timeout も同様に...
	return c
}

func main() {
	cfg := Config{}
	cfg = applyDefaults(cfg) // 戻り値で再代入する

	fmt.Printf("Name=%q Retries=%d Timeout=%d\n", cfg.Name, cfg.Retries, cfg.Timeout)
}

方法(B)を選ぶ場合は、func applyDefaults(c *Config)とし、呼び出しをapplyDefaults(&cfg)に変える骨格を自分で組み立ててみましょう。

模範解答

package main

import "fmt"

// Config はサービスの設定を表す構造体。
// json タグは encoding/json でエンコード/デコードする際のキー名を指定する。
type Config struct {
	Name    string `json:"name"`
	Retries int    `json:"retries"`
	Timeout int    `json:"timeout"`
}

// applyDefaults は Name・Retries・Timeout がゼロ値の場合にデフォルト値を
// 適用した「新しい Config」を返す。呼び出し側は戻り値を使って更新する。
func applyDefaults(c Config) Config {
	if c.Name == "" {
		c.Name = "default-service"
	}
	if c.Retries == 0 {
		c.Retries = 3
	}
	if c.Timeout == 0 {
		c.Timeout = 30
	}
	return c
}

// applyDefaultsInPlace はポインタ経由でフィールドを直接書き換える版。
// 比較のために両方の方法を示す。
func applyDefaultsInPlace(c *Config) {
	if c.Name == "" {
		c.Name = "default-service"
	}
	if c.Retries == 0 {
		c.Retries = 3
	}
	if c.Timeout == 0 {
		c.Timeout = 30
	}
}

func main() {
	// 方法(A): 戻り値で再代入する版
	cfg := Config{}
	cfg = applyDefaults(cfg)
	fmt.Printf("(A) Name=%q Retries=%d Timeout=%d\n", cfg.Name, cfg.Retries, cfg.Timeout)

	// 方法(B): ポインタを渡してその場で書き換える版
	cfg2 := Config{}
	applyDefaultsInPlace(&cfg2)
	fmt.Printf("(B) Name=%q Retries=%d Timeout=%d\n", cfg2.Name, cfg2.Retries, cfg2.Timeout)
}
▶ 実行結果を見る(go run main.go)
(A) Name="default-service" Retries=3 Timeout=30
(B) Name="default-service" Retries=3 Timeout=30

※ この実行結果は実際に go run main.go で検証済みです(バグありコードが Name="" Retries=0 Timeout=0 を出力することも合わせて確認済みです)。

🪜 Step-by-Step 解説

1
バグの原因を突き止める
元のコードのapplyDefaults(c Config)は、引数cを値として受け取ります。applyDefaults(cfg)を呼び出した瞬間、cfgの中身は丸ごとcコピーされます。関数内でc.Name = "default-service"と書き換えても、それは「コピーであるc」を書き換えているだけで、呼び出し元のcfgとは既に無関係な別のメモリ領域です。関数が終了するとcは破棄され、呼び出し元のcfgはゼロ値のまま残ります。これが「関数を呼んだのに変化がない」ように見えた理由です。
2
方法(A) — 新しい値を返して再代入する
func applyDefaults(c Config) Config {
	// ... c を書き換える ...
	return c
}

cfg = applyDefaults(cfg)
関数内で書き換えたcreturnで返し、呼び出し側でcfg = applyDefaults(cfg)明示的に再代入します。これにより「関数が返した新しい値でcfgを上書きする」という処理の流れがコード上にはっきり現れます。副作用(呼び出し元の変数がこっそり書き換わる)がなく、データの流れを追いやすいのが利点です。
3
方法(B) — ポインタを渡して直接書き換える
func applyDefaultsInPlace(c *Config) {
	c.Name = "default-service" // 実際は (*c).Name の糖衣構文
}

applyDefaultsInPlace(&cfg2)
*Config型の引数にすると、渡されるのは「cfg2のアドレス」であり、コピーされません。関数内のcは呼び出し元のcfg2そのものを指しているため、c.Name = ...はそのままcfg2.Nameを書き換えます。Goでは(*c).Nameと書く代わりにc.Nameと書けます(コンパイラが自動的にポインタを経由してくれる)。呼び出し側は&cfg2のように&演算子でアドレスを渡す必要があります。
4
フィールドタグは今回のバグに無関係であることを確認する
json:"name"のようなフィールドタグは、encoding/jsonのような外部の処理系がリフレクションで読み取るだけの静的な文字列です。applyDefaultsの中でフィールドを書き換える通常のコードには一切影響しません。今回のバグは「値渡しかポインタ渡しか」という構造体のコピーの仕組みだけが原因である点を切り分けて理解しておくことが重要です。

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

📌
予測可能性を優先する: Goが構造体を値型として設計しているのは、「予測可能性を優先する」という一貫した思想の表れです。値型であれば、「この変数を関数に渡しても、呼び出し元の値は絶対に変わらない」という強い保証が得られます。逆に参照型(スライス・マップ・チャネルなど)は「共有されている」という前提でコードを読む必要がありますが、構造体はデフォルトでは共有されないため、読み手が身構えずに済みます。
📌
デフォルトは安全、必要な場面だけ明示的にポインタ: 大きな構造体を値渡しするたびにフィールド全体がコピーされるのはパフォーマンス上のコストにもなり得ます。そのためGoでは、「変更を呼び出し元に反映させたい場合」「コピーコストを避けたい大きな構造体を扱う場合」に限って、*Configのようにポインタを明示的に使うという選択肢を用意しています。この設計は、次回学ぶメソッドのレシーバ(値レシーバ vs ポインタレシーバ)の判断基準にも直結します。

🌐 他言語との比較

観点GoJavaPythonTypeScript
構造体/クラスのデフォルトの受け渡し値渡し(フィールドがまるごとコピーされる)参照渡し(オブジェクトは常に参照。全てのクラスは実質ポインタ的)参照渡し(全てのオブジェクトは参照)参照渡し(オブジェクト・クラスは常に参照)
「コピーしたい」場合何もしなくてもデフォルトでコピーされる明示的にclone()やコピーコンストラクタを書く必要があるcopy.deepcopy()などを使う必要があるスプレッド構文{...obj}などで明示的にコピーする
「共有したい」場合明示的にポインタ(*T)を使う必要がある何もしなくてもデフォルトで共有される何もしなくてもデフォルトで共有される何もしなくてもデフォルトで共有される
メタデータ付与フィールドタグ(`json:"name"`)をリフレクションで読むアノテーション(@JsonProperty)をリフレクションで読むデコレータやクラス変数で代用デコレータ(実験的機能)やスキーマライブラリで代用

Java/Python/TypeScript経験者は「オブジェクトは常に参照」という前提が染み付いているため、Goの構造体が値型であることを見落として今回のようなバグを踏みやすいポイントです。Goでは「値渡しがデフォルトで安全、共有したければポインタを明示する」という逆の発想に切り替える必要があります。

🏆 実務での使いどころ

  • 設定・DTO(Data Transfer Object)の受け渡し: Configのような設定用構造体、APIリクエスト/レスポンスを表す構造体は、値渡しによる「呼び出し元を汚染しない」性質を活かして安全に受け渡しされます
  • JSON APIのリクエスト/レスポンス変換: フィールドタグ(json:"name")は、REST APIのJSONペイロードを構造体にデコードしたり、構造体をJSONにエンコードしたりする際にキー名を制御するために実務で必ず使われます(例: Goのフィールド名は大文字始まりの慣例だが、JSONのキーはキャメルケース/スネークケースにしたい、といった変換)
  • 「値を返して更新する」パターン: 今回のapplyDefaultsのように、設定のマージ・デフォルト値の適用・バリデーション後の正規化などは「新しい値を返して呼び出し元が再代入する」スタイルで書かれることが多く、context.WithValueなど標準ライブラリの一部の設計思想とも共通します
  • 大きな構造体のポインタ渡し: データベースの行を表す構造体や、多数のフィールドを持つ設定構造体を関数間で頻繁に受け渡しする場合、コピーコストを避けるために*Tで渡すのが一般的です

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
構造体を関数に渡せば、関数内でのフィールド変更が呼び出し元に反映されると思い込むJava/Python/TypeScriptなど「オブジェクトは常に参照」という言語での経験を持ち込むGoの構造体は値型。関数へ渡すと丸ごとコピーされる。呼び出し元に反映したいなら*T(ポインタ)を明示的に使う必要がある
c2 := c1で構造体を代入した後、c2を書き換えるとc1も変わってしまうと思い込むスライスやマップが参照的に振る舞うことと混同する構造体の代入は値のコピー。c2を書き換えてもc1には一切影響しない
フィールドタグを付けるだけで自動的にバリデーションや変換が行われると思い込むアノテーション駆動のフレームワーク(Java の Bean Validation 等)と混同するフィールドタグはただの文字列。encoding/jsonのようなそれを読むコードが存在して初めて意味を持つ。タグを付けただけでは何も起きない
ポインタを使えば常に安全・高速だと思い込み、何でも*Tにする「ポインタ渡しの方がコピーが起きないので速い」という一部の事実だけを一般化する小さい構造体(フィールド数個程度)は値渡しの方がシンプルで、コピーコストも無視できることが多い。ポインタは「変更を反映したい」「構造体が大きい」など明確な理由がある場合に選ぶべき

🚀 次のステップ

  • 発展: Config構造体にTags []stringのようなスライスフィールドを追加し、applyDefaultsを値渡し版で呼び出した場合に、スライスフィールドへのappendが呼び出し元にどう影響するか(構造体自体は値型でコピーされるが、スライスフィールドの中身の基底配列は共有されたままである点)を実験してみましょう
  • 次回予告: Day 009 — メソッドとレシーバ(値レシーバ vs ポインタレシーバ)(概念理解→実装問題)。今回学んだ「値渡し vs ポインタ渡し」の判断基準が、そのままメソッドのレシーバ選択の基準に直結します

🎯 自己評価

自分の回答

気づき・メモ