在Golang中实现Rust的unwrap()功能,求反馈

在Golang中实现Rust的unwrap()功能,求反馈 几个月前我开始用Go语言编程,现在逐渐熟悉这门语言后,我整理了一些实用函数并上传到了Github。其中一个是类似Rust中unwrap()的重新实现,旨在提供更便捷的错误处理。以下是包含所有解释和示例的文档:g package - github.com/n-mou/yagul/g - Go Packages,但长话短说:

import (
    "os"
    "github.com/n-mou/yagul/itertools"
)

// 这样写:
fileInfo := g.Unwrap(os.Stat("."))

// 等价于这样写:
fileInfo, err := os.Stat(".")
if err != nil {
    panic(err)
}

我希望能得到Gopher们的反馈。我知道不应该每次函数返回错误时都触发panic。通常,如果我写的是库,我会传播错误;如果我写的是程序,我会单独处理错误。但有时触发panic是合适的做法(或者在处理了合理的预期错误后触发panic)。对于这些情况,Rust为Result类型实现了unwrap()函数,但在Go中没有现成的解决方案,你需要重复写那三行代码(if err != nil { panic(err) })直到筋疲力尽。这是我在像Go这样缺乏宏的语言中能写出的所有语法糖了。

我是唯一觉得这有用的人吗?如果不是,Unwrap是最好的名字吗?在Rust中它有意义,因为它解包了一个Result,但在Go中,“解包”这个术语用于错误处理(而且errors包中有一个名为Unwrap的函数),所以应该避免使用,但我想不出更好的名字了(除了Force,但同一个模块中的其他函数已经使用了这个名字)。你们怎么看?

提前感谢


更多关于在Golang中实现Rust的unwrap()功能,求反馈的实战教程也可以访问 https://www.itying.com/category-94-b0.html

20 回复
func main() {
    fmt.Println("hello world")
}

更多关于在Golang中实现Rust的unwrap()功能,求反馈的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


你说得对。这个函数的初衷是作为原型设计时的语法糖。我怀疑在生产代码中这会有多大用处(如果是应用程序,我会尽量避免使用 panic;如果是库,则绝不会使用 panic)。在那个环境中,可读性应该优先于开发者的便利性。

