🚫

Goにはなぜ例外がないのか

に公開

はじめに

こんにちは、Lapi(@dragoneena12)です。

Goではtry-catch型のエラー処理(いわゆる例外処理)ではなくエラーを関数の戻り値として扱うようになっています。他の言語に慣れている人からするとこの書き方は冗長に見えるようです。
なぜGoではこのようなエラーの扱い方をしているのか。冗長に書かざるを得ないように見えるのはなぜなのか。自分なりに調べてみた内容を社内LT会で発表したので、ブログ記事に再編してみました。

Cのエラー処理

Goがこのようなエラー処理方法を採用した背景を知るため、まずはCのエラー処理について振り返ってみます。
まず重要な点として、Cの言語仕様では値を1つしか返すことができません。そのためエラーの場合は -1 を返すといったエラー処理がよくみられるパターンです。

int main() {
  pid_t pid = fork();
  if (pid == -1)
    printf("fork()に失敗しました\n");
}

Cのエラー処理は以下のような課題があります。

  • -1 がなんなのかわからない(マジックナンバー)
  • 例えば負数を含む整数を返す関数だと、-1 ではエラーを表現できず工夫が必要
    • 例)構造体へのポインタを受け取って操作し、エラーコードを返す
  • その関数について良く知らないとエラー処理ができない

例外スロー型のエラー処理

Cのエラー処理の課題を解決すべく、C++やJavaScriptなどの多くの言語では例外スロー型のエラー処理が採用されました。
関数の戻り値としてエラーを返すのではなく例外としてスローし、try-catchなどの構文を利用して例外を処理するスタイルです。

const fetchSomeAPI = () => {
  throw new FetchError("データ取得に失敗しました")
}

try {
  await fetchSomeAPI()
} catch (e) {
  console.error(e)
}

例外スロー型のエラー処理には以下のような利点があります。

  • Cのように返り値とエラーを混同することがない
  • エラーに対して明確な型を与えられる
  • 例外が処理できない場合、処理できるまで外側の関数に例外を伝搬できる

これによってCのエラー処理における悩みの多くが解決されましたが、同時に新たな課題も生じました。
まず、その関数の内容のコードを読まない限りその関数が例外をスローするかどうかがわかりません。そのため結局はその関数のコードを読み、正しく例外を処理できていることを確認する必要があります。
さらに、ドメインエラーのような復旧可能なものから配列の範囲外アクセスのような致命的なものまで、あらゆるエラーが「例外」として扱われる傾向を生んでしまいました。結果として以下のような、重要なエラーが握りつぶされてしまうコードが頻出する事態になってしまいました。

try {
  await fetchSomeAPI()
} catch (e) {
  // ignore
}

Goのエラー処理

Goでは以上のような例外スロー型のエラー処理の問題を、例外を使わないことで解決するアプローチを選択しました。GoはCと違い関数の戻り値として複数の値を返却(多値返却)できるため、これを利用して値とエラーを分けた上で戻り値として返却できます。

func Sqrt(f float64) (float64, error) {
  if f < 0 {
    return 0, errors.New("負の数の平方根は取得できません。")
  }
}

func main() {
  f, err := Sqrt(-1)
  if err != nil {
    fmt.Println(err)
  }
}

Goのエラー処理は関数の戻り値として型定義に含まれているため、その関数がエラーを返すことが明示的にわかります。
さらに、Goには致命的なエラーを扱うための方法としてPanic, Recoverという方法が用意されています。

func f() {
  defer func() {
    if r := recover(); r != nil {
      fmt.Println("Recovered!")
    }
  }()
  g()
}

func g() {
  panic("Panic!")
}

これによって通常のエラーは値で返し、致命的なエラーはPanicするという区別が明確になり、例外スロー型の問題を解決しているというわけです。
以上の内容についてはGoのFAQにおいて簡潔に記されています。

We believe that coupling exceptions to a control structure, as in the try-catch-finally idiom, results in convoluted code. It also tends to encourage programmers to label too many ordinary errors, such as failing to open a file, as exceptional.

