Skip to content

Instantly share code, notes, and snippets.

@podhmo
Created June 5, 2025 05:09
Show Gist options
  • Save podhmo/2f8635c2aece9b39e889beadac0f4e3a to your computer and use it in GitHub Desktop.
Save podhmo/2f8635c2aece9b39e889beadac0f4e3a to your computer and use it in GitHub Desktop.
ハッピーパスに集中したいrustとgoのエラーハンドリングの冗長さについて

対話履歴

ユーザー: あなたは主にrustを使ってる開発者です。Haskellもわかります。後のメッセージに答えてください。

https://go.dev/blog/error-syntax

感想として思ったのは以下のような感じ。

エラー対応のマクロや構文で対応できるのは一回きりのeitherとかmaybeまでな感じはしていて、失敗値のメッセージをラップするような操作を加えると(つまり記事の後に出てくるfmt.Errorfの%wを使った記述のばあいはあまり関心事の分離といあう点では大差がなく)結構悲しい感じにはなるんだよな。

つまりエラーの発生源の話ではなく伝搬と装飾としての構文のとき結局似たような見た目のコードにならない?


AI: はい、その感覚はよくわかります。Rustの?演算子やHaskellのdo記法に慣れていると、エラー処理の冗長性については特に敏感になりますよね。

まず、ご提示いただいたGoのブログ記事を拝見します。 AI: はい、その感覚はよくわかります。Rustの?演算子やHaskellのdo記法に慣れていると、Goの冗長なエラー処理は特に気になりますよね。

ご提示いただいたGoのブログ記事、拝見しました。 まさに長年議論されてきたGoのエラー処理の冗長性と、それに対する構文変更の試みがまとめられていますね。 結論として、Goチームは当面、エラー処理のための構文変更を追求しない方針とのことです。

おっしゃる通り、エラー対応のマクロや構文でスッキリするのは、エラー値をそのまま右から左へ流すような、比較的単純なケースに限られることが多いと感じます。記事の中でも、fmt.Errorf%w を使ってエラーをラップする例が出てきますが、こういった「装飾」が加わると、結局のところエラーハンドリングの記述量自体はそこまで劇的に減らせない、あるいは本質的な複雑さは変わらない、というのは多くの言語で共通する部分かもしれません。

Goのブログ記事で触れられている check/handletry、あるいはRust風の ? 演算子の提案も、主にエラーの早期リターンを簡潔に書くことに焦点が当たっていたように見えます。 これらはエラーの「伝搬」をスムーズにはしますが、エラーにコンテキスト情報を付加したり、型を変えたりといった「装飾」の部分では、結局 if err != nil ブロックの中で一手間加えるのと大差ないコードになる可能性はありますね。

Rustでは map_errcontext のようなメソッドチェーンでエラーのラップを行うことができ、? と組み合わせることで比較的スッキリ書ける印象がありますが、それでもエラーの変換処理自体は記述する必要があります。Haskellの MonadError における catchErrorthrowError、あるいは withExceptT のような関数を使ったエラー処理も、do記法の中で行うと、エラーの種類に応じた処理や情報の付加を記述することになります。

Goの現状の if err != nil { return fmt.Errorf("...: %w", err) } というパターンは、冗長ではあるものの、エラー処理が明示的であるという見方もできます。記事でも、デバッグのしやすさや、エラーが実際に「処理」される場合(単に返されるだけでなく、情報が付加されるなど)には冗長性が薄れるといった点が指摘されています。

結局のところ、エラー処理の構文は言語の設計思想やトレードオフが大きく影響する部分で、Goは現状の明示的なエラー処理を維持しつつ、標準ライブラリの拡充やIDEのサポートなどで対応していくという方向性を選んだということなのでしょう。 おっしゃるように、エラーの「装飾」という観点では、どの言語でもある程度の記述は避けられないものの、その表現方法や簡潔さにはそれぞれの言語の特色が出ると言えそうですね。

(返答終了理由: STOP)


ユーザー: 実際、エラーの発生源のコードではなく装飾を付けたコードはどんな感じになりますか?

スタックトレースを付けたい場合も都度装飾する感じになりますか?


AI: はい、エラーの「装飾」というのは、エラーが発生した箇所から伝搬していく過程で、より詳細なコンテキスト情報や、場合によってはスタックトレースなどを付加していくことを指しますね。Goの現状のやり方で、具体的なコード例をいくつか示します。

1. fmt.Errorf%w を使った基本的なエラーのラップ(装飾)

これは一番シンプルな装飾で、エラーメッセージにコンテキスト情報を追加しつつ、元のエラーをラップするものです。

package main

import (
	"fmt"
	"os"
)

