• 关于Go错误处理新提案的一个想法:?操作符这样用行不行
  • 发布于 2个月前
  • 149 热度
    0 评论
一. 背景
Ian Taylor在关闭了旨在消除Go错误处理样板代码的issue[1]之后,又另起了一个“同名”的discussion[2]。错误处理真不愧是Go社区呼声最高的问题,几天之内又收到了近500条回复!不过到目前为止,依然没有形成统一和高赞的意见。关于error handling的样板代码过多,其实我个人是可以接受的,即便不做出任何改变也是ok的,估计Go社区与我有相同看法的也不在少数。比如就有人引用了Rob Pike的权威观点[3],并认为Go应该按照Rob大神的思路,保持Go语法稳定:  

不过自然也会有另外一批人强烈希望错误处理的样板代码得到改进。

Ian Taylor在discussion中明确了该提案的目标是引入一种新语法,在不影响控制流清晰度的前提下,减少正常情况下检查错误所需的代码量。   Ian最初的Proposal由于隐式声明变量err以及可选代码块等问题而备受“批评”,并且似乎该proposal违反了他自己提出的目标。   今天在discussion中看到一位名为Mukunda Johnson的gopher的评论[4],我觉得很有道理。其核心观点就是:尽量保持Go的传统语法形式。他还给出了期望中的语法示例:
// 当前错误处理样板代码过多的示例
f, err := open(file)
if err != nil {
   return err
}
defer f.Close()

if err = binwrite(f, signature); err != nil {
   return err
}
if err = binwrite(f, header); err != nil {
   return err
}
if err = binwrite(f, zeroSegment); err != nil {
   return err
}

for _, s := range segments {
   if err = binwrite(f, s); err != nil {
      return err
   }
}

if err = binwrite(f, footer); err != nil {
   return err
}

vs. 
// 堆代码 duidaima.com
// 使用新语法改进后的代码
f, err := open(file)?
defer f.Close()

binwrite(f, signature)?
binwrite(f, header)?
binwrite(f, zeroSegment)?

for _, s := range segments {
   binwrite(f, s)?
}

binwrite(f, footer)?
这给了我很大启发:我们可以引入?语法,但是如果结合原先err变量的声明形式岂不是更好!比如:
f, err := open(file)?
岂不是要比下面两种形式更好!
f := open(file)?

f := open(file)? err { }
通过仅引入一个问号(?)操作符,并避免引入过多的新语法形式,却能解决60%的错误处理样板问题。根据jba对Go开源代码中错误处理的抽样统计[5],超过60%的错误处理都是直接返回err,而没有对err进行任何修饰。此外,显式声明err可以最大程度地避免隐式声明带来的问题,同时提升代码的可读性。

因此,基于尽量使用已有Go代码风格、最大程度避免隐式声明,并仅解决最常见的错误处理样板代码的原则,下面我基于Ian提案的错误处理改进语法,谈点自己关于新?操作符使用的想法,大家看看是否可行。

二. 对于最常见的未经修饰的错误处理代码
err := SomeFunction2()
if err != nil {
 return err
}
或是
if err := SomeFunction2(); err != nil {
    return err
}
我们使用下面的新语法做等价替代:
err := SomeFunction2() ?
如果声明的错误变量名为err,也可省略赋值操作符左侧代码,从而简化为:
SomeFunction2() ? // 这里略带隐式
三. 如果函数返回值有多个,甚至有多个错误变量的情况
比如下面代码:
a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
    return err2
}
我们可以将其改写为:
a, b, err0, err1, err2 := SomeFunction3()? 
其语义是如果err2不为nil,返回err2,但前提要保证赋值语句的左侧的最后一个变量err2必须是实现error接口的类型的变量。
如果是像下面这样在err2 != nil时有多个返回值,又该如何处理呢?
a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
    return a, b, err2
}
对于这种情况,我认为可以不在新方案的考虑范围之内,现在怎么写,请继续这么写。如果非要解决,请继续看后面支持可选代码块的情况。实现以上两种情况,就能解决60%以上的错误样板代码问题了!

四. 对于对返回的error值进行修饰的情况
对于像下面两种对返回的error变量进行修饰的情况:
r, err := SomeFunction()
if err != nil {
    return fmt.Errorf("something failed: %v", err)
}