Go takes a different approach. For plain error handling, Go’s multi-value returns make it easy to report an error without overloading the return value.

私たちは、try-catch-finally のように例外を制御構造と結びつけるやり方は、コードを複雑にしてしまうと考えています。また、この方法は、ファイルを開けないといった普通のエラーのようなものまで、例外的なものとして扱うことをプログラマに促してしまう傾向があります。

Go はこれとは異なるアプローチを取ります。通常のエラー処理については、Go の 多値返却(multi-value returns) によって、戻り値を無理に流用することなくエラーを簡単に報告できます。

https://go.dev/doc/faq#exceptions

エラーの値返却は冗長なのか?

Goのエラー処理に対するよくある批判として、以下のような冗長なエラー処理を何度も書かなければいけないというものがあります。

if err != nil {
  return err
}

Goのエラー処理は本当に冗長なのでしょうか?これについてはGoの開発者の一人であるRob Pike氏が記事 Errors are values の中で反論しています。
こちらのブログ記事よりコード例を引用して説明します。

// https://go.dev/blog/errors-are-values
_, err = fd.Write(p0[a:b])
if err != nil {
    return err
}
_, err = fd.Write(p1[c:d])
if err != nil {
    return err
}
_, err = fd.Write(p2[e:f])
if err != nil {
    return err
}

このようなコードは冗長に見えますが、これは例外スロー型のエラー処理をそのままGoに書き直したような形になっており、Goらしいコードではありません。
Goの「エラーは値である」という性質に着目すると、以下のように書くことができます。

// https://go.dev/blog/errors-are-values
type errWriter struct {
    w   io.Writer
    err error
}

func (ew *errWriter) write(buf []byte) {
    if ew.err != nil {
        return
    }
    _, ew.err = ew.w.Write(buf)
}

ew := &errWriter{w: fd}
ew.write(p0[a:b])
ew.write(p1[c:d])
ew.write(p2[e:f])
if ew.err != nil {
    return ew.err
}

このコードを簡単に説明すると、エラーが値である性質を利用してエラーをすぐに処理するのではなく、構造体の変数として保持しておいて、すべての処理が終わってからエラーを処理しています。
このようにエラーが起きたらその場で処理するのではなく、変数として保持しておいて最後にまとめて処理するようにすれば、冗長なエラー処理コードを書くことなく簡潔に処理を書くことができます。

もちろんこのコードにも欠点があります。処理が最後に到達するまで途中でエラーが起こったことを検知できません。このような詳細なエラー処理をする場合にはもっと複雑な方法をとる必要がありますが、多くの場合は最後にエラー処理を行うことで十分です。

また、エラーを値として扱う典型的な例として、errors.Joinを使った以下のようなコードも好例でしょう。

var errs []error

for _, item := range items {
  if err := validate(item); err != nil {
    errs = append(errs, err)
  }
}

return errors.Join(errs...)

上記のコードではエラーをまずは配列に格納し、最後にJoinして返すという処理を行なっています。
複数のエラーが起きた際にまとめて返したいバリデーションエラーなどで有用です。

まとめ

Goでは他の言語でよく採用されている例外スロー型のエラー処理をあえて採用せず、多値返却によりエラーを返す簡潔な方法を採用しています。さらに、Goにおいてはエラーは単なる値のため、変数に格納することによってより柔軟なエラー処理を書くことができます。

いままでなんとなくで使ってきたGoのエラー処理ですが、LT会を通じていろんなブログ記事を漁り、その決定の理由や利点を知ることができました。この記事が読者の方の参考になれば幸いです。

参考文献

https://go.dev/doc/faq#exceptions
https://go.dev/blog/error-handling-and-go
https://go.dev/blog/errors-are-values
https://dave.cheney.net/2012/01/18/why-go-gets-exceptions-right
https://qiita.com/Maki-Daisuke/items/80cbc26ca43cca3de4e4

GitHubで編集を提案
TOKIUMプロダクトチーム テックブログ

Discussion