ブログに戻る

Go の else キーワード:if/else、else if、早期リターンとスコープ

Go の else キーワードの仕組みを解説します。if/else の構文、else を閉じ波括弧と同じ行に書く理由、else if の連鎖、if 文のスコープ、早期リターン、三項演算子がないことを扱います。

Go の else キーワード:if/else、else if、早期リターンとスコープ

else キーワードは、直前の if の条件が false のときにブロックを実行します。別の条件を調べるには else if と書き、必要なだけ連ねられます。else は if ブロックの閉じ波括弧と同じ行に置く必要があり、そうしないとコンパイルできません。Go らしいコードでは、ほかの多くの言語より else を使う場面が少なくなります。if ブロックが return で終わるなら、if の後ろのコードがすでに else の役割を果たしています。そのため Go のコードは早めに return し、正常系のコードをインデントせずに残します(Go spec)。

要約

  • if cond { ... } else { ... } は、両方のブロックに波括弧が必要で、条件を丸括弧で囲みません。
  • } else { は 1 行に書く必要があります。} の後で改行すると if 文がそこで終わり、コンパイラは syntax error: unexpected keyword else, expected } を報告します。
  • else if の連鎖は上から順に評価され、最初に true になった条件が採用されます。長い連鎖は、式を持たない switch にしたほうが読みやすくなります。
  • if の短い文(if v, err := f(); err != nil)で宣言した変数は、すべての else if と else の分岐から見えます。最後の } の後では使えません。
  • if ブロックが return するなら else は外します。revive linter は、else が残った形を if block ends with a return statement, so drop this else and outdent its block で指摘します。
  • Go に ?: 演算子はありません。if/else を使うか、ゼロ値のときのデフォルトだけが必要なら cmp.Or を使います。

Go で if/else はどう書くのか?

if は bool の条件とブロックを取ります。その後ろの else ブロックは、条件が false のときに実行されます。例として、リクエストが失敗した後のリトライ判定を使います。

example.gogo
package main

import "fmt"

func main() {
	attempt := 3
	maxAttempts := 5

	if attempt < maxAttempts {
		fmt.Printf("attempt %d failed, retrying\n", attempt)
	} else {
		fmt.Printf("attempt %d failed, giving up\n", attempt)
	}
}
example.texttext
attempt 3 failed, retrying

C、Java、JavaScript とは 3 つのルールが異なります。

  1. 条件を丸括弧で囲みません。if (attempt < maxAttempts) { もコンパイルは通りますが、gofmt が丸括弧を削除します。
  2. 本体が 1 行でも波括弧が必要です。if code >= 500 fmt.Println("server error") は syntax error: unexpected name fmt, expected { で失敗します。
  3. 条件は bool でなければなりません。Go には truthy な値がないので、if len(items) や if user は型エラーになります。if len(items) > 0 や if user != nil と書きます。

同じ波括弧のルールは else にも当てはまります。else の後ろに文をそのまま書くと syntax error: else must be followed by if or statement block で失敗します。else の後ろに置けるのは、別の if か { ... } ブロックだけです。条件と初期化文については、if 側から Go の if キーワードで解説しています。

else を } と同じ行に書かなければならないのはなぜか?

Go の文法では文の終わりをセミコロンで示しますが、自分でセミコロンを書くことはほとんどありません。行の最後のトークンが識別子、リテラル、break、continue、fallthrough、return のいずれかのキーワード、++ などの演算子、または閉じる )、]、} のとき、字句解析器が行末にセミコロンを挿入します(Go spec, Semicolons)。

次のコードが失敗するのは、このルールのためです。

example.gogo
	if retries > 0 {
		fmt.Println("retrying")
	}
	else {
		fmt.Println("giving up")
	}
