发布于 2026-01-06 0 阅读
0

明白了:当 nil != nil 时

明白了:当 nil != nil 时

在我的第一个 Go 项目中,我实现了自己的函数Error struct,以传达比 Go 内置函数error interface提供的简单错误消息更详细的错误信息。

我最初实现的时候,是用 `<div>` 来实现的struct。后来我发现用 `<div>` 更好interface

package myerr

type Code string

type Error interface {
    error            // implementations must satisfy error interface
    Code() Code      // project-specific error code
    Cause() Error    // the Error that caused this Error, if any
    OrigErr() error  // the original error
    // ...
}
Enter fullscreen mode Exit fullscreen mode

关于错误代码的题外话

我之所以选择string使用整数而不是二进制数作为错误代码,是因为整数的唯一优势在于它们便于类 C 语言代码处理switch。然而,整数代码也存在一些缺点,包括:

  1. 它们对人类读者来说并不友好:你必须查找代码,或者错误提供程序必须包含字符串描述(在这种情况下,提供程序一开始就把代码做成字符串还不如)。
  2. 您必须确保来自不同子系统的代码不会冲突。

是的,比较字符串和比较整数确实会略微降低性能,但是:

  1. 想必错误处理是例外情况,所以代码运行速度稍慢一些也无妨,因为它不是关键路径上的代码。
  2. 如果你正在开发一个 REST 应用,其中错误代码是通过 HTTP 以 JSON 响应的形式发送的,那么由于错误代码是以文本形式通过网络传输的,因此在itoa处理和atoi解析整数代码(更不用说解析整个 JSON 文档)时,性能就已经受到了影响。所以,额外的字符串比较操作只会增加性能负担。

尽管现在是一个interfaceError但仍然需要由一个来实现struct

type MyError struct {
    code    Code
    origErr error
    cause   *MyError
}

func (e *MyError) Cause() Error {
    return e.cause
}

func (e *MyError) Code() Code {
    return e.code
}

func (e *MyError) OrigErr() error {
    return e.origErr
}
Enter fullscreen mode Exit fullscreen mode

这一切看起来都相当简单明了。(注:其他用于生成错误的功能在此省略,因为它们与本文无关;但它们同样简单明了。)

问题

当时我正在编写一些负面单元测试(确保能够正确检测和报告错误),因此我需要一种方法来比较两个Error对象。GoDeepEqual()的标准库中有一个 `isEquals` 函数,虽然它能用,但它只能告诉你两个对象是否相等。对于测试来说,如果两个对象不相等,那么知道具体不相等的是什么就很有帮助,所以我编写了ErrorDeepEqual()一个 `isEquals` 函数,它返回一个error描述此情况的 `isEquals` 对象:

func ErrorDeepEqual(e1, e2 myerror.Error) error {
    if e1 == nil {
        if e2 != nil {
            return errors.New("e1(nil) != e2(!nil)")
        }
        return nil
    }
    if e2 == nil {
        return errors.New("e1(!nil) != e2(nil)")
    }
    if e1.Code() != e2.Code() {
        return fmt.Errorf("e1.Code(%v) != e2.Code(%v)", e1.Code(), e2.Code())
    }
    // ...
    return ErrorDeepEqual(e1.Cause(), e2.Cause())
}
Enter fullscreen mode Exit fullscreen mode

