Go
読了目安 50 分
- if err != nil を書く理由を説明できる
- interface を小さく切る意味を説明できる
- データ競合が起きる条件を説明できる
Go は、バックエンドのサービスを書くための言語です。
TypeScript から来ると、最初は不便に感じます。 例外がない、三項演算子がない、クラスの継承がない、書くコードが冗長になる。
これらは機能が足りないのではなく、意図的に入れていないものです。 その理由が分かると、Go のコードが読めるようになります。
Go はブラウザ上で実行できないため、この章に実行できる演習はありません。 コードを読んで挙動を予測し、解説で答え合わせをする形式です。
実際に動かす時は Go Playground を使ってください。
設計思想: 読みやすさを最優先する
Go の設計は、次の考え方に貫かれています。
書く人が10人いても、同じようなコードになるようにする。
大規模なチームでは、コードを書く時間より他人のコードを読む時間の方が長くなります。 だから Go は、書き方の選択肢をあえて減らしました。
- フォーマットは
gofmtが強制する(スタイル論争が起きない) - 三項演算子がない(
ifで書くしかない) - 継承がない(合成を使う)
- 未使用の変数・import はコンパイルエラー(ゴミが残らない)
if err != nil が何度も出てくるのを見ると「冗長だ」と感じます。
しかし、エラー処理が目に見えるということでもあります。 例外を使う言語では、どこで失敗するかがコードから読み取れません。
Go では「この行は失敗しうる」が一目で分かります。
error を返す文化
Go に例外(try-catch)はありません。エラーは戻り値です。
func fetchUser(id string) (*User, error) {
row := db.QueryRow("SELECT ... WHERE id = ?", id)
var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
return nil, err
}
return &user, nil
}呼ぶ側は、必ずエラーを受け取ります。
user, err := fetchUser("42")
if err != nil {
return fmt.Errorf("ユーザーの取得に失敗: %w", err)
}
// ここから先では user が使える
fmt.Println(user.Name)%w でラップする
fmt.Errorf の %w は、元のエラーを保持したまま情報を足す書き方です。
// 悪い: 元のエラー情報が失われる
return fmt.Errorf("ユーザーの取得に失敗: %v", err)
// 良い: 元のエラーを辿れる
return fmt.Errorf("ユーザーの取得に失敗: %w", err)%w でラップしておくと、呼び出し元で errors.Is / errors.As を使って
元のエラーの種類を判定できます。
if errors.Is(err, sql.ErrNoRows) {
// 「見つからなかった」場合の処理
}user, _ := fetchUser(id) // エラーを _ で捨てている_ はエラーを明示的に無視する記法です。基本的に書いてはいけません。
Go はエラーを見えるようにした言語なので、それを _ で消すのは
言語の意図に逆らう行為です。レビューで必ず指摘されます。
%v と %w でラップしたエラーを、それぞれ errors.Is で判定してみてください。
メッセージは同じなのに、判定できるかどうかが変わります。
examples/go/errors_demo.go仮想マシンが起動するまで少し時間がかかります。書き換えるには CodeSandbox 側で Fork してください。
nil に注意する
Go の nil は、TypeScript の undefined より厄介です。
var user *User // nil ポインタ
fmt.Println(user.Name) // 実行時に panic(クラッシュ)Go にはコンパイル時の null チェックがありません。
TypeScript の strictNullChecks に相当するものがないので、
nil チェックは自分で書く必要があります。
user, err := fetchUser(id)
if err != nil {
return err
}
if user == nil {
return errors.New("ユーザーが見つかりません")
}nil ポインタ参照や配列の範囲外アクセスは panic を起こします。
panic は、そのゴルーチンを(対処しなければプロセス全体を)落とします。
Web サーバーでは通常、ミドルウェアが recover して 500 を返しますが、
そもそも起こさないように書くのが基本です。
nil ポインタ・nil マップ・nil スライス・nil インターフェースが、それぞれどう振る舞うかを 1つのプログラムで並べています。落ちるものと落ちないものの境目を確かめてください。
examples/go/nil_traps.go仮想マシンが起動するまで少し時間がかかります。書き換えるには CodeSandbox 側で Fork してください。
構造体とメソッド
Go にクラスはありません。構造体(struct)と、それに紐づく関数があるだけです。
type Order struct {
ID string
UserID string
Subtotal int
}
// レシーバ (o Order) が「このメソッドは Order のもの」を表す
func (o Order) Total() int {
return o.Subtotal + o.tax()
}
func (o Order) tax() int { // 小文字始まり = パッケージ外から見えない
return o.Subtotal / 10
}大文字と小文字が公開範囲を決める
これは Go 独特のルールです。
| 名前 | 意味 |
|---|---|
Total | 公開(他のパッケージから使える) |
tax | 非公開(同じパッケージ内だけ) |
public private というキーワードはありません。名前の1文字目で決まります。
値レシーバとポインタレシーバ
func (o Order) SetSubtotal(v int) { o.Subtotal = v } // コピーを変更(元に影響しない)
func (o *Order) SetSubtotal(v int) { o.Subtotal = v } // 元を変更するGo は値渡しが基本です。構造体を関数に渡すとコピーされます。
元を変更したいならポインタ(*)を使います。
新人が必ず一度は踏みます。
func (o Order) ApplyDiscount() {
o.Subtotal = o.Subtotal * 9 / 10 // コピーを変更しているだけ
}
order.ApplyDiscount()
fmt.Println(order.Subtotal) // 変わっていない状態を変えるメソッドはポインタレシーバ(*Order)にしてください。
値レシーバのメソッドを3回呼んでも値が増えないことを、実際の出力で確かめてください。
IncrementByValue のレシーバを *counter に変えると結果が変わります。
examples/go/receivers.go仮想マシンが起動するまで少し時間がかかります。書き換えるには CodeSandbox 側で Fork してください。
interface と暗黙の実装
Go の interface は、他の言語と大きく違います。
type UserStore interface {
Get(ctx context.Context, id string) (*User, error)
}implements を書きません。 メソッドが揃っていれば、自動的にその interface として使えます。
type SpannerUserStore struct { ... }
func (s *SpannerUserStore) Get(ctx context.Context, id string) (*User, error) { ... }
// これだけで UserStore として扱えるinterface は「使う側」が定義する
ここが Go の重要な作法です。
// service パッケージ(使う側)が、必要なものだけを定義する
package service
type UserStore interface {
Get(ctx context.Context, id string) (*User, error)
}
func NewOrderService(users UserStore) *OrderService { ... }必要な分だけの小さな interface を、使う側が宣言します。 こうするとテストでの差し替えが簡単になり、実装への依存も減ります。
Go の標準ライブラリの io.Reader は、メソッドが1つだけです。
type Reader interface {
Read(p []byte) (n int, err error)
}「大きな interface を1つ」より「小さな interface を複数」が Go の流儀です。
goroutine と channel
Go が最も得意とする領域です。
goroutine
関数の前に go を付けるだけで、並行に実行されます。
go sendEmail(user) // 完了を待たずに次へ進む非常に軽量で、数万個作っても動きます(OS スレッドとは別物です)。
channel
goroutine 同士でデータを受け渡す仕組みです。
results := make(chan int)
go func() { results <- heavyCalculation() }() // 送る
value := <-results // 受け取る(届くまで待つ)Go の有名な標語です。
共有変数をロックで守るより、channel でデータを渡す方が安全で読みやすい、 という設計思想を表しています。
データ競合
複数の goroutine が同じ変数を同時に書き換えると壊れます。
counter := 0
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
counter++ // データ競合
}()
}
wg.Wait()
fmt.Println(counter) // 1000 にならないcounter++ は「読む → 足す → 書く」の3段階なので、
途中で別の goroutine が割り込むと更新が失われます。
直す
var mu sync.Mutex
go func() {
defer wg.Done()
mu.Lock()
defer mu.Unlock()
counter++
}()go test -race ./...
go run -race main.goデータ競合は「たまにしか起きない」ので、テストでは見つかりません。
-race を付けると検出してくれます。CI に入れておくのが定石です。
1000個の goroutine が counter++ を実行したのに、結果が 1000 になりません。原因は?
10万回の加算を、保護なし・Mutex・channel の3通りで実行しています。
保護なしの行が実行するたびに変わることを、何度か実行して確かめてください。
CodeSandbox のターミナルで go run -race . を実行すると WARNING: DATA RACE が出ます。
examples/go/race_demo.go仮想マシンが起動するまで少し時間がかかります。書き換えるには CodeSandbox 側で Fork してください。
context — キャンセルとタイムアウト
マイクロサービスで必ず使う仕組みです。
func (s *OrderService) Get(ctx context.Context, id string) (*Order, error) {
// ctx を下流にそのまま渡していく
user, err := s.users.Get(ctx, id)
...
}context.Context は、次の2つを下流に伝えます。
- キャンセル — 呼び出し元が諦めたら、下流の処理も止める
- デッドライン — 「あと1秒で打ち切る」
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
result, err := externalAPI.Call(ctx, payload)ユーザーがブラウザを閉じても、ctx を下流に渡していなければ、
サーバー側は処理を続けます。DB クエリも外部 API 呼び出しも走り続けます。
Go のコードで ctx context.Context が第1引数にあるのは、
これを必ず伝播させるためです。省略しないでください。
defer は「この関数を抜ける時に実行する」という予約です。
f, err := os.Open(name)
if err != nil { return err }
defer f.Close() // どのルートで return しても必ず閉じられる早期リターンが多い Go では、後始末を書く場所として必須の機能です。
タイムアウトより早く終わる処理と、遅い処理を並べています。
show に渡す時間を書き換えて、境目がどこにあるかを確かめてください。
examples/go/context_demo.go仮想マシンが起動するまで少し時間がかかります。書き換えるには CodeSandbox 側で Fork してください。
TypeScript と比べて
| TypeScript | Go | |
|---|---|---|
| エラー | 例外(throw / catch) | 戻り値(if err != nil) |
| null 安全 | strict で保証される | 保証されない(自分でチェック) |
| 継承 | class の extends | なし(合成と interface) |
| 並行処理 | イベントループ(1スレッド) | goroutine(マルチスレッド) |
| 公開範囲 | export | 名前の1文字目の大小 |
| フォーマット | prettier(設定できる) | gofmt(議論の余地なし) |
Go のコードを読む時は、if err != nil のブロックを一旦飛ばして、
正常系の流れだけを追うと構造が見えます。
慣れると、エラー処理は視界から自然に消えるようになります。
実務の落とし穴まとめ
_でエラーを捨てる — Go の思想に反する。必ず扱う%vでエラーをラップ —%wを使わないと元のエラーを辿れない- 値レシーバで状態を変更 — コピーを変えているだけ
- nil チェック漏れ — コンパイラは守ってくれない。panic する
- context を渡さない — キャンセルが伝わらず処理が残り続ける
- データ競合 —
-raceを CI に入れる
まとめ
- Go は読みやすさのために選択肢を減らした言語。冗長さは意図的
- エラーは戻り値。
if err != nilが見えるのが利点 - ラップは
%w。errors.Is/errors.Asで判定できる - nil チェックは自分で書く。コンパイラは守ってくれない
- 公開・非公開は名前の1文字目で決まる
- 状態を変えるメソッドはポインタレシーバ
- interface は使う側が小さく定義する。
implementsは書かない - goroutine は軽量。ただしデータ競合に注意(
-race) ctxは必ず下流に渡す。渡さないとキャンセルが効かない
章末問題
order.ApplyDiscount() を呼んだのに、order.Subtotal が変わりません。原因として最も可能性が高いのは?
fmt.Errorf でエラーをラップする時、%v ではなく %w を使う理由は?
ユーザーがブラウザを閉じたのに、サーバー側の重い処理が走り続けています。考えられる原因は?
次の章からは第5部です。この Go と TypeScript で書いたサービス同士を、 どう繋いで動かすのかを扱います。