• Go与Zig错误处理哲学对比
  • 发布于 1个月前
  • 85 热度
    0 评论
使用Go语言有些年头的开发者,大多对其错误处理机制有着复杂的情感。一方面,我们认同 Rob Pike 所倡导的“错误即值 (Errors are values)”的核心哲学——错误不是需要特殊通道(如异常)处理的“二等公民”,它们是普通的值,可以传递、检查,甚至被编程。这赋予了错误处理极大的灵活性和明确性。但另一方面,我们也不得不承认Go的错误处理有时可能相当冗长。标志性的if err != nil代码块几乎遍布在Go代码的各个角落,占据了相当大的代码比例,这常常成为社区讨论的热点。 有趣的是,近期另一门备受关注的系统编程语言 Zig,也采用了“错误即值”的哲学,但其实现方式却与Go大相径庭。

近期自称是Zig新手的packagemain.tech博主在他的一期视频中也分享了自己敏锐地观察到的Zig和Go在设计哲学上的相似性(都追求简洁、快速上手)以及在错误处理实现上的显著差异。今天,我们就基于这位开发者的分享,来一场 Go 与 Zig 错误处理的对比,看看同一种哲学思想,是如何在两种语言中开出不同但各有千秋的花朵。

Go 的错误处理:接口、显式检查与可编程的值
我们先快速回顾下 Go 的错误处理方式,这也是大家非常熟悉的:
error 接口
Go中的错误本质上是实现了Error() string方法的任何类型。这是一个极其简单但强大的约定。
// $GOROOT/src/builtin/builtin.go
// 堆代码 duidaima.com
// 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
}
显式返回值
函数通过返回 (result, error) 对来表明可能出错。通常error放到函数返回值列表的最后一个,并且一个函数通常只返回一个错误值。
显式检查
调用者必须显式检查返回的 error 是否为 nil。
package main

import (
 "fmt"
 "os"
)

func readFileContent(filename string) (string, error) {
 data, err := os.ReadFile(filename) // ReadFile returns ([]byte, error)
 if err != nil {
  // If an error occurs (e.g., file not found), return it
  return "", fmt.Errorf("failed to read file %s: %w", filename, err) // Wrap the original error
 }
 return string(data), nil // Success, return data and nil error
}

func main() {
 content, err := readFileContent("my_file.txt")
 if err != nil {
  // The iconic check
  fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err)
  // Here you would typically handle the error (log, return, etc.)
  return
 }
 fmt.Println("File content:", content)

 // Slightly shorter form for functions returning only error (like Close)
 // Use dummy file creation/opening for example that runs
 f, createErr := os.Create("temp_file.txt")
 if createErr != nil {
        fmt.Fprintf(os.Stderr, "Error creating file: %v\n", createErr)
        return
    }
 if f != nil {
  // Ensure file is closed even if writes fail later (using defer is better practice)
  defer f.Close()
  defer os.Remove("temp_file.txt") // Clean up the dummy file

  // Example usage...
  _, _ = f.WriteString("hello")

  // Now explicitly check close error if needed at the end of func,
  // though defer handles the call itself.
  // For demonstration of the if err := ... style on Close:
  // (Note: defer already schedules the close, this is just for syntax demo)
        // closerFunc := func() error { return f.Close() } // Wrap Close if needed
        // if err := f.Close(); err != nil { // Potential re-close if not careful with defer
        //     fmt.Fprintf(os.Stderr, "Error closing file: %v\n", err)
        // }
        // A more practical place for this pattern might be a non-deferred close.
 }
}
示例中,对每一处返回错误的地方都做了显式检查,这保证了错误不会被轻易忽略,控制流清晰可见,但也导致了代码冗长。上面代码因my_file.txt文件不存在,会输出“Error reading file: failed to read file my_file.txt: open my_file.txt: no such file or directory”并退出。

错误是可编程的
自定义错误类型
开发者可以定义自己的 struct 实现 error 接口,从而携带更丰富的上下文信息。
package main

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

// Custom error type
type OperationError struct {
 Op      string
 Err     error // Underlying error
 Timestamp time.Time
}

// Implement the error interface
func (e *OperationError) Error() string {
 return fmt.Sprintf("[%s] operation %s failed: %v", e.Timestamp.Format(time.RFC3339), e.Op, e.Err)
}