由于一个条件Error可能存在原因,ErrorDeepEqual()因此最终会通过递归调用来检查这些原因是否相等。问题在于其中一个测试nil在这一行因指针问题而引发 panic:

    if e1.Code() != e2.Code() {  // panics here because e1 is nil
Enter fullscreen mode Exit fullscreen mode

但是if这行代码前面的几行代码检查了nil,所以这里既不是e1也不e2nil,对吧?怎么可能e1 == nilfalsee1nil

原因

事实证明,在 Go 语言中,这种情况确实会发生,而且让很多人感到困惑,以至于专门为此设立了一个常见问题解答。简单来说,一个 `int` 对象interface包含两部分:类型该类型的只有当类型interface为 `int` 时,`int` 对象才是`int` 对象。nilnil

var i interface{}                // i = (nil,nil)
fmt.Println(i == nil)            // true
var p *int                       // p = nil
i = p                            // i = (*int,nil)
fmt.Println(i == nil)            // false: (*int,nil) != (nil,nil)
fmt.Println(i == (*int)(nil))    // true : (*int,nil) == (*int,nil)
fmt.Println(i.(*int) == nil)     // true : nil == nil
Enter fullscreen mode Exit fullscreen mode

现在,我们暂且搁置 Go 语言为何以这种方式运行(以及这种运行方式是否合理)的理由。

修复方案

与此同时,我仍在努力弄清楚为什么会得到一个nil Error带有nil值的非零值。结果发现罪魁祸首是:

func (e *MyError) Cause() Error {
    return e.cause               // WRONG!
}
Enter fullscreen mode Exit fullscreen mode

问题在于,无论何时给一个值赋值interface(无论是显式赋值还是隐式赋值),它都会同时return具有该值及其具体类型。一旦它具有了具体类型,它就永远不会与(无类型的)相等。解决方法是:nil

func (e *MyError) Cause() Error {
    if e.cause == nil {          // if typed pointer is nil ...
        return nil               //     return typeless nil explicitly
    }
    return e.cause               // else return typed non-nil
}
Enter fullscreen mode Exit fullscreen mode

也就是说:当一个函数的返回类型是interface并且你可以返回一个nil指针时,你必须检查并且如果指针是 ,nil则返回字面量nilnil

理由

Go 语言之所以这样运行,有几个原因。go -nuts 邮件列表有一个很长的讨论帖。我仔细阅读了帖子,并总结出了主要原因。

首先,Go 允许任何用户自定义类型为其定义方法,而不仅仅是struct其他语言中的类型(或类)。例如,我们可以定义自己的类型intStringer interface为其实现方法:

type Aint int                    // accounting int: print negatives in ()

func (n Aint) String() string {
    if n < 0 {
        return fmt.Sprintf("(%d)", -n)
    }
    return fmt.Sprintf("%d", n)
}
Enter fullscreen mode Exit fullscreen mode

现在,让我们使用一个Stringer interface变量:

var n Aint                       // n = (Aint,0)
var s fmt.Stringer = n           // s = (Aint,0)
fmt.Println(s == nil)            // false: (Aint,0) != (nil,nil)
Enter fullscreen mode Exit fullscreen mode

这里,s是非的,nil因为它指的是一个Aint——而它的值恰好是0。0值没有什么特别之处,因为0对于一个来说是一个非常好的值int

第二个原因是 Go 语言具有非常强的类型。例如:

fmt.Println(s == 0)              // error: can't compare (Aint,0) to (int,0)
Enter fullscreen mode Exit fullscreen mode

这是一个编译时错误,因为你不能将s(指的是一个Aint)与 0 进行比较(因为普通的 0 是类型为 的int)。但是,你可以使用类型转换或类型断言:

fmt.Println(s == Aint(0))        // true: (Aint,0) == (Aint,0)
fmt.Println(s.(Aint) == 0)       // true: 0 == 0
Enter fullscreen mode Exit fullscreen mode

第三个原因是Go 明确允许nil方法具有指针接收器

type T struct {
    // ...
}

func (t *T) String() string {
    if t == nil {
        return "<nil>"
    }
    // ...
}
Enter fullscreen mode Exit fullscreen mode

就像 0 对于 `int` 来说是一个完全合适的值一样Aintnil对于指针来说也是一个完全合适的值。由于 ` nilint` 不指向任何 `int` T因此它可以用来实现 `int` 的默认行为T。重复之前的例子,Aint但这次是 `int` *T

var p *T                         // p = (*T,nil)
var s fmt.Stringer = p           // s = (*T,nil)
fmt.Println(s == nil)            // false: (*T,nil) != (nil,nil)
fmt.Println(s == (*T)(nil))      // true : (*T,nil) == (*T,nil)
fmt.Println(s.(*T) == nil)       // true : nil == nil
Enter fullscreen mode Exit fullscreen mode

我们对指针也得到了类似的结果——几乎如此。(你能看出其中的矛盾之处吗?)

哪里出了问题

与之前将类型为 的元素与类型为 0 的元素Aint进行比较会出错的例子不同:sAintint

fmt.Println(s == 0)              // error: can't compare (Aint,0) to (int,0)
Enter fullscreen mode Exit fullscreen mode

而将s(类型为*T)与nil(类型为nil)进行比较是可以的:

fmt.Println(s == nil)            // OK to compare
Enter fullscreen mode Exit fullscreen mode

问题在于,在 Go 语言中,nil根据上下文的不同,它有两种不同的含义:

  1. nil指的是值为 0 的指针。
  2. nilinterface类型为空且值为 0 的对象。

假设 Go 语言nilinterface为情况 2nil准备了另一个关键字,而该关键字则专门用于情况 1。那么上面的代码行应该写成:

fmt.Println(s == nilinterface)   // false: (*T,nil) != nilinterface
Enter fullscreen mode Exit fullscreen mode

这样一来,就很明显你是在检查它interface本身是否为无类型且值为 0,而不是检查它的interface

此外,使用原始值nil会导致错误(原因与你不能Aint在不进行类型转换或类型断言的情况下将一个值与 0 进行比较相同):

fmt.Println(s == nil)            // what-if error: can't compare (*T,nil) to (nil,nil)
fmt.Println(s == (*T)(nil))      // still true: (*T,nil) == (*T,nil)
fmt.Println(s.(*T) == nil)       // still true: nil == nil
Enter fullscreen mode Exit fullscreen mode

Go语言中令人困惑的地方在于,当新手程序员编写代码时:

fmt.Println(s == nil)            // checks interface for empty, not the value
Enter fullscreen mode Exit fullscreen mode

他们以为自己在检查接口的值,但实际上是在检查接口本身是否为空类型且值为 0。如果 Go 有一个类似 `is` 的独立关键字nilinterface,所有这些困惑都会消失。

黑客

如果你觉得这些都很麻烦,你可以编写一个函数来检查interface一个nil值,而忽略它的类型(如果有的话):

func isNilValue(i interface{}) bool {
    return i == nil || reflect.ValueOf(i).IsNil()
}
Enter fullscreen mode Exit fullscreen mode

虽然这种方法可行,但速度很慢,因为反射本身速度就慢。一种速度快得多的实现方式是:

func isNilValue(i interface{}) bool {
    return (*[2]uintptr)(unsafe.Pointer(&i))[1] == 0
}
Enter fullscreen mode Exit fullscreen mode

这会直接检查值部分interface是否为零,而忽略类型部分。

文章来源:https://dev.to/pauljlucas/go-tcha-when-nil--nil-hic