example.texttext
./main.go:10:2: syntax error: unexpected keyword else, expected }

} で終わる行にはセミコロンが挿入され、そこで if 文が終わります。すると次の行は else から始まる新しい文になりますが、else で始まる文は存在しません。直すには } else { を 1 行に書きます。これは gofmt が出力する形でもあります。Go で開き波括弧を if と同じ行に書くのも、同じルールによるものです。

Allman スタイル(波括弧を独立した行に書く)に慣れた C や Java のプログラマーは、最初の 1 週間でこの問題にぶつかります。ただ、どの Go のコードベースでも if/else の書き方が同じになるので、レビューでスタイルが話題になることはありません。

Go の else if はどう動くのか?

else if は独立したキーワードではありません。else の後ろに別の if 文が続いているだけで、文法でも "else" ( IfStmt | Block ) とそのまま認められています。必要なだけ連ねられます。Go は上から順に条件を調べ、最初に true になった分岐を実行します。

次のループは、リクエストのレイテンシをメトリクスのバケットに振り分けます。

example.gogo
package main

import (
	"fmt"
	"time"
)

func main() {
	latencies := []time.Duration{
		40 * time.Millisecond,
		250 * time.Millisecond,
		3 * time.Second,
		90 * time.Millisecond,
	}

	var fast, slow, timedOut int
	for _, d := range latencies {
		if d < 100*time.Millisecond {
			fast++
		} else if d < time.Second {
			slow++
		} else {
			timedOut++
		}
	}
	fmt.Printf("fast=%d slow=%d timed_out=%d\n", fast, slow, timedOut)
}
example.texttext
fast=2 slow=1 timed_out=1

順番が重要です。最初の 2 つの条件を入れ替えて d < time.Second を先にすると、出力は fast=0 slow=3 timed_out=1 になります。100ms 未満のリクエストはすべて 1 秒未満でもあるので、範囲の広い条件が先に拾ってしまい、fast の分岐は一度も実行されません。範囲の狭い条件を先に置きます。

連鎖が 3 つの分岐を超えたら、式を持たない switch のほうがたいてい読みやすくなります。各 case が条件になり、評価は同じく上から順に行われ、最後の else の代わりに default を使います。

example.gogo
	for _, d := range latencies {
		switch {
		case d < 100*time.Millisecond:
			fast++
		case d < time.Second:
			slow++
		default:
			timedOut++
		}
	}

出力は同じです。Go の switch が C とどう違うかは、Go キーワード解説の switch のセクションを参照してください。switch と select では「どれにも当てはまらない」場合に default を使うので、else が現れるのは if の後ろだけです。

if 文で宣言した変数のスコープはどこまでか?

if は短い文から始められます。多くの場合は := による宣言です。そこで宣言した変数のスコープは if 文全体で、すべての else if と else の分岐も含みます。仕様では、各 if はそれぞれ暗黙のブロックに置かれ、else の分岐はその内側にあります(Go spec, Blocks)。

ここは else をほかの書き方に置き換えにくい場面です。環境変数から読んだ PORT の値をチェックするには、どの分岐でもパースした数値が必要になります。

example.gogo
package main

import (
	"fmt"
	"strconv"
)

func main() {
	for _, raw := range []string{"8080", "80", "http"} {
		if port, err := strconv.Atoi(raw); err != nil {
			fmt.Printf("PORT=%q is not a number: %v\n", raw, err)
		} else if port < 1024 {
			fmt.Printf("PORT=%d is privileged (below 1024)\n", port)
		} else {
			fmt.Printf("PORT=%d ok\n", port)
		}
	}
}
example.texttext
PORT=8080 ok
PORT=80 is privileged (below 1024)
PORT="http" is not a number: strconv.Atoi: parsing "http": invalid syntax

port と err は 3 つの分岐すべてで使えます。閉じ波括弧の後では、もう存在しません。if の後ろに fmt.Println("listening on", port) を追加すると、ビルドは undefined: port で失敗します。後でその値が必要なら、if より前に独立した行で宣言します。

シャドーイングの落とし穴

短い文は新しい変数を宣言するので、外側にある同じ名前の変数を隠してしまうことがあります。次のコードはコンパイルが通り、go vet も通ります。

example.gogo
	var cfg Config
	if cfg, err := loadConfig(data); err != nil {
		log.Fatal(err)
	} else {
		fmt.Printf("loaded config, addr=%q\n", cfg.Addr)
	}
	fmt.Printf("starting server, addr=%q\n", cfg.Addr)
example.texttext
loaded config, addr=":8443"
starting server, addr=""

if の中の cfg は新しい変数です。外側の cfg はゼロ値のままなので、サーバーは空のアドレスで起動します。cfg, err := loadConfig(data) を独立した行に書き、err は普通の if でチェックします。シャドーイングと、それを検出する shadow アナライザーについては、Go の var キーワードで解説しています。

Go らしいコードで else を避けるのはなぜか?

Go の関数の多くは、それぞれが失敗しうる手順の連続です。各手順を前の手順の else の中に入れ子にしていくと、成功時のコードはどんどん右にずれ、エラー処理はその原因となったチェックから遠く離れていきます。次の注文ハンドラーはその書き方になっています。

example.gogo
func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
	var req CreateOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err == nil {
		if req.Quantity > 0 {
			if id, err := saveOrder(req); err == nil {
				w.WriteHeader(http.StatusCreated)
				json.NewEncoder(w).Encode(map[string]string{"id": id})
			} else {
				http.Error(w, "could not save order", http.StatusInternalServerError)
			}
		} else {
			http.Error(w, "quantity must be positive", http.StatusBadRequest)
		}
	} else {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
	}
}