func readFileAndProcess(filename string) error {
	data, err := os.ReadFile(filename)
	if err != nil {
		// エラー発生源に近いところで、具体的な操作とファイル名を付加してラップ
		return fmt.Errorf("failed to read file %s: %w", filename, err)
	}

	// dataを使った何らかの処理
	if len(data) == 0 {
		// 別の種類のアプリケーションエラー
		return fmt.Errorf("file %s is empty, nothing to process", filename)
	}
	fmt.Printf("Successfully processed file %s\n", filename)
	return nil
}

func main() {
	err := readFileAndProcess("non_existent_file.txt")
	if err != nil {
		fmt.Printf("Error: %v\n", err) // この時点でラップされたメッセージが表示される
		// errors.Is や errors.As を使って元のエラーを検査することも可能
		if os.IsNotExist(err) {
			fmt.Println("Specific error: File does not exist.")
		}
	}

	err = readFileAndProcess("empty_file.txt") // 空ファイルを作成して試す場合
	if err != nil {
		fmt.Printf("Error: %v\n", err)
	}
}

上記の readFileAndProcess 関数内で os.ReadFile がエラーを返した場合、fmt.Errorf を使って「どのファイルの読み込みに失敗したか」というコンテキスト情報を追加しています。呼び出し元では、このラップされたエラーを受け取ります。

2. カスタムエラー型を使った装飾

より構造化された情報をエラーに持たせたい場合、独自のエラー型を定義することがあります。

package main

import (
	"errors"
	"fmt"
	"os"
)

// AppError はアプリケーション固有のエラー情報を保持するカスタムエラー型
type AppError struct {
	OriginalError error  // ラップする元のエラー
	Message       string // 追加のメッセージ
	Context       map[string]string // 追加のコンテキスト情報
}

func (e *AppError) Error() string {
	return fmt.Sprintf("%s (context: %v): %v", e.Message, e.Context, e.OriginalError)
}

// Unwrap を実装することで、errors.Is や errors.As が機能する
func (e *AppError) Unwrap() error {
	return e.OriginalError
}

func performComplexOperation(itemID string) error {
	// 何らかのDB操作などをシミュレート
	_, err := os.Stat("some_dependency_file_" + itemID) // 仮の依存操作
	if err != nil {
		return &AppError{
			OriginalError: err,
			Message:       fmt.Sprintf("failed to access dependency for item %s", itemID),
			Context:       map[string]string{"itemID": itemID, "operation": "dependency_check"},
		}
	}
	// ... さらなる処理
	return nil
}

func main() {
	err := performComplexOperation("item123")
	if err != nil {
		fmt.Printf("Operation failed: %v\n", err)

		var appErr *AppError
		if errors.As(err, &appErr) {
			fmt.Printf("  - AppError Message: %s\n", appErr.Message)
			fmt.Printf("  - Context: %v\n", appErr.Context)
			if appErr.OriginalError != nil {
				fmt.Printf("  - Original Error: %v\n", appErr.OriginalError)
			}
		}
	}
}

この例では、AppError という構造体を定義し、元のエラー、追加メッセージ、そしてキーバリュー形式のコンテキスト情報を保持できるようにしています。これにより、エラーハンドリング側でより詳細な情報を取得して処理を分岐させたり、ログに出力したりできます。

3. スタックトレースの付加

Goの標準ライブラリには、エラーにスタックトレースを自動で付加する直接的な機能は %w には含まれていません。そのため、スタックトレースをエラーに含めたい場合は、いくつかの方法があります。

  • runtime.Callers を使う: エラーが発生した箇所で runtime.Callers を呼び出してスタック情報を取得し、それをエラーメッセージに含めたり、カスタムエラー型に保持させたりします。これは手動での実装になります。
  • サードパーティライブラリの利用: pkg/errors (現在はアーカイブされていますが、思想は多くのフォークや後継ライブラリに引き継がれています) や、github.com/cockroachdb/errorsemperror.dev/errors のようなライブラリは、エラーをラップする際に自動的にスタックトレースをキャプチャしてくれる機能を提供しています。

もし標準ライブラリの範囲でやるなら、エラーをラップする際に手動でスタック情報を取得して付加するイメージです。

package main

import (
	"fmt"
	"runtime"
	"strings"
)

type ErrorWithStackTrace struct {
	OriginalError error
	Message       string
	StackTrace    string
}

func (e *ErrorWithStackTrace) Error() string {
	return fmt.Sprintf("%s: %v\nStack trace:\n%s", e.Message, e.OriginalError, e.StackTrace)
}

func (e *ErrorWithStackTrace) Unwrap() error {
	return e.OriginalError
}

func newErrorWithStackTrace(originalErr error, message string) *ErrorWithStackTrace {
	buf := make([]uintptr, 32) // ある程度の深さのスタックを保持できるバッファ
	n := runtime.Callers(2, buf) // 2は newErrorWithStackTrace とその呼び出し元をスキップするため
	frames := runtime.CallersFrames(buf[:n])

	var sb strings.Builder
	for {
		frame, more := frames.Next()
		fmt.Fprintf(&sb, "- %s:%d %s\n", frame.File, frame.Line, frame.Function)
		if !more {
			break
		}
	}

	return &ErrorWithStackTrace{
		OriginalError: originalErr,
		Message:       message,
		StackTrace:    sb.String(),
	}
}

