🐈

「Gopher塾 #2 - Goらしいコードの書き方」のまとめpart1

2023/05/04に公開

この記事は2022/12/22に行われた@tenntennさんによる「Gopher塾 #2 - Goらしいコードの書き方 - DAY 2」 part1まとめです。
Goが作られた背景やコメントの仕方、ファイルの分け方、エラーハンドリングの仕方などをまとめております。
早速ですが内容に入っていきます。

Goが作られた背景

Google社内の課題を解決するために作られた。
ビルドが速い、開発ツールの作りやすい言語が求められた。
具体的には、、、

  • 依存関係の解決に無駄がない、ビルドが遅くなる原因を排除できる
  • 標準で静的解析をサポートしたり、ソースコードだけで解析が可能だったりする
    静的解析:プログラムを実行せずに、ソースコードを入力としてソースの構造や型情報などを解析すること
    また、マルチコアをフルで使えるようにしたかったため、ガベージコレクタと並列処理が同時に採用された。

使っていないimport宣言がエラーになる理由
→コンパイルが遅くなる

Goの特徴

  • リリースサイクルがおおよそ決まっている
  • 後方互換が保たれている(セキュリティや重大なバグは後方互換が保たれていない)
  • スケールしやすく設計されている
  • 小規模から大規模の開発まで行える
  • シンプル(複雑だとスケールしにくい)

シンプルさを維持するためには

  • 複雑なライブラリを使用しない
  • 命名やコーディングをシンプルに
  • 過度な抽象化をしない(抽象化は目的と効果を定めて行うもののため)
    -- 実装を隠す側面は便利だが、コードリーディングの際に読みづらくなる
    -- インターフェースを実装するコストが高くなる。数個のメソッドを持つインターフェースの実装は楽だが、数十個数百個になると実装が大変

前方互換の話

go.modのバージョンと手元のバージョンが違う場合、goコマンドが自動でgo.modのバージョンをダウンロードしてくれる。
toolchainにバージョンが指定されていたら、指定されたバージョンでビルドしてくれる。
toolchain:ある目的を達成するために使用するツールセットのこと

銀の弾丸はない

ベストプラクティスを鵜呑みにしない。
→他ではベストプラクティスだが、自分や自分のチームが行う際にはベストプラクティスではない可能性がある
→作っているものの違い、人数の違い、会社の風土などが原因

安易にライブラリを導入しない。
→コストを考える。必要なら自作するのも良い

命名について

情報量が同じなら短い名前の方が良い

プログラムは識別子の集まり。名前が分かりづらいとプログラムもわかりにくくなる。
例えば、
common/utilみたいな情報量のない名前をつけない
→なんのためのutilかを名付けてあげると良い

改行は根本的な命名をシンプルにする解決になっていない

短くできるなら短くする。
そのためには、、、
package, 型, 関数とかの名前との相対的な名前をつける
例1)
net/http のDo関数はhttpが何かをすると取れる
仮にsendHttpRequestという関数がnet/httpに存在した場合、名前が冗長である
例2)
http.Client型

単体テストを行うと命名が良いかどうかわか

→命名が悪いとテストがしにくかったりわかりづらくなったりするため
DB操作の関数はgetByAaaAndBbbとかじゃなくて、何を取ってくるかを名前に入れてあげると良い。
例)今日までに退会した人全てとか

if文で変数代入できる箇所は代入する

→変数のスコープを小さくできるため

example.go
if err := exampleFunc; err != nil {
    ......
}

switchを活用しよう

switch式を省略した場合は、trueを指定したのと同じ意味になる。case式がtrueになる場合にcase句が実行される。
breakを省略できる。

for rangeを活用しよう

スライス、マップ、配列、文字列、チャネルで使える
チャネル:受信した値、closeされたらループ終了
ループ変数はイテレーションごとに別の変数にならず、for文内で同じ変数
→変数をイテレーションごとにしようというdiscussionが出ているため、今後変わる可能性あり
https://github.com/golang/go/discussions/56010

プログラムの複雑さを図るためには

循環複雑度という指標を用いて複雑さを図ることができる。
gostaticanalysis/funcstatというライブラリが複雑度を図るのに便利である。

コメントについて

  • コードからわからなかったことを書く(なぜ何何しなかったのかみたく、why notを書く)
  • なぜその値なのか、マジカルなコードの説明
  • コードを読んでいる際につまづきやすいポイントを書く。条件が通常と逆の場合とか(例えばerr==nil)
    標準パッケージにはコメントが書いてあることが多いので参考にしてみると良い

1.19からコメントに関する新機能が出た

go.dev/doc/comment

関数の分け方

わかりづらい処理、深いネストは理解に苦しむ。
役割に名前がつけれる単位に分ける。
何何当番、何何係みたいな名前をつけられる単位にする。(責務の分離)
XxxAndYyyAnd.....みたいな関数名の場合うまく分類できていないため、命名を見直したり、さらに関数に分けたりするのが良い。

クロージャの多様に注意

無名の関数が増えると処理がわかりにくくなる、可読性が低くなる。
for rangeとの組み合わせ(for文の変数はイテレーションごとではないため)や自由変数の扱いの点でバグが起こりやすい。

