プログラマのための IT 教科書

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("ユーザーが見つかりません")
}
panic はクラッシュ

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++
}()
-race で検出できる
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. キャンセル — 呼び出し元が諦めたら、下流の処理も止める
  2. デッドライン — 「あと1秒で打ち切る」
ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
defer cancel()
 
result, err := externalAPI.Call(ctx, payload)
context を渡さないと止まらない

ユーザーがブラウザを閉じても、ctx を下流に渡していなければ、 サーバー側は処理を続けます。DB クエリも外部 API 呼び出しも走り続けます。

Go のコードで ctx context.Context が第1引数にあるのは、 これを必ず伝播させるためです。省略しないでください。

defer

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 と比べて

TypeScriptGo
エラー例外(throw / catch)戻り値(if err != nil)
null 安全strict で保証される保証されない(自分でチェック)
継承class の extendsなし(合成と interface)
並行処理イベントループ(1スレッド)goroutine(マルチスレッド)
公開範囲export名前の1文字目の大小
フォーマットprettier(設定できる)gofmt(議論の余地なし)
読む時のコツ

Go のコードを読む時は、if err != nil のブロックを一旦飛ばして、 正常系の流れだけを追うと構造が見えます。

慣れると、エラー処理は視界から自然に消えるようになります。

実務の落とし穴まとめ

  1. _ でエラーを捨てる — Go の思想に反する。必ず扱う
  2. %v でエラーをラップ — %w を使わないと元のエラーを辿れない
  3. 値レシーバで状態を変更 — コピーを変えているだけ
  4. nil チェック漏れ — コンパイラは守ってくれない。panic する
  5. context を渡さない — キャンセルが伝わらず処理が残り続ける
  6. データ競合 — -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 Gohttps://go.dev/doc/effective_go
Go 標準ライブラリhttps://pkg.go.dev/std
Go Code Review Commentshttps://go.dev/wiki/CodeReviewComments

章末問題

order.ApplyDiscount() を呼んだのに、order.Subtotal が変わりません。原因として最も可能性が高いのは?

fmt.Errorf でエラーをラップする時、%v ではなく %w を使う理由は?

ユーザーがブラウザを閉じたのに、サーバー側の重い処理が走り続けています。考えられる原因は?

ここまでで3つの言語を見ました。次の章では、 4つ目以降を短時間で習得するための手順そのものを扱います。

読み終わったら記録しておくと、目次で進み具合が分かります。