Gopher塾でGoらしいコードの書き方を入門した
おことわり
Go Advent Calendar 2022 25日目の記事です。
はじめに
先日,tenntennさん主催の Gopher塾 #2 に参加してきました.第2回目のテーマは「Goらしいコードの書き方」でした.自分は #1 に引き続き参加させて頂いたのですが,今回もとても面白い内容でした.
ということで今回は参加記事として Goらしいコードの書き方 について紹介したいと思います.
Goらしいコードって何??
そもそもGoらしさって何なのでしょうか??よく,Gopherの間では「Goらしいコード」であったり,「Goに入ってはGoに従え」[1] などの言葉を耳にするのではないでしょうか.で,Goらしいコードってなんなのよって話なのですが,個人的には Goの文化を理解したコード だと思っています(もちろん人によって解釈は異なると思いますし,この考えが絶対だとも思いませんが).
Goの文化を知るためには,Goの開発背景的であったり,各企業がなぜGoを採用したのかなどを知ることが近道になると思います.
【参考】
- Why Go - The Go Programming Language
- Go at Google: Language Design in the Service of Software Engineering
- 鵜飼文敏氏「Goに入ってはGoに従え」可読性のあるコードにするために ~Go Conference 2014 Autumn基調講演2人目
少しだけGoの開発背景的なところものぞいてみたいと思います.Go at Google: Language Design in the Service of Software Engineering によればGoは既存の言語の以下のような問題点を解決するために開発された言語であるとのことです.
- slow builds (ビルドが遅い)
- uncontrolled dependencies (依存関係がコントロールできない)
- each programmer using a different subset of the language (各プログラマが言語の異なる部分を使っている)
- poor program understanding (code hard to read, poorly documented, and so on) (コードの可読性が低い)
- duplication of effort (労力が重複している)
- cost of updates (更新のコストが高い)
- version skew (バージョンのずれ)
- difficulty of writing automatic tools (自動ツールの作成が難しい)
- cross-language builds (複数の言語を跨使った際のビルドが難しい)
つまり,ここから読み取れるGoの言語的な特徴は
- ビルドが速い
- 依存関係をコントロールできる(Go Modules)
- プログラマが言語の異なる部分を使わないようにする(Goの言語仕様はシンプルである)
- コードの可読性が高い
- 労力が重複しない
- 更新のコストが低い(可読性が高い,言語の学習コストが低い)
- バージョンのずれが起きない(Go Modules, Go version)
- 自動ツールの作成が簡単
- 複数の言語を跨使った際のビルドが簡単(CGOなど,少しズレるがクロスコンパイルが簡単なども)
ではないでしょうか.大半が シンプルさ というところに落ち着きますが,これが最大の特徴なのではないでしょうか.少しマクロな視点で考えてしまいましたが,結局「Goらしいコード」とは上の特徴を掴んだコード,つまり
- シンプルに書く
- 可読性が高い
- 更新が簡単にできる
などの特徴を持つコードであると言えるのではないでしょうか.では実際にGoらしいコードを書くための具体的なテクニックを見ていきましょう.(上に紹介した記事 鵜飼文敏氏「Goに入ってはGoに従え」可読性のあるコードにするために ~Go Conference 2014 Autumn基調講演2人目 にも色々紹介されています.)
既存のコードGoらしく書き換える
では実際に既存のコードをGoらしく書き換えてみましょう.テクニックだけ気になる方は Goらしく書き換える に飛んでください.
ここから先の内容に関するコードなどは以下のGitHubリポジトリにもあります.気になる方は覗いてみてください.
題材
今回は簡単な題材として,お馴染み ls
コマンドをGoで書いてみます.
簡単に補足すると,ls
コマンドは(カレント)ディレクトリのファイル一覧を表示するコマンドです.
% ls
Makefile bin go.mod main.go sampledir
また引数としてディレクトリ名を指定することで,そのディレクトリのファイル一覧を表示することもできます.
% ls sampledir
txt1 txt2 txt3 txt4 txt5
その他にもオプションとして
-
-a
: 隠しファイル(.hoge
)も表示 -
-l
: ファイルの詳細情報も表示 -
-G
: カラー表示
などがあります.(サンプル: ls -l
の実行結果)
% ls -l
total 24
-rw-r--r-- 1 junyaokabe staff 120 12 12 02:44 Makefile
drwxr-xr-x 3 junyaokabe staff 96 12 12 02:54 bin
-rw-r--r-- 1 junyaokabe staff 32 12 12 02:34 go.mod
-rw-r--r--@ 1 junyaokabe staff 427 12 12 03:05 main.go
drwxr-xr-x 7 junyaokabe staff 224 12 12 02:55 sampledir
とりあえず実装してみる
Goでとりあえず ls
コマンドを実装してみましょう[2].ただし,簡単のために
- (あれば)第一引数はディレクトリ名として扱う
- オプションは考慮しない
として実装します.するとコードの核の部分は例えば
func lswithdir(dirname string) {
dir_entries, err := os.ReadDir(dirname)
if err != nil {
panic(err)
}
output(dir_entries)
}
func output(entries []os.DirEntry) {
for _, entry := range entries {
fmt.Printf("%s ", entry.Name())
}
fmt.Println()
}
のように書けます.os.ReadDir 関数を使うと簡単にディレクトリのファイル一覧を取得できます.[3]
あとはいい感じに出力する関数 output
を実装して
func output(entries []os.FileInfo) {
for _, entry := range entries {
if string(entry.Name())[0] == '.' {
continue
}
fmt.Printf("%s ", entry.Name())
}
fmt.Println()
}
最後に main
関数で呼び出してあげれば完成です.
func main() {
if len(os.Args) == 1 {
ls()
} else {
lswithdir(os.Args[1])
}
}
これをコンパイルするなり go run
するなりして実行してみましょう.個人的にはコンパイルしてバイナリを生成する方が ls
感があるので好きです.
% go build -o bin/myls
% ./bin/myls
Makefile bin go.mod main.go sampledir
もちろん第一引数にディレクトリ名を指定することもできます.
% ./bin/myls sampledir
txt1 txt2 txt3 txt4 txt5
Goらしく書き換える
では既存のコードをGoらしく書き換えてみましょう.
名前に気を使う
Goに限らずプログラムを書くとき「良い」名前をつけるというのはめちゃくちゃ難しいですよね.特に印象に残った考え方として
- 長い名前
良い名前\neq - 名前に文脈を与える
というものがあります.というのも,情報量が同じであれば短い名前の方が読みやすいからです.これはめちゃくちゃ当たり前のことなのですが,例えば
fruits_list := []string{"apple", "banana", "orange"}
というコード,わざわざ _list
をつけなくとも fruits
だけで十分意味が通る(情報量はほとんど変わらない)と思います.つまり
fruits := []string{"apple", "banana", "orange"}
にした方がいいよね,後から使うときにも十分意味伝わるよね,ということです.よく「長い名前にしておけば可読性が上がる」という考え方をされることがある[4]のですが,実際は文脈に応じて名前をつけることが大事だと思います.
ということで,今回のコードを見てみましょう.関数 lswithdir
の
dir_entries, err := os.ReadDir(dirname)
の部分,dir_entries
という変数名はちょっと長いですね.entries
だけでも十分意味が通ります.
entries, err := os.ReadDir(dirname)
確かにこっちの方が読みやすそうですね.
フローを考える/変数のスコープを狭める
Goに限らず,コードを書くときには「スコープ」に意識する必要はあります.例えば,for文の中の変数として i
を用いるのと,100行ある関数の冒頭で i
を宣言するのとでは全然違います.(スコープの大きい)変数が多いコードを読むのは脳への負担が大きくなるので,できるだけ変数のスコープを狭めるようにしたいですね.
Goでは,変数のスコープを狭めるためのテクニックの一つとしてif文への代入があります.これは例えば
a := f(b)
if a < 0 {
a = 0
}
という処理を
if a := f(b); a < 0 {
a = 0
}
と書くことができます.このとき,変数 a
のスコープはif文の中だけ[5]になります.つまり
if a := f(b); a < 0 {
a = 0
}
fmt.Println(a)
はコンパイルエラーになります.(PlayGroundで動かす)
この考え方を応用すると関数 lswithdir
内の
entries, err := os.ReadDir(dirname)
if err != nil {
panic(err)
}
output(dir_entries)
の部分は次のように書き換えることができます.
if entries, err := os.ReadDir(dirname); err != nil {
panic(err)
} else {
output(entries)
}
これで entries
, err
という変数のスコープがif文の中だけになりました.
適切にコメントを書く
今回の講義の中でも紹介されていたコメントに対する考え方として,和田卓人さん[6]の下記ツイートがあります.
さらにGo1.19以降では,go doc によってコメントを元に構造化されたドキュメントを生成することができるようになりました.詳細は長くなってしまうので割愛しますが,気になる方は Go Doc Comments などを参照してください.
エラーを適切に処理する
今回は panic を使ってエラーを処理していましたが,panicは(予期せぬエラーなどが発生したときに)プログラムを強制的に終了させるためのものです.このような使い方は基本的には避けた方が良いです.
今回の場合の起こりうるエラーは,
- 引数が多すぎる
- 引数が無効なディレクトリを指している
の2つです.1の場合は,エラーを返すあるいは引数を無視するのが適切な対応でしょう.2の場合は,素直にエラーを返すのが適切です.いずれにしても panic で終了させるようなものではないので,適切にエラーを処理した方がいいですね.
実際に 1 の「引数が多すぎる場合」を修正してみます.今回は,引数が多すぎる場合は Usage: ...
の形式で実行方法を返すようにします.さらに異常終了したことを示すために,終了コードを1にします.
if len(os.Args) == 1 {
ls()
} else if len(os.Args) == 2 {
lswithdir(os.Args[1])
} else {
fmt.Println("Usage: ls [dirname]")
os.Exit(1)
}
次に 2 の「引数が無効なディレクトリを指している」場合ですが,こちらは素直にエラーを返すようにします.
- どこでエラーが発生したのか
- エラーメッセージが連なる可能性がある
の2点を考慮して,fmt.Errorf
を使ってエラーをラップして返すようにします.
if entries, err := os.ReadDir(dirname); err != nil {
err = fmt.Errorf("func ReadDir error: %v", err)
fmt.Println(err)
os.Exit(1)
}
実際に実行してエラーを発生させてみると・・・
% ./bin/myls not-exist-dir
func ReadDir error: open not-exist-dir: no such file or directory
であったり,
% ./bin/myls Makefile
func ReadDir error: fdopendir Makefile: not a directory
といった感じで「どこでどんなエラーが発生したのか」が分かりやすくなりましたね.
その他
「コードの書き方」という範疇になるかは怪しいですが,もう少しマクロな視点で気をつけた方が良いことを簡潔に紹介します.
関数の分割
今回は引数の有無を考えて当初は ls
と lswithdir
という2つの関数を実装しました,しかし,ls
は lswithdir
を呼び出すだけの関数になっているので,ls
を呼び出す側で引数を無理やり与えることで関数を統合することができます.つまり main
関数側で
func main() {
if len(os.Args) == 1 {
ls(".")
} else {
ls(os.Args[1])
}
}
としてあげれば関数を減らせますね.
パッケージの分割
今回のサンプルではコードサイズが小さかったので単一のファイルで実装してしまいましたが,実際の開発ではそうもいかないことがほとんどです.その際は /pkg/
以下などにパッケージを分割してあげると良いでしょう.個人的によく使っているレイアウトは Standard Go Project Layout です.
まとめ
最終的に3つの関数 main
,ls
,lswithdir
を2つにし,ls
は lswithdir
を呼び出すだけの関数になりました.変更前後の差分を見てみると次のようになります.
main
関数
@@ -8,8 +8,11 @@ import (
func main() {
if len(os.Args) == 1 {
ls()
- } else {
+ } else if len(os.Args) == 2 {
lswithdir(os.Args[1])
+ } else {
+ fmt.Println("Usage: ls [dirname]")
+ os.Exit(1)
}
}
lswithdir
関数
func lswithdir(dirname string) {
- dir_entries, err := os.ReadDir(dirname)
- if err != nil {
- panic(err)
+ if entries, err := os.ReadDir(dirname); err != nil {
+ err = fmt.Errorf("func ReadDir error: %v", err)
+ fmt.Println(err)
+ os.Exit(1)
+ } else {
+ output(entries)
}
- output(dir_entries)
}
今回はGoらしいコードの書き方についてまとめてみました.いかがだったでしょうか.「Goらしいコード」にはこれと明確に決まった正解がないので非常に難しいですが,自分なりにGoらしいコードについてあれこれ考えてみました.「ここはこうした方がいいんじゃない?」とか「こういうテクニックがあるよ」などあればコメントやissues/PR でぜひ教えてください.
Gopher塾ではこういった内容をもっと詳しく学ぶことができます.この内容(Goらしいコードについて)も Day3 が年明けに開催される予定らしいので,興味のある方はご参加してみてください!!
各種リンク
参考資料
- Why Go - The Go Programming Language
- Go at Google: Language Design in the Service of Software Engineering
- 鵜飼文敏氏「Goに入ってはGoに従え」可読性のあるコードにするために ~Go Conference 2014 Autumn基調講演2人目
- Go Doc Comments
- Effective Go
- Google Style Guide - Go Style
- Standard Go Project Layout
Discussion