📖

Go言語100Tipsを読んでGoを学び始めた頃に知っておきたかったと思ったこと

2024/12/02に公開

この記事はPREVENTアドベントカレンダー2日目の記事です。
https://adventar.org/calendars/10319

はじめに

今日はGo言語100Tipsという書籍を読んで、Goを学び始めた頃に知っておきたかったなあと思ったことについて書いていきたいと思います。

https://book.impress.co.jp/books/1122101133

不必要なネストやelseを避ける

まずはコードの読みやすさに関わる部分です。
以下のようなコードがあるとします。

func processNumber(n int) {
    if n > 0 {
        if n % 2 == 0 {
            fmt.Println("positive and even")
        } else {
            fmt.Println("positive and odd")
        }
    } else {
        fmt.Println("negative")
    }
}

このコードはif文がネストしておりぱっと見でどういう条件でどの処理が実行されるのか分かりにくくなっています。不必要なネストは避けるべきであり、それを考慮すると以下のようにコードを書き換えることができるでしょう。

func processNumber(n int) {
    if n <= 0 {
        fmt.Println("negative")
        return
    }

    if n % 2 == 0 {
        fmt.Println("positive and even")
    } else {
        fmt.Println("positive and odd")
    }
}

こうすることでif文のネストが解消され、各処理が走る条件がわかりやすくなりました。ネストしたif文を書く場合は、本当にそのネストが必要なのか、ネストしない書き方はないか考えてみるべきです。

また、elseについても不要なものは書かないことでコードが読みやすくなります。
例えば以下のようなコードがあるとします。

func describeNumber(n int) string {
    if n < 0 {
        return "negative"
    } else {
        if n == 0 {
            return "zero"
        } else {
            return "positive"
        }
    }
}

ここでelseを使っていますが、ifブロック内でreturnする場合はelseは不要なので書くべきではないです。以下のように修正すべきでしょう。

func describeNumber(n int) string {
    if n < 0 {
        return "negative"
    }
    if n == 0 {
        return "zero"
    }
    return "positive"
}

このように、不要なelse文も省いてしまうことでコードが簡潔で読みやすくなります。

意味の無いパッケージ名を避ける

これはGoの公式ブログでも以下のように書かれています。

Avoid meaningless package names. Packages named util, common, or misc provide clients with no sense of what the package contains.

https://go.dev/blog/package-names

utilやcommonといった、miscといった意味の無いパッケージ名をつけてしまうと、そのパッケージが何を提供しているのかが不明瞭になります。このようなパッケージを作成する場合、中には色々な箇所から呼び出される汎用的な関数が書かれることになると思います。汎用的なものであっても、共通点があるものごとにパッケージを分けて管理すべきです(例えば文字列に関するものであればstring〜など)。こうすることでそのパッケージが何を提供しているのかが明確になります。

効率的にスライスを初期化する

Goでスライスを初期化する場合、以下のようにmakeを使うケースと使わないケースがあります。

var s []int
s := []int{}
s := make([]int, 0, 5)

makeを使う場合はスライスの長さ(スライスが含む要素数)と容量(基底配列の要素数)を指定できます。上の例だと長さが0で容量が5です。スライスを初期化する場合、あらかじめ数がわかっている場合は長さと容量を指定した方がパフォーマンスが良くなります。これはスライスの容量が増えるときに新たなメモリ領域が確保され、その新たなメモリ領域に元のスライスの値がコピーされるという処理が実行されるためです。パフォーマンスは基本的に以下の左から順に良くなっていきます。

何も指定しない < 容量のみ指定 < 長さと容量を指定

余分なメモリ領域の確保や値のコピーを避け、効率的にスライスを初期化するために長さ、容量を指定できる時は指定して初期化すべきです。

スライスに要素が存在するかどうかの判断方法

Goのスライスにはnilと空の区別が存在します。

func main() {
	var a []int
	var b []int = nil
	var c []int = []int{}

	fmt.Printf("0=%t, nil=%t\n", len(a) == 0, a == nil) // 0=true, nil=true
	fmt.Printf("0=%t, nil=%t\n", len(b) == 0, b == nil) // 0=true, nil=true
	fmt.Printf("0=%t, nil=%t\n", len(c) == 0, c == nil) // 0=true, nil=false
}

