ユーザー: あなたは主に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/handle
や try
、あるいはRust風の ?
演算子の提案も、主にエラーの早期リターンを簡潔に書くことに焦点が当たっていたように見えます。 これらはエラーの「伝搬」をスムーズにはしますが、エラーにコンテキスト情報を付加したり、型を変えたりといった「装飾」の部分では、結局 if err != nil
ブロックの中で一手間加えるのと大差ないコードになる可能性はありますね。
Rustでは map_err
や context
のようなメソッドチェーンでエラーのラップを行うことができ、?
と組み合わせることで比較的スッキリ書ける印象がありますが、それでもエラーの変換処理自体は記述する必要があります。Haskellの MonadError
における catchError
や throwError
、あるいは 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/errors
、emperror.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::Error
や eyre::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`)");
}
解説:
-
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 run
やRUST_BACKTRACE=full cargo run
のように環境変数を設定して実行します。{:?}
フォーマットでエラーを出力すると、エラーチェーンと共にバックトレースが表示されます。
-
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=1
やRUST_BACKTRACE=full
) によって制御されます。つまり、コード自体に変更を加えなくても、実行時のオプションでバックトレースの有無や詳細度を切り替えられるということです。 anyhow::Error
の役割:anyhow::Error
は、エラーが生成されたり、?
演算子で伝播する際に、内部的にバックトレースを(条件が満たされれば)保持します。
エラーの装飾について
「メッセージ付与のみ」というよりは、「コンテキスト情報の付加」が非常に簡潔に行える、と捉えるのが良いでしょう。このコンテキスト情報には、人間が読むためのメッセージだけでなく、エラーが発生した際の状況を示す値(例えばどのファイルで失敗したか、どのIDの処理で問題があったかなど)も含まれます。
どう「きれい」になるか?
Goの例と比較して、Rust (特にanyhow
やthiserror
を使った場合) のエラー装飾がどのように「きれい」または「シュッとする」と感じられるか、いくつかのポイントがあります。
-
?
演算子とのシームレスな統合: 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(...)
) と?
演算子で完結します。これにより、本筋のロジックがエラーハンドリングのコードで中断されにくくなります。
-
-
エラーの種類に応じた構造化された情報の付加 (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
のマクロがDisplay
やError
トレイトの実装を自動生成してくれるため、エラーの定義が宣言的になります。また、エラーの種類が型として明確になるため、呼び出し側でのパターンマッチによるハンドリングがしやすくなります。fmt.Errorf
のような文字列フォーマットに頼る部分が減り、型安全性が向上します。
-
-
ボイラープレートの削減:
if err != nil
の繰り返しや、エラーメッセージを毎回fmt.Errorf
で組み立てる手間が、?
と.context()
/.with_context()
/map_err()
の組み合わせで大幅に削減されます。装飾がない場合(エラーをそのまま上に投げるだけ)は、Goでも
return nil, err
、Rustでもsome_function()?
とシンプルですが、装飾が加わった瞬間のコードの増加量と煩雑さが、Rustの方が抑えられる傾向にあるということです。 -
バックトレースの容易さ: 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のコミュニティ(特にanyhow
やthiserror
のユーザー)は、これを「きれい」とか「改善」と感じるのでしょうか? それは、おそらく「ハッピーパスに集中する気持ち」でコードを読むときの視点と、エラー処理の「質」や「表現力」に対する期待値の違いから来ているのだと思います。
ハッピーパスに集中して見たときの「きれいさ」
-
視線の流れとノイズの少なさ:
-
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
ブロックがなくなるため、視覚的にハッピーパスのコードが直線的に連なっているように見えやすくなります。「もしエラーだったら…」という分岐がコードの表面から隠蔽される(?
に集約される)ため、ハッピーパスのロジックを追いやすくなるという感覚です。
-
-
インラインでのコンテキスト付加: Goの
fmt.Errorf
は新しいエラー値を生成し、それをリターンする形です。Rustのcontext
やwith_context
は、既存のエラー値(Result
の中のErr
)に対して操作を行い、そのまま?
で処理できるため、より「その場で」情報を追加している感覚になります。
「装飾」の質と表現力
「見た目があまり変わらない」と感じる部分の奥には、エラー情報の「質」と「扱いやすさ」の違いがあります。
-
Goの
fmt.Errorf
: 主に文字列ベースでコンテキストを付加します。元のエラーをラップする機能 (%w
) は強力ですが、付加される情報は基本的にはメッセージ文字列です。より構造化された情報を追加したい場合は、カスタムエラー型を定義し、手動でフィールドを設定する必要があります。 -
Rust (
thiserror
とanyhow
):anyhow::Context
: 文字列ベースのコンテキスト付加はGoと似ていますが、バックトレースの自動付与という大きなメリットがあります。thiserror
: カスタムエラー型を非常に簡単に、かつ表現力豊かに定義できます。#[error("...")]
でエラーメッセージのテンプレートを指定したり、エラーの原因 (#[source]
) や固有のフィールドを型として持つことができます。このように、エラーの種類に応じた構造化されたデータをエラー自体に持たせることが容易です。これは、単にメッセージを装飾する以上に、エラーハンドリングのロジックで役立ちます(例:特定のエラーフィールドに基づいて処理を分岐する)。// thiserrorで定義したエラー Err(DatabaseError::ConnectionFailed { host: "localhost".into() })?; // これをanyhowでラップしてさらにコンテキストを付加 // .context("Failed to initialize application services")?;
まとめると
「装飾が加わった後の見た目がGoとRustであまり変わらない」というのは、エラー処理のコードブロックが依然として存在し、エラー情報を付加する記述が必要であるという点ではその通りです。
しかし、Rust(特にエコシステムの力を借りた場合)の利点は、
- ハッピーパスの可読性向上: エラー処理の記述がよりインライン化・メソッドチェーン化されることで、本筋のコードの流れが分断されにくくなる。
- エラー情報の構造化と型安全性:
thiserror
を使うことで、エラーに意味のある型とデータを付与しやすくなり、コンパイル時のチェックの恩恵も受けられる。 - バックトレースの容易さ:
anyhow
を使えば、追加のコーディングなしにリッチなバックトレースが得られる(実行時オプションで制御)。 - ボイラープレートの削減:
if err != nil
の繰り返しや、それに伴うインデントが減る。
といった点にあります。
早期リターンという観点だけでなく、「コードを読むときの認知負荷」「エラー情報の表現力と利用しやすさ」「定型的なコードの記述量」といった複数の側面から比較すると、Rustのアプローチの良さが見えてくるかもしれません。特に、複雑なエラー処理や、エラーの種類に応じたきめ細やかな対応が求められる場合に、その差が顕著になる傾向があります。
おっしゃる通り、「ハッピーパスに集中する気持ち」でコードを読むと、Rustの ?
とエラー処理メソッドチェーンは、エラー処理を「ちょっとした注釈」のように扱えるため、煩雑さを感じにくい設計になっていると言えるでしょう。
(返答終了理由: STOP)
ユーザー: バックトレースに関してはxerrorsが標準にはいらなかったせいなんですよね…悲しい。
AI:
そうですね、xerrors
パッケージが提案されたものの、最終的にGoの標準ライブラリにそのままの形で取り込まれなかった経緯は、多くのGo開発者にとって記憶に新しい出来事だと思います。お気持ちお察しします。
xerrors
は、エラーラッピングの標準化(現在の fmt.Errorf
の %w
や errors.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/handle
や try
といった構文は、主にエラーの伝搬を簡潔にするためのものでした。しかし、エラーを単に伝搬させるだけでなく、途中で意味のある情報を付加したり、発生源を特定したりすることも同じくらい重要です。xerrors
は後者の課題意識に対応しようとしていた部分が大きいと言えます。
Goチームが最終的に xerrors
をそのままの形で採用せず、fmt.Errorf
の %w
と errors.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": {}
}