🐥

良書『Go言語 100Tips』を読んでの学び ~サンプルコードを添えて~

2024/02/28に公開

はじめに

株式会社CastingONEでバックエンドエンジニアをしている しんのすけ と申します。
「Go言語 100Tips」という本を読んで学んだことの一部をtech blogに残したいと思います。

なぜこの本を読むことになったのか

「Go言語 100Tips」はかなり勉強になる本であるということでお薦めされ、読んでみようかということで購入をしました。
また弊社では、頻繁に読書会を開催しており、幸運にも購入したタイミングと読書会のタイミングが重なったため、読書会で他のエンジニアの方と感想を共有することしながら読み進めることができ様々な視点から学びを得ることができました。

本の概要

「学ぶのは容易、習得は難しい」と題された第1章から始まります。
Go言語で開発をする際に陥りがちな悪い習慣や間違いが紹介され、それらの問題の解決策が提示されています。
私はこの本を読んで、新たな学びになったことや、勘違いをしていたことがたくさんあり、Go言語についての知見が深くなったと思っています。
以下に学びになった内容の一部を紹介したいと思います。

学びになった内容

runeについて

Go言語で文字列を一文字ずつ取り扱い時に想定した動作ができなかったという経験は多くの人がしているのではないかと思います。
Go言語で文字列を扱う際はruneについて理解しておく必要があります。
まず、文字列の長さを求めるサンプルコードを以下に示しますが、本来は4が出力されることを期待するところで、結果は12が表示されています。
これはstringに対して、Goではlen関数を使うとバイト数が返されるからです。(「田中太郎」の各文字は3バイトで構成されているため、3×4(文字)=12)

name := "田中太郎"
fmt.Println(len(name))

// 実行結果 
12

解決するには、文字を一つの値で表すようにする必要があります。
そこで重要なのが、「Unicodeでは、一つの値で表される項目を示すために、コードポイントという概念を用いる」という点です。
そして、Goでは「rune」はUnicodeのコードポイントとなります。
よって、runeを用いることで、文字列が期待値と異なるという点が解消されます。
以下に、runeで文字列を取得するコードを記載します。このコードで期待できる4が出力されます。

name := []rune("田中太郎")
fmt.Println(len(name))

// 実行結果
4

田中太郎のコードポインタは以下の値で識別されます。
田: U+7530
中: U+4E2D
太: U+592A
郎: U+90CE

また、以下のように関数を使うことでも文字数を取得することができます。

name := "田中太郎"
fmt.Println(utf8.RuneCountInString(name))

// 実行結果
4

https://go.dev/play/p/Ka12zC0ztsM

次の例でも、同じような問題に発展します。

name := "田中太郎"
for i := range name {
	fmt.Printf("position: %d, character: %c\n", i, name[i])
}

// 実行結果
position: 0, character: ç
position: 3, character: ä
position: 6, character: å
position: 9, character: é

https://go.dev/play/p/I3zxjSxvZ4l

rangeでループさせた時にindexがそれぞれ0,3,6,9となってしまいます。
そして、name[i]の文字が取得したい文字ではないです。
この問題も先ほどの各文字が3バイトで構成されているためです。

// 田は以下で表現できる
ta := []byte{0xE7, 0x94, 0xB0}

そのため、name[0]は0xE7を指すことになってしまいます。
これを解決するにはruneを使用することです。
以下のようにすると想定した値で処理をすることができます。

name := "田中太郎"
for i, v := range []rune(name) {
	fmt.Printf("position: %d, character: %c\n", i, v)
}

// 実行結果
position: 0, character: 田
position: 1, character: 中
position: 2, character: 太
position: 3, character:

https://go.dev/play/p/YfBMOvwtmeI

runeを使うことで一つのコードポイントで表すことができます。よって、ループ内のindexの値も0~3となっています。
文字列を一文字ずつ処理したいという際はruneを使うことで、予期せぬバグから防げるようになります。
文字列を扱う際はバイト配列になってしまうという点とruneを用いれば1文字ずつ扱えるという点を頭に入れながら開発をする必要があります。

反復処理中のmapへの挿入についての動作について

mapはとても使いやすい反面、動作が複雑です。
よくあるのはmapをループさせた時、取り出せす順番はランダムになるということです。

m := map[string]string{
	"1": "one",
	"2": "two",
	"3": "three",
	"4": "four",
	"5": "five",
}
for k, v := range m {
	fmt.Println(k, ": ", v)
}

// 実行結果
5 :  five
1 :  one
2 :  two
3 :  three
4 :  four

mapを扱う際に反復処理中のmapへの挿入は注意が必要です。
以下に例のコードを載せます。

