型と構造体
.NET (C#) の経験がある開発者向けに、struct、型定義、構造体タグ、埋め込み (embedding)、値セマンティクス、class / record との違いを解説します。
1. Go の型システムの全体像
Go には class がありません。データは struct で定義し、振る舞いはメソッド (次章のメソッドとインターフェース) で追加します。継承はなく、合成 (composition) で再利用します。
2. .NET との対応表
| .NET (C#) | Go |
|---|---|
class / record | struct |
| 継承 | 埋め込み (embedding) による合成 |
| コンストラクター | New〜 ファクトリ関数 |
enum | const + 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. フィールド名を指定 (推奨)
// 2. 位置による指定 (全フィールド必須。フィールド追加で壊れるので非推奨)
// 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-a | has-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()を実装する。範囲チェックは自前で行う。
次はメソッドとインターフェースを参照してください。