不正な JSON のときに何が起きるかを知るには、関数の一番下まで読まなければなりません。各条件を反転させて、エラーの場合を先に処理して return するようにします。

example.gogo
func handleCreateOrder(w http.ResponseWriter, r *http.Request) {
	var req CreateOrderRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "invalid JSON body", http.StatusBadRequest)
		return
	}
	if req.Quantity <= 0 {
		http.Error(w, "quantity must be positive", http.StatusBadRequest)
		return
	}

	id, err := saveOrder(req)
	if err != nil {
		http.Error(w, "could not save order", http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusCreated)
	json.NewEncoder(w).Encode(map[string]string{"id": id})
}

4 種類のボディ(壊れた JSON、数量 0、在庫切れの SKU、正しい注文)で両方のバージョンを httptest で実行すると、ステータスコードもボディも同じになります。2 つ目のバージョンには else が 1 つもありません。各チェックはそのエラーレスポンスのすぐ隣にあり、最後の 2 行が左端にある成功時のパスです。4 つ目の検証を追加するときも、入れ子を 1 段深くするのではなく、if ブロックを 1 つ足すだけです。

Go チームはこのスタイルを Effective Go で説明しています。「エラーの場合は return 文で終わることが多いので、結果としてコードに else 文は必要なくなる。」Go Code Review Comments のページでは、これを Indent Error Flow と呼んでいます。「通常のコードパスのインデントは最小限に保ち、エラー処理をインデントして先に片付けるようにする。」

linter は else について何を指摘するか

revive linter(golint の後継としてメンテナンスされているもの)は、else に関するルールを 2 つデフォルトで有効にしています。次のコードに対して実行すると、

example.gogo
func readTimeout() (int, error) {
	secs, err := strconv.Atoi(os.Getenv("TIMEOUT_SECONDS"))
	if err != nil {
		return 0, fmt.Errorf("parse TIMEOUT_SECONDS: %w", err)
	} else {
		return secs, nil
	}
}

func countValid(lines []string) int {
	n := 0
	for _, line := range lines {
		if line == "" {
			continue
		} else {
			n++
		}
	}
	return n
}

次のように報告されます。

example.texttext
lint.go:13:9: if block ends with a return statement, so drop this else and outdent its block
lint.go:23:10: if block ends with a continue statement, so drop this else and outdent its block

1 つ目は indent-error-flow によるもので、if ブロックが return で終わるときに報告されます。2 つ目は superfluous-else によるもので、continue、break、goto のほか、panic、os.Exit、log.Fatal のように戻ってこない呼び出しも対象です。revive には、else ブロックの側が return する逆の形を対象にした、オプトインの early-return ルールもあります。golangci-lint でも、revive linter を通じて同じルールを使えます。

上の入れ子のハンドラーは、どちらのデフォルトのルールにも引っかかりません。どのブロックも return しないからです。linter が検出するのは機械的に判断できるケースだけで、成功チェックのピラミッドを平らにするリファクタリングは自分で行う必要があります。

Go で else を使うべきなのはどんなときか?

else を外すのは、ブロックが処理を抜ける場合のルールです。両方の分岐で実際に処理を行い、どちらも関数を抜けないなら、「2 つのうちどちらか」を表すには else がいちばん明確です。そうした状況は 3 つあります。

1 つ目は、どちらの結果でも処理を続ける場合です。キャッシュの検索では、ヒットかミスかを数えてから処理を続けます。どちらの分岐も return しないので、インデントを戻す対象がありません。

example.gogo
	if entry, ok := cache[key]; ok {
		hits++
		resp = entry
	} else {
		misses++
		resp = fetchFromOrigin(key)
		cache[key] = resp
	}

2 つ目は、2 つの値から 1 つを選ぶ場合です。コストのかからない値なら、デフォルトを設定してから上書きします。

example.gogo
	level := slog.LevelInfo
	if debug {
		level = slog.LevelDebug
	}

コネクションを開くなど、デフォルトを作るのにコストや副作用がある場合は、if/else を使って片方の分岐だけが実行されるようにします。

example.gogo
	var store Store
	if redisURL != "" {
		store = RedisStore{url: redisURL}
	} else {
		store = MemoryStore{}
	}