func criticalOperation() error {
	// 何らかの失敗をシミュレート
	err := errors.New("something went wrong at the lowest level")
	// スタックトレースを付けてラップ
	return newErrorWithStackTrace(err, "critical operation failed")
}

func main() {
	err := criticalOperation()
	if err != nil {
		fmt.Printf("Error details:\n%v\n", err)

		// errors.Asでスタックトレース付きエラーか確認も可能
		var ews *ErrorWithStackTrace
		if errors.As(err, &ews) {
			// fmt.Println("Custom stack trace was captured.")
		}
	}
}

この newErrorWithStackTrace のような関数をエラーが発生する可能性のある各所で呼び出す、あるいはエラーをラップする共通関数として用意することになります。これが「都度装飾する感じ」という部分に該当します。

Rustの anyhow::Erroreyre::Report のように、? を通るだけで自動的にバックトレースが付与されるような手軽さと比較すると、Goでスタックトレースを扱うのは一手間かかる印象があるかもしれません。Goでは、エラーハンドリングの明示性を重視し、スタックトレースのような情報は必要な場合に開発者が意識して付加するというスタンスが基本にあると言えます。

これらの例からわかるように、エラーの「装飾」は、単にメッセージを追加するだけでなく、エラーに関する構造化された情報を付加したり、デバッグに役立つスタックトレースを含めたりすることを意味します。そして、Goではこれらの装飾を、エラーをラップする各ポイントで開発者が明示的に行うことが多いです。

(返答終了理由: STOP)


ユーザー: ですです。似たような装飾をrustのコードでrustらしいやり方でやってください。


AI: はい、承知いたしました。Goの例で見たようなエラーの「装飾」(コンテキスト情報の付加、カスタムエラー型、スタックトレースの付加)を、Rustらしい慣用的な方法で書いてみます。

Rustでは、エラー処理に関してエコシステムが充実しており、特に anyhow クレートと thiserror クレートが広く使われています。これらを利用した例を示します。

  • anyhow: アプリケーションレベルでのエラーハンドリングに適しており、任意のエラー型を anyhow::Error という統一された型にラップし、コンテキストの追加やバックトレースの取得を容易にします。
  • thiserror: ライブラリや、より具体的なエラー型を定義したい場合に便利です。deriveマクロを使って簡単にカスタムエラー型を定義できます。

まず、Cargo.toml に以下の依存関係を追加してください。

[dependencies]
anyhow = "1.0"
thiserror = "1.0"

次に、main.rs のコード例です。

// main.rs

use std::fs;
use std::io;

// 1. anyhow を使った基本的なエラーラップとコンテキスト付加、バックトレース
// anyhow::Error はデフォルトでバックトレースをキャプチャします
// (RUST_BACKTRACE=1 もしくは RUST_BACKTRACE=full 環境変数を設定して実行すると表示されます)
mod anyhow_example {
    use anyhow::{anyhow, bail, Context, Result}; // anyhow::Result は anyhow::Error をエラー型として使います

    pub fn read_file_and_process(filename: &str) -> Result<String> {
        let data = fs::read_to_string(filename)
            // `with_context` を使ってエラーにコンテキスト情報を付加します。
            // これはGoの fmt.Errorf("...: %w", err) のような役割を果たします。
            .with_context(|| format!("Failed to read file '{}'", filename))?;

        if data.is_empty() {
            // `bail!` マクロは、カスタムメッセージで新しい anyhow::Error を生成し、即座にリターンします。
            bail!("File '{}' is empty, nothing to process", filename);
            // または `anyhow!` マクロを使ってエラーを生成することもできます:
            // return Err(anyhow!("File '{}' is empty, nothing to process", filename));
        }

        println!("(Anyhow) Successfully processed file '{}'", filename);
        Ok(data)
    }

    // 別の種類の操作でエラーが発生する例
    pub fn complex_operation(item_id: &str) -> Result<()> {
        perform_sub_operation(item_id)
            .with_context(|| format!("Complex operation failed for item_id '{}'", item_id))?;
        Ok(())
    }

    fn perform_sub_operation(item_id: &str) -> Result<()> {
        if item_id == "trigger_error" {
            // 何らかの具体的なエラー
            Err(anyhow!("Sub-operation failed due to invalid state"))
        } else {
            Ok(())
        }
    }
}

// 2. thiserror を使ったカスタムエラー型による装飾
mod thiserror_example {
    use super::*; // fs, io などを親モジュールからインポート
    use thiserror::Error;

