Go言語の記述の迷いどころについて
この記事はGoのコードをいくらか書いていてもっとGoらしい書き方に興味を持ってからみてね!(Go初見で読んでも響かない内容です)
Goは「シンプルで迷いなく書ける」というのが売りではあるのですが、
実際書き始めると、「あれ?これどうやって書くほうがいいの?」ってポイントにちょくちょく巡り合います。そのようなポイントを思い出しながら今思うベターな書き方を紹介しようと思う。
err変数束縛について
err変数の受け取りを複数回繰り返していると「:=」だけで書けないという状況に出会うでしょう。
err := funcA()
if err != nil { ... }
err := funcB() // <- コンパイルエラー: "no new variables on left side of :="
if err != nil { ... }
このメッセージが示す通り、新しい変数が左辺にあればこのエラーは出ません。
err := funcA()
if err != nil { ... }
res, err := funcB() // <- no compile error
if err != nil { ... }
ではerrしか返さない関数を複数使うときは「var err error」宣言が必要?というと回避方法があります。
以下の様にifの事前処理に書くとifブロック内に閉じたerr変数が新たに作られます。
(そのためifブロックの外では参照できません)
err := funcA()
...
if err := funcB(); err != nil { ... }
ほとんどのケースで以下のルールを守れば、返値の受け取りは「:=」だけで記述でき、err変数は「err」というシンボルのみで済みます。
- 多値返しの場合はifの手前に書く
- エラーのみ返す処理はifブロックの事前処理に書く
エラー値の取り回しが複雑になる様なレアケースのみ、返値に名前をつけたり、「var err error宣言」や「名前付き返値」を使いましょう。
パラメータを行単位に並べる
func funcA(a, b, c int) {}
上記の様な関数があって、以下の様に呼び出すのが一般的ではあるんですが、
funcA(1234154, 13423532, 535366)
もっとながーい複合リテラルのパラメータが並びだすとぱっと見でどこまでが引数か分かりにくくなります。
もちろんGoのフォーマッタが長すぎるパラメータを自動で改行したレイアウトに分割してくれますが、長い行に引っかからない程度のパラメータはどうしましょうか?
実はパラメータや複合リテラルの途中の改行をフォーマッタは無闇に削らないのです。
一行にまとまりそうで、書き手が複数行展開を意図していない場合にまとめるという挙動があります。
なので、複数行にバラして書くとそれは維持されます。
ただ、シンタックスとして「カッコの終わり」もしくは「カンマ」で終わっていない改行はエラーになるので以下の様に最後の引数に「カンマ」を書いた後、フォーマッタにかけてみましょう。
funcA(
1234154,
13423532,
535366,
)
すると以下の様にパラメータを一行ごとに分割したままフォーマットしてくれます。
funcA(
1234154,
13423532,
535366,
)
つまり「ケツカンマ」である程度コントロールできるということです。
このルールは複合リテラルを書くときにも有効です。
m := map[string][]string{
"hoge": {"a","b"},
"moge": {
"long-message1",
"long-message2",
},
}
ここは書き手の好みを載せても良いということなのでみやすいと思う書き方をすれば良いでしょう。
アーリーリターンスタイル
Goでは以下の様に書くことは推奨されません。
インデントが深くなりがちでかなり読みにくいコードになってしまいます。
func Do() error {
var err error
err = setup1()
if err == nil {
err = setup2()
if err == nil {
do()
return nil
}
}
return fmt.Errorf("failed: %w", err)
}
「err変数束縛について」にて説明したことも併せて以下の様に書きましょう。
これは「アーリーリターンスタイル」と言います。
do()
を処理するのに必要なsetup1()
とsetup2()
が正常に完了した時だけdo()
を呼ぶのは前述の書き方と変わりないのですが、setup1()
とsetup2()
いずれかが失敗したらこの関数自体離脱するという書き方です。
func Do() error {
if err := setup1(); err != nil {
return fmt.Errorf("setup1 failed: %w", err)
}
if err := setup2(); err == nil {
return fmt.Errorf("setup2 failed: %w", err)
}
do()
return nil
}
deferの書きどころ
実は、Goの多値返しには暗黙のルールがあります。
関数やメソッドが処理結果とエラーを多値返しする時、
- エラーがnilではない時、処理結果が保有するリソースは開放する必要がない
- エラーがnilの時、処理結果が保有するリソースは不要になったらリソース解放処理の呼び出しが必要
- エラーがnilの時、処理結果がポインタ型である場合このポインタはnilではないことを期待していい
最初に出会うであろうそういう関数はos.Open(...)
かos.Create(...)
かな。
fp, err := os.Open("sample.txt")
fp, err := os.Create("sample.txt")
このとき、err==nil
ならばfp
は必ずnilではない有効な値を持ち、ファイルハンドルというOSのリソースを握っています。このリソースは不要になったタイミングで適切にfp.Close()
を呼んで解放する必要があります。なので、一般的には以下の様に書きます。
func hogeProc(fn string) error {
fp, err := os.Open(fn)
if err != nil {
return fmt.Errorf("failed: %w", err)
}
defer fp.Close()
// fpを利用する処理
return nil
}
つまり、その様な関数呼び出しで多値を受け取ったら、以下の手順で処理を書きます。
- まずエラーチェックをします
- エラーがあればアーリーリターン
- deferでリソースの解放処理を記述
- リソースを利用した処理を記述
では、nilでないエラーを返しつつ解放が必要なリソースも返す様な処理関数やメソッドは存在するのか?というと「存在しないことが期待されている」ということです。
自作の関数やメソッドを実装するときも上記の手順でさばける様に実装することが求められます。
あと気をつけることはfor構文の中でdeferが必要になったらfor内側のブロックは別の関数に括り出しましょう。deferが発火するのはあくまで関数呼び出しから戻る時だけだからです。
nilの返し方
ダメな例
type MyError struct {}
func (e *MyError) Error() string { ... }
func hoge() error {
var myErr *MyError // この時点ではmyErr == nil
// 処理中にエラーがあればmyErrに代入
return myErr
}
このダメな例は処理にエラーがない場合、「*MyError型のnil」という値がerrorインターフェース型に変換され返されるが、「*MyError型のnil」という値はインターフェース型にとってはnilではない(インターフェース型は「型と値」の双方を格納できる特殊型で、何らかの型情報が付与されているnilは有効な値として扱う)のでhoge関数の呼び出し元はこのerror返値が簡単にnilチェックできない。
推奨される書き方
type MyError struct {}
func (e *MyError) Error() string { ... }
func hoge() error {
if エラーを検出 {
return &MyError{}
}
// 正常終了
return nil
}
この書き方を見てもらえば理解してもらえると思いますが、「アーリーリターンスタイル」がこの推奨の形を後押ししています。
コツは
- nilを返すときは「nilリテラル」を返しましょう
- nilかもしれないポインタは早めにチェックしてアーリーリターンで捨てましょう
func Proc(string) (*Result, error) {...}
...
res, err := Proc("hoge")
if err != nil {
return fmt.Errorf("not found: %w", err)
}
// ここに到達した場合、resはnilではないことが保証されている
つまり、エラーチェック必須なGoにとってresのnilチェックは自然に行われることになるのです。
mapのキー存在判定と関数の返値
Goのmapは存在のないキーで値を取り出すと「ゼロ値」が返されます。
m := map[int]int{1:123}
println(m[1]) // -> 123
println(m[0]) // -> 0
存在しないかどうかを判定するには?
専用の特殊な返値の受け取り方があります。
以下の様に値を取り出す際、2つの返値受け取りをします。
2番目の返値には存在の有無が返ります。
m := map[int]int{1:1}
value, ok := m[0]
println(ok) // -> false
mapをベースに作ったコンテナにてキーで値を取り出そうとする時、キーが存在しないかどうかが判別できたとして、そのことを上流へ伝える方法は何通りかありどれを使うかは悩ましいところです。
- 見つからない場合をエラーで通知
- 見つからない場合をnilを返して通知
- エラーにnil、結果もnil(正常に検索完了したが対象は見つからなかったこと)を通知
最後の手法はGoのエラーハンドリング慣習とミスマッチなので採用しない方がいいですね。
エラー扱いにする例:
type Container struct {
m map[string]*Item
}
func (c *Cnotainer) GetItem(key string) (*Item, error) {
v, ok := c.m[key]
if !ok {
return nil, fmt.Errorf("item %q not found", key)
}
return v, nil
}
nilが見つからなかった扱いとする例:
type Container struct {
m map[string]*Item
}
func (c *Cnotainer) GetItem(key string) *Item {
c.m[key]
}
個人的にはmapアクセスのみかつItemがポインタ型であれば後者のやり方でいいのかなと思う。
nilチェックで見つからなかったのかどうかは判定できるので。
ただし、GetItemがネットワークアクセスやファイルアクセスなどのI/Oを伴う様な場合はerrorも返す前者の実装が望ましい。
recoverの使い所
なし
明確な理由がなければrecoverを使う必要はほとんどないと思います。
deadlock
やdivision by zero
、index out of range
、nil pointer dereference
をrecoverはしない様にしましょう。これらも含むpanicが起きる要因の多くはコードの不備であり事前チェックにより回避されるべき状況なのです。recoverしても誤ったロジックのプログラムが走り続けるだけであり、逆に迅速にプログラムが停止する方がメリットが多いのです。
「念のためrecover」しちゃうのはGo初級者にありがちなのですが、panicをrecoverする前提で投げる様なライブラリは敬遠されるし、Goに慣れてくると「きちんと運用されている実装」が上げうるpanicは「使い方が間違っているか、前提条件揃っていない時」などプログラムを停止すべきタイミングでしか上げることはありません。つまり運用上必要なrecoverというのは基本的にありません。
「panicが上がればプログラムを停止して、実装の不具合を修正する」これがGoにおける正しいpanicハンドリングです。決してrecoverでこの不具合を抑え込んで動作を継続する様な実装を書かないように。
それでもなお通常運用の中でpanicを上げてしまう様なサードパーティライブラリに出会うことは、きっとあるとは思いますが、それはサードパーティライブラリの問題であってそれをrecoverしてまで使い続けるのはリスクがあるのでプロダクションの開発ではそのサードパーティライブラリを使うのをやめるかコントリビュートして問題を解決すべきなのです。
recover使うのってreflectつかってて事前チェック実装を端折りたい時や、前述の様な問題を含むライブラリを仕方なく抑え込んで運用する場合に限ります。それでも境界をまたぐ様なタイミングで必ず上流でerror返値に置き換えてエラーハンドリングすべきなのです。
クリティカルな問題や前提条件不足、意図しない利用法でのみpanicを投げるというのは自分たちが書くコードにも要求されます。つまり、関数やメソッドの戻り値設計には注意が必要です。エラーが起こる可能性がほんの少しでもあるならエラー返値を設けておく必要があります。さもないと内部で起こったエラーのハンドリングに選択肢が少なくなってしまう(ログに吐くくらいしかできなくなる)。逆に、エラーハンドルしても復旧できない状況や利用方法にそぐわない入力を検出した場合はどんどんpanicでプログラムを終わらせよう。
レシーバーの型
メソッドレシーバーにポインタ型を使うか非ポインタ型を使うか。
func (c *Hoge) Something1() error { ... } // ポインタレシーバメソッド
func (c Hoge) Something2() error { ... } // 値レシーバメソッド
Goの仕様で値型「T」のメソッドセットは「値型レシーバーのメソッドセット」のみであり、ポインタ型「*T」のメソッドセットは「ポインタ型レシーバーのメソッドセット」と「値型レシーバーのメソッドセット」の両方を持ちます。
呼び出し元の型 | ポインタレシーバーメソッド | 値レシーバーメソッド |
---|---|---|
ポインタ型 | メソッドセットに含む | メソッドセットに含む |
値型 | メソッドセットに含まない | メソッドセットに含む |
つまり、値型を扱う時、メソッドセットも値型レシーバーで統一されている必要があるということです。
取れる組み合わせは以下の三通り。
- *T型とポインタレシーバーメソッド
- T型と値レシーバーメソッド
- *T型と値レシーバーメソッド
これ、迷うくらいなら1.に統一しましょう。3.の使い道はメリットのある用途は限定的です。
2.がマッチする用法は構造体定義の宣言内容が将来において不変でサイズが固定長であるものに限ります。例えばベクトルや固定サイズの行列など。
値レシーバーメソッドの特徴である「呼び出しごとに複製を行う」ために元型の値を破壊できない「イミュータビリティ」という特性がありますが、これは副産物的に利用するのはアリですがそのためだけに2.の用法を採用することはお勧めしません。大きなアプリケーションになると複製コストがデメリットとして影響が大きくなります。
イミュータビリティを実現したければ、プライベートなフィールドをゲッターメソッドで取り出せる様にした構造体を定義しましょう。
空インターフェース
極力空インターフェース型「interface{}」の利用は控えましょう。型制限を無くした型の取り回しはコードの複雑さを招きます。なんでも受け付けるが故にあらゆる型への対応を実装するのは無駄なコストです。現実の実装ではそこに渡す型の種類は限定的であるはずです。(本当にあらゆる型への対応が必要な時、あらゆる型の分岐を実装することになるがそこまで必要なケースはレアケースなはず)
処理の分離
例えばファイル名を指定してなんらかのデータを書き出す処理があるとして、
func SaveData(fpath string, data []*Data) error {...}
上記の様に、「ファイル作成と書き出し」がひとまとめな場合、「ファイル操作の課題」と「書き出しの課題」が密結合してしまいます。
ファイル操作の課題として、ファイルが作れない時はユーザー指定パラメータの問題で、速やかにユーザーにその旨を伝えてプログラムを終了したり再入力を促したりする必要があります。そして現実問題として「ファイル作成に成功し、書き出しに失敗した」場合は空ファイルを削除したいという事情なども発生するでしょう。
こういう事情を上記の実装に盛り込んでしまうとファイル操作の課題と主な処理の課題の双方が一つの処理に作り込まれてしまいます。
こういう処理は以下の様に「ファイル操作」と「io.Reader/io.Writerを引数に取る処理」に分離実装するのがおすすめです。
func SaveDataFile(fpath string, data []*Data) error {
fp, err := os.Create(fpath)
if ... {...}
remove := false
defer func() {
fp.Close()
if remove {
os.Remove(fpath)
}
}()
if err := SaveDataStream(fp, data); err != nil {
remove = true
return fmt.Errorf("write failed: %w", err)
}
if err := fp.Sync(); err != nil {
remove = true
return fmt.Errorf("sync failed: %w", err)
}
return nil
}
func SaveDataStream(w io.Writer, data []*Data) error {...}
このように「ファイル作成」と「書き出し」に責任分解をすることで、Goのエラーハンドリングにて適切にハンドリングしやすくなります。まとまったままエラーハンドリングを上流に委譲してしまうとエラーを分類する必要が発生してしまいます。
またストリーム版のSaveDataStreamは他の用途に流用できる様になります。
- HTTPレスポンスに書き出す
- オンメモリなバッファに書き出す
- ファイルに書くと同時に標準出力や標準エラー出力にも書き出す(io.MultiWriter)
- Zip、Tarなどの圧縮処理を挟む
まとめ
ここにあげたいくつかのGoの慣習がつながってGoのベターな記述スタイルが構成されていることが伝わると幸いです。
その他迷ったら「Goや標準ライブラリのソースコード」をみて真似をするのが良いでしょう。結局この一文に全てが集約されるわけです・・・。
Goらしくない他の処理系の慣習を持ち込もうとしてイライラするのはやめてGoらしく書きましょう!
Discussion