m := map[int]bool{
	1: true,
	2: false,
	3: true,
}

for k, v := range m {
	if v {
		m[10+k] = true
	}
}
fmt.Println(m)

// 複数回実行した結果
map[1:true 2:true 3:true 11:true 13:true 23:true 33:true]
map[1:true 2:true 3:true 11:true 13:true]
map[1:true 2:true 3:true 11:true 13:true 21:true]

https://go.dev/play/p/55ucETwmGFb

コード例でもわかるように、反復処理中に挿入された要素が反復で呼び出されるかは実行によって異なるということです。
予測不要にならないようにするには、呼び出されるマップと更新されるマップを分離することで予測可能になります。

m := map[int]bool{
	1: true,
	2: false,
	3: true,
}
var m2 = make(map[int]bool)
for k, v := range m {
	m2[k] = v
	if v {
		m2[10+k] = true
	}
}
fmt.Println(m2)

// 複数回実行した結果
map[1:true 2:false 3:true 11:true 13:true]
map[1:true 2:false 3:true 11:true 13:true]
map[1:true 2:false 3:true 11:true 13:true]

https://go.dev/play/p/lMl0FtHMb6G

マップを使う時は以下が依存しないようにする必要があり、注意が必要です。

  • キーで順序づけされたデータ
  • 挿入順序の保持
  • 決定的な反復順序
  • 反復処理中に追加された要素の生成

メソッドのレシーバ型の選択、ポインタレシーバを使うべき、値レシーバを使うべきか

メソッドを使う際にポインタレシーバか値レシーバーのどちらかを使うべきか、悩むことがあると思います。
今回はどのような場面でどちらを使うべきかについて、記載をしたいと思います。
まずポインタについて学ぶ際は以下のようなコード例で学ぶと思います。

type User struct {
	Point int
}

func (u *User) IncrementPoint() {
	u.Point++
}

func (u User) IncrementPointWithoutPointer() {
	u.Point++
}

func main() {
	u := &User{10}
	// 値レシーバを使用した場合、関数を抜けると点数は元に戻る
	u.IncrementPointWithoutPointer()
	fmt.Println(u.Point)

	// ポインタレシーバを使用した場合、関数を抜けても点数は増加したまま
	u.IncrementPoint()
	fmt.Println(u.Point)
}

// 実行結果
10
11

https://go.dev/play/p/jZHbwi4AGQW

上記のコードの場合、値レシーバの場合は関数を抜けると値は戻ってしまい、ポインタレシーバは関数を抜けてもポイントが増加したままです。
ポインタレシーバーが使われると、関数には構造体へのポインターが渡されます。
そのため、関数内での処理は元の構造体に直接影響を与え、その状態が変化したままとなります。

ただ、値レシーバにするべきか、ポインタレシーバにするべきかの選択は上記のことだけを考慮するだけではいけません。
以下にどのような選択をするべきかを紹介します。

レシーバがポインタでなければいけない

  • メソッドがレシーバを変更する必要がある時
    上記のコードのIncrementPoint()が当てはまります。

レシーバがポインタであるべき

  • レシーバが大きなオブジェクトの場合
    ポインタを使うことで大きなコピーが作成されないため、呼び出しが効率的になり、不要なメモリを使わずに済みます。

レシーバが値でなければいけない

  • レシーバの不変性を強制する場合
    上記のコードのIncrementPointWithoutPointer()が当てはまります。
  • レシーバがmap, 関数、チャネルの場合
    上記の型は参照型のためポインタにする必要がなく、メソッドを抜けても値は変更した状態になります。

レシーバが値であるべき

  • レシーバが基本データ型の場合(int, stringなど)
  • レシーバが変更する必要のないスライスの場合
  • レシーバが小さな配列や、可変なフィールドを持たず必然的に値型である場合

まとめ

今回は「Go言語 100Tips」を読んで学んだ中の一部を紹介しました。
紹介した箇所以外にも基本的な箇所で勘違いしていることや、より深いGo言語を動かすCPUのところ等で学びがたくさんあり、今後何度も読み直したいと思いました。
Go言語を扱うエンジニアにとっては必読書と言っても過言ではないのではないでしょうか。
また機会があれば、他に学んだ箇所を紹介できればと思います。

おわりに

弊社でいっしょに働いてくれるエンジニアを募集中です。
社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください。

https://www.wantedly.com/projects/1130967

https://www.wantedly.com/projects/768663

参考文献

テイヴァ,ハルサニー(2023)『Go言語 100Tips ありがちなミスを把握し、実装を最適化する』(柴田芳樹訳), インプレス

Discussion