    // カスタムエラー型を `thiserror` を使って定義します。
    // これがGoのカスタムエラー構造体 AppError に相当します。
    #[derive(Error, Debug)]
    pub enum AppError {
        // `#[error(...)]` でDisplayトレイトの実装が自動生成されます。
        // `#[source]` アトリビュートで、ラップされた元のエラー (cause) を示します。
        #[error("Failed to read file '{filename}': {source}")]
        FileReadError {
            filename: String,
            #[source] // `std::error::Error` の `source()` メソッドでアクセス可能
            source: io::Error,
        },

        #[error("File '{filename}' is empty and cannot be processed")]
        EmptyFileError { filename: String },

        #[error("Configuration error for key '{key}': {message}")]
        ConfigError {
            key: String,
            message: String,
            // 必要であれば、ここにさらに構造化された情報を追加できます。
            // (例: `offending_value: String`)
        },

        // 別のエラー型からの変換を #[from] で自動実装することも可能です。
        // 例えば、serde_json::Error をラップする場合:
        // #[error("JSON parsing error: {0}")]
        // JsonParseError(#[from] serde_json::Error),
    }

    // このモジュール内でのResult型エイリアスを定義しておくと便利です。
    pub type Result<T> = std::result::Result<T, AppError>;

    pub fn load_item_data(filename: &str) -> Result<String> {
        let data = fs::read_to_string(filename)
            // `map_err` を使って `io::Error` をカスタムの `AppError::FileReadError` に変換(装飾)します。
            .map_err(|io_err| AppError::FileReadError {
                filename: filename.to_string(),
                source: io_err,
            })?;

        if data.is_empty() {
            return Err(AppError::EmptyFileError {
                filename: filename.to_string(),
            });
        }
        println!("(ThisError) Successfully loaded item data from '{}'", filename);
        Ok(data)
    }

    pub fn get_configuration(key: &str) -> Result<String> {
        if key == "missing_secret" {
            Err(AppError::ConfigError {
                key: key.to_string(),
                message: "The secret key is not found in the environment".to_string(),
            })
        } else {
            Ok(format!("config_value_for_{}", key))
        }
    }
}

fn main() {
    println!("--- anyhow example ---");

    // 存在しないファイル (anyhow)
    match anyhow_example::read_file_and_process("non_existent_file.txt") {
        Ok(_) => {}
        Err(e) => {
            // {:?} で出力すると、エラーの連鎖とバックトレース(RUST_BACKTRACE=1 設定時)が表示されます。
            eprintln!("Error: {:?}", e);
            // 通常の表示 (Display トレイト)
            // eprintln!("Error: {}", e);
        }
    }

    // 空ファイルを作成してテスト (anyhow)
    let empty_filename_anyhow = "empty_file_anyhow.txt";
    fs::write(empty_filename_anyhow, "").expect("Failed to create empty file for anyhow test");
    match anyhow_example::read_file_and_process(empty_filename_anyhow) {
        Ok(_) => {}
        Err(e) => {
            eprintln!("Error: {:?}", e);
        }
    }
    fs::remove_file(empty_filename_anyhow).ok();

    // 別の操作でのエラー (anyhow)
    match anyhow_example::complex_operation("trigger_error") {
        Ok(_) => {}
        Err(e) => {
            eprintln!("Error: {:?}", e); // ここでもエラーチェーンとバックトレースが見られます
        }
    }


    println!("\n--- thiserror example ---");

    // 存在しないファイル (thiserror)
    match thiserror_example::load_item_data("non_existent_item_data.txt") {
        Ok(_) => {}
        Err(e) => {
            eprintln!("Error: {}", e); // Display トレイトによる整形されたメッセージ
            // e.source() を使って原因となったエラーを辿ることもできます。
            if let Some(source) = e.source() {
                eprintln!("  Caused by: {}", source);
            }
            // eprintln!("Error (Debug): {:?}", e); // Debug トレイトによる詳細情報
        }
    }

    // 空ファイルを作成してテスト (thiserror)
    let empty_filename_thiserror = "empty_file_thiserror.txt";
    fs::write(empty_filename_thiserror, "").expect("Failed to create empty file for thiserror test");
    match thiserror_example::load_item_data(empty_filename_thiserror) {
        Ok(_) => {}
        Err(e) => {
            eprintln!("Error: {}", e);
        }
    }
    fs::remove_file(empty_filename_thiserror).ok();

    // 設定エラーの例 (thiserror)
    match thiserror_example::get_configuration("missing_secret") {
        Ok(val) => println!("Config value: {}", val),
        Err(e) => {
            eprintln!("Error: {}", e);
            if let thiserror_example::AppError::ConfigError { key, message } = e {
                eprintln!("  Details: Key='{}', Message='{}'", key, message);
            }
        }
    }

    println!("\nTo see backtraces with anyhow, run with `RUST_BACKTRACE=1 cargo run` (or `RUST_BACKTRACE=full`)");
}

