この記事では2人目の鵜飼文敏氏の基調講演についてレポートを書きたいと思います。この基調講演では「Goに入ってはGoに従え」というタイトルで、Go言語らしく書く方法について話がされました(スライド)。
Go言語の可読性レビュー
Go Readability Approver
まずはじめに、「Go Readability Approver」と呼ばれる、GoogleにおけるGo言語のReadability(可読性)をレビューするチームについて話がありました。このチームは、コードレビューを通じてGo言語の良いコードの書き方を教えることを目的としているそうです。メンバーは、メインのプロジェクトとは別のプロジェクトのコードをレビューするそうで、同氏は1年ほど前に参加し、20%ルールの時間を使って、200ほどのChange Listをレビューしたと述べていました。
「Go Readability Approver」には、「Readabilityスキル」が必要とされるそうです。これは、レビューするプログラミング言語のリテラシーを持っていることと、その言語の「作法にかなったやり方」でコードを読んだり書いたりする能力のことであるそうです。同氏は、作法は言語ごとに異なり、さらにC++ではプロジェクトごとに違うと主張していました。たとえば、Chromeの開発ではChromiumの部分は、Googleのスタイルガイドに従って書かれており、Webkit(Blink)の部分は、Webkit(Blink)のコーディングスタイルに従って書かれているそうです。そのため同氏は、C++/Java/Pythonで書くようにGo言語のコードを書くと読みにくいコードになり、Go言語の考え方とは違う書き方で書いてしまって、非常に書きづらくなると指摘していました。
可読性のあるGo言語のコード
同氏は、Go言語のコードを書くうえで、言語の単純さをうまく使って書くことが大切であると主張していました。そして、Go言語で書くと簡単に以下のようなコードにできると述べていました。
- 明瞭・簡潔
- 使いやすいAPI
- 適切なコメント
- 素直なコードフロー
特にゴルーチンを使った並行プログラミングは、C++などで書いた場合に比べて、非常に読みやすいコードフローになると述べていました。このようにGo言語で書かれたコードは単純で読みやすいコードになりやすく、とあるGoogler(Googleの社員)は、Googleが提供するサービスで、サーバで何が行われるかを調べるときは、Go言語での実装だと余計なコードが少なく、本質が一番わかりやすいと述べていたそうです。
Go言語で読みやすいコードを書くための優れたツールとして、以下のツールが紹介されていました。
しかし同氏は、読みやすい(情報を認識しやすい/脳に負担がかからない)コードを書くには、これらのツールに頼るだけでは不十分で、書き手と読み手の双方が言語特有の手法・慣用表現を理解する必要があると主張していました。また、Go言語の言語仕様はシンプル(50ページほど)であるため、言語仕様の理解はそんなに大変ではないと述べていました。
この講演では、以下の点に着目してレビューを行い、実際に指摘したことについて説明がされました。
- ミス/バグはないか
- 見やすくレイアウトされているか
- コードフローはわかりやすいか
- APIはわかりやすいか
エラー
Must-
を使った初期化
Go言語では、戻り値の最後にエラーを取ることが一般的で、そのエラーは必ず受け取る必要があります。しかし、_
で受け取ることで、エラー処理を行わなくて済むようになります。同氏が行ったレビューにおいても、このようなコードを見かけることがあるそうで、エラーは必ずチェックする必要があると指摘していました。たとえば、正規表現をコンパイルするregexp.Compile
関数も第2戻り値にエラーを返しますが、以下のように_
で受け取ることでエラー処理を回避できます。
同氏は、上のコードは以下のコードのように修正すべきだと述べていました。
同氏は、パッケージ変数をvar
やinit()
関数で初期化する場合などは、エラー処理を回避するのではなく、regexp.MustCompile
などを使用するべきだと主張していました。一方、関数やメソッドの内では戻り値のerror
を受け取って適切に処理すべきだと述べていました。
このように、標準パッケージが提供する関数で最後の戻り値にエラーを返すものの一部に対して、Must
で始まる関数が用意されている場合があります。Must
で始まる関数は、内部で対応するerror
を返す関数を呼び出し、戻り値のerror
がnil
ではない場合は、panic
を起こすように定義されています。
また、上記の例では、文字列のエスケープで正規表現が読みにくいため、raw string literal
を使うべきだと指摘されていました。
defer
におけるエラー処理
以下のように、defer
を使ってファイルをClose
する場合があると思います。defer
を使えば、関数からリターンするときにClose
を呼び出すことができます。
このとき、読み込むために開いたファイルの場合はそのままdefer in.Close()
のように閉じてしまっても問題ないですが、書き込みのために開いたファイルの場合はそのまま閉じると、うまく書き込めなかった場合などにエラーが適切に処理できません。そのため、同氏は以下のように修正できると述べていました。
defer
の中で戻り値であるerr
がnil
である場合に、Close
で発生したエラーを代入することで、そのエラーを戻り値として返すことができます。そして、some code
以下でエラーがあった場合は、戻り値のerr
代入することでそのエラーを返すことができます。しかし、defer
を使うと処理が複雑になることがあるため必ずしもdefer
を使えば良いというわけではないとのことでした。上記の例では、some code
以下がシンプルな場合はdefer
を使わず、関数の最後でClose
を呼び出してエラーをチェックするほうがシンプルになると同氏は述べていました。
値とエラーを混ぜない
値としてエラーを使用している場合の例として以下のコードが挙げられていました。
このコードでは、C言語やC++のコードのように特別な値をエラーとして扱っています。具体的には、time.Duration
は期間を表す型なので、マイナスの値をエラーとして扱っています。Go言語では複数の戻り値を返すことができます。そのため同氏は、エラーを値に混ぜて返すのではなく、最後の戻り値としてerror
型で返すべきだと主張していました。
エラーの設計
エラー処理の方法によって、エラーの設計方法が分けられるそうです。エラー処理の際に、エラーの区別が必要なく、単にerr != nil
でエラーの有無をチェックする場合は以下のようにfmt.Errorf
とerrors.New
を使用すると良いそうです。
また、エラーにはいくつか種類があり、それを区別する必要がある場合は以下のように変数に代入しておいて使用するほうが良いそうです。なお、このときの変数名はErr-
が一般的なようです。
error
型はError
メソッドを持つインターフェースです。そのため同氏は、エラーにいろいろな情報を含めたい場合は、構造体でそれらの情報を定義し、Error
メソッドを実装することでerror
として使用することができると述べていました。なお、このときのエラー型の名前は-Error
という名前を使用するのが一般的らしいです。
同氏は、Error
メソッドのレシーバをポインタにした場合、nil
との比較に注意すべきだと指摘していました。インターフェースは値と型情報を持ち、その両方がnil
の場合にnil
となります。そのため、以下のコードはif err != nil {
がtrue
となります(参考:FAQ: Why is my nil error value not equal to nil?)。
また同氏は、panic
は極力使わないようにすべきだと主張していました。どうしてもpanic
を使いたい場合は、recover
を使ってパッケージ外に出るときはerror
にすべきだと述べていました。
インターフェースの埋込み
以下のコードはscan.Writer
インターフェースを埋め込むことでをColumnWriter
がscan.Writer
インターフェースを実装していることを明示的にしようとしています。
たしかにscan.Writer
を埋め込むことで、ColumnWriter
はscan.Writer
を実装していることになります。しかし、初期化時にscan.Writer
を埋め込む必要があるうえにこれではColumnWriter
がscan.Writer
を本当に実装しているかどうかの保証にはなりません。同氏は、以下のようにscan.Writer
型に代入することで、*ColumnWriter
がscan.Writer
を実装しているかどうか、コンパイル時にチェックすることができると述べています。この方法であれば、*ColumnWriter
がscan.Writer
インターフェースを実装していない場合には、コンパイルエラーとなります。なお、_
で代入を受けているため、実際には変数代入は行われず、型チェックだけ行うことができます。
なお、構造体がインターフェースの定義するメソッドを明示的に実装せず、さらに構造体にインターフェースを埋め込んで、その値を設定しなかった場合(nil
の場合)、そのメソッドを呼び出すとpanic
が発生します。
同氏は、この挙動はテストで一部のメソッドだけ実装したいときは便利だと述べていました。
コードを見やすくする
構造体のフィールドのレイアウト
以下のように、フィールドの並び順を意識せずに構造体を定義してしまうことがあると思います。しかし同氏は、この定義の仕方だとmu
がどのフィールドを保護しているのかわからないと指摘していました。
そのため、フィールドリストは関連が深いものをブロックに分け、sync.Mutex
はそれが保護しているフィールドのブロックの先頭に置くとわかりやすいとのことでした。
長い行は簡潔な変数名を使って短く
以下のように、関数の引数が多くなり1行が長くなってしまうことがあると思います。
同氏は、Go言語には行の長さには制限がないため、コードをgrep
することを考えると1行にする方が良いと述べていました。
しかし同氏は、この場合であれば引数に簡潔な名前を用いることで、1行を短くすることは可能であると述べていました。また変数名などは、与えられたコンテキストの中でわかりやすい名前にすべきで、長い名前が必ずしもわかりやすい名前ではないと指摘していました。そして、以下のように冗長な名前は避けるべきだと述べていました。
上記の1行が長いコードは、名前を簡潔にすることで、以下のように短くなります。
素直なコードフロー
インデントは最小にする
同氏は、基本のコードパスのインデントは最小にすべきだと述べていました。たとえば、以下のコードはif
の条件を入れ替えることでもっと簡潔に書けるそうです。
また、マップのキーが存在するかどうかを表す変数はいつでも変数名をok
にする必要はなく、否定的な意味で使う場合はok
だと不自然であると指摘していました。 この場合であれば、ok
の代わりにfound
という名前を使い、条件式を逆にすることで、!ok
をfound
にすることができるとのことでした。
上記のコードのように、うまく条件式を書くことでelse
を使う必要がなく、インデントが少なくなります。
関数の分割
以下のコードでは、条件によってHTTPステータスを変え、結果をJSONで返すという処理をしています。しかし、この書き方では基本のコードパスがわかりづらく、どういうときにどういうHTTPステータスが返されるのかわかりにくくなっていると、同氏は指摘していました。
そこで、ステータスを決める関数(finishStatus
)を別に作ることで、HTTPステータスを決めて、JSONで返すという基本のコードパスをわかりやすくすることができると同氏は主張していました。また、ステータスを決める関数も条件が判定できれば、すぐにリターンすることができるため、非常にわかりやすくなるそうです。
switch
を使う
以下のコードでは、Webブラウザのサイズによって対応する文字列("small"
、"medium"
、"large"
、"null"
)を返す処理をしています。
このコードでは、if
が入れ子になっていて、さらにif else
によって長くなっています。 同氏は、このような場合はif
ではなくswitch
を使うと以下のように簡潔に書けると述べていました。
シンプルに実装する
time.Duration
を使う
同氏は、期間を表す値は以下のようにint
型ではなく、time.Duration
型を使うべきだと指摘していました。また、コマンドライン引数として期間を取る場合は、flag.Duration
関数を使うことでtime.Duration
型の値を取得できると述べていました。
以下の2つの例では、確かにtime.Duration
型を使用していますが、型変換は不要です。定数は型情報を持たないため、time.Second
と掛け算をするとその結果はtime.Duration
型となります。
同氏は型変換をなくすと、上記のコードは以下のように簡潔に書くことができると述べていました。また、30 * time.Second
で十分意味が通じるので、// Thirty seconds
のような冗長なコメントは必要ないと指摘していました。
チャネルを使う
同氏は、今までC言語やC++でコードを書いてきた人たちが以下のようなコードを書きがちだと述べていました。
Stream
がClose
されているかどうかをisConnClosed
で管理し、sync.Mutex
とsync.Cond
を使って制御しています。同氏は、このコードは決して間違ってはいないが、受信のみのチャネルを使うことでもっと簡潔に書けると指摘していました。
上記のチャネルを使ったコードでは、チャネルが開いているか閉じているかをStream
が開いているか閉じているかに対応させています。Close
メソッドはチャネルを閉じるだけで実現しています。そして、Wait
メソッドは単にチャネルから受信すればよく、チャネルが閉じられれば直ちにゼロ値が送られてくるそうです。またIsClosed
メソッドは、開いているチャネルから受信を行うと何かしらの値が送信されるまで処理がブロックされることを利用して実現されています。チャネルが開いていると、select
のcase <-s.cc
のケースは実行されず、default
のケースが実行されます。一方、チャネルが閉じていると直ちにゼロ値が送られてくるため、case <-s.cc
のケースが実行されます。
型がわかっている場合にreflect
は使わない
同氏は、reflect
パッケージは強力であり、効果的に使える場合もあるが安易に使うべきではないと主張していました。以下のコードは、いくつかのフィールドに対して似たような処理を行う必要があり、そのためにreflect
を使用しているとのことでした。
上記のコードの場合、reflect
の対象のLayers
がどのような型であるかコンパイル時にわかっているため、reflect
を使う必要はないと同氏は指摘していました。そして、以下のようにSlice
というLayer
とExperiment
の対のスライスを返すメソッドを追加することで、同様の処理が書けると述べていました。同氏はプログラム自体は長くなるが、reflect
を使うよりも意図がわかりやすくなると主張していました。
テスト
同氏は可読性のレビューにおいて、テストがうまく書けていない・可読性に欠けていることを指摘することがよくあると述べていました。典型的なテストコードは以下のように、ある処理において期待した結果が出なかった場合に、t.Errorf
メソッドで入力と期待する出力と実際の出力をメッセージとして出すものであると述べていました。このように書いておけば、どういう場合にどのような値が出るのかがわかりやすいとのことでした。
なお、多くの人が独自アサート機能を実装しがちですが、同氏は言語の機能を使うことをお勧めしていました。また、コメントにAPIの使い方を詳細に書くのであれば、以下のようなExampleテストを書くべきだと主張していました。Exampleテストはテストとして実行されるため、より正しいExampleとして提供することが可能とのことでした。
コメント
レビューにおいて、コメントに対する指摘を行うことが多いと同氏は述べていました。そして、よく指摘する項目として
を挙げていました。
上記の3つの項目を満たす正しいコメントは以下のようになるそうです。
このように書いておくと、grep
もしやすくgodoc
で確認した場合も見やすくなるとのことでした。また、同氏はgodoc
は-http
オプションをつけてWebブラウザで確認することもできると述べていました。
同氏は、コメントがわかりにくい/うまく書けないときは、APIの設計を考え直したほうが良いと主張していました。また、上記のようにコメントを書いてgodoc
で確認することで、APIの設計が問題ないか考えることができると述べていました。
APIデザイン
同氏は、APIをデザインするうえで最も大切なことは適切な名前のパッケージを作ることであると述べていました。たとえば、よくutil
というパッケージを作ってそこにいろいろなものを入れてしまいがちだが、もっと細かくパッケージを分けるか、もっと大きなパッケージの一部として定義するかのどちらかにするべきだと指摘していました。
同氏はAPIをシンプルにするためのルールとして以下の項目を挙げていました。
まとめ
同氏は、コードはコミュニケーションであると述べていました。 そのためには明瞭に表現することが大切で、以下のことを守る必要があると述べていました。
- 適切な名前を選ぶ
- シンプルなAPIを提供する
- わかりやすいドキュメントを書く
- 複雑にしすぎない
この基調講演では、どうやったらGo言語らしく書けるのかということを具体例とともに説明されていました。聴講した多くの方にとって、Go言語のコードを書くことはあってもレビューされる/する機会というのはまだ少ないと思います。そのため、この講演は、可読性のあるコードにするために、どういった観点でレビューをされるのか知る貴重な機会になったかと思います。