Go ではエラーを文字列比較する?という話について
Go で関数の戻り値のエラーを判別するときに、エラーメッセージの文字列をチェックするコードが存在します。 (例)
これは、 Go が言語設計としてエラー処理が貧弱だったり、標準ライブラリがエラー処理を軽視しているからでしょうか? 言語設計や標準ライブラリのAPIの設計をみて行きましょう。
TL;DR
- 言語設計としては、Java的例外機構と同等以上の(文字列比較によらない)エラー検査が可能
- ただし Go のエラーに関する哲学により、公開されていないエラーが多い
- 実際にエラーを文字列比較されている実例についての解説
Go のエラー検査方法
Java の例外機構では、例外をキャッチするために専用の構文が用意されており、型によりマッチングすることができます。 これはクラスのツリー構造を利用してサブクラスをまとめて分岐することもできます。 一方で、同じクラスでも値によりエラー処理が異なる場合には、 try をネストしないといけないケースが有ります。
作為的な例ですが、例外が Errno クラスで、なおかつその errno が EINTR の場合はリトライし、それ以外のケースでは終了するコードはこうなります。
try { while (true) { try { // ... } catch (Errno e) { if (e.getErrno() == Errno.EINTR) { continue; } throw e; } } } catch (Exception e) { System.err.println(e.getMessage()); System.exit(1); }
Go の場合、一般的にエラーは error というインターフェイスによって返されます。 Go にはエラー検査専用の構文はありませんが、インターフェイス一般に使える conversion 構文が Java の catch と同等以上の機能を持っているので、問題になりません。conversion には次の2種類の書き方があり、場面によって使い分けます。
// シンプルな conversion.主に if 文と組み合わせて使う。型以外の条件式を if 文に書ける。 if nerr, ok := err.(net.Error); ok && nerr.Temporary() { continue } // Type switch. 複数の型の振り分けを見やすく書ける switch e := err.(type) { case net.Error: if e.Temporary() { continue } // ... }
実際には複数のエラー型を扱うケースはあまりないのと、 io.EOF
の様に型ではなく定数と比較するエラーがあるので、シンプルな conversion と if 文の組み合わせがよく使われます。
conversion は具体型だけでなく別の interface への変換も可能なので、 interface を抽象型として使うことで、 Java の例外階層と同じように複数の型のエラーをまとめて扱うことができます。例えば、 syscall.Errno
は net.Error
インターフェイスを実装しているので、先ほどのコードは次のようにより一般的に書くことができます。
defer r.Close() for { data, err := r.Read() // 最後のデータを読み込みつつEOFが返る事があるので、エラーチェックよりdataチェックが先 if len(data) > 0 { received <- data } if err == io.EOF { break } if err != nil { if nerr, ok := err.(net.Error); ok && nerr.Temporary() { continue } log.Error(err) break } }
Go のエラーに関する哲学
Go の作者の一人である Dave Cheney 氏のBlog に、Goのエラーに対する重要な哲学が紹介されています。
In the majority of cases, error values returned from functions should be opaque to the caller. That is to say, a test that error is nil indicates if the call succeeded or failed, and that’s all there is to it.
A small number of cases, generally revolving around interactions with the world outside your process, like network activity, require that the caller investigate the nature of the error to decide if it is reasonable to retry the operation.
訳:
ほとんどのケースにおいて、関数が返した error 値は呼び出し元にとって不透明であるべきです。 error が nil かどうかチェックすることで呼び出しが成功したかどうかを知ることができ、そしてそれ以上のことはできません。
少ないケースにおいて、主にネットワークなどプロセス外の世界とやりとりする場合に、呼び出し側がエラーの種別を調べて、操作をリトライするべきかどうかを決める必要があります。
この "error ... should be opaque to the caller" の部分は、後で紹介する issue でも Go の philosophy だと言及されています。 この Blog 記事は改めて翻訳したいと思いますが、要約すると次のような方針になっているようです。
- 可能な限り個別の具体的なエラー型やエラー定数はパッケージ外に公開しない
- エラー処理が必要な場合は、代わりに
Timeout() bool
やTemporary() bool
といった目的に応じたメソッドを定義する
この設計思想のために、特定のエラーを判別するためにエラーメッセージを文字列マッチするしか方法がないケースがあります。
Go で文字列でエラーを判別している実例
一番よく文字列比較されてしまっているエラーが、たぶん net.errClosing
です。これは次のように非公開で定義されているエラー定数です。
errClosing = errors.New("use of closed network connection")
このエラーは、 Close された Listener
や Conn
の Accept()
, Read()
, Write()
, Close()
などのメソッドを呼び出した時に返されます。
このエラーは net パッケージ自身も戻り値として返す場合とテスト中でしか使われておらず、 if err == errClosing
のような使い方はされていません。
このエラーを受け取った呼び出し側はができる回復処理は無いので、 "should be opaque" なのだと思います。
一方で、 Go はネットワークのブロッキングIOをランタイムで隠蔽してノンブロッキングIOのAPIを提供している言語なので、 Listener.Accept()
や Conn.Read()
は呼び出し側をブロックします。これらのメソッドを利用するときは独立した goroutine を使います。そして Accept()
や Read()
している goroutine を止める手段は、その Listener
や Conn
を Close()
することです。一般的な設計のネットワークプログラムで、正常系として、 errClosing
が返るのです。
errClosing
のような回復不能なエラーが発生した時、一般的な後処理は、Connであればそのセッションを捨てる、Listener であればプログラム自体を止めることですが、その際にエラーをログに書くべきです。しかし、 errClosing
のような正常系で発生するエラーをエラーログに書いていたら、より重要なエラーログが埋もれてしまいます。
Close()
したあとの Accept()
や Read()
のエラーを無視することもできますが、 Close()
するのは別の goroutine なので面倒ですし、 errClosing
以外のエラーまで無視してしまう可能性がある気持ち悪さも残ります。
この errClosing
を公開してほしいという Issue golang/go#4373 があり、エラーログに書きたくないというユースケースが投稿された結果、 Won't fix ラベルが解除されました。まだ修正されると決まったわけではありませんが、設計者の想定(回復できないエラーを個別に見せる必要がない)と実際のユースケース(このエラーはログに書きたくない)のミスマッチの一番の例だと思います。
go#4373 と関連する Issue として、golang/go#9405 もあります。
こちらは、 http クライアントをタイムアウト付きで利用した際に (Timeout() bool
を実装していない) errClosing
がそのまま返されてしまうという問題で、開発中の Go 1.5 では修正済みの問題なのですが、現在リリースされている Go 1.4 までではタイムアウトしたかどうかを判定するために errClosing
の文字列比較が必要になってしまっています。
このように、決して多くは無いものの、設計者の想定していないユースケースやバグによりエラーを文字列比較で判定しているケースが残念ながら存在します。 ただし、あくまでもこれらは例外であって、 Go のエラー処理が弱いからエラーを文字列比較しないといけないといったようなことはなく、 Go の哲学は現実的にうまく機能していると思います。
@methane