// Function that might return our custom error
func performCriticalOperation() error {
 // Simulate a failure
 err := errors.New("connection refused")
 return &OperationError{
  Op:      "connect_database",
  Err:     err,
  Timestamp: time.Now(),
 }
}

// (main function using this will be shown in the next point)
错误检查
标准库 errors 包提供了 errors.Is (检查错误值是否匹配特定目标) 和 errors.As (检查错误链中是否有特定类型并提取) 方法,允许对错误进行更精细的判断和处理。
// (Continuing from previous snippet within the same package)
func main() {
 err := performCriticalOperation()
 if err != nil {
  fmt.Fprintf(os.Stderr, "Operation failed: %v\n", err) // Prints the formatted custom error

  // Example: Check if the underlying error is a specific known error
  // Note: Standard errors package doesn't export connection refused directly,
  // this is conceptual. Real check might involve string matching or syscall types.
  // if errors.Is(err, someSpecificNetworkError) {
  //     fmt.Println("It was specifically a network error")
  // }

  // Check if the error is of our custom type and extract it
  var opErr *OperationError
  if errors.As(err, &opErr) {
   fmt.Fprintf(os.Stderr, "  Operation details: Op=%s, Time=%s, UnderlyingErr=%v\n",
    opErr.Op, opErr.Timestamp.Format(time.Kitchen), opErr.Err)
   // Can now use opErr.Op, opErr.Timestamp etc. for specific handling
  }
 }
}
该博主认为,Go的方式虽然有点“乏味”和冗长,但非常直接 (straightforward),且自定义错误携带丰富上下文的能力是一大优势,使得错误本身更具“可编程性”。

Zig的错误处理:错误联合类型、语法糖与强制处理
Zig作为一门较新的语言(诞生于2016年),同样推崇简洁和“无隐藏控制流”,并在错误处理上给出了不同的答案:

错误联合类型
Zig中可能失败的函数,其返回类型会使用!标记,形式如 !ReturnType 或 !void。这表示函数要么返回 ReturnType 类型的值,要么返回一个错误集 (Error Set) 中的错误值。错误本质上是一种特殊的枚举值。
const std = @import("std");

// Define possible errors for our function
const MyError = error{
    InvalidInput,
    ConnectionFailed,
    SomethingElse,
};

// Function signature indicating it can return MyError or u32
fn doSomething(input: u32) MyError!u32 {
    if (input == 0) {
        return MyError.InvalidInput; // Return a specific error
    }
    if (input > 100) {
        return MyError.ConnectionFailed; // Return another error
    }
    // Simulate success
    return input * 2; // Return the successful result (u32)
}
// Example usage needs a main function
// pub fn main() !void { // Example main, !void indicates main can return error
//     const result = try doSomething(50);
//     std.debug.print("Result: {}\n", .{result});
// }
强制处理
在Zig 中,你不能像在 Go 中那样直接忽略一个可能返回错误值的函数的错误。Go 允许你使用空白标识符 _ 来丢弃返回值,包括错误,这在 Zig 中是不允许的,因为 Zig编译器强制要求调用者必须处理所有潜在的错误,不允许忽略。但是,Zig 提供了几种方法来处理你不想显式处理的错误,尽管这些方法都需要你明确地承认你正在忽略错误,而不是简单地丢弃它。这个我们在下面会提及。

简洁的语法糖
Zig 提供了多种简洁的语法来处理错误:
try: 极其简洁的错误传播机制
下面代码中的**一行 try 基本等同于 Go 中三四行的 if err != nil { return err }**:
const std = @import("std");
const MyError = error{InvalidInput, ConnectionFailed}; // Simplified error set

// Function definition (same as above)
fn doSomething(input: u32) MyError!u32 {
    if (input == 0) return MyError.InvalidInput;
    if (input > 100) return MyError.ConnectionFailed;
    return input * 2;
}
// 堆代码 duidaima.com
// This function also returns MyError or u32
fn processData(input: u32) MyError!u32 {
    // If doSomething returns an error, 'try' immediately propagates
    // that error from processData. Otherwise, result holds the u32 value.
    const result = try doSomething(input);

    // ... further processing on result ...
    std.debug.print("Intermediate result in processData: {}\n", .{result});
    return result + 1;
}