解説:

  1. anyhow を使った装飾:

    • anyhow::Result<T>Result<T, anyhow::Error> のエイリアスです。
    • .context("message").with_context(|| format!("message with {}", variable)) を使うことで、std::error::Error を実装する任意のエラーに簡単にコンテキストメッセージを付加(装飾)できます。これは ? 演算子と非常に相性が良いです。
    • anyhow::Error は、内部的に元のエラーと追加されたコンテキスト、そしてバックトレース(キャプチャが有効な場合)を保持します。
    • bail!("message")Err(anyhow!("message")) で、文字列から直接 anyhow::Error を生成できます。
    • スタックトレース (バックトレース): anyhow::Error は、エラーが生成された時点でバックトレースをキャプチャしようとします。これを表示するには、プログラムを RUST_BACKTRACE=1 cargo runRUST_BACKTRACE=full cargo run のように環境変数を設定して実行します。{:?} フォーマットでエラーを出力すると、エラーチェーンと共にバックトレースが表示されます。
  2. thiserror を使った装飾:

    • #[derive(Error, Debug)]#[error("...")] アトリビュートを使って、ボイラープレートコードなしにカスタムエラー型(通常は enum)を定義できます。
    • #[error("...")]Display トレイトの実装を提供し、エラーメッセージのフォーマットを指定できます。フィールド値をメッセージに含めることも可能です(例: filename)。
    • #[source] アトリビュートは、std::error::Error トレイトの source() メソッドが返す「原因となったエラー」を指定します。これにより、エラーチェーンを辿ることができます。
    • map_err() を使って、ライブラリなどから返されるエラー(例: std::io::Error)を、定義したカスタムエラー型(例: AppError::FileReadError)に変換し、必要な情報(filename など)を付加します。これが構造化された装飾の一例です。
    • thiserror で定義したエラー型は、より具体的なエラーの種類を表現し、パターンマッチで詳細なハンドリングを行うのに適しています。バックトレースを直接含める機能は thiserror 自体にはありませんが、anyhow::Error にラップすることでバックトレースを得たり、手動で std::backtrace::Backtrace 型のフィールドをエラー型に追加することも可能です(ただし、anyhow を使う方が一般的です)。

これらの方法は、Goの fmt.Errorf("%w", ...) やカスタムエラー型+手動スタックトレース収集と比べて、Rustの型システムやマクロの力を活用して、より型安全かつ表現力豊かにエラーの装飾を行うアプローチと言えます。特に anyhow は、アプリケーションのエッジに近い部分でのエラー集約と報告に非常に便利です。

(返答終了理由: STOP)


ユーザー: つまりスタックトレースは発生箇所で自動付与をビルド時や起動時のオプションで切り替え、そしてエラーの装飾はメッセージ付与のみという理解で良いですか?

どうきれいになるか教えてください。装飾がない場合はシュッときれいになることは分かるのです。


AI: はい、ほぼその理解で良いですが、少し補足させてください。

スタックトレース(バックトレース)について

  • 自動付与のタイミング: Rust (特に anyhow を使った場合) のバックトレースは、ビルド時ではなく、実行時にエラーが発生した箇所でキャプチャされます。そして、その表示は環境変数 (RUST_BACKTRACE=1RUST_BACKTRACE=full) によって制御されます。つまり、コード自体に変更を加えなくても、実行時のオプションでバックトレースの有無や詳細度を切り替えられるということです。
  • anyhow::Error の役割: anyhow::Error は、エラーが生成されたり、?演算子で伝播する際に、内部的にバックトレースを(条件が満たされれば)保持します。

エラーの装飾について

「メッセージ付与のみ」というよりは、「コンテキスト情報の付加」が非常に簡潔に行える、と捉えるのが良いでしょう。このコンテキスト情報には、人間が読むためのメッセージだけでなく、エラーが発生した際の状況を示す値(例えばどのファイルで失敗したか、どのIDの処理で問題があったかなど)も含まれます。

どう「きれい」になるか?