デフォルトを設定して上書きする形にすると、var store Store = MemoryStore{} の後で置き換えることになります。ここでそれが成り立つのは、MemoryStore{} にコストがかからないからにすぎません。デフォルトがファイルやコネクションプールを開くものなら、上書きによって一度も使わないリソースを捨てることになります。

3 つ目は、短い文で宣言した変数を使う場合です。PORT の例のように、すべての分岐でその変数が必要なら、else を使うとスコープをチェックの中に閉じ込められます。if の前で宣言しても動きますが、その場合は関数の残りの部分でも変数がスコープに残ります。

Go に三項演算子はあるか?

ありません。Go には cond ? a : b がありません。その理由は Go FAQ で説明されています。「Go に ?: がないのは、この演算が不可解なほど複雑な式を作るのに使われすぎているのを、言語の設計者たちが見てきたからである。if-else の形は長くなるが、明らかにわかりやすい。言語に必要な条件分岐の制御構造は 1 つだけである。」

代わりに使うのは、if/else か、前のセクションで紹介したデフォルトを設定して上書きする形です。「空でなければこの値を使う」というよくあるケースのために、Go 1.22 で cmp.Or が追加されました。cmp.Or は、引数のうちゼロ値でない最初のものを返します。

example.gogo
package main

import (
	"cmp"
	"fmt"
	"os"
)

func main() {
	addr := cmp.Or(os.Getenv("LISTEN_ADDR"), ":8080")
	fmt.Println("listening on", addr)
}
example.texttext
listening on :8080

cmp.Or は演算子ではなく関数なので、Go は呼び出しの前にすべての引数を評価します。cmp.Or(cachedToken, fetchToken()) は、cachedToken が設定されているときでも毎回 fetchToken を呼び出します。汎用の ternary(cond, a, b) ヘルパーにも同じ問題があり、Go のコードベースでそうしたヘルパーをめったに定義しない理由の 1 つになっています。分岐にコストがあるなら if を書きます。

LevelUpGo で学ぶ

LevelUpGo では、ブラウザ上で本物の Go コードを実行する演習を通じて Go を学べます。Go Basics では、ゼロから言語を学ぶ流れの中で、if、else if、switch、エラー時の早期リターンを扱います。Simplification には、入れ子になったコードを平らにする、真偽値のロジックを単純にする、else if の連鎖を switch に置き換える、といったレッスンがあります。残り 24 個の予約語については、Go のキーワード一覧:全 25 個の予約語を解説をご覧ください。

よくある質問

Go の else は何をしますか?

else は、直前の if の条件が false のときにブロックを実行します。後ろにはブロック(else { ... })か、別の if(else if cond { ... })を続けられます。Go の 25 個の予約語の 1 つで、if ブロックの後ろにしか書けません。

Go で「syntax error: unexpected keyword else」と表示されるのはなぜですか?

if ブロックの閉じ } の後で改行し、else を次の行に書いているからです。Go は行末の } の後にセミコロンを挿入し、そこで if 文が終わるので、次の行の else はつながる先がありません。} else { を 1 行に書くか、gofmt を実行してください。

Go に elif や elseif はありますか?

ありません。Go では else if と 2 語で書きます。これは else の後ろに新しい if 文が続いたものです。長い連鎖は、キーワードの後ろに式を持たない switch にしたほうがすっきり読めます。

Go で return の後に else を使うべきですか?

使うべきではありません。if ブロックが return で終わるなら、if の後ろのコードは条件が false だったときにしか実行されないので、else はインデントを増やすだけです。revive は indent-error-flow ルールでこれを指摘します。Go Code Review Comments も、エラーを先に処理して通常のパスをインデントしないよう勧めています。

if 文の変数を else ブロックで使えますか?

使えます。if n, err := strconv.Atoi(s); err != nil のように短い文で宣言した変数は、if ブロックと、すべての else if と else の分岐でスコープ内にあります。最後の閉じ波括弧の後でスコープから外れます。

Go で if/else を 1 行で書くには?

フォーマット済みのコードでは書けません。1 行の if/else もコンパイルは通りますが、gofmt が複数行に分割します。また、Go には三項演算子がありません。普通の if/else を使うか、デフォルトを代入して if で上書きするか、ゼロ値のときの代替値が必要なら cmp.Or を使います。

出典

シニアエンジニアのように Go を書く

ブラウザで学べるインタラクティブなレッスン。最初のレッスンは無料です。

無料レッスンを試すまたは無料アカウントを作成