「Go初心者が気を付けること」の解説

公開:2020/10/13
更新:2020/10/15
13 min読了の目安(約12500字TECH技術記事
Likes54

以前書いた Go初心者が気を付けること に解説をつけてみようと思いました。

情報検索や環境構築

golang.jpを見に行ってしまう

この辺りはググラビリティの問題ではあるんですが、まだまだ「golang.jp」にたどり着いちゃう人が多いんです(そのせいでなかなか検索ランクも下がらない)。
golang.jp」はGo0.9の頃の情報からほとんどアップデートされていません。なので今のGoとのミスマッチな情報がまばらにちりばめられている状態ですので見に行かない方がスムーズにGoを学べると思います。(たまたま現状と変わっていない情報を読み物として読むくらいなら良いのですが・・・。)

Golang(ごーらんぐ)と呼んでしまう(by hogedigo)

「Go」というワードのググラビリティの低さから「golang」で検索するのはテクニックではあるんですが、あくまでGo言語の正式な名称は「Go」です。

depが最新推奨のパッケージマネージャだと勘違いする(Go標準の「go mod」を使おう)

depやvgo試験提供の成果としてGoモジュールが生まれ、goコマンドのサブコマンド「mod」として標準に組み込まれました。標準ができた以上、新規プロジェクトにおいてもはやわざわざ別途インストールが必要なツールを使う理由はありません。

「GO???」環境変数を理解せずに設定しまくる(わからない場合は一切設定しないのが正しい)

しょっぱなからgvm,gobrew,goenvなどのマルチバージョンのマネージャを入れようとしてエディタ連携環境構築に失敗する

このへん、理解せずに設定すると意外と面倒な状態になっちゃいます。特にGoのバージョン別環境ツールはインストール前の環境変数セットが保存されそれをもとにバージョン切り替えを行うものがあります。これでハマると解決は結構手間がかかります。
また、Goは1.0からの後方互換性はかなり気を付けて保たれています。ほとんどのケースでGoのバージョンを最新に更新して問題になることはありません。(GopherJS/WASM/CGO周りは注意が必要)

さらに、以下の操作で任意のバージョンのGoを利用可能です。

> go get golang.org/dl/go1.##.#
...
> go1.##.# download
...
> go1.##.# version
go version go1.##.# .../...

この操作はGoさえインストールされていれば任意のインターネットにつながっている任意のOSのマシンで実行できるため、CIやMakefile、Dockerfileなどでも利用可能です。

もう一つ付け加えるとGoモジュールモードはたとえ複数バージョンのGoを利用していてもGOPATHは単一のままで何も問題ありません。GOPATHはバイナリとキャッシュにすぎないと考えて問題ないです。

Goモジュールモードでは外部モジュールのバージョンが決定的に選択されバージョン別のキャッシュを構築します。なのでGOPATHをGoのバージョン別に分ける必要はなくなったのです。そしてその方がエディタのLSP連携がうまく構成しやすいはずです。(バージョン切り替えたらエディタのLSPパラメータを再設定しないといけなくなる)

結論をいうと手元の開発マシンには「最新のGo」を「標準のインストール方法」でインストールして、
GOXXXX環境変数は一切設定しない。別のバージョンのGoに切り替えたいならGo標準のマルチバージョンサポートを使いましょう。CIには固定バージョンのGoをセットアップしてビルドするシナリオを書きましょう。

エディタにgoimportsやgolintを設定し忘れる

これらを設定すると「未使用のimport文がエラーになっちゃう!」っていう状況を「勝手にimport文書いてくれる!」という状況に逆転できます。

OSのパッケージマネージャまかせで古いGoやgccgoをインストールしてしまう

正直、古いバージョンを使い続ける、使い始めるのはデメリットが多いです。
特にGoモジュールサポートの有無やLSPとの連携を考えると面倒ごとが増えるだけでいいことありません。
最新のバージョンを入れるようにしましょう。

エラーハンドリング周り

err変数名のバリエーションを増やしすぎる(ほとんどの場合「err」だけで済む)

if構文の事前処理に書くことでerrのスコープをif構文の中だけに限定できます。
また、多値のうち左に新しいシンボルがあれば「:=」による代入が可能です。
こう言った書き方に準じていれば、エラーを受け取る変数はerrだけで済むはずです。

if err := Func0(); err != nil {
    ...
}
v1, err := Func1()
v2, err := Func2()
v3, err := Func3()

サンプルのコピペで「エラーの握り潰し」ごとコピってしまう

サンプルコードは正常系の流れをできるだけ簡素に説明するためにエラーハンドリングを省略して書いている場合があります。それが通常の書き方だと誤解しないでください。(Goの標準パッケージドキュメントのExampleではほとんどの例で省略はしていないはずです)

エラーの種別判定にメッセージの文字列を使う

エラーメッセージはより良い表現に変更されることがありうるのでその文字列に頼ってエラー判別していると
バージョンアップで動かなくなる可能性があります。

nilなエラーをラップしてしまう

エラーはラップして階層化することができます。もっとも簡単な例は以下のコード。

return fmt.Errorf("failed: %w", err)

この時、errがnilの場合、ラップしてはいけません。
後段でのエラー判定が期待通りに動かなくなります。

Go1.14にてエラーのラップやアンラップの機能は追加されたけど、
エラーを受け取った側の責任でnilチェックはやはり必要なままです。
(とりあえずなんでもラップして上流に判断をまかすということはできない)

panic/recoverを多用しちゃう

Goには標準的なエラーハンドリング方法があり、これ以外の方法でエラーを通知するようなインターフェースは慎みましょう。さもないと標準的なエラーハンドリング方法を期待しているユーザーはびっくりしてしまいます。

panicを単独で使うことはアリです。あるプログラムの動作条件を満たさないような状況を検出したらpanicすると良いと思います。例えば、必須の設定ファイルが読み込めなかったり必須の設定項目が見つからない場合など。log.Panic等を使うとエラーメッセージをログに出力した後panicします。

テンプレートや正規表現のコンパイルにMust関数を使うとコンパイルエラーの時点でpanicになります。
このようにプログラムの前提となる書き方や動作条件を満たせないような場合(メモリ不足や依存ランタイムのロード失敗なども含む)は容赦無くpanicで落とす方が良い結果になることが多いと思います。(特に自動テストで前提条件が揃っていないのにテスト続行しても意味がありません)

9年近く書いてきてrecoverが必要だったことは数えるほどしかないし、それらも今から新規に書き直すならrecoverを使わないと思う。recoverしないと心配って思うかもしれませんが、書き慣れるに従ってランタイムpanicを起こすコードを書かなくなるしpanic=コードの記述ミスまたは前提条件が揃っていないのでプログラムが停止した方がその修正機会を手早く得やすいです。

まとめると、「panic発生」したらコードを修正しなきゃいけないなにかがあるということ。「recover」はこの修正の機会を隠すだけでほぼ全てのケースで使う必要がありません。つまり「recover」を使わずに「panic」が出る要因を直すのが品質向上の基本。(「recover」を使うということはこの品質向上を諦めるということ)

Goの仕様への理解不足

deferの呼ばれるタイミングを関数スコープより狭いスコープと勘違いする

「ブロック単位」で動くと勘違いしがちなんですがあくまで関数(func付随ブロック)単位でdeferは処理されます。

  • 単独{...}
  • for付随ブロック
  • if付随ブロック、elseブロック
  • switch付随ブロック
  • select付随ブロック

これらのブロックはdefer処理には関係ありません。

型付nilをinterface型に変換してしまいnil比較を困難にしてしまう

Hoge構造体ポインタがComponentインターフェースと互換がある時、以下のコードのようにnil構造体ポインタをComponentインターフェース型に「変換して引数に渡したり」、「変換して返値として返してしまう」というパターンがありがちです。

type Component interface{...}
func NewHoge() (*Hoge, error) {...}
func foo() Component {
	hoge, _ := NewHoge()
	return hoge
}
func moge(c Component) {...}
func main() {
	hoge, _ := NewHoge()
	moge(hoge)
}

これはhoge変数が有効かどうかで扱いを変えていないことが原因です。
以下のように書くとよいでしょう。

func foo() Component {
	hoge, err := NewHoge()
	if err!=nil {
		log.Print(err)
		return nil // あらためてnilリテラルを返す
	}
	return hoge // このhogeは必ず有効な値をもつポインタ
}
func main() {
	hoge, err := NewHoge()
	if err!=nil {
		log.Panic(err) // プログラム終了
	}
	moge(hoge) // nilじゃないhogeだけがmoge関数に渡される
}

コツは

  • 異常系では改めてnilリテラルを返すこと
  • 極力ポインタをnilのまま持ちまわらないこと。
  • ポインタ変数は初期化する時にnilではなくすことを心がけること。
  • ポインタ型変数のゼロ値初期化var hoge *Hogeを極力使わない。
  • Goプログラムの外から受け取ったデータを参照する時はnilチェックをすること。

といったGoの書き方を真似ていればnilポインタをインターフェース型に変換することは自然となくなるはず。そうしてしまった「型付nil」をnilかどうか判定するロジックを決して実装したりしないでください。

インターフェース型にまでポインタ宣言を持ち込もうとする

考え方としては「通常型とインターフェース型」があって、
それらの「ポインタ型、非ポインタ型」があると考えてはいけません。

インターフェース型はポインタ値も非ポインタ値も内包できる概念なので、
Goには「ポインタ型、非ポインタ型、インターフェース型」の三種類あると考えると良いでしょう。
インターフェース型は非ポインタ型もポインタ型も互換性があれば代入可能な型です。
なので、Goではインターフェース型へのポインタという概念は役に立つ場面がありません。
「インターフェース型へのポインタ」を書いても無視されたりエラーになったりするだけです。

多値返しをタプルオブジェクトのように扱おうとしてしまう

多値で返しているので他の処理系のようにタプルではないのでタプルの機能を期待しても使えません。
(水面下で実装されている内容が異なるのだからタプルのように扱えないのは当然なのです)

また、多値返しの仕組みは少数の返値に特化して実装されているので大量の値を返すのには向いていません。
タプル返しはタプルオブジェクトへの参照(ポインタ)をひとつだけ返す仕組みですが、多値返しは本当に多値をスタックに積んで返す仕組みです。

多値返しとタプル返しはそれぞれメリットデメリットがあるけれども、大きな理由のひとつはCPUの得意な仕組みを利用して多値返しを実装しているのでオーバーヘッドが少ないというものがあります。それを含めてGoは多値返しを選んだというだけなのです。

mapの要素が非ポインタ構造体値のフィールド(配列値の要素も同様)にアクセスしようとしてエラーに遭遇

以下のコードはfunc1は正常に実行可能だけど、func2はコンパイルエラーです。

type Hoge struct {
	Field string
}
func func1() {
	m := map[string]*Hoge{"hoge": &Hoge{}}
	m["hoge"].Field = "hello"
}
func func2 () {
	m := map[string]Hoge{"hoge": Hoge{}}
	m["hoge"].Field = "hello"
}

func2は以下のように書く必要があります。

func func2 () {
	m := map[string]Hoge{"hoge": Hoge{}}
	hoge := m["hoge"]
	hoge.Field = "hello"
	m["hoge"] = hoge
}

func1ではマップから取り出した値が構造体ポインタなので元の実体のフィールドに代入することはできます。func2ではマップから取り出した値は構造体のコピーなのでそのフィールドを書き換えても元の値が書き換わることはありません。これを放置すると問題発覚がなかなかできないのでコンパイラがエラーにするという挙動になっています。

同名変数のシャドウイングを理解せずに使ってしまう

理解して使ってるうちはいいんですが・・・。
ループ内でvalの値を更新しようとして間違って「:=」を書いてしまい、
意図せずシャドウイングを起こしてしまうパターン。
これだとループの内「:=」の左辺以降とそれ以外でval変数は別物という扱いになります。
val := val + vこれの右のvalはループの外のvalで、左のvalが新しいvalです。

val := "a!"
for _, v := range []string{"a", "b", "c"} {
	val := val + v
	fmt.Println(val)
}
fmt.Println(val)

ループ内で新しくvalに値を束縛した場合、それをだれも参照していなかったら
未使用変数エラーで気付けるのですが。

jsonのオプジェクトを構造体で定義するとき、小文字のフィールドを書いて、読めない書けないで困る

誰もが通るあるあるなんですが、Goの公開フィールドの仕様に従うので。
その上でjsonでのフィールド名を任意の表記にしたいのならフィールドタグで指定しましょう。

goroutineの起動時間が少しかかるのを忘れてコード書いちゃう(by shibukawa)

var wg sync.WaitGroup
go func() {
    wg.Add(1)
    :
}()
wg.Wait()

こういう場合、以下のようにgoroutine起動前にwg.Addしないといけない。

var wg sync.WaitGroup
wg.Add(1)
go func() {
    :
}()
wg.Wait()

goroutineから外のループ変数を参照しちゃう(by hogedigo)

以下のコードの場合、ループが回りきった後、準備の整ったgoroutineが起動していくんだけど、
その時点でvの値はループの最後の値になっちゃってる。

https://play.golang.org/p/r6EQ-PXlezI

var wg sync.WaitGroup
for _, v := range []string{"a", "b", "c"} {
	wg.Add(1)
	go func() {
		fmt.Println(v)
		wg.Done()
	}()
}
wg.Wait()

しかし、この件go vetコマンドが検出してくれるようになっててPlaygroundで実行しようとすれば
ちゃんと以下の警告を出してくれます。なのでコードをコミットする前にはgolintやgo vetをかけましょう。

./prog.go:13:16: loop variable v captured by func literal
Go vet exited.

だめではないけどいいことない

mapを初期化し忘れる(参照操作ではpanicしないので気づくのが遅れる)

mapは値がなくてもとりあえず初期化しておきましょう。map型変数をnilのまま持っていても何もいいことないです。たいしてメモリ節約になるわけでもないし。

逆にsliceはnilでも問題ないです。appendで要素追加していきますが、元のスライスがnilでも問題ないように動作します。

最初っから細かいパッケージツリー構成を作ろうとして行き詰まる

すこしGoに慣れた頃に誰もが通る登竜門です。

依存関係をきっちり整理して取りかからないとすぐ循環参照エラーに。
そして思ったよりもさらに細かくパッケージを分けなければならない状況になります。
まずは同じパッケージ配下で適切にファイル分割できるようになるまではあまり細かくパッケージを分けない方が良いです。そうしてるうちにこれは別パッケージに切り出しても問題なさそうなものだけ切り出すという風に進めるのがお勧めです。

インポートパスに相対パスを書いちゃう

相対だと常に基準を意識する必要があり周辺ツールもコードの読み手にも負担が大きい。
しかも、GO111MODULE=onでエラーになるようになりました。
もはや相対パスインポートは使わない方が良いでしょう。
自動的に依存を管理するGoモジュールの恩恵が得られなくなりますし。
コード補完ツールなどの想定などももうGoモジュール前提なのでそこから解離した状態だと
デメリットの方が多いです。

chanとmutex併用を多用する

goroutine群とシンクロするためにchanを使うのであって、
そのchanの取り回しのためにsyncパッケージの同期オブジェクトを併用
しだしたら何か使い方をミスってるかもと思った方が良いです。
レアケースでchanと同期オブジェクト併用が適切な場合もありますが、
最初はどちらで同期をかけるか決めて片方だけを使う方が良いと思います。

chanの不便さはある程度ねらってそのように作られています。

例えば、以下のような要求は多くの場合使い方が間違っています。

  • chanにメッセージがあるかないかチェックしたい
  • chanがクローズ済みか値を取り出さずにチェックしたい

CSP非同期メッセージングにはセオリーみたいなものがあるのです。
chanの状態チェックー>chan操作の間に別の処理が挟まることはありうるわけで、
状態チェックの意義はまったくありません。
chanを状況に応じて操作を変えるというような使い方を避けましょう。
慣れないうちはここに同期オブジェクトを持ち出したりしちゃうわけだけど、
そうなってくるとchanである必要もなくなってきたりします。

メッセージキューとタスクキャンセルを兼任させられる場合と分離した方がいい場合がある。
分離したいもしくはタイムアウトも欲しい場合はcontext.Contextを使いましょう。

goroutineが止まらないライブラリを書く

goroutineで無限ループを組む場合、終了フローを設ける癖をつけておきましょう。
有限個数でアプリケーション実装であれば止まらないgoroutineで問題になることはほとんどないのですが、
ライブラリでやっちゃうとgoroutineリークといってバグの一つになっちゃいます。

パフォーマンスチューニング周り

正規表現を関数内で初期化して使っちゃう(by shibukawa)

データベースのPrepareを使うタイミングでないところに書いちゃう

これらはあらかじめ済ませられる処理があるなら事前に済ませておくことで性能を確保しようということ。

しかし、正規表現やテンプレートの事前処理がわかりやすくメインのループ処理の外側で事前処理をやっておくだけなのに比べ、databaseのPrepareは使うタイミングが難しい。

基本、トランザクション処理の内側にPrepareを書きます。そうすることでdatabaseドライバーが解釈済みオブジェクトを取得できたらその参照を返すし、取得できない場合に事前パース処理を実行します。

測定もせずに固定値でもないスライスのキャパシティサイズを指定するチューニングを行う

これは明確な根拠もないのにキャパシティを指定しちゃうようなチューニングのことを言ってて、これを指定しちゃうと想定する要素数を決め打ちにしちゃうということなのです。そう言ったコードを長く転用などしているとほんのちょっぴり想定サイズを超えた状況に投入しちゃって無用なメモリコピーで負荷を増やしてしまったりするわけです。
根拠がわからないのならGoのランタイムが持っているアルゴリズムに任せましょう。

Go開発コアメンバーも「早すぎる最適化はするな」ということを言っています。

もちろん、入力データのサイズが把握できてそれを中継する処理がサイズに準じたキャパシティを指定するというのは安定した性能を確保するという意味で有用なのでどんどん指定しましょう。

reflectを速度の必要な場面で使っちゃう

速度の必要なところでスライスの型変換が必要なデータ構造を作っちゃう

これらはデータ構造設計が大切であるということです。もっとも「アクセス頻度が高い状況に合わせたデータ構造」を設計しておき、その他は「利便性の高いデータ構造」を利用すればいいのです。
準備段階で「利便性の高いデータ構造」から「アクセス頻度が高い状況に合わせたデータ構造」への変換をすましておくのがコツです。

goroutineをやたら頻繁にスケジューラスイッチさせるような実装を書いてしまう

マイクロベンチでgoroutineを使って遅くなるような処理を書いて遅い遅いという意見が時々見られますが
真に受けてしまわないようにしましょう。

いかにgoroutineが軽量とはいえ無駄にgoroutineスイッチをさせられれば当然パフォーマンスは落ちます。
「goroutineに依頼した処理コスト」を「goroutineスイッチコスト」で割った値が小さければ小さいほどパフォーマンスが悪い結果になります。goroutineを使って比較をしたい場合はgoroutineスイッチの頻度が適切である必要があります。

goroutiineのスケジューラはもちろん依頼処理コストが大きい場合をフォーカスしてチューニングされています。ひとつのgoroutineがCPUを占有しすぎないように分散してくれる仕組みがありますが、小さすぎる処理コストをまとめるのは実装者の責任で行わなくてはなりません。小さすぎる処理を頻繁に繰り返すだけであればシングルスレッドの方が速い結果が出るのは当たり前なのです。

私の感覚的には実用的な実装であれば「C++玄人」が書いたC++実装の90%以上の性能を「Goの中級者」が書いたGo実装で出せるというのが実感です。こうなっていない処理はなんらかの「ベンチマーク向け最適化」が含まれている可能性が高いです。「ベンチマーク向け最適化」の多くは実用的な実装ではあまり機能しません。
計算結果のメモ化やHTTPヘッダーパースの評価遅延など。こういった最適化は実用の用途にはあまり当てにできません。投げ込まれるデータは多様性が高くメモ化のメリットは少なく単にメモリ占有量の増大を招くし、HTTPのヘッダは常に全てパースしないと安全に通信できてるとはいえなくなります。

他の処理系の風習を持ち込む

再帰呼び出しを多用する

Goはデバッガビリティのために末尾再帰最適化をあえて実装していません。
なので膨大な再帰回数を処理させようとするとそのまんまスタックメモリを浪費してしまい、
効率が良くありません。また、再帰呼び出し処理は容易にループによる処理に置き換えができます。

ループによる処理の方がCPUには優しいので効率が良くなります。

有限の再帰呼び出し(数十回以下)であれば問題なく使って良いとは思います。ループの代わりに再帰呼び出しというのは避けましょう。

高階関数を作ろうとする

スライスに対し、一定の処理関数を通すという処理ですが、結局のところ素直にforループを書くのがGoには適していて、汎用の型に適用可能な高階関数を実装しようとするのはミスマッチです。
gennyなどを使ったコード生成を行うのも一つの手法です。

Go2で導入予定のジェネリクスを待ちましょう。

自作ライブラリの利用方法をメソッドチェインで作ろうとする

メソッドチェインがGoのライブラリで使われていないのかというと存在はするんですが、
I/Oが絡まないオンメモリのロジック組み立てなどに使われています。
この用途ではエラーが不意に発生することがないから成り立ちます。
エラーが発生する可能性がある処理をメソッドチェインで実装してしまうと、エラーの通知方法を発明しなくてはなりません。

panicによる通知のrecoverはユーザーに書かせないのがGo流の考え方なのでそれ以外の方法にするしかありません。これでは「Go」の良い特徴の一つ「エラーハンドリング方法が画一的」というのが壊れます。
(もちろんレアな例外はあるので絶対ダメというわけではないのですが多用されると困る感じ)

継承の仕組みを構造体のembedで実現しようとしてしまう(by hnakamur2)

  • 構造体のembedは継承とは異なります(正しくは移譲)。
  • 埋め込み先の型と埋め込み元の型はあくまで違う型なのです。
  • 型横断の特性はインターフェースで定義してそこで多態性を持たせるのがGoのやり方。

httpクライアント処理

resp.StatusCodeを確認しない

適切なタイミングで「defer resp.Body.Close()」を呼び忘れる

  • http.Get/http.Post、http.Doで返されるrespとerr
  • まずはエラーをチェックして有効なresp(レスポンス)かどうかを判定します
  • 次にresp.StatusCodeを確認します。
  • その後、必要であればresp.Bodyを全て読みます。(一部のエンコーディング+コネクションのキープアライブの時にこうしていることが想定されています)
  • その後、resp.Body.Closeを呼ぶ。

定石的なhttpクライアントのコード例
https://play.golang.org/p/SgZSR4lkUmC

opts := Option{Method: "GET"}
if opt != nil {
	opts = *opt
}
req, err := http.NewRequest(opts.Method, url, opts.Body)
if err != nil {
	return err
}
for _, v := range opts.Header {
	req.Header.Add(v.Key, v.Value)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
	return err
}
defer func() {
	io.Copy(ioutil.Discard, resp.Body)
	resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
	return fmt.Errorf("error: %s", resp.Status)
}
// 正常系の処理 resp.Bodyを読んで処理する
return nil

まとめ

いろんな観測されたつまづいているポイントを収集していたものをまとめました。
他にあればまた教えてください。