Goの例と比較して、Rust (特にanyhowthiserrorを使った場合) のエラー装飾がどのように「きれい」または「シュッとする」と感じられるか、いくつかのポイントがあります。

  1. ?演算子とのシームレスな統合: Goでは、エラーをチェックし、コンテキストを付加してリターンする、という一連の操作が数行にわたることが多いです。

    • Goの典型的な装飾:

      result, err := someFunction()
      if err != nil {
          return fmt.Errorf("failed during someFunction for item %s: %w", itemID, err)
      }
      // result を使う
    • Rust (anyhow) の場合:

      // anyhow::Result を返す関数内
      use anyhow::Context;
      let result = some_function()
          .with_context(|| format!("failed during some_function for item {}", item_id))?;
      // result を使う

      if err != nil のブロックが不要になり、エラー処理とコンテキスト付加が1行のメソッドチェーン (.with_context(...)) と ? 演算子で完結します。これにより、本筋のロジックがエラーハンドリングのコードで中断されにくくなります。

  2. エラーの種類に応じた構造化された情報の付加 (with thiserror): thiserror を使うと、エラーの種類ごとに独自の型を定義し、その型にエラーに関する構造化されたデータを持たせることができます。

    • Goのカスタムエラー (再掲):

      type AppError struct {
          OriginalError error
          Message       string
          Context       map[string]string
      }
      // ... 手動でフィールドに値を設定 ...
      return &AppError{
          OriginalError: err,
          Message:       fmt.Sprintf("failed to access dependency for item %s", itemID),
          Context:       map[string]string{"itemID": itemID, "operation": "dependency_check"},
      }
    • Rust (thiserror) の場合:

      #[derive(Error, Debug)]
      pub enum MyError {
          #[error("Dependency check failed for item '{item_id}' during operation '{operation}': {source}")]
          DependencyError {
              item_id: String,
              operation: String,
              #[source]
              source: io::Error, // 例としてio::Error
          },
          // 他のエラーバリアント
      }
      // ...
      return Err(MyError::DependencyError {
          item_id: item_id.to_string(),
          operation: "dependency_check".to_string(),
          source: err, // io::Error が err に入っていると仮定
      });

      thiserror のマクロが DisplayError トレイトの実装を自動生成してくれるため、エラーの定義が宣言的になります。また、エラーの種類が型として明確になるため、呼び出し側でのパターンマッチによるハンドリングがしやすくなります。fmt.Errorf のような文字列フォーマットに頼る部分が減り、型安全性が向上します。

  3. ボイラープレートの削減: if err != nil の繰り返しや、エラーメッセージを毎回 fmt.Errorf で組み立てる手間が、?.context() / .with_context() / map_err() の組み合わせで大幅に削減されます。

    装飾がない場合(エラーをそのまま上に投げるだけ)は、Goでも return nil, err 、Rustでも some_function()? とシンプルですが、装飾が加わった瞬間のコードの増加量と煩雑さが、Rustの方が抑えられる傾向にあるということです。

  4. バックトレースの容易さ: Goでバックトレースを付加するには、サードパーティライブラリを使うか、手動で runtime.Callers などを使って収集・整形するコードを書く必要があります。anyhow を使えば、この部分がほぼ自動化され、環境変数で制御できるため、開発者の負担が軽減されます。

まとめると、Rustのエラー装飾は、

  • ?演算子とエラー処理用のメソッド (context, map_errなど) がうまく連携し、エラー伝搬とコンテキスト付加をインラインで簡潔に記述できる点。
  • thiserror を使うことで、カスタムエラー型の定義と、それに伴う定型的な実装 (Display, Errorトレイトなど) が劇的に簡単になる点。
  • anyhow を使うことで、バックトレースの付加が容易になる点。

これらが、Goの if err != nil { ... }fmt.Errorf を中心としたエラーハンドリングと比較して、「シュッときれいになる」と感じられる主な理由です。コードの見た目がスッキリし、本質的なロジックに集中しやすくなります。

(返答終了理由: STOP)


ユーザー: うーん、装飾が加わったあとの見た目がgoとrustであまり変わらないと思ってしまうのです。

これは早期リターンのような見方でコードを読んでしまってるからなのでしょうか?ハッピーパスに集中する気持ちで見ると煩雑ではないと感じるのでしょうか?


AI: おっしゃる通り、その感覚は非常に的を射ていると思います。Goの if err != nil { return ... } も、Rustの ? も、基本的にはエラーの早期リターンを扱う構文です。そして、エラーに情報を付加する(装飾する)という行為は、どちらの言語でも何らかのコード記述を伴います。そのため、エラーが発生した場合の処理フローに着目すると、装飾が加わったコードは、結局のところ「エラー用の処理を書いている」という点で見た目の印象が大きく変わらないと感じられるのは自然なことです。

では、なぜRustのコミュニティ(特にanyhowthiserrorのユーザー)は、これを「きれい」とか「改善」と感じるのでしょうか? それは、おそらく「ハッピーパスに集中する気持ち」でコードを読むときの視点と、エラー処理の「質」や「表現力」に対する期待値の違いから来ているのだと思います。