pub fn main() !void { // Main now can return errors (due to try)
    const finalResult = try processData(50); // Propagate error from processData
    std.debug.print("Final result: {}\n", .{finalResult});

     // Example of triggering an error propagation
     // Uncommenting the line below will cause main to return InvalidInput
     // _ = try processData(0);
}
注:Zig中的try可不同于Java等支持try-catch等错误处理机制中的try。Zig 的 try 用于传播错误,而 Java 的 try-catch 用于捕获和处理异常。

catch: 用于捕获和处理错误
与代码块结合 (catch |err| { ... }),执行错误处理逻辑
const std = @import("std");
const MyError = error{InvalidInput, ConnectionFailed};
fn doSomething(input: u32) MyError!u32 { /* ... */ if (input == 0) return MyError.InvalidInput; return input * 2; }
pub fn main() void { // Main does not return errors itself
    const result = doSomething(0) catch |err| {
        // Error occurred, execution enters the catch block
        std.debug.print("Caught error: {s}\n", .{@errorName(err)}); // Prints "Caught error: InvalidInput"
        // Handle the error, maybe exit or log differently
        // For this example, we just print and return from main
        return; // Exit main gracefully
    };
    // This line only executes if doSomething succeeded
    // If input was non-zero, this would print.
    std.debug.print("Success! Result: {}\n", .{result});
}
与回退值结合 (catch fallbackValue),在出错时提供一个默认的成功值
const std = @import("std");
const MyError = error{InvalidInput, ConnectionFailed};
fn doSomething(input: u32) MyError!u32 { /* ... */ if (input == 0) return MyError.InvalidInput; return input * 2; }

pub fn main() void {
    // If doSomething fails (input is 0), result will be assigned 999
    const result = doSomething(0) catch 999;
    std.debug.print("Result (with fallback): {}\n", .{result}); // Prints 999

    const success_result = doSomething(10) catch 999;
    std.debug.print("Result (with fallback, success case): {}\n", .{success_result}); // Prints 20
}
与命名块结合
label: ... } catch err| { ... break :label fallbackValue; ),既能执行错误处理逻辑,又能返回一个回退值。
const std = @import("std");

const MyError = error{
    FileNotFound,
    InvalidData,
};

fn readDataFromFile(filename: []const u8) MyError![]const u8 {
    // 模拟读取文件,如果文件名是 "error.txt" 则返回错误
    if (std.mem.eql(u8, filename, "error.txt")) {
        return MyError.FileNotFound;
    }

    // 模拟读取成功
    const data: []const u8 = "Some valid data";
    return data;
}

fn handleReadFile(filename: []const u8) []const u8 {
    return readDataFromFile(filename) catch |err| {
        std.debug.print("Error reading file: {any}\n", .{err});
        std.debug.print("Using default data\n", .{});
        return "Default data";
    };
}

pub fn main() !void {
    const filename = "data.txt";
    const errorFilename = "error.txt";
    const data = handleReadFile(filename);
    std.debug.print("Data: {s}\n", .{data});
    const errorData = handleReadFile(errorFilename);
    std.debug.print("Error Data: {s}\n", .{errorData});
}
注:对于Gopher而言,是不是开始感觉有些复杂了:)。

if/else catch
分别处理成功和失败的情况,else 块中还可以用 switch err 对具体的错误类型进行分支处理。
const std = @import("std");
const MyError = error{InvalidInput, ConnectionFailed, SomethingElse};
fn doSomething(input: u32) MyError!u32 {
     if (input == 0) return MyError.InvalidInput;
     if (input > 100) return MyError.ConnectionFailed;
     if (input == 55) return MyError.SomethingElse; // Add another error case
     return input * 2;
}

