メインコンテンツまでスキップ

型と構造体

.NET (C#) の経験がある開発者向けに、struct、型定義、構造体タグ、埋め込み (embedding)、値セマンティクス、class / record との違いを解説します。

1. Go の型システムの全体像​

Go には class がありません。データは struct で定義し、振る舞いはメソッド (次章のメソッドとインターフェース) で追加します。継承はなく、合成 (composition) で再利用します。

2. .NET との対応表​

.NET (C#)Go
class / recordstruct
継承埋め込み (embedding) による合成
コンストラクターNew〜 ファクトリ関数
enumconst + iota
オブジェクト初期化子 new T { X = 1 }複合リテラル T{X: 1}
record の with 式構造体のコピーしてフィールドを変更
属性 ([JsonPropertyName("x")])構造体タグ (`json:"x"`)
typealias / using X = Y型エイリアス type X = Y
readonly / initなし (非公開フィールド + コンストラクターで代用)
Equals / ==比較可能な構造体は == で比較できる
partial classなし (同一パッケージなら複数ファイルでメソッド追加可)

3. struct の定義と初期化​

定義​

type User struct {
ID int
Name string
Email string
Tags []string
}

初期化​

// 1. フィールド名を指定 (推奨)
u1 := User{ID: 1, Name: "Alice", Email: "[email protected]"}

// 2. 位置による指定 (全フィールド必須。フィールド追加で壊れるので非推奨)
u2 := User{2, "Bob", "[email protected]", nil}

// 3. ゼロ値
var u3 User // ID=0, Name="", Email="", Tags=nil

// 4. ポインタ
u4 := &User{ID: 4, Name: "Dave"}
u5 := new(User) // ゼロ値の *User
  • 指定しなかったフィールドはゼロ値になる (null にはならない)
  • 末尾のフィールドの後にはカンマが必須 (複数行で書く場合)
  • フィールドへのアクセスは u.Name。ポインタ経由でも同じ . で書ける (u4.Name。-> はない)

無名構造体​

point := struct {
X, Y int
}{X: 1, Y: 2}

テーブル駆動テストや、一度しか使わない JSON のリクエストなど、局所的な用途に便利です。

コンストラクター関数​

Go にコンストラクターはありません。慣習として New〜 という関数を定義します。

func NewUser(name, email string) (*User, error) {
if name == "" {
return nil, errors.New("name is required")
}
return &User{Name: name, Email: email}, nil
}
  • 検証や初期値の設定が必要なときだけ作る。単純な構造体なら複合リテラルで直接作る
  • 検証が失敗するなら (*User, error) を返す
  • ゼロ値が有用になるように設計すれば、コンストラクターが不要になることも多い

関数オプションパターン​

オプション引数がないので、多数の設定がある場合は次のように書きます。

type Server struct {
addr string
timeout time.Duration
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}

func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}

srv := NewServer(":8080", WithTimeout(5*time.Second))

4. 値セマンティクス​

C# の class は参照型、struct は値型ですが、Go の struct は常に値型です。代入や関数呼び出しでコピーされます。

a := User{Name: "Alice"}
b := a // コピー
b.Name = "Bob"
fmt.Println(a.Name) // Alice (a は変わらない)

p := &a // a へのポインタ
p.Name = "Carol"
fmt.Println(a.Name) // Carol

値渡しとポインタ渡し​

func rename(u User) { u.Name = "changed" } // コピーを変更 (元は変わらない)
func renameP(u *User) { u.Name = "changed" } // 元を変更

u := User{Name: "Alice"}
rename(u)
fmt.Println(u.Name) // Alice
renameP(&u)
fmt.Println(u.Name) // changed

使い分けの目安:

状況選択
値を変更したいポインタ
構造体が大きい (コピーのコストが気になる)ポインタ
sync.Mutex などコピーしてはいけないフィールドを含むポインタ
小さく不変な値 (座標、金額、日時など)値
迷ったときメソッドはポインタレシーバーで統一する

詳しくはポインタとメモリ管理を参照してください。

record の代わりに​

C# の record が持つ「不変」「値による等価比較」「with 式」は、Go では次のように実現します。

type Point struct{ X, Y int }

p1 := Point{1, 2}
p2 := Point{1, 2}
fmt.Println(p1 == p2) // true (全フィールドが比較可能なら == が使える)

p3 := p1 // コピー (with 式の代わり)
p3.X = 10
  • すべてのフィールドが比較可能なら、== でフィールドごとの比較ができる
  • スライス、マップ、関数を含む構造体は == で比較できない (reflect.DeepEqual や slices.Equal / maps.Equal を使う)
  • 構造体は map のキーにも使える (比較可能な場合)

5. フィールドの可視性と構造体タグ​

可視性​

フィールドも大文字で始まれば公開、小文字なら非公開です。

type Account struct {
ID string // 公開
balance int64 // 非公開 (パッケージ内のみ)
}

詳細はモジュールとパッケージを参照してください。

構造体タグ​

フィールドにメタデータを付ける文字列リテラルで、C# の属性に近い役割です。encoding/json や DB ライブラリ、バリデーションライブラリがリフレクションで読み取ります。

type CreateUserRequest struct {
Name string `json:"name"`
Email string `json:"email,omitempty"`
Age int `json:"age"`
Password string `json:"-"` // JSON に含めない
Created time.Time `json:"created_at"`
}
data, _ := json.Marshal(CreateUserRequest{Name: "Alice", Age: 30})
// {"name":"Alice","age":30,"created_at":"0001-01-01T00:00:00Z"}

var req CreateUserRequest
err := json.Unmarshal(data, &req)
タグ意味
json:"name"JSON のキー名を指定
json:"name,omitempty"ゼロ値なら出力しない
json:"-"出力・入力の対象外
db:"user_id"DB ライブラリのカラム名 (ライブラリ依存)
validate:"required"バリデーションライブラリ (ライブラリ依存)

注意点:

  • 非公開フィールドは JSON に含まれません。小文字のままにして値が出てこないのは典型的なバグです
  • System.Text.Json の既定は PascalCase → camelCase の変換ですが、Go ではタグを付けないとフィールド名がそのまま出力されます
  • 値がゼロ値かどうか区別したい場合は、ポインタ (*int) を使う (omitempty と組み合わせる)

6. 型定義と型エイリアス​

新しい型の定義​

type UserID int
type Celsius float64
type Handler func(w http.ResponseWriter, r *http.Request)
type Users []User

type UserID int は、int と別の型を作ります。暗黙の変換はできません。

var id UserID = 1
var n int = int(id) // 明示的な変換が必要
// var m int = id // コンパイルエラー

これにより、意味の違う ID の取り違えをコンパイル時に検出できます (UserID と OrderID など)。型にメソッドも追加できます。

func (c Celsius) Fahrenheit() float64 {
return float64(c)*9/5 + 32
}

型エイリアス​

type MyInt = int // 同じ型の別名 (= がある)

エイリアスは別名であり、新しい型ではありません。大規模なリファクタリング時の段階的な移行などに使い、通常はあまり使いません。

7. 埋め込み (継承ではなく合成)​

Go に継承はありません。代わりに、構造体の中に型名だけを書いて埋め込むことで、そのフィールドとメソッドを「昇格」させます。

type Base struct {
ID int
CreatedAt time.Time
}

func (b Base) Age() time.Duration {
return time.Since(b.CreatedAt)
}

type Article struct {
Base // 埋め込み
Title string
}

a := Article{
Base: Base{ID: 1, CreatedAt: time.Now()},
Title: "Go",
}

fmt.Println(a.ID) // a.Base.ID と同じ
fmt.Println(a.Age()) // a.Base.Age() と同じ

継承との違い​

C# の継承Go の埋め込み
関係is-ahas-a (+ 委譲の糖衣構文)
多態性基底クラス型の変数に代入できるできない (Base 型に Article は代入できない)
メソッドの上書きvirtual / override外側で同名のメソッドを定義して隠す
内側から外側のメソッドを呼ぶ可能 (仮想メソッド)不可能 (内側は外側を知らない)
多重単一継承複数の型を埋め込める
type Animal struct{ Name string }

func (a Animal) Speak() string { return a.Name + " makes a sound" }
func (a Animal) Intro() string { return a.Speak() } // 常に Animal.Speak

type Dog struct{ Animal }

func (d Dog) Speak() string { return d.Name + " barks" }

d := Dog{Animal{"Rex"}}
fmt.Println(d.Speak()) // Rex barks
fmt.Println(d.Intro()) // Rex makes a sound (Dog.Speak は呼ばれない)

C# なら virtual により「Rex barks」になるところが、Go ではならない点が重要です。多態性が必要な場合は interface を使います。

実践的な使い方​

type Logger struct{ *slog.Logger } // ポインタの埋め込みも可能

type Service struct {
Logger
repo Repository
}

s.Info("started") // Logger のメソッドが昇格している
  • 埋め込みは「フィールドとメソッドの委譲を自動化する」ための仕組みで、設計上の IS-A を表すものではない
  • 同名のフィールドやメソッドが複数の埋め込みで競合すると、曖昧なアクセスはコンパイルエラーになる
  • 公開型に埋め込むと、その公開メソッドもすべて API に露出するので注意する

8. 列挙型 (const + iota)​

type OrderStatus int

const (
OrderPending OrderStatus = iota
OrderPaid
OrderShipped
)

func (s OrderStatus) String() string {
switch s {
case OrderPending:
return "pending"
case OrderPaid:
return "paid"
case OrderShipped:
return "shipped"
default:
return fmt.Sprintf("OrderStatus(%d)", int(s))
}
}
  • String() string を実装すると、fmt.Println で人間が読める形で出力される (Stringer インターフェース)
  • 範囲外の値を防ぐ仕組みはない。外部入力から変換するときは検証が必要
  • JSON で文字列として扱いたい場合は、const を文字列にするか、MarshalJSON / UnmarshalJSON を実装する
  • 型を付けない iota ではなく、型付きの定数にする (OrderStatus のように)。取り違えをコンパイラが検出できる
// 文字列ベースの列挙 (シリアライズしやすい)
type Role string

const (
RoleAdmin Role = "admin"
RoleMember Role = "member"
)

9. C# 開発者がつまずきやすいポイント​

  • 構造体は常に値型: 代入・引数渡しでコピーされる。class のつもりで渡して、変更が反映されないバグが出る
  • 継承を探してしまう: 埋め込みは継承ではなく、多態性もない。多態性は interface で実現する
  • コンストラクターがない: 初期化を強制できない。ゼロ値でも安全に動く設計にするか、New〜 を提供して慣習で使わせる
  • フィールド名を小文字にして JSON に出ない: 公開フィールド + json タグが必要
  • nil スライス / マップをフィールドに持つ: ゼロ値の map には書き込めない (panic)。コンストラクターで make する
  • sync.Mutex を含む構造体のコピー: ロックの状態もコピーされて壊れる。ポインタで扱う (go vet の copylocks が検出する)
  • 位置指定の初期化: フィールドを追加すると壊れる。必ずフィールド名を指定する
  • ループ変数の構造体はコピー: for _, u := range users { u.Name = "x" } は元のスライスを変更しない
  • readonly や init がない: 不変性は、非公開フィールドと公開メソッド (getter) で表現する
  • getter に Get を付けない: フィールド name の getter は Name()、setter は SetName() が慣習

10. コード例​

C#​

public enum OrderStatus { Pending, Paid, Shipped }

public record Address(string City, string Zip);

public class Order
{
public int Id { get; init; }
public string Customer { get; set; } = "";
public OrderStatus Status { get; set; } = OrderStatus.Pending;
public Address? ShipTo { get; set; }
public List<string> Items { get; } = new();

public Order(int id, string customer)
{
if (string.IsNullOrEmpty(customer))
throw new ArgumentException("customer is required");
Id = id;
Customer = customer;
}
}

public class AuditedOrder : Order
{
public DateTime CreatedAt { get; } = DateTime.UtcNow;
public AuditedOrder(int id, string customer) : base(id, customer) { }
}

Go​

package order

import (
"errors"
"time"
)

type Status int

const (
StatusPending Status = iota
StatusPaid
StatusShipped
)

type Address struct {
City string `json:"city"`
Zip string `json:"zip"`
}

type Order struct {
ID int `json:"id"`
Customer string `json:"customer"`
Status Status `json:"status"`
ShipTo *Address `json:"ship_to,omitempty"` // nil なら出力しない
Items []string `json:"items"`
}

// New は検証付きのコンストラクター関数
func New(id int, customer string) (*Order, error) {
if customer == "" {
return nil, errors.New("customer is required")
}
return &Order{
ID: id,
Customer: customer,
Status: StatusPending,
Items: []string{}, // JSON で null ではなく [] にする
}, nil
}

// AuditedOrder は Order を埋め込んで合成する
type AuditedOrder struct {
Order
CreatedAt time.Time `json:"created_at"`
}

func NewAudited(id int, customer string) (*AuditedOrder, error) {
o, err := New(id, customer)
if err != nil {
return nil, err
}
return &AuditedOrder{Order: *o, CreatedAt: time.Now()}, nil
}
o, err := order.NewAudited(1, "Alice")
if err != nil {
log.Fatal(err)
}
fmt.Println(o.ID, o.Customer, o.CreatedAt) // 昇格したフィールド

11. まとめ​

  • Go に class はなく、struct + メソッドでデータと振る舞いを表現する。複合リテラル (T{X: 1}) で初期化し、フィールド名を指定する。
  • struct は値型で、代入・引数渡しでコピーされる。変更したい・大きい・コピー禁止の場合はポインタを使う。
  • コンストラクターはなく、New〜 関数が慣習。ゼロ値を有用にする設計を心がける。
  • 構造体タグ (`json:"..."`) でシリアライズを制御する。公開フィールドのみが対象。
  • 継承はなく、埋め込みによる合成を使う。埋め込みには多態性もオーバーライドもないため、多態性は interface で実現する。
  • enum は type + const + iota で表現し、String() を実装する。範囲チェックは自前で行う。

次はメソッドとインターフェースを参照してください。

参考資料​