このように、nilのスライスはnilに等しいですが、空のスライスはnilだとは限りません。このことを踏まえると、スライスに要素が存在するかどうかを判断する際にnilと比較するのは適切で無いことがわかります。この場合、スライスの長さを確認することで要素が存在するかどうかを確認すべきです。

if len(slice) > 0 {
    // スライスに要素が存在する場合の処理
} else {
    // スライスに要素が存在しない場合の処理
}

nilスライスは常に空であり、空スライスの長さは0になるので、スライスの長さを確認することで要素の有無を例外無く確認することができます

time.Time型には比較演算子を使うべきではない

Goのtime.Time型の比較には==>と言った比較演算子を使うべきではありません。time.Time型の比較を行う場合はTime.BeforeTime.AfterTime.Equalを使うべきです。

// a == b
a.Equal(b)

// a > b
a.After(b)

// a < b
a.Before(b)

// a >= b
!a.Before(b)

// a <= b
!a.After(b)

また、Go1.20から追加されたTime.Compareを使っても比較を行うことができます。
以下の例で言うとaがbより前の時間であれば-1を、後の時間であれば1を、同時刻であれば0を返します。

// a == b
a.Compare(b) == 0

// a > b
a.Compare(b) > 0

// a < b
a.Compare(b) < 0

// a >= b
a.Compare(b) >= 0

// a <= b
a.Compare(b) <= 0

なぜtime.Time型の比較に比較演算子を使うべきでないかというと、比較演算子はLocationとmonotonic clockの値も比較してしまうからです。GoのTimeパッケージには以下のように書かれています。

Note that the Go == operator compares not just the time instant but also the Location and the monotonic clock reading. Therefore, Time values should not be used as map or database keys without first guaranteeing that the identical Location has been set for all values, which can be achieved through use of the UTC or Local method, and that the monotonic clock reading has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u) to t == u, since t.Equal uses the most accurate comparison available and correctly handles the case when only one of its arguments has a monotonic clock reading.

https://pkg.go.dev/time#Time

ここにもあるように、もしマップのキーとしてtime.Time型の値を使用したい場合は全てのキーに同一のLocationが設定されているかつ、monotonic clockの読み取り値が取り除かれている必要があります。

deferの挙動

deferはdefer文を取り囲んでいる関数がreturnするまで関数の実行を遅延させます。このdefer文はスタックのようなもので、複数書くと後に書かれたものの方が先に実行されます

func main() {
    fmt.Println("start")

    defer fmt.Println("first defer")
    defer fmt.Println("second defer")

    fmt.Println("end")
}

/*
実行結果
start
end
second defer
first defer
*/

defer文の関数は遅延実行されますが、関数に渡す引数については即時評価されます

func main() {
    x := 10
    defer fmt.Println("defer:", x)
    x = 20
    fmt.Println("current:", x)
}

/*
実行結果
current: 20
defer: 10
*/

ループ内でdeferを使う場合は注意が必要

deferをループ内で使用する場合注意が必要です。deferをループ内で使用するとdeferがスタックに積まれ、関数がreturnされるときにまとめて実行されます。これがどのような問題に繋がるのでしょうか。
例えばファイルのクローズをループの中でdeferを使って行うとすると、deferを取り囲む関数がreturnされなかった場合、ファイルがクローズされずにリークを引き起こす可能性があります。

// これだとprocessFilesがreturnされなかった場合ファイルが閉じられない
func processFiles(filePaths []string) error {
	for _, path := range filePaths {
		file, err := os.Open(path)
		if err != nil {
			return fmt.Errorf("failed to open file %s: %w", path, err)
		}

		// deferを使用してファイルを閉じる
		defer file.Close()

		// 何かしらの処理を行う
	}

	return nil
}

この問題を解決するには、ループ内で行う処理を別の関数に切り出す必要があります。

func processFiles(filePaths []string) error {
	for _, path := range filePaths {
                // ループごとに確実にdefer文が実行される
		if err := processSingleFile(path); err != nil {
			return err
		}
	}
	return nil
}

func processSingleFile(path string) error {
	file, err := os.Open(path)
	if err != nil {
		return fmt.Errorf("failed to open file %s: %w", path, err)
	}
	defer file.Close()

	// 何かしらの処理を行う
	return nil
}