pub fn main() void {
    // Test Case 1: Success
    if (doSomething(10)) |successValue| {
        std.debug.print("Success via if/else (input 10): {}\n", .{successValue}); // Prints 20
    } else |err| { std.debug.print("Error (input 10): {s}\n", .{@errorName(err)}); }

    // Test Case 2: ConnectionFailed Error
    if (doSomething(101)) |successValue| {
         std.debug.print("Success via if/else (input 101): {}\n", .{successValue});
    } else |err| {
        std.debug.print("Error via if/else (input 101): ", .{});
        switch (err) {
            MyError.InvalidInput => std.debug.print("Invalid Input\n", .{}),
            MyError.ConnectionFailed => std.debug.print("Connection Failed\n", .{}), // This branch runs
            else => std.debug.print("Unknown error\n", .{}),
        }
    }

     // Test Case 3: SomethingElse Error (falls into else)
    if (doSomething(55)) |successValue| {
         std.debug.print("Success via if/else (input 55): {}\n", .{successValue});
    } else |err| {
        std.debug.print("Error via if/else (input 55): ", .{});
        switch (err) {
            MyError.InvalidInput => std.debug.print("Invalid Input\n", .{}),
            MyError.ConnectionFailed => std.debug.print("Connection Failed\n", .{}),
            else => std.debug.print("Unknown error ({s})\n", .{@errorName(err)}), // This branch runs
        }
    }
}
catch unreachable
在不期望出错或不想处理错误(如脚本中)时使用,若出错则直接 panic。
const std = @import("std");
// Assume this function logically should never fail based on guarantees elsewhere
fn doSomethingThatShouldNeverFail() !u32 {
    // For demo, make it fail sometimes
    // if (std.time.timestamp() % 2 == 0) return error.UnexpectedFailure;
    return 42;
}

pub fn main() void {
    // If doSomethingThatShouldNeverFail returns an error, this will panic.
    // Useful when an error indicates a programming bug.
    const result = doSomethingThatShouldNeverFail() catch unreachable;
    std.debug.print("Result (unreachable case): {}\n", .{result});

    // To see it panic, you'd need doSomethingThatShouldNeverFail to actually return an error.
}
该博主认为,Zig 的错误处理方式功能更丰富、更强大、也**更简洁 (concise)**。try 关键字尤其强大,极大地减少了错误传播的样板代码。

对比与思考:殊途同归,各有侧重
对比 Go 和 Zig 的错误处理,我们可以看到:
特性 Go Zig
核心哲学 错误即值 错误即值
实现机制 error 接口, (T, error) 返回值 错误联合类型 (!T), 特殊错误枚举
处理方式 显式 if err != nil 检查 try, catch (多种形式), if/else catch
强制性 靠约定和 Linter 编译器强制
简洁性 某些时候略冗长 简洁 (try)
上下文 通过自定义类型携带丰富上下文 错误值本身通常不带上下文 (目前)
编程性 高 (errors.Is/As, 自定义类型) 相对较低 (主要靠错误类型区分)
易理解性 非常直接 语法糖需学习,但逻辑算是清晰
两者都坚守了“错误即值”的阵地,避免了异常带来的隐式控制流跳转。但:
Go 选择了更直接、更“笨拙”但上下文信息更丰富的路径。 它的冗长换来的是每一处错误检查点的明确无误,以及通过自定义类型深度编程错误的能力。

Zig 则选择了更精巧、更简洁且由编译器强制保证的路径。 它通过强大的语法糖显著减少了样板代码,提升了编写体验,但在错误本身携带上下文信息方面目前有所欠缺。


该博主最后总结道,他个人很喜欢这两种语言的实现方式(特别是与有异常的语言相比)。Zig提供了一种功能更丰富、强大且简洁的方式;而 Go 则更直接,虽冗长但易于理解,且拥有丰富的上下文错误处理能力。

小结
Go 与 Zig 在错误处理上的不同实现,完美诠释了语言设计中的权衡 (trade-offs)。追求极致简洁和强制性,可能会牺牲一部分灵活性或信息承载能力;追求灵活性和信息丰富度,则可能带来冗余和对开发者约定的依赖。这场对比并非要评判孰优孰劣,而是展示“错误即值”这一共同哲学在不同设计选择下的具体实践。了解这些差异,有助于我们更深刻地理解自己所使用的语言,并在技术选型或学习新语言时做出更明智的判断。或许,Go 的未来版本可以借鉴 Zig 的某些简洁性?又或者,Zig 的生态会发展出更丰富的错误上下文传递机制?这都值得我们期待。

你更喜欢 Go 还是 Zig 的错误处理方式?为什么?欢迎在评论区留下你的看法!
用户评论