本文永久链接 – https://tonybai.com/2025/04/30/go-vs-zig-in-error-handling
大家好,我是Tony Bai。
使用Go语言有些年头的开发者,大多对其错误处理机制有着复杂的情感。一方面,我们认同 Rob Pike 所倡导的“错误即值 (Errors are values)”的核心哲学——错误不是需要特殊通道(如异常)处理的“二等公民”,它们是普通的值,可以传递、检查,甚至被编程。这赋予了错误处理极大的灵活性和明确性。
但另一方面,我们也不得不承认Go的错误处理有时可能相当冗长。标志性的if err != nil代码块几乎遍布在Go代码的各个角落,占据了相当大的代码比例,这常常成为社区讨论的热点。 有趣的是,近期另一门备受关注的系统编程语言 Zig,也采用了“错误即值”的哲学,但其实现方式却与Go大相径庭。
近期自称是Zig新手的packagemain.tech博主在他的一期视频中也分享了自己敏锐地观察到的Zig和Go在设计哲学上的相似性(都追求简洁、快速上手)以及在错误处理实现上的显著差异。
今天,我们就基于这位开发者的分享,来一场 Go 与 Zig 错误处理的对比,看看同一种哲学思想,是如何在两种语言中开出不同但各有千秋的花朵。
我们先快速回顾下 Go 的错误处理方式,这也是大家非常熟悉的:
Go中的错误本质上是实现了Error() string方法的任何类型。这是一个极其简单但强大的约定。
// $GOROOT/src/builtin/builtin.go
// 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作为一门较新的语言(诞生于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 基本等同于 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;
}
// 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 用于捕获和处理异常。
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});
}
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而言,是不是开始感觉有些复杂了:)。
分别处理成功和失败的情况,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
}
}
}
在不期望出错或不想处理错误(如脚本中)时使用,若出错则直接 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 的错误处理,我们可以看到:
两者都坚守了“错误即值”的阵地,避免了异常带来的隐式控制流跳转。但:
该博主最后总结道,他个人很喜欢这两种语言的实现方式(特别是与有异常的语言相比)。Zig提供了一种功能更丰富、强大且简洁的方式;而 Go 则更直接,虽冗长但易于理解,且拥有丰富的上下文错误处理能力。
Go 与 Zig 在错误处理上的不同实现,完美诠释了语言设计中的权衡 (trade-offs)。追求极致简洁和强制性,可能会牺牲一部分灵活性或信息承载能力;追求灵活性和信息丰富度,则可能带来冗余和对开发者约定的依赖。
这场对比并非要评判孰优孰劣,而是展示“错误即值”这一共同哲学在不同设计选择下的具体实践。了解这些差异,有助于我们更深刻地理解自己所使用的语言,并在技术选型或学习新语言时做出更明智的判断。或许,Go 的未来版本可以借鉴 Zig 的某些简洁性?又或者,Zig 的生态会发展出更丰富的错误上下文传递机制?这都值得我们期待。
你更喜欢 Go 还是 Zig 的错误处理方式?为什么?欢迎在评论区留下你的看法!
深入探讨,加入我们!
今天讨论的 Go 与 Zig 错误处理话题,只是冰山一角。在我的知识星球 “Go & AI 精进营” 里,我们经常就这类关乎 Go 开发者切身利益、技术选型、生态趋势等话题进行更深入、更即时的交流和碰撞。
如果你想:
欢迎扫描下方二维码加入星球,和我们一起精进!
感谢阅读!
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。如有需求,请扫描下方公众号二维码,与我私信联系。
© 2025, bigwhite. 版权所有.