エラー処理
Go における例外には 3 つのレベルがあります。
error:通常のフローのエラー。処理が必要ですが、直接無視してもプログラムはクラッシュしません。panic:非常に深刻な問題。プログラムは問題を処理した直後に終了すべきです。fatal:非常に致命的な問題。プログラムは直ちに終了すべきです。
正確に言えば、Go 言語には例外という概念はなく、エラーを通じて表現します。同様に、Go には try-catch-finally のような文もありません。Go の創設者はエラーを制御可能にすることを望んでおり、何かを行う際に try-catch を多数ネストさせることを望んでいません。したがって、ほとんどの場合、関数の戻り値として返されます。以下のコード例をご覧ください。
func main() {
// ファイルを開く
if file, err := os.Open("README.txt"); err != nil {
fmt.Println(err)
return
}
fmt.Println(file.Name())
}このコードの意図は明確で、README.txt という名前のファイルを開きます。開くことに失敗した場合、関数はエラーを返し、エラー情報を出力します。エラーが nil であれば、開くことに成功したことを意味し、ファイル名を出力します。
try-catch よりも簡潔に見えるようですが、特に多くの関数呼び出しがある場合、if err != nil のような判断文が至る所に現れます。以下の例をご覧ください。これはファイルのハッシュ値を計算するデモです。この短いコードスニペットには、if err != nil が 3 回現れています。
func main() {
sum, err := checksum("main.go")
if err != nil {
fmt.Println(err)
return
}
fmt.Println(sum)
}
func checksum(path string) (string, error) {
file, err := os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash := sha256.New()
_, err = io.Copy(hash, file)
if err != nil {
return "", err
}
var hexSum [64]byte
sum := hash.Sum(nil)
hex.Encode(hexSum[:], sum)
return string(hexSum[:]), nil
}そのため、Go に対する外部の最も批判的な点はエラー処理にあります。Go のソースコードでは if err != nil が相当な部分を占めています。Rust も同様にエラー値を返しますが、誰もその点について言及しません。なぜなら、Rust は構文糖を通じてこの種の問題を解決しているからです。これと比較して、Go の構文糖はほとんどありません。
しかし、物事を弁証法的に見る必要があります。凡事には長所と短所があります。Go のエラー処理にはいくつかの利点があります。
- 精神的負担が少ない:エラーがあれば処理し、処理しなければ返すだけです。
- 可読性:処理方法が非常に単純なため、ほとんどの場合、コードを理解しやすいです。
- デバッグが容易:すべてのエラーは関数呼び出しの戻り値を通じて生成されるため、一層一層遡って見つけることができ、突然エラーが発生してどこから来たのかわからないという状況はほとんど発生しません。
しかし、短所も少なくありません。
- エラーにスタック情報が含まれていない(サードパーティパッケージで解決するか、自分でカプセル化する必要があります)
- 醜く、重複コードが多い(個人の好みによる)
- カスタムエラーは
varで宣言され、定数ではなく変数です(確かに望ましくありません) - 変数シャドウの問題
Go のエラー処理に関する提案と議論は、Go が誕生して以来絶えることがありません。このような冗談があります。「Go のエラー処理を受け入れられるなら、あなたは合格した Gopher です。」
TIP
Go チームによるエラー処理に関する記事が 2 つあります。興味がある方はご覧ください。
error
error は通常のフローエラーに属し、その出現は許容されます。ほとんどの場合、処理すべきですが、無視しても構いません。error の深刻度はプログラム全体を停止させるほどではありません。error は事前定義されたインターフェースであり、このインターフェースには Error() という 1 つのメソッドのみがあります。このメソッドの戻り値は文字列で、エラー情報を出力するために使用されます。
type error interface {
Error() string
}error は歴史的にも大幅な変更がありました。1.13 バージョンで、Go チームはチェーンエラーを導入し、より完善されたエラーチェックメカニズムを提供しました。次にそれぞれを紹介します。
作成
error を作成するには、以下のいくつかの方法があります。1 つ目は errors パッケージの New 関数を使用する方法です。
err := errors.New("これはエラーです")2 つ目は fmt パッケージの Errorf 関数を使用する方法です。フォーマットパラメータ付きの error を取得できます。
err := fmt.Errorf("これは%d 個のフォーマットパラメータ付きのエラーです", 1)以下に完全な例を示します。
func sumPositive(i, j int) (int, error) {
if i <= 0 || j <= 0 {
return -1, errors.New("正の整数である必要があります")
}
return i + j, nil
}ほとんどの場合、保守性を高めるために、一時的に error を作成するのではなく、一般的に使用される error をグローバル変数として使用します。以下に os\erros.go ファイルからのコードスニペットを示します。
var (
ErrInvalid = fs.ErrInvalid // "invalid argument"
ErrPermission = fs.ErrPermission // "permission denied"
ErrExist = fs.ErrExist // "file already exists"
ErrNotExist = fs.ErrNotExist // "file does not exist"
ErrClosed = fs.ErrClosed // "file already closed"
ErrNoDeadline = errNoDeadline() // "file type does not support deadline"
ErrDeadlineExceeded = errDeadlineExceeded() // "i/o timeout"
)これらがすべて var で定義された変数であることがわかります。
カスタムエラー
Error() メソッドを実装することで、簡単に error をカスタマイズできます。例えば、erros パッケージの errorString は非常にシンプルな実装です。
func New(text string) error {
return &errorString{text}
}
// errorString 構造体
type errorString struct {
s string
}
func (e *errorString) Error() string {
return e.s
}errorString の実装が単純すぎて表現能力が不足しているため、多くのオープンソースライブラリや公式ライブラリは、さまざまなエラー要件を満たすために error をカスタマイズすることを選択します。
伝播
一部の状況では、呼び出し元が呼び出した関数がエラーを返しますが、呼び出し元自身がエラーを処理する責任がないため、エラーを戻り値として返し、上位の呼び出し元にスローします。このプロセスを伝播と呼びます。エラーは伝播過程中に何重にもパッケージ化される可能性があります。上位の呼び出し元がエラーのタイプを判断して異なる処理を行おうとする場合、エラーのクラスを識別できないか、誤って判断する可能性があります。チェーンエラーはこの状況を解決するために登場しました。
type wrapError struct {
msg string
err error
}
func (e *wrapError) Error() string {
return e.msg
}
func (e *wrapError) Unwrap() error {
return e.err
}wrappError も error インターフェースを実装しており、Unwrap というメソッドも追加されています。これは内部の元 error への参照を返すために使用されます。何重にもパッケージ化することで、エラーチェーンが形成されます。チェーンを遡って検索することで、元のエラーを簡単に見つけることができます。この構造体は外部に公開されていないため、fmt.Errorf 関数を使用して作成する必要があります。例えば以下の通りです。
err := errors.New("これは元のエラーです")
wrapErr := fmt.Errorf("エラー,%w", err)使用する際は、%w 形式動詞を使用する必要があり、パラメータは1 つの有効な error である必要があります。
処理
エラー処理の最後のステップは、エラーをどのように処理およびチェックするかです。errors パッケージはエラー処理に便利ないくつかの関数を提供しています。
func Unwrap(err error) errorerrors.Unwrap() 関数はエラーチェーンをアンラップするために使用されます。その内部実装は非常に単純です。
func Unwrap(err error) error {
u, ok := err.(interface { // 型アサーション、該メソッドを実装しているかどうか
Unwrap() error
})
if !ok { // 実装されていない場合、基礎的な error です
return nil
}
return u.Unwrap() // それ以外の場合、Unwrap を呼び出します
}アンラップ後、現在のエラーチェーンがラップしているエラーを返します。ラップされたエラーは依然としてエラーチェーンである可能性があります。エラーチェーン内で対応する値またはタイプを見つけたい場合は、再帰的に検索してマッチングできます。ただし、標準ライブラリはすでに同様の関数を提供しています。
func Is(err, target error) boolerrors.Is 関数は、エラーチェーン内に指定されたエラーが含まれているかどうかを判断するために使用されます。例を以下に示します。
var originalErr = errors.New("this is an error")
func wrap1() error { // 元のエラーをラップ
return fmt.Errorf("wrapp error %w", wrap2())
}
func wrap2() error { // 元のエラー
return originalErr
}
func main() {
err := wrap1()
if errors.Is(err, originalErr) { // if err == originalErr を使用すると false になります
fmt.Println("original")
}
}したがって、エラーを判断する際は、== 演算子を使用するのではなく、errors.Is() を使用するべきです。
func As(err error, target any) boolerrors.As() 関数は、エラーチェーン内で最初にタイプがマッチするエラーを検索し、値を渡された err に代入するために使用されます。一部の状況では、error 型のエラーを具体的なエラー実装タイプに変換して、より詳細なエラー詳細を取得する必要があります。エラーチェーンに対して型アサーションを使用しても無効です。元のエラーは構造体でラップされているためです。これも As 関数が必要な理由です。例を以下に示します。
type TimeError struct { // カスタム error
Msg string
Time time.Time // エラー発生時間を記録
}
func (m TimeError) Error() string {
return m.Msg
}
func NewMyError(msg string) error {
return &TimeError{
Msg: msg,
Time: time.Now(),
}
}
func wrap1() error { // 元のエラーをラップ
return fmt.Errorf("wrapp error %w", wrap2())
}
func wrap2() error { // 元のエラー
return NewMyError("original error")
}
func main() {
var myerr *TimeError
err := wrap1()
// エラーチェーン内に *TimeError タイプのエラーがあるかどうかをチェック
if errors.As(err, &myerr) { // TimeError の時間を出力
fmt.Println("original", myerr.Time)
}
}target は error へのポインタである必要があります。構造体を作成する際に構造体ポインタを返すため、error は実際には *TimeError 型です。したがって、target は **TimeError 型である必要があります。
ただし、公式が提供する errors パッケージは実際には十分ではありません。スタック情報がなく、位置を特定できないため、一般的には公式のもう 1 つの強化パッケージを使用することを推奨します。
github.com/pkg/errors例
import (
"fmt"
"github.com/pkg/errors"
)
func Do() error {
return errors.New("error")
}
func main() {
if err := Do(); err != nil {
fmt.Printf("%+v", err)
}
}出力
some unexpected error happened
main.Do
D:/WorkSpace/Code/GoLeran/golearn/main.go:9
main.main
D:/WorkSpace/Code/GoLeran/golearn/main.go:13
runtime.main
D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/proc.go:267
runtime.goexit
D:/WorkSpace/Library/go/root/go1.21.3/src/runtime/asm_amd64.s:1650フォーマット出力を通じて、スタック情報を確認できます。デフォルトではスタックは出力されません。このパッケージは標準ライブラリの errors パッケージの強化版であり、どちらも公式によって作成されていますが、なぜ標準ライブラリに統合されていないのかはわかりません。
panic
panic は中国語で恐慌と訳され、非常に深刻なプログラム問題を表します。プログラムはこの問題を処理するために直ちに停止する必要があります。否则プログラムは直ちに停止し、スタック情報を出力します。panic は Go の実行時例外の表現形式であり、通常、いくつかの危険な操作で発生します。主にタイムリーに損害を食い止め、より深刻な結果を避けるためです。ただし、panic は終了前にプログラムの後処理を行い、同時に panic は回復してプログラムを継続実行させることもできます。
以下は nil の map に値を書き込む例です。確実に panic をトリガーします。
func main() {
var dic map[string]int
dic["a"] = 'a'
}panic: assignment to entry in nil mapTIP
プログラムに複数のゴルーチンが存在する場合、いずれかのゴルーチンで panic が発生し、それをキャッチしない場合、プログラム全体がクラッシュします。
作成
明示的に panic を作成するのは非常に簡単です。組み込み関数 panic を使用するだけです。関数シグネチャは以下の通りです。
func panic(v any)panic 関数は any 型のパラメータ v を受け取ります。エラーのスタック情報を出力する際、v も出力されます。使用例を以下に示します。
func main() {
initDataBase("", 0)
}
func initDataBase(host string, port int) {
if len(host) == 0 || port == 0 {
panic("無効なデータリンクパラメータ")
}
// ...その他のロジック
}データベース接続の初期化に失敗した場合、プログラムは起動すべきではありません。データベースがないとプログラムは実行する意味がないため、ここで panic をスローすべきです。
panic: 無効なデータリンクパラメータ後処理
プログラムが panic で終了する前に、いくつかの後処理を行います。例えば defer 文を実行します。
func main() {
defer fmt.Println("A")
defer fmt.Println("B")
fmt.Println("C")
panic("panic")
defer fmt.Println("D")
}出力は以下の通りです。
C
B
A
panic: panic上位関数の defer 文も同様に実行されます。例を以下に示します。
func main() {
defer fmt.Println("A")
defer fmt.Println("B")
fmt.Println("C")
dangerOp()
defer fmt.Println("D")
}
func dangerOp() {
defer fmt.Println(1)
defer fmt.Println(2)
panic("panic")
defer fmt.Println(3)
}出力
C
2
1
B
A
panic: panicdefer 内でも panic をネストできます。以下はより複雑な例です。
func main() {
defer fmt.Println("A")
defer func() {
func() {
panic("panicA")
defer fmt.Println("E")
}()
}()
fmt.Println("C")
dangerOp()
defer fmt.Println("D")
}
func dangerOp() {
defer fmt.Println(1)
defer fmt.Println(2)
panic("panicB")
defer fmt.Println(3)
}defer 内にネストされた panic の実行順序も一貫しています。panic が発生すると、後続のロジックは実行できません。
C
2
1
A
panic: panicB
panic: panicA综上所述、panic が発生すると、所在する関数は直ちに終了し、現在の関数の後処理(例えば defer)を実行します。その後、一層一層上位にスローされ、上位関数も同様に後処理を実行し、プログラムが実行を停止するまで続きます。
子ゴルーチンで panic が発生した場合、現在のゴルーチンの後処理はトリガーされません。子ゴルーチンが終了するまで panic を回復しない場合、プログラムは直ちに停止します。
var waitGroup sync.WaitGroup
func main() {
demo()
}
func demo() {
waitGroup.Add(1)
defer func() {
fmt.Println("A")
}()
fmt.Println("C")
go dangerOp()
waitGroup.Wait() // 親ゴルーチンは子ゴルーチンの実行完了をブロックして待機
defer fmt.Println("D")
}
func dangerOp() {
defer fmt.Println(1)
defer fmt.Println(2)
panic("panicB")
defer fmt.Println(3)
waitGroup.Done()
}出力は以下の通りです。
C
2
1
panic: panicBdemo() 内の defer 文が 1 つも実行されずにプログラムが終了したことがわかります。注意すべき点は、waitGroup で親ゴルーチンをブロックしない場合、demo() の実行速度が子ゴルーチンの実行速度より速くなる可能性があり、出力結果が非常に紛らわしくなることです。以下にコードを少し変更します。
func main() {
demo()
}
func demo() {
defer func() {
// 親ゴルーチンの後処理に 20ms かかります
time.Sleep(time.Millisecond * 20)
fmt.Println("A")
}()
fmt.Println("C")
go dangerOp()
defer fmt.Println("D")
}
func dangerOp() {
// 子ゴルーチンはいくつかのロジックを実行し、1ms かかります
time.Sleep(time.Millisecond)
defer fmt.Println(1)
defer fmt.Println(2)
panic("panicB")
defer fmt.Println(3)
}出力は以下の通りです。
C
D
2
1
panic: panicBこの例では、子ゴルーチンで panic が発生したとき、親ゴルーチンはすでに関数の実行を完了し、後処理に入っています。最後の defer を実行しているときに、たまたま子ゴルーチンで panic が発生したため、プログラムは直ちに実行を停止します。
回復
panic が発生したとき、組み込み関数 recover() を使用してタイムリーに処理し、プログラムを継続実行させることができます。defer 文内で実行する必要があります。使用例を以下に示します。
func main() {
dangerOp()
fmt.Println("プログラム正常終了")
}
func dangerOp() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("panic 回復")
}
}()
panic("panic 発生")
}呼び出し元は dangerOp() 関数内部で panic が発生したことを全く知らず、プログラムは残りのロジックを実行して正常に終了します。したがって、出力は以下の通りです。
panic 発生
panic 回復
プログラム正常終了しかし、実際には recover() の使用には多くの隠れた落とし穴があります。例えば、defer 内で再度クロージャを使用して recover を使用する場合です。
func main() {
dangerOp()
fmt.Println("プログラム正常終了")
}
func dangerOp() {
defer func() {
func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("panic 回復")
}
}()
}()
panic("panic 発生")
}クロージャ関数は関数を呼び出したと見なせます。panic は上に伝播するものであって、下に伝播するものではありません。当然、クロージャ関数は panic を回復できません。したがって、出力は以下の通りです。
panic: panic 発生除此之外、还有一种非常极端的情况,那就是 panic() 的参数是 nil。
func main() {
dangerOp()
fmt.Println("プログラム正常終了")
}
func dangerOp() {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
fmt.Println("panic 回復")
}
}()
panic(nil)
}この場合、panic は確かに回復しますが、エラー情報は出力されません。
出力
プログラム正常終了総じて言えば、recover 関数にはいくつかの注意点があります。
defer内で使用する必要があります。- 複数回使用しても、回復できるのは 1 つの
panicのみです。 - クロージャ
recoverは外部関数のpanicを回復しません。 panicのパラメータにnilを使用してはいけません。
fatal
fatal は非常に深刻な問題です。fatal が発生したとき、プログラムは直ちに停止する必要があります。後処理は一切実行されません。通常、os パッケージの Exit 関数を呼び出してプログラムを終了します。以下に示します。
func main() {
dangerOp("")
}
func dangerOp(str string) {
if len(str) == 0 {
fmt.Println("fatal")
os.Exit(1)
}
fmt.Println("正常ロジック")
}出力
fatalfatal レベルの問題は、明示的にトリガーすることはほとんどなく、ほとんどの場合、受動的にトリガーされます。