ループごとに関数を呼び出すことで、その関数の実行のたびにdeferが実行されるようになるので確実にファイルをクローズできるということですね。

ポインタレシーバと値レシーバの使い分け

レシーバにはポインタレシーバと値レシーバがありますが、いつどちらを使うべきなのでしょうか。
まず前提としてポインタレシーバを使う場合はレシーバを直接操作できます。値レシーバはレシーバのコピーを操作する形になります。これらを踏まえていつどちらを使うべきか書いていきます。

ポインタレシーバを使うべきケース
メソッドがレシーバのデータを変更する必要がある場合はポインタレシーバを使う必要があります。値レシーバを使って操作できるのはレシーバのコピーなので、レシーバ本体に変更を加えたい場合はポインタレシーバを使う必要があります。
また、ポインタレシーバを使うとレシーバのコピーが行われないため、レシーバが大きなオブジェクトであるケースなど、パフォーマンスに影響がありそうな場合はポインタレシーバを使う方が良いケースがあります。

値レシーバを使うべきケース
レシーバの不変性を担保する必要がある場合は値レシーバを使う必要があります。値レシーバはレシーバのコピーなので元のレシーバに影響を与えません。

type Counter struct {
    Count int
}

func (c *Counter) IncrementTrue() {
    c.Count++
}

func (c Counter) IncrementFalse() {
    c.Count++
}

func main() {
    c := Counter{Count: 0}
    fmt.Println("Before Increment:", c.Count)

    // ポインタレシーバを使っているのでインクリメントされる
    c.IncrementTrue()
    fmt.Println("After IncrementTrue:", c.Count)
    // 値レシーバを使っているのでインクリメントされない
    c.IncrementFalse()
    fmt.Println("After IncrementFalse:", c.Count)
}

/*
実行結果
Before Increment: 0
After IncrementTrue: 1
After IncrementFalse: 1
*/

このように、レシーバを変更する必要があるか、パフォーマンスへの影響があるかなどの観点からポインタレシーバと値レシーバの使い分けを考える必要があります。

名前付き戻り値の有効な使い方

名前付き戻り値とは関数やメソッドの戻り値に名前をつけたものです。名前付き戻り値を使うと、関数やメソッドの開始時にその戻り値はゼロ値で初期化されます。また、名前付き戻り値を使うと引数無しの空return文を呼び出すことができ、その場合returnを呼び出した時点の戻り値の値を返すことができます。Goではあまり使われませんが、場合によっては名前付き戻り値を使うことでコードが読みやすくなることがあります。
例えば以下の例のように戻り値が何なのかを明示的に示すことでシグネチャを見るだけで関数からどのような値がどの順番で返ってくるかを判断することができます。特に同じ型の値を複数返す場合に有用です。

func calculate(a, b int) (sum int, difference int) {
    sum = a + b
    difference = a - b
    return
}

func main() {
    a, b := 10, 5
    sum, difference := calculate(a, b)
    fmt.Printf("sum: %d, difference: %d\n", sum, difference)
}

/*
実行結果
sum: 15, difference: 5
*/

エラーのラップとエラーの比較

エラーのラップとは元のエラー情報を保持しつつ、エラーに情報を付加することです。Goでは%w(ヴァープ)を使ってエラーをラップできます。例えばどのユーザーが何の操作を行った時にエラーが起きたのかなどの情報を付加するとデバッグがしやすくなるでしょう。

func readFile(userID, filename string) error {
	file, err := os.Open(filename)
	if err != nil {
		// ファイルオープンエラーをラップし、情報を追加
		return fmt.Errorf("user %s: cannot open file %s: %w", userID, filename, err)
	}
	defer file.Close()
	// 他の処理
	return nil
}

func main() {
	userID := "user123"
	err := readFile(userID, "nonexistent.txt")
	if err != nil {
		fmt.Println("Error:", err)
	}
}

// 実行結果:Error: user user123: cannot open file nonexistent.txt: open nonexistent.txt: no such file or directory

%wではなく%vを使うこともできます。ただ、%vの場合はエラーをラップするのではなく、情報を付加した別のエラーに変換します。そのため呼び出し元でエラーをアンラップ(ラップされたエラーから元のエラーを取得する操作)を行うことができません。

