プログラマのための IT 教科書
第4部 プログラミング言語

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 で判定してみてください。 メッセージは同じなのに、判定できるかどうかが変わります。

Go — 実行できるサンプル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("ユーザーが見つかりません")
}
panic はクラッシュ

nil ポインタ参照や配列の範囲外アクセスは panic を起こします。 panic は、そのゴルーチンを(対処しなければプロセス全体を)落とします

Web サーバーでは通常、ミドルウェアが recover して 500 を返しますが、 そもそも起こさないように書くのが基本です。

nil ポインタ・nil マップ・nil スライス・nil インターフェースが、それぞれどう振る舞うかを 1つのプログラムで並べています。落ちるものと落ちないものの境目を確かめてください。

Go — 実行できるサンプル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 に変えると結果が変わります。

Go — 実行できるサンプル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++
}()
-race で検出できる
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 が出ます。

Go — 実行できるサンプル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. キャンセル — 呼び出し元が諦めたら、下流の処理も止める
  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 では、後始末を書く場所として必須の機能です。

タイムアウトより早く終わる処理と、遅い処理を並べています。 show に渡す時間を書き換えて、境目がどこにあるかを確かめてください。

Go — 実行できるサンプルexamples/go/context_demo.go

仮想マシンが起動するまで少し時間がかかります。書き換えるには CodeSandbox 側で Fork してください。

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 が見えるのが利点
  • ラップは %werrors.Is / errors.As で判定できる
  • nil チェックは自分で書く。コンパイラは守ってくれない
  • 公開・非公開は名前の1文字目で決まる
  • 状態を変えるメソッドはポインタレシーバ
  • interface は使う側が小さく定義する。implements は書かない
  • goroutine は軽量。ただしデータ競合に注意-race
  • ctx は必ず下流に渡す。渡さないとキャンセルが効かない

章末問題

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

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

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

次の章からは第5部です。この Go と TypeScript で書いたサービス同士を、 どう繋いで動かすのかを扱います。

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