if err := SomeFunction2(); err != nil {
    return fmt.Errorf("something else failed: %v", err)
}
我的第一想法是保持现状 ,不在新方案考虑范围之内。不过如果非要在新方案中解决,那就需要引入可选代码块(optional block)了!比如:
r, err := SomeFunction() ? {
    return fmt.Errorf("something failed: %v", err)
}

err := SomeFunction2() ? {
    return fmt.Errorf("something else failed: %v", err)
}
和Ian的原proposal中语法不同,这里我们依然显式声明了err,当然你也可以不用err这个名字,由于是显式声明,你用任何名字均可,比如:
r, e := SomeFunction() ? {
    return fmt.Errorf("something failed: %v", e)
}

myErr := SomeFunction2() ? {
    return fmt.Errorf("something else failed: %v", myErr)
}
这将避免隐式声明带来的诸多问题!
基于可选代码块,我们也可以处理一下前面提到的返回多个值的情况。下面代码
a, b, err0, err1, err2 := SomeFunction3()
if err2 != nil {
    return a, b, err2
}
可以改写为:
a, b, err0, err1, err2 := SomeFunction3() ? {
    return a, b, err2
}
这里加入可选代码块后,我建议开发人员负责显式调用return,而不是由?操作符来自动return,也就是说完全将控制权交给你。如果你没有在可选代码块中调用return,那么代码在执行完可选代码块中的代码后,还会继续向下执行。可选代码块相当于一个error handler,而不带可选代码块的情况,默认的error handler其实就是一个return err,伪代码类似这样:
err := SomeFunction2() ?
<=>
err := SomeFunction2() ? {
 return err
}
这样解释后,你是不是觉得在语义层面,不带可选代码块与带有可选代码块的情况就统一和一致了呢!
本质上来说,?+可选代码块仅是让你少敲了个if以及err != nil。

五. 综合示例
Mukunda Johnson给出的示例其实已经可以很好地展示?操作符+显式声明err方案带来的消除样板代码的效果,这里再回顾一下(这里没用到可选代码块,因此代码显得格外清晰):
f, err := open(file)?
defer f.Close()

binwrite(f, signature)?
binwrite(f, header)?
binwrite(f, zeroSegment)?

for _, s := range segments {
   binwrite(f, s)?
}

binwrite(f, footer)?
此外,在原discussion中,另外一个gopher提出的示例,我们也可以用上面的想法改写一下:
// 最常见的情况
SomeFunc() ?

// 多个返回值,最后一个为error变量
a, err1 := SomeFunction2() ?

// 返回前对err进行修饰
err := SomeFunc() ? {
  return fmt.Errorf("oh no: %w", err)
}

// 显式声明避免变量遮蔽
err := SomeFunc() ?  {
  otherErr := OtherFunc() ?  {
    err = errors.Wrap(err, otherErr) // 在可选代码块中没有显式调用return,代码还会继续向后执行
  }
  return fmt.Errorf("oh no: %w", err)
}
六. 小结
再来简单总结一下上面想法中的语法形式的优势:
1.与传统Go语法形式几乎一致,尽量避免引入过多新语法形式,在不使用可选代码块的时候,只是多了一个问号(?)。
2.显式声明err变量,最大程度避免隐式声明带来的问题。
3.专注解决最常见的错误处理样板情景,其他场景保持当前写法即可。
4.即便引入可选代码块,本质上与不用可选代码块的语法在语义层面也是统一和一致的。
这一语法方案保留了原Ian提案中的优势,并能消除一些缺点,如变量遮蔽和隐式声明等。不过,仍然有些原proposal中的劣势问题无法完全消除,但这些问题显然不是主要关注点。

需要注意的是,以上想法目前仅停留在形式讨论层面,技术层面是否可行尚不确定。
大家认为我的想法可行吗?希望大家能提出更具建设性的意见^_^。

参考资料
[1] 旨在消除Go错误处理样板代码的issue: https://github.com/golang/go/issues/71203
[2] discussion: https://github.com/golang/go/discussions/71460
[3] Rob Pike的权威观点: https://go.dev/talks/2015/simplicity-is-complicated.slide#9
[4] Mukunda Johnson的gopher的评论: https://github.com/golang/go/discussions/71460#discussioncomment-12084482
[5] jba对Go开源代码中错误处理的抽样统计: https://github.com/golang/go/issues/71203#issuecomment-2585915103
用户评论