一个需要注意的地方:实际上,执行 panic() 会在从函数返回之前运行所有延迟函数。否则,defer(func(){ recover()...}() 就无法像现在这样正常工作。

defer(func(){ recover()...}()

我不知道你在寻找什么,我看了你的实现代码,我想我明白你的想法了。 我只会在测试代码中使用这种类型的代码,而且正如我之前所说,在出现错误的情况下我不会使用 panic,所以我不会在正式代码中使用这样的代码。 在 panic 的情况下,直接写比再包装一层要直观得多。

哦,原来区别在这里。我之前不知道 panic 不会触发清理操作。我肯定会把它改成 log.Fatal()。我选择 panic 而不是 log.Fatal() 是因为它还会打印堆栈跟踪。在调用 log.Fatal() 之前,我该如何打印堆栈跟踪呢?

嗯,有意思。我也会包装我的错误,但我不能指望我使用的每一个第三方库都这么做,这就是为什么我想打印堆栈跟踪。所以,为了确保在退出前所有清理代码都已运行,我不能在 main() 函数之外的任何其他函数中退出程序……感谢所有建议,我会再考虑一下。

是的,在大多数情况下,我使用它来进行更快速的原型设计(毕竟,在一个成熟的应用中,几乎所有的错误都应该被处理)。这只是我在 Rust(一种以类似方式处理错误的语言)中看到的一种语法糖,在实验这门语言时,我想到将其引入 Go。我只是想知道其他在 Go 方面更有经验的人对此有何看法,看看是只有我一个人这样想,还是其他人也可能觉得它有用。感谢您的反馈。

最简单的区别在于,工具库不应引发恐慌,而应用程序(区别在于是否会被外部引用)和初始化过程(例如 init)可以适当地引发恐慌(例如,你的程序业务是写入文件,但文件无法打开,作为应用程序工具,那么引发恐慌是可以接受的)。

所有逻辑都为业务服务,这是我的理念。如果它影响了主要业务,那么我会毫不犹豫地停止,以避免造成无意义的工作(例如,Web 服务甚至无法监听套接字)(但如果只是路由异常,则不应停止整个服务)。

log.Fatal* 也不会触发延迟函数。如果你有任何延迟函数,它们将被跳过。我不使用追踪,因为我在错误出现的地方用扩展描述包装了错误,并且可以毫无问题地找到代码中的位置。如果你想要有追踪功能,可以尝试自定义错误类型,它可以保存额外的信息,例如:

type myError struct {
    trace string
    err   error
}

func (m *myError) Error() string {
    return fmt.Sprintf("%s\n%s\n", m.trace, m.err)
}

我明白了。我们都同意在有限的情况下,panic 是可以接受的,但问题在于我实现了一个会为用户 panic 的库。如果那个函数本应被用于另一个不同的目的,并且作为一个副作用它 panic 了而不是返回错误,我同意这是不可接受的。但在这种情况下,这个函数的唯一用例就是 panic,并且在文档和 README 中明确说明了这个函数旨在用作 panic 的语法糖。

然而,我理解之前的表述方式有误导性,我将在文档中添加一个警告,说明这个函数被认为是辅助原型设计的,并且在生产代码中 panic 几乎从来不是一个好的选择。感谢大家的反馈。

您还可以通过将错误处理移到其他地方来减少主函数中的错误,例如:

func main() {
    // 设置和无错误处理的部分。

    if err := run(); err != nil {
        // 主函数中没有defer语句,清理工作已经完成,
        // 因此可以直接使程序崩溃。
        log.Fatalln(err)
    }

    // 如果没有错误,执行其他操作。
}

func run() error {
    defer cleanup() // 在这里进行清理。

    // 主逻辑放在这里...
    if err := doSomething(); err != nil {
          return fmt.Errorf("error in doSomething: %w", err)
    }

    // ...

    return nil
}

嗯,这更多是关于个人偏好、习惯以及每个人偏好的工作流程方式。如果我在 main 函数中执行某些可能返回错误的操作,我会使用 log.Fatal*,它在底层会调用 Exit(1)。但当我添加了用于清理的 defer 语句或其他与优雅关闭相关的代码时,这种方法就会失效。如果你触发 panic 或直接退出,defer 将不会被执行。因此,我更倾向于控制我的运行时。你的例子更多是关于处理你作用域之外的错误。如果简单的 Mkdir 返回一个错误,这个错误来自底层操作系统,即使在这种情况下,我也看不出有任何理由要 panic,而不是像处理其他错误一样来处理它。

Rust 和 Go 在错误处理方面没有相似的方法。它们的基本相似之处在于将错误视为值,仅此而已。Go 社区内部有很多关于如何摆脱 err != nil 的讨论,但这是另一个需要讨论的话题。在这里,我认为,如果第三方库引发恐慌(panic),那它就不是一个好的库。我同意,我能看到其使用场景的地方可能只有测试,而且可能性很小。例如,我使用 Go 处理的所有工作在任何情况下都需要优雅地关闭,这就是为什么即使是 std 也会返回错误以便进行适当处理,而不是在运行时因恐慌而崩溃。相信我,编写 recover 恢复代码比就地执行 nil 检查更令人疲惫。

func main() {
    fmt.Println("hello world")
}

我已经在我的帖子中说过,我并不总是使用它。我知道错误必须单独处理或传播到其他地方处理,但有时一个函数返回的错误是应用程序不应该处理的(例如:当内存不足、用户权限不足或硬件错误时,os.Mkdir 会返回错误,这些情况都不是用户空间应用程序应该处理的)。这并不是说每次遇到错误都要系统性地退出应用程序,我确实会单独处理错误,但有时应用程序并不打算处理某些类型的错误,如果收到这些错误就应该停止(就像我上面提到的例子)。

我并不反对将错误作为返回值,这与 Rust 的 Result 概念类似,处理方式也相似(在 Rust 中,你可以使用 unwrap_or_else() 和一个通过模式匹配错误类型来处理特定情况的代码块,但也有 unwrap() 函数,如果返回错误就会触发 panic,这不是为了系统性地忽略每个错误,而是为了处理这些特定情况)。

问题的核心在于,哪种写法更容易编写和阅读。

  1. x := g.Must(foo())

    • 只提供通用的错误信息
    • 可能无法意识到这段代码会引发恐慌
    • 必须阅读 g.Must 的源代码才能确定其工作原理
    • 引入了来源不明/小众的额外依赖(存在安全隐患)
  2. x, err := foo() if err != nil { panic(fmt.Errf("could not foo: %w', err)) }

    • 需要多写62个字符/3行代码(但其中大部分可以由IDE自动生成,并且可以通过IDE插件使其不那么显眼)
    • 错误信息明确
    • 任何Go开发者都能一眼看出发生了什么

我个人认为更详细的第二种形式更容易理解。既然代码被阅读的次数远多于被编写的次数,我几乎总是会选择“易于阅读”而不是“易于编写”。

另一方面,也许我遗漏了什么。所以,能否请你告诉我,在我实际应用中使用 panic 的具体案例?

这是一个用于备份的文件管理工具(一个程序,而非供他人使用的库)。在创建目录时,我必须检查目录是否已存在(如果存在,os.MkdirAll 不会返回错误),如果不存在则创建它。在研究 MkdirAll 可能返回哪些类型的错误时,我发现了这些情况(权限不足、内存存储问题以及较低级别的外部或网络驱动器问题)。这个工具本不应该处理这些错误,所以我使用了 panic,因为在我看来,panic 与打印错误信息及堆栈跟踪并以代码 1 退出之间并没有太大区别。

是否存在一个我忽略的理由,使得这样做:

if err != nil {
    fmt.Println(err.Error())
    os.Exit(1)
}

比这样做更可取:

if err != nil {
   panic(err)
}

感谢你的反馈。

这不是一个好的处理方式,实际上你只是把错误塞进了上下文里,并没有解决异常被捕获的情况。(你是想在遇到错误时直接 panic 吗?)如果是这样,我建议你不要那样处理。

看起来你只想要结果(xxx:=),而不是(xxx, err:=)。

我遇到过不止一个人这样想,实际上在我学习了 Go 语言之后再看其他语言,比如 Kotlin 或其他使用 try-catch 来处理错误的语言,那有点类似于 Go 的 panic-recover。

但事实上,这两者并不相同,如果你用这种方式处理 Go 语言的错误,那么我建议你不要这样做。

对于这类错误处理,Go 语言这种简单的逻辑可能看起来有些繁琐,但正因为如此,开发者能够掌握每一个产生错误的情况,并且更加直观,这是我非常喜欢的一点。

处理错误的正确做法是,不要忽略它(除非你知道自己在做什么)。如果能使用 error,就不要 panic。panic 的影响级别非常高,尽管有 defer 来处理后续步骤,但这并不是良好的开发实践。

如果你真的讨厌这种方式:1. 使用其他开发语言;2. 使用 IDE 的点号代码补全功能。

当然,我们绝不应该在第三方库中使用 panic,这个实验也并非提倡这样做。我的意思是,这就像是把 if err != nil { panic(err) } 写在一行里,并且只应在你原本就会使用 panic() 的极少数有限场景中使用。

关于 Go 和 Rust 的错误处理,我知道它们并不完全相同,我说的是相似。我所说的相似,是指它们都将错误作为返回值,而不是通过 try/catch 块来中断程序流程。我理解 Rust 的设计晚于 Go,其设计者可以调整方案(例如:可能失败(fallible)的函数不返回一个值和一个错误,而是返回一个类型联合体,以强制用户处理错误,因为在 Go 中确实存在错误被忽略而非被妥善处理的情况)。然而,尽管 Rust 被设计为必须处理结果而不能忽略错误,它也提供了 unwrap 函数。因此,当遇到程序确实应该 panic 的罕见情况时,有一种更符合人体工程学的方法来实现。

我也了解关于错误处理的讨论,并理解其繁琐之处(Go 被设计成一种简单的语言,具有最少的必要抽象和语法特性,这与 Rust 相反,尽管随着泛型和迭代器的加入,它已略微偏离了最初的愿景……)。我个人不会对它做太多改变,我认为将错误作为值返回远比 try/catch 块要好(我来自 Java 和 Python 背景)。不过,如果能有一个用于 panic 的单行语句,以及另一个用于返回错误的单行语句(某种“传播”关键字,当在函数中执行时,如果栈帧中最后一个类型为 error 的变量不为 nil,则停止函数执行并返回该错误以及函数返回类型的零值),那将会方便得多。如果 Go 支持元编程,我会写一个宏来实现同样的功能(一行代码等价于 if err != nil { return zeroVal, err })。但是,重申一下,Go 的设计初衷就是简单,而元编程与简单背道而驰。

尽管有以上所有观点,我知道我对这门语言还是新手,在参与语言设计的讨论之前,我还有很多东西需要学习。我喜欢 Go,它拥有巨大的优势,其中许多优势都源于它的简单性。因此,尽管存在这些轻微的不便,我仍然使用它。这个实验只是为了测试我是否能使其使用稍微顺畅一些。

再补充一点。在维基中有一个名为 Go 箴言 的部分,其中包含一条 不要恐慌

参见 https://go.dev/doc/effective_go#errors。不要将 panic 用于正常的错误处理。请使用 error 和多个返回值。

来自 effective go 的说明:

为此,有一个内置函数 panic,它实际上会创建一个运行时错误并停止程序(但请参阅下一节)。该函数接受一个任意类型的参数(通常是字符串),在程序终止时打印出来。它也是一种表示发生了不可能的事情的方式,例如退出无限循环。 … 这只是一个示例,但真正的库函数应避免使用 panic

他们确实指出,在初始化期间 可能 适合使用 panic:

这只是一个示例,但真正的库函数应避免 panic。如果问题可以被掩盖或绕过,让程序继续运行总比让整个程序崩溃要好。一个可能的例外是在初始化期间:如果库确实无法完成自身设置,可以说,panic 可能是合理的。

var user = os.Getenv("USER")

func init() {
   if user == "" {
       panic("no value for $USER")
   }
}

但我可能不希望外部库为我 panic。应用程序初始化从定义上讲是一次性的,所以我希望在那里有意识地处理错误(因为有时错误确实是可以恢复的)。好消息是:你的函数至少非常明确地指出了它会 panic。

在我看到的大多数示例代码中,当他们忽略错误并直接放弃处理问题时,通常会使用类似 Must 这样的名称。例如 g.Must(os.Stat("."))Unwrap 这个名称可能特别令人困惑,因为正如你所指出的,这个术语在错误处理领域有其特定含义。当我看到这篇帖子的标题时,我本以为它与解包错误有关。

如果你感兴趣,这里有一些相关的讨论:

https://www.reddit.com/r/golang/comments/6v07ij/copypasting_if_err_nil_return_err_everywhere/

总结来说:对于来自其他语言的开发者来说,不喜欢 Go 的错误处理方式是很自然的。我们中的许多人已经习惯了它,并且现在更喜欢这种方式。

在Go中实现类似Rust的unwrap()功能确实是个有趣的想法。从你的实现来看,g.Unwrap()函数通过panic来处理错误,这在特定场景下是合理的。以下是一些专业反馈:

实现分析

你的实现核心是:

func Unwrap[T any](val T, err error) T {
    if err != nil {
        panic(err)
    }
    return val
}

这种模式在某些场景下确实能减少样板代码,特别是在原型开发、测试或确定错误应该导致程序终止的情况下。

命名建议

关于命名,确实需要考虑Go的惯例:

  1. Must前缀:Go标准库中常见模式,如template.Must()regexp.MustCompile()
  2. PanicOnErr:更明确地表达行为
  3. AssertNoErr:强调这是断言式的检查

示例:

// 使用Must前缀
fileInfo := g.Must(os.Stat("."))

// 或更明确的命名
fileInfo := g.PanicIfErr(os.Stat("."))

使用场景示例

package main

import (
    "encoding/json"
    "fmt"
    "github.com/n-mou/yagul/g"
)

func main() {
    // 1. 配置加载(失败应终止程序)
    config := g.Unwrap(loadConfig("config.json"))
    
    // 2. 初始化必须成功的资源
    db := g.Unwrap(connectDB(config.DSN))
    
    // 3. 测试代码中的简化
    result := g.Unwrap(processData(testInput))
    
    fmt.Printf("Config: %+v\n", config)
}

func loadConfig(path string) (Config, error) {
    data := g.Unwrap(os.ReadFile(path))
    var cfg Config
    err := json.Unmarshal(data, &cfg)
    return cfg, err
}

type Config struct {
    DSN string `json:"dsn"`
}

注意事项

  1. 性能影响:panic/defer/recover比正常错误处理开销大
  2. 可读性:对于不熟悉Rust的Go开发者,Unwrap可能不够直观
  3. 错误上下文:panic会丢失调用栈的某些信息

替代实现考虑

可以考虑提供带上下文的版本:

func UnwrapWith[T any](val T, err error, msg string) T {
    if err != nil {
        panic(fmt.Errorf("%s: %w", msg, err))
    }
    return val
}

// 使用
fileInfo := g.UnwrapWith(os.Stat("."), "failed to stat current directory")

结论

你的Unwrap()实现为Go的错误处理提供了一个有用的工具,特别是在快速原型开发或确定错误应导致程序终止的场景。建议考虑使用Must前缀以符合Go的命名惯例,这样对其他Go开发者会更直观。

回到顶部