Chapter 02

まずはキホンから

Spiegel
Spiegel
2020.12.10に更新

もはや「例外」は Legacy

私は C/C++ や Java などから来た人間なので Go を始めたばかりの頃は「例外(Exception)」のないエラーハンドリングに面食らったものだが,今ではすっかり慣れてしまった。

今年(2020年)になって Rust の勉強を少しだけ始めたが,改めて分かった。

💡 もはや「例外」は Legacy だ! 💡

たとえば Rust は列挙型と match 式を組み合わせてエラーの抽出と評価を行うことでエラー・ハンドリングを実装できる。

fn main() {
    let n = match parse_string("-1") {
        Ok(x) => x,
        Err(e) => panic!(e), //Output: thread 'main' panicked at 'Box<Any>', src/main.rs:8:19
    };
    println!("{}", n); //do not reach
}

実にスマート!

「例外」の問題は “goto” と同じ

「例外」の問題は “goto” と同じと言える[1]

「例外」では,あるオブジェクトに関する記述が少なくとも2つ(たとえば try と catch)下手をすると3つ以上のスコープに分割されてしまう。しかもオブジェクトの状態ごと大域脱出するため,その状態(の可能性)の後始末をスコープ間で漏れなく矛盾なく記述しきらなければならない。この一連に不備があれば,バグやリークやその他の脆弱性のもとになる。考えるだけで面倒である。

一方, Go におけるエラーの扱いは,とにかく「シンプル」の一言に尽きる。以降から具体的に見てみよう。

error 型

まずエラーを扱う組込み interface 型の error は以下のように定義される。

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}

つまり,文字列を返す Error() メソッドを持つ型であれば全て error 型として扱うことができる。汎化にもほどがある(笑)

エラーを含む処理の一連

しかも Go ではエラーを普通に関数の返り値として返す。

file, err := os.Open(filename)

他に返すべき値があれば組(tuple)にして最後の要素に error 型のインスタンスを配置するのが慣例らしい。

検出したエラーは(投げ出さないでw)その場で評価してしまう。

file, err := os.Open(filename)
if err != nil {
    fmt.Fprintln(os.Stderr, err)
    return
}

また if 構文は内部に構文を含めることもできるので

if err := file.Close(); err != nil {
    fmt.Fprintln(os.Stderr, err)
    return
}

てな感じに書くこともできる[2]

Open と Close のように一連の処理が要求される場合は defer 構文で後始末を先に書いてしまう。 Defer 構文で指定された処理は,関数スコープの最後(具体的には return の直後)に実行されることが保証されているので[3] その後の処理で安心して return できる。

一連の処理をまとめるとこんな感じ。

file, err := os.Open(filename)
if err != nil {
    fmt.Fprintln(os.Stderr, err)
    return
}
defer func() {
    if err := file.Close(); err != nil {
        fmt.Fprintln(os.Stderr, err)
    }
}()

これが Go の基本的な書き方。特徴的なのは,あるオブジェクトに纏わる処理をセットで記述できる点である。とても文芸的なコードであるとも言える[4]

一番簡単なエラー型

一番簡単なエラー型は errors 標準パッケージで定義されている。

errors/errors.go
// New returns an error that formats as the given text.
// Each call to New returns a distinct error value even if the text is identical.
func New(text string) error {
    return &errorString{text}
}

// errorString is a trivial implementation of error.
type errorString struct {
    s string
}

func (e *errorString) Error() string {
    return e.s
}

これを使って

internal/oserror/errors.go
var (
    ErrInvalid    = errors.New("invalid argument")
    ErrPermission = errors.New("permission denied")
    ErrExist      = errors.New("file already exists")
    ErrNotExist   = errors.New("file does not exist")
    ErrClosed     = errors.New("file already closed")
)

などとエラー・インスタンスを定義できるわけだ。また fmt.Errorf() 関数を使って

package main

import (
    "fmt"
)

func main() {
    const name, id = "bueller", 17
    err := fmt.Errorf("user %q (id %d) not found", name, id)
    fmt.Println(err.Error())
}

のように,その場で作ったエラーメッセージをエラー・インスタンスとして扱うこともできる。

脚注
  1. ちなみに Gogoto や ラベル付きの break, continue飛び先に制約があり,どこにでもジャンプできるわけではない。 ↩︎

  2. if 構文内で宣言(:=)された変数は,そのスコープでのみ有効となる。同名変数の shadowing に注意。 ↩︎

  3. os.Exit() 関数等で強制終了した場合は defer 構文で指定した処理は実行されない。 ↩︎

  4. 個人的な意見で恐縮だが,これからのコードは「文芸的」であることが必要条件だと思う。何故ならエンジニアにとって最も信頼できる「設計書」は(動いている)コードだからだ。コードをひとりで考えてひとりで書いてひとりで使ってひとりでメンテナンスするなら(本人さえ理解していれば)文芸的である必要はないかもしれない。が,実用的なコードでそんな状況はもはやありえない。コードにおいても暗黙知をできるだけ排除していくことが重要である。 ↩︎