Go
この部の 3 / 9 章 ・ 全体で 36 / 76 章 ・ 読了目安 50 分
- if err != nil を書く理由を説明できる
- interface を小さく切る意味を説明できる
- データ競合が起きる条件を説明できる
Go は、大規模なチームで、長く動かすサービスを書くために作られた言語です。
生まれた背景がはっきりしています。Google 社内で、
□ ビルドに何十分もかかる
□ 人によってコードの書き方が違いすぎて、他人のコードが読めない
□ 並行処理を書くのが難しく、バグが埋め込まれやすい
という問題が深刻になっていました。 そこで**「速くビルドでき、誰が書いても同じ形になり、並行処理が書きやすい」** 言語として設計されたのが Go です。
だから、TypeScript から来ると最初は不便に感じます。 例外がない、三項演算子がない、クラスの継承がない、書くコードが冗長になる。
これらは機能が足りないのではなく、意図的に入れていないものです。 その理由が分かると、Go のコードが読めるようになります。
以下の出力は、すべて実際に動かした結果です。自分でも動かして、書き換えてください。
git clone https://github.com/makoto-developer/learning-it-skills-examples
cd learning-it-skills-examples/go
go run . # 5本まとめて実行される
go run -race . # データ競合を検出する各ファイルが、この章の節に対応しています。
短いコードを1つ試すだけなら Go Playground でも構いません
(ただし -race は使えません)。
設計思想: 読みやすさを最優先する
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 で判定すると、こうなります
(errors_demo.go)。
■ %v でラップした場合
メッセージ: プロフィールの取得に失敗: ユーザーが見つかりません
errors.Is(err, errUserNotFound) = false <- 判定できない
■ %w でラップした場合
メッセージ: プロフィールの取得に失敗: ユーザーが見つかりません
errors.Is(err, errUserNotFound) = true <- 判定できるメッセージが同じでも、判定できるかどうかが違います。 「404 を返すか 500 を返すか」は、この判定で決まります。
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 インターフェースの振る舞いを並べると、
落ちるものと落ちないものの境目が見えます(nil_traps.go)。
■ nil マップ / nil スライス(挙動が違う)
nil マップの読み取り: 0 (ゼロ値が返る。落ちない)
nil マップへの書き込み -> panic: assignment to entry in nil map
nil スライスへの append: [1 2] (こちらは落ちない)
■ nil インターフェースの罠(Go で最も有名な事故)
findUserBad("found") == nil ? false <- nil を返したはずなのに false
findUserGood("found") == nil ? trueインターフェースは**(型, 値)の組**です。型が入っていれば、値が nil でも nil ではありません。
だから、error を返す関数の戻り値は具体型ではなく error 型で宣言します。
構造体とメソッド
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回呼んでも、値は増えません(receivers.go)。
値レシーバで3回呼んだあと : 0 <- 増えていない
ポインタレシーバで3回呼んだあと: 3コンパイルは通り、テストがなければ気づけません。
手元で IncrementByValue のレシーバを *counter に変えると、結果が変わります。
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 の流儀です。
文脈によって2つの意味で使われます。
1. Web アプリの「ミドルウェア」
リクエストが本処理に届く前後に、共通処理を挟む仕組み
(認証・ログ・エラー処理・CORS など)
2. インフラの「ミドルウェア」
OS とアプリの間で動くソフトウェア(DB・Web サーバー・キューなど)
この教科書で「ミドルウェア」と書いた時は、原則として1のほうです。 会話で出てきたら、どちらの話かを確認してください。
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通りで実行すると、こうなります
(race_demo.go)。
期待値 : 100000
保護なし : 45478 <- 実行するたびに変わる
sync.Mutex で保護 : 100000
channel で集約 : 100000保護なしの行は実行するたびに変わります。 そして、たまたま期待値と一致することもあります。
だから怖いのです。手元で go run -race . を実行すると WARNING: DATA RACE が出ます。
CI で -race を回すのは、この「たまたま動く」を潰すためです。
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 では、後始末を書く場所として必須の機能です。
タイムアウトより早く終わる処理と、遅い処理を並べると、境目が見えます
(context_demo.go)。
■ 300ms で終わる処理に 500ms のタイムアウト
300ms で成功: user-service の結果
■ 800ms かかる処理に 500ms のタイムアウト
500ms で失敗: user-service の呼び出しを中断: context deadline exceeded
errors.Is(err, context.DeadlineExceeded) = true手元で show に渡す時間を書き換えると、境目がどこにあるかを確かめられます。
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は必ず下流に渡す。渡さないとキャンセルが効かない
公式ドキュメント
迷ったら一次情報に戻ってください。
| 対象 | リンク |
|---|---|
| A Tour of Go(日本語) | https://go-tour-jp.appspot.com/ |
| Effective Go | https://go.dev/doc/effective_go |
| Go 標準ライブラリ | https://pkg.go.dev/std |
| Go Code Review Comments | https://go.dev/wiki/CodeReviewComments |
章末問題
order.ApplyDiscount() を呼んだのに、order.Subtotal が変わりません。原因として最も可能性が高いのは?
fmt.Errorf でエラーをラップする時、%v ではなく %w を使う理由は?
ユーザーがブラウザを閉じたのに、サーバー側の重い処理が走り続けています。考えられる原因は?
ここまでで3つの言語を見ました。次の章では、 4つ目以降を短時間で習得するための手順そのものを扱います。