ラップされたエラーが特定の型であるかどうかを調べるにはerrors.Asを使う必要があります。errors.Asはラップされたエラーを再帰的にアンラップしていき、アンラップされたエラーが指定した型と一致する場合trueを返します。

// 独自のエラー型を定義
type MyError struct {
	Msg string
}

func (e *MyError) Error() string {
	return e.Msg
}

func doSomething() error {
	// 独自エラーを発生させる
	return fmt.Errorf("operation failed: %w", &MyError{Msg: "this is a custom error"})
}

func main() {
	err := doSomething()

	var myErr *MyError
	if errors.As(err, &myErr) {
		// 型がMyErrorである場合の処理
		fmt.Println("custom error detected:", myErr.Msg)
	} else {
		// その他のエラー
		fmt.Println("An error occurred:", err)
	}
}

// 実行結果:custom error detected: this is a custom error

errors.Asはエラーの型を検査しましたが、エラーの値を検査したいケースもあると思います。その時はerrors.Isを使います。Goのdatabase/sqlパッケージで定義されているsql.ErrNoRowsというエラーを例にコードを書いてみます(例では%wヴァーブを使っていませんが、使っていても比較できます)。

func findUserByID(id int) error {
	// ここでクエリは成功したが、結果が0件だった場合sql.ErrNoRowsが返される。
}

func main() {
	err := findUserByID(123)
	if errors.Is(err, sql.ErrNoRows) {
		fmt.Println("no rows found for the given ID")
	}
}

// 実行結果:no rows found for the given ID

デフォルト設定のHTTPクライアントとサーバーは使わない

本番環境で動かすコードでデフォルト設定のままHTTPクライアント、サーバーは使うべきではありません。まずクライアントについてですが、デフォルトのHTTPクライアントはタイムアウトを指定していないため、何らかの理由でリクエストが完了しなかった場合リソースの枯渇に繋がる恐れがあります。そのため適切なタイムアウト時間を設定すべきです。

client := &http.Client{
    // 全体のリクエストタイムアウト
    Timeout: 10 * time.second,
    Transport: &http.Transport{
        // ダイアルのタイムアウト
        DialContext: (&net.Dialer{
            Timeout: time.Second,
        }).DialContext,
        // TLSハンドシェイクのタイムアウト
        TLSHandshakeTimeout: time.Second,
        // レスポンスヘッダーのタイムアウト
        ResponseHeaderTimeout: time.Second,
    },
}

サーバーについても同様でデフォルトのままだとタイムアウト時間が設定されていないので、悪意のあるユーザーがタイムアウトが無いことを利用した悪意あるリクエストを送ってくる危険性があります。これについても適切なタイムアウト時間を設定することで対策を取ることができます。

server := &http.Server{
    // リクエストヘッダー読み込みのタイムアウト
    ReadHeaderTimeout: 10 * time.Second,
    // リクエスト全体の読み込みのタイムアウト
    ReadTimeout:       10 * time.Second,
    Addr:              ":8080",
    Handler:           serveMux,
}

本番環境で動かすコードには必ずタイムアウトを設定しましょう。

テストの各種フラグ

Goのテストには色々なフラグがあります。

-raceフラグ
-raceフラグはゴールーチンを使用する際にデータ競合が発生していないかを検出してくれます。go test -race ./...のようにテスト実行時に-raceフラグを付与することでこの機能を使用することができます。ただ競合検出を有効にするとメモリ使用量やテスト実行時間が大幅に増加します。そのためローカルやCI上でのテストにのみ使用することが推奨されます。

-parallelフラグ
-parallelフラグはテストを並列実行するためのフラグです。これを上手く使うことでテストの高速化に繋がります。

-shuffleフラグ
-shuffleフラグを使うとテストの実行順をランダムにすることができます。これによりテストが実行順序に依存していないか検証することができます。

-coverprofileフラグ
-coverprofileフラグはコードカバレッジを計測するために利用できます。このフラグをつけてテストを実行することでカバレッジ情報をファイルに出力することができます。

最後に

Go言語100Tipsを読んでGoを学び始めた頃に知っときたかったなあと思ったことについてまとめてみました。
この本はとても良い本でここに書いたこと以外にも多くの学びを得られました。今後も繰り返し読み直してしっかり自分の中に落とし込んでいきたいと思います。
最後までご覧いただきありがとうございました。

Discussion