ファイルの分け方

長くなりすぎないように気をつける。

  • 型に関する処理が1ファイルに全てなくても良い。Goでは同じパッケージの記述を別ファイルに切り出すことができる
  • ファイル名から中身が想像できるような分け方をする
  • 短く簡潔に
  • ファイルの中身はまとまりよくする
  • テストやbuild constraint以外で_はあまり使わない

バッケージの分け方

  • 学習コスト
    • 統一化されたアーキテクチャにして考えることを少なくする
  • 高い可読性
    • 必要なら自作のアーキテクチャを導入するのも良い
  • リファクタリングありきで考える
    • アーキテクチャを壊しながら開発を行う
    • 新しいアーキテクチャのあり方、packageの分け方チャレンジを繰り返す
    • リアーキテクチャしながら成長させるとチームのアーキテクチャの知識が増え、人の流動に依存しない知識を蓄えられる

機能を正しく使う

名前付き定数

単位のように使われることが多い。
例)
10 * time.Secondみたいに
time.Secondはtime.Duration型だが、10はint型ではない。

型なしの定数について

型なし定数の既定の型は構文によって決まる

fmt.Printf("%T", 10)

はintと出力されるが、

fmt.Printf("%T", 10*time.Second)

はtime.Durationと出力される。
組み込み関数の戻り値は型なしの定数になる場合がある。

名前付き戻り値について

戻り値がわかりづらくなるため、名前付き戻り値は多用しない。
ドキュメントとして残す場合や、defer文で戻り値を変更したい場合のみ使用する。

構造体に構造体を埋め込んだものは継承ではない

example.go
type Hoge struct{ n int }

func (this *Hoge) Do() { fmt.Println(this.n) }

type Fuga struct {
	Hoge
	n int
}

func main() {
	f := &Fuga{Hoge: Hoge{n: 100}, n: 200}
	f.Do()  // f.Hoge.Do()と同じ(シンタックスシュガー)
}

は100を出力する。
匿名フィールドは型名でアクセスできる。

エラー処理

エラーを扱う関数はerror型で扱う。
error型はerrorメソッドを定義している組み込みのインターフェースである。
error型は抽象型のため、具象型で表現しないようにする。
抽象型で扱うと、エラーハンドリングする側では具象型に依存する必要がなくなる。
具象型:構造体のポインタなど
具象型に依存してしまうと、変更に弱くなったり汎化性が低くなる。

エラーのラップ

fmt.Errorf関数を使用してエラーのラップを行う。

errors.New関数ではラップしない。
→ラップしたものを剥がす機能がない。複数のエラーをラップしていた場合、特定のエラーが発生したかどうかを検出するにはラップを剥がす必要がある。文字列をparseするという選択肢もあるが、parseよりもunwrapの方が信頼できる。

example.go
import (
	"errors"
	"fmt"
	"os"
)

func main() {
	_, err := os.Open("not-exists-file.txt")
	err1 := errors.New("wrap: " + err.Error())
	err2 := fmt.Errorf("wrap: %w", err)
	if errors.Is(err1, err) {
		fmt.Println("err1 = err")
	} else {
		fmt.Println("err1 != err")
	}
	// output: err1 != err
	
	if errors.Is(err2, err) {
		fmt.Println("err2 = err")
	} else {
		fmt.Println("err2 != err")
	}
	// output: err2 = err
}

この例だと、errors.New()でラップした場合、unwrapの機能がないためerrors.Is()のoutputはfalseになってしまう。

どんな情報を付与するか

どういう状況で、何をしようとしてエラーが発生したのかという情報を付与する。
また、エラーメッセージが連なることを意識して、情報を重複して持たせないようにする。
ラップしてもerrors.Is関数, errors.As関数を使える

errors.Is関数

エラーがラップされている可能性があるため、エラーの比較には==を使用しない。

errors.Is関数の特徴

  • ==で比較できる場合は==で比較を行う
  • errがIsメソッドを実装している場合はそれで比較を行う
  • 判定不能の場合はerrors.Unwrap関数を呼んでunwrap後にerrors.Isを使って判定を行う

errors.As関数

第一引数のエラーを第二引数のエラーに変換する。
第二引数には変換したい型のポインタをもらう。

example.go
var pathError *os.PathError
if errors.As(err, &pathError) {
    fmt.Println("failed at path:", pathError.Path)
} else {
    fmt.Println(err)
}

os.PathErrorのポインタ型がErrorメソッドを実装しているため、そのポインタを第二引数に渡している。
参考:https://pkg.go.dev/io/fs#PathError

errors.Join関数

go 1.20でリリース予定の関数
複数のエラーを一つにまとめる。
結合したエラーは分解できない。

err1 := errors.New("err1")
err2 := errors.New("err2")
err := errors.Join(err1, err2)
if errors.Is(err, err1) {
	fmt.Println("err is err1")
}
if errors.Is(err, err2) {
	fmt.Println("err is err2")
}

err is err1, err is err2どちらも出力される。

Discussion