ハッピーパスに集中して見たときの「きれいさ」

  1. 視線の流れとノイズの少なさ:

    • Goの場合:

      value, err := possiblyFails()
      if err != nil {
          // 数行にわたるエラー処理(ログ、メトリクス、そしてラップしてリターン)
          // 例: log.Printf("Error during possiblyFails for ID %s: %v", itemID, err)
          //      metrics.Increment("failures,operation=possiblyFails")
          return fmt.Errorf("context for %s: %w", itemID, err)
      }
      // ここからハッピーパス
      anotherValue, err := nextStep(value)
      if err != nil {
          // 同様のエラー処理ブロック
          return fmt.Errorf("context for nextStep with %v: %w", value, err)
      }
      // ...

      エラー処理のブロックが、ハッピーパスのコードの間に明確に割り込んできます。インデントも深くなることがあります。

    • Rust (anyhow を使用) の場合:

      // anyhow::Result を返す関数内
      let value = possibly_fails()
          // .inspect_err(|e| log::error!("Error during possibly_fails for ID {}: {:?}", item_id, e)) // 必要ならログ
          // .inspect_err(|_| metrics::increment!("failures,operation=possibly_fails"))             // 必要ならメトリクス
          .with_context(|| format!("context for {}", item_id))?; // コンテキスト付加と早期リターン
      // ここからハッピーパス
      let another_value = next_step(value)
          .with_context(|| format!("context for next_step with {:?}", value))?;
      // ...

      エラーに関連する処理(ログ、メトリクス、コンテキスト付加)が、?演算子に至るまでのメソッドチェーンの中に比較的コンパクトに収まることが多いです。ifブロックがなくなるため、視覚的にハッピーパスのコードが直線的に連なっているように見えやすくなります。「もしエラーだったら…」という分岐がコードの表面から隠蔽される(?に集約される)ため、ハッピーパスのロジックを追いやすくなるという感覚です。

  2. インラインでのコンテキスト付加: Goの fmt.Errorf は新しいエラー値を生成し、それをリターンする形です。Rustの contextwith_context は、既存のエラー値(Resultの中のErr)に対して操作を行い、そのまま ? で処理できるため、より「その場で」情報を追加している感覚になります。

「装飾」の質と表現力

「見た目があまり変わらない」と感じる部分の奥には、エラー情報の「質」と「扱いやすさ」の違いがあります。

  • Goの fmt.Errorf: 主に文字列ベースでコンテキストを付加します。元のエラーをラップする機能 (%w) は強力ですが、付加される情報は基本的にはメッセージ文字列です。より構造化された情報を追加したい場合は、カスタムエラー型を定義し、手動でフィールドを設定する必要があります。

  • Rust (thiserroranyhow):

    • anyhow::Context: 文字列ベースのコンテキスト付加はGoと似ていますが、バックトレースの自動付与という大きなメリットがあります。
    • thiserror: カスタムエラー型を非常に簡単に、かつ表現力豊かに定義できます。#[error("...")] でエラーメッセージのテンプレートを指定したり、エラーの原因 (#[source]) や固有のフィールドを型として持つことができます。
      // thiserrorで定義したエラー
      Err(DatabaseError::ConnectionFailed { host: "localhost".into() })?;
      // これをanyhowでラップしてさらにコンテキストを付加
      // .context("Failed to initialize application services")?;
      このように、エラーの種類に応じた構造化されたデータをエラー自体に持たせることが容易です。これは、単にメッセージを装飾する以上に、エラーハンドリングのロジックで役立ちます(例:特定のエラーフィールドに基づいて処理を分岐する)。

まとめると

「装飾が加わった後の見た目がGoとRustであまり変わらない」というのは、エラー処理のコードブロックが依然として存在し、エラー情報を付加する記述が必要であるという点ではその通りです。

しかし、Rust(特にエコシステムの力を借りた場合)の利点は、

  1. ハッピーパスの可読性向上: エラー処理の記述がよりインライン化・メソッドチェーン化されることで、本筋のコードの流れが分断されにくくなる。
  2. エラー情報の構造化と型安全性: thiserror を使うことで、エラーに意味のある型とデータを付与しやすくなり、コンパイル時のチェックの恩恵も受けられる。
  3. バックトレースの容易さ: anyhow を使えば、追加のコーディングなしにリッチなバックトレースが得られる(実行時オプションで制御)。
  4. ボイラープレートの削減: if err != nil の繰り返しや、それに伴うインデントが減る。

といった点にあります。

早期リターンという観点だけでなく、「コードを読むときの認知負荷」「エラー情報の表現力と利用しやすさ」「定型的なコードの記述量」といった複数の側面から比較すると、Rustのアプローチの良さが見えてくるかもしれません。特に、複雑なエラー処理や、エラーの種類に応じたきめ細やかな対応が求められる場合に、その差が顕著になる傾向があります。

おっしゃる通り、「ハッピーパスに集中する気持ち」でコードを読むと、Rustの ? とエラー処理メソッドチェーンは、エラー処理を「ちょっとした注釈」のように扱えるため、煩雑さを感じにくい設計になっていると言えるでしょう。

(返答終了理由: STOP)


ユーザー: バックトレースに関してはxerrorsが標準にはいらなかったせいなんですよね…悲しい。


AI: そうですね、xerrors パッケージが提案されたものの、最終的にGoの標準ライブラリにそのままの形で取り込まれなかった経緯は、多くのGo開発者にとって記憶に新しい出来事だと思います。お気持ちお察しします。

xerrors は、エラーラッピングの標準化(現在の fmt.Errorf%werrors.Is, errors.As に繋がる思想)と共に、スタックトレースの簡単な付加という点でも注目されました。もし xerrors のような仕組みが標準で入っていれば、Goのエラーハンドリングもまた少し違った様相を呈していたかもしれませんね。

Goチームは、言語のシンプルさ、明示性、そしてパフォーマンスへの影響などを慎重に検討した結果、エラーに自動的にスタックトレースを付加する機能を標準ライブラリのコア部分には含めないという判断を下しました。その代わりに、エラーラッピングの基本的な仕組みを提供し、スタックトレースのようなよりリッチな機能はサードパーティライブラリに委ねる、あるいは開発者が必要に応じて実装するという形を選択したわけです。

この決定は、Goの設計哲学である「明示性は暗黙性に勝る (explicit is better than implicit)」や「少しの繰り返しは、少しの依存よりも良い (a little copying is better than a little dependency)」といった考え方を反映しているとも言えます。しかし、その結果として、Rustの anyhow のような手軽さでスタックトレースを扱いたい開発者にとっては、一手間増える形になっているのは事実です。

エラー処理は言語設計の中でも特に思想が分かれる部分であり、どの言語のアプローチにも一長一短があるものですね。xerrors の議論とその後の標準ライブラリの進化は、Goコミュニティがエラーハンドリングのあり方について深く考え、議論を重ねてきた証左でもあると思います。

(返答終了理由: STOP)


ユーザー: 元の記事に戻るとやっぱりエラー用の構文をいれるよりはxerrorsの標準化の方が先だった感じはしますね…個人的には


AI: そうですね、その感覚は非常によくわかります。エラー処理のための新しい構文を導入するというのは、言語仕様に大きな変更を加えることであり、学習コストや既存のコードベースへの影響も考慮しなければなりません。それに対して、xerrors が目指していたようなエラーのラッピングやスタックトレースの付与といった機能は、ライブラリレベルでの改善として提供できる可能性がありました。

もし xerrors の主要な機能、特にスタックトレースの簡単な取得方法が標準ライブラリに取り込まれていれば、多くの開発者が日々のデバッグで恩恵を受けられたでしょう。エラーの原因を特定する際にスタックトレースは非常に有効な情報ですから、これが標準的な方法で手軽に扱えるようになることの価値は大きいです。

ブログ記事で検討されていた check/handletry といった構文は、主にエラーの伝搬を簡潔にするためのものでした。しかし、エラーを単に伝搬させるだけでなく、途中で意味のある情報を付加したり、発生源を特定したりすることも同じくらい重要です。xerrors は後者の課題意識に対応しようとしていた部分が大きいと言えます。

Goチームが最終的に xerrors をそのままの形で採用せず、fmt.Errorf%werrors.Is/As という形でエラーラッピングの標準化を進めたのは、おそらく以下のようなバランスを考慮した結果でしょう。

  • 言語のシンプルさの維持: スタックトレースを自動的に全ての error に付与することは、パフォーマンスへの懸念や、必ずしもスタックトレースが不要なケースでのオーバーヘッドを生む可能性がありました。
  • 明示性の重視: エラーに関する情報をいつ、どのように付加するかは、開発者が明示的にコントロールすべきという思想。
  • 段階的な改善: まずはエラーの基本的な連鎖と型検査の仕組みを標準化し、より高度な機能(スタックトレースなど)は必要に応じてサードパーティライブラリやツールで補うというアプローチ。

しかし、開発者の立場からすると、特に問題解決の場面では、エラー発生時のコンテキスト情報(メッセージ、関連する値、そしてスタックトレース)が豊富であればあるほど助かります。その意味で、新しい構文の議論よりも先に、xerrors のようなエラー情報の充実に繋がる機能が標準化されることを望む声があったのは自然なことだと思います。

結局のところ、Goはエラー処理の「定型文」は残しつつ、エラーの「内容」を扱うための基本的な道具(%w, Is, As)を提供することを選んだ、と言えるのかもしれませんね。そして、それ以上の便利機能(スタックトレースの自動付与など)は、現状ではコミュニティのソリューションに委ねられている形です。おっしゃる通り、その優先順位については様々な意見があり得るところだと思います。

(返答終了理由: STOP)

メタデータ

{
  "runSettings": {
    "temperature": 1,
    "model": "models/gemini-2.5-pro-preview-05-06",
    "topP": 0.95,
    "topK": 64,
    "maxOutputTokens": 65536,
    "safetySettings": [
      {
        "category": "HARM_CATEGORY_HARASSMENT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_HATE_SPEECH",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
        "threshold": "OFF"
      },
      {
        "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
        "threshold": "OFF"
      }
    ],
    "responseMimeType": "text/plain",
    "enableCodeExecution": false,
    "enableSearchAsATool": false,
    "enableBrowseAsATool": true,
    "enableAutoFunctionResponse": false
  },
  "systemInstruction": {}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment