Go语言第一深坑 - interface 与 nil 的比较

xiaonanln · 2017-08-14 04:03:06 · 15963 次点击 · 预计阅读时间 5 分钟 · 大约8小时之前 开始浏览    
这是一个创建于 2017-08-14 04:03:06 的文章,其中的信息可能已经有所发展或是发生改变。

interface简介

Go语言以简单易上手而著称,它的语法非常简单,熟悉C++,Java的开发者只需要很短的时间就可以掌握Go语言的基本用法。

interface是Go语言里所提供的非常重要的特性。一个interface里可以定义一个或者多个函数,例如系统自带的io.ReadWriter的定义如下所示:

type ReadWriter interface {
    Read(b []byte) (n int, err error)
    Write(b []byte) (n int, err error)
}

任何类型只要它提供了Read和Write的绑定函数实现,Go就认为这个类型实现了这个interface(duck-type),而不像Java需要开发者使用implements标明。

然而Go语言的interface在使用过程中却有一个特别坑的特性,当你比较一个interface类型的值是否是nil的时候,这是需要特别注意避免的问题。

一次真实的踩坑

这是我们在GoWorld分布式游戏服务器的开发中,碰到的一个实际的bug。由于GoWorld支持多种不同的数据库(包括MongoDB,Redis等)来保存服务端对象,因此GoWorld在上层提供了一个统一的对象存储接口定义,而不同的对象数据库实现只需要实现EntityStorage接口所提供的函数即可。

// EntityStorage defines the interface of entity storage backends
type EntityStorage interface {
    List(typeName string) ([]common.EntityID, error)
    Write(typeName string, entityID common.EntityID, data interface{}) error
    Read(typeName string, entityID common.EntityID) (interface{}, error)
    Exists(typeName string, entityID common.EntityID) (bool, error)
    Close()
    IsEOF(err error) bool
}

以一个使用Redis作为对象数据库的实现为例,函数OpenRedis连接Redis数据库并最终返回一个redisEntityStorage对象的指针。

// OpenRedis opens redis as entity storage
func OpenRedis(url string, dbindex int) *redisEntityStorage {
    c, err := redis.DialURL(url)
    if err != nil {
        return nil
    }

    if dbindex >= 0 {
        if _, err := c.Do("SELECT", dbindex); err != nil {
            return nil
        }
    }

    es := &redisEntityStorage{
        c: c,
    }

    return es
}

在上层逻辑中,我们使用OpenRedis函数连接Redis数据库,并将返回的redisEntityStorage指针赋值个一个EntityStorage接口变量,因为redisEntityStorage对象实现了EntityStorage接口所定义的所有函数。

var storageEngine StorageEngine // 这是一个全局变量
storageEngine = OpenRedis(cfg.Url, dbindex)
if storageEngine != nil {
    // 连接成功
    ...
} else {
    // 连接失败
    ...
}

上面的代码看起来都很正常,OpenRedis在连接Redis数据库失败的时候会返回nil,然后调用者将返回值和nil进行比较,来判断是否连接成功。这个就是Go语言少有的几个深坑之一,因为不管OpenRedis函数是否连接Redis成功,都会运行连接成功的逻辑。

寻找问题所在

想要理解这个问题,首先需要理解interface{}变量的本质。在Go语言中,一个interface{}类型的变量包含了2个指针,一个指针指向值的类型,另外一个指针指向实际的值。 我们可以用如下的测试代码进行验证。

// InterfaceStructure 定义了一个interface{}的内部结构
type InterfaceStructure struct {
    pt uintptr // 到值类型的指针
    pv uintptr // 到值内容的指针
}

// asInterfaceStructure 将一个interface{}转换为InterfaceStructure
func asInterfaceStructure (i interface{}) InterfaceStructure {
    return *(*InterfaceStructure)(unsafe.Pointer(&i))
}

func TestInterfaceStructure(t *testing.T) {
    var i1, i2 interface{}
    var v1 int = 0x0AAAAAAAAAAAAAAA
    var v2 int = 0x0BBBBBBBBBBBBBBB
    i1 = v1
    i2 = v2
    fmt.Printf("sizeof interface{} = %d\n", unsafe.Sizeof(i1))
    fmt.Printf("i1 %x %+v\n", i1, asInterfaceStructure(i1))
    fmt.Printf("i2 %x %+v\n", i2, asInterfaceStructure(i2))
    var nilInterface interface{}
    fmt.Printf("nil interface = %+v\n", asInterfaceStructure(nilInterface))
}

这段代码的输出如下:

sizeof interface{} = 16
i1 aaaaaaaaaaaaaaa {pt:5328736 pv:825741282816}
i2 bbbbbbbbbbbbbbb {pt:5328736 pv:825741282824}
nil interface = {pt:0 pv:0}

所以对于一个interface{}类型的nil变量来说,它的两个指针都是0。这是符合Go语言对nil的标准定义的。在Go语言中,nil是零值(Zero Value),而在Java之类的语言里,null实际上是空指针。关于零值和空指针有什么区别,这里就不再展开了。

当我们将一个具体类型的值赋值给一个interface类型的变量的时候,就同时把类型和值都赋值给了interface里的两个指针。如果这个具体类型的值是nil的话,interface变量依然会存储对应的类型指针和值指针。

func TestAssignInterfaceNil(t *testing.T) {
    var p *int = nil
    var i interface{} = p
    fmt.Printf("%v %+v is nil %v\n", i, asInterfaceStructure(i), i == nil)
}

输入如下:

<nil> {pt:5300576 pv:0} is nil false

可见,在这种情况下,虽然我们把一个nil值赋值给interface{},但是实际上interface里依然存了指向类型的指针,所以拿这个interface变量去和nil常量进行比较的话就会返回false

如何解决这个问题

想要避开这个Go语言的坑,我们要做的就是避免将一个有可能为nil的具体类型的值赋值给interface变量。以上述的OpenRedis为例,一种方法是先对OpenRedis返回的结果进行非-nil检查,然后再赋值给interface变量,如下所示。

var storageEngine StorageEngine // 这是一个全局变量
redis := OpenRedis(cfg.Url, dbindex)
if redis != nil {
    // 连接成功
    storageEngine = redis // 确定redis不是nil之后再赋值给interface变量
} else {
    // 连接失败
    ...
}

另外一种方法是让OpenRedis函数直接返回EntityStorage接口类型的值,这样就可以把OpenRedis的返回值直接正确赋值给EntityStorage接口变量。

// OpenRedis opens redis as entity storage
func OpenRedis(url string, dbindex int) EntityStorage {
    c, err := redis.DialURL(url)
    if err != nil {
        return nil
    }

    if dbindex >= 0 {
        if _, err := c.Do("SELECT", dbindex); err != nil {
            return nil
        }
    }

    es := &redisEntityStorage{
        c: c,
    }

    return es
}

至于那种方法更好,就见仁见智了。希望大家在实际项目中不要踩坑,即使踩了也能快速跳出来!

开源分布式游戏服务器引擎:https://github.com/xiaonanln/goworld,欢迎赏星,共同学习

对Go语言服务端开发感兴趣的朋友欢迎加入QQ讨论群:662182346


有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

15963 次点击  ∙  1 赞  
加入收藏 微博
17 回复  |  直到 2021-03-04 14:52:15
hucsmn
hucsmn · #1 · 8年之前

对于此类问题,即多个底层实现全部通过同一个 interface 类型来提供 API 的情况,个人的习惯是,构造函数的返回类型直接写成 interface 类型而不是具体的底层实现类型。比如文中的 func OpenRedis(url string, dbindex int) *redisEntityStorage 就可以改写成 func OpenRedis(url string, dbindex int) EntityStorage。这样一来构造函数里非 nil 的返回值会被 Go 自动装箱,返回 nil 则当作 nil interface value 而不是 nil concrete-typed value 来处理。此外尽量避免 type assertion。

另外,前些天 dave cheney 的一篇文章里提到了关于 go2 typed nil 的一些设想,非常期待 Go 2 到时候可以把这个坑给填了。

xiaonanln
xiaonanln · #2 · 8年之前

我也觉得interface{}和nil进行比较的时候,应该只比较值部分就行了,类型部分不管是不是空,都应该算作nil

qiangmzsx
qiangmzsx · #3 · 8年之前

我建议再加返回error,而不只是仅仅返回interface

mortemnh
mortemnh · #4 · 8年之前

所以说为什么要加一个返回error.....因为有些情况下用返回的值去判断返回是否成功本身就是一个不恰当的设计。 Go不是不支持多返回值。

xiaonanln
xiaonanln · #5 · 8年之前
qiangmzsxqiangmzsx #3 回复

我建议再加返回error,而不只是仅仅返回interface

其实这里是为了描述方便,我原来的代码里确实是多返回了一个error,但是其他部分的代码依然出了这个问题

marlonche
marlonche · #6 · 8年之前

确实应该按照go的习惯返回error表示是否成功,如果java写习惯了很容易写下面的代码,如果一个人先学的go再去学java,会发现 if (str != ”hello“)同样是坑

var storageEngine StorageEngine // 这是一个全局变量
storageEngine = OpenRedis(cfg.Url, dbindex)
if storageEngine != nil {
    // 连接成功
    ...
} else {
    // 连接失败
    ...
}
slidoooor
slidoooor · #7 · 8年之前

小白有个小问题提问,第一次赋值时候

i1 aaaaaaaaaaaaaaa {pt:5328736 pv:825741282816}
i2 bbbbbbbbbbbbbbb {pt:5328736 pv:825741282824}

这两个结构体pt和pv都有值

第二次赋值一个空值<nil> {pt:5300576 pv:0} is nil false pv的值就是零,我的疑惑是pt,pv是怎么确定自己是指向什么类型的指针呢..因为没看到pt是指向类型,pv是指向值的操作,也不知道为什么

第二个pt有值,,这个值是空数据的地址吗?麻烦你了

xiaonanln
xiaonanln · #8 · 8年之前

@slidoooor 给interface赋值的时候,右边的那个东西是有类型的,pt就是指向Go在编译时所确定的类型。

lixiaojun629
lixiaojun629 · #9 · 6年之前
package main

import (
    "fmt"
)

type EntityStorage interface {
    Close()
}

type redisEntityStorage struct {
    a string
}

func (p *redisEntityStorage) Close() {
    fmt.Println("close")
}

func OpenRedis(url string, dbindex int) *redisEntityStorage {
    es := &redisEntityStorage{
        a: "redis",
    }
    if false {
        return es
    } else {
        return nil
    }
}

func main() {
    var storageEngine = OpenRedis("", 0)
    if storageEngine != nil {
        // 连接成功
        fmt.Println("sucess")
    } else {
        // 连接失败
        fmt.Println("error")
    }

}

程序最终打印出”error" 没有复现作者的问题

focussoft
focussoft · #10 · 6年之前
lixiaojun629lixiaojun629 #9 回复

``` package main import ( "fmt" ) type EntityStorage interface { Close() } type redisEntityStorage struct { a string } func (p *redisEntityStorage) Close() { fmt.Println("close") } func OpenRedis(url string, dbindex int) *redisEntityStorage { es := &redisEntityStorage{ a: "redis", } if false { return es } else { return nil } } func main() { var storageEngine = OpenRedis("", 0) if storageEngine != nil { // 连接成功 fmt.Println("sucess") } else { // 连接失败 fmt.Println("error") } } ``` 程序最终打印出”error" 没有复现作者的问题

难道这个问题和go的版本有关? 毕竟作者的帖子是2年前的内容了. 如果interface{}无法直接和nil比较那真的是太挫了.

lixiaojun629
lixiaojun629 · #11 · 6年之前

https://golang.org/ref/spec#Comparison_operators

Interface values are comparable. Two interface values are equal if they have identical dynamic types and equal dynamic values or if both have value nil.

根据go语言标准,不存在搂主的问题。

jiabaozhanglixl
jiabaozhanglixl · #12 · 6年之前

go语言既然是一门新语言,有它一些自己特有的编码习惯。 楼主这个问题就是属于其中之一。 go语言的编码习惯是错误用error,鼓励这样子做。 判断返回值是否是nil,这种是java的习惯,不鼓励这样子做。 我理解的go,是这样子的思维导向。

ewfkeke
ewfkeke · #13 · 6年之前

具体类型*redisEntityStorage赋值给了接口EntityStorage,虽然*redisEntityStorage是nil值,但是赋值给接口EntityStorage时,EntityStorage的指向类型的指针为*redisEntityStorage,而指向实际值为nil,这个时候EntityStorage肯定不等于nil,为什么你没有复现,是因为你的代码有问题,修改如下就能出现文章复现文章说提到的问题,如下:

var storageEngine = OpenRedis("", 0) 
// 修改如下
var storageEngine EntityStorage
storageEngine = OpenRedis("", 0)
gopher8
gopher8 · #14 · 5年之前

我写的一个demo

package main

import "fmt"

type Person interface {
    getName() string
}

type User struct {
    Name string
    Age  int
}

func (u User) getName() string {
    return u.Name
}

var p Person
var ok bool = true

func main() {
    fmt.Println(p, p == nil)

    // 坑
    p = newUser()
    fmt.Println(p, p == nil)

    // 方法一,先判断,再赋值
    u := newUser()
    fmt.Printf("%v %#v %T\n", u, u, u)
    fmt.Println(u == nil)
    if u != nil {
        p = u
        fmt.Println(p, p == nil)
    }

    // 方法二,返回接口类型
    p = newUser2()
    fmt.Println(p, p == nil)
}

func newUser() *User {
    if !ok {
        return nil
    }
    return &User{}
}

func newUser2() Person {
    if !ok {
        return nil
    }
    return &User{}
}

输出结果是

 true
&{ 0} false
&{ 0} &main.User{Name:"", Age:0} *main.User
false
&{ 0} false
&{ 0} false
gopher8
gopher8 · #15 · 5年之前

不建议用第二种方法,方法应该返回具体的类型,而不是返回实现了哪个接口,因为返回的值可能实现了多个接口,而且返回的是接口值的话也太笼统了。 对于方法可能出错的情况,应该加一个返回值error,根据跟返回值来判断是否执行成功。

davidyanxw
davidyanxw · #16 · 5年之前

楼主可能描述的有点小问题,但是结论是对的,是个坑。 复现代码:

type EntityStorage interface {
    Close()
}
type RedisEntityStorage struct {
}

func (*RedisEntityStorage) Close() {

}

func OpenRedis() *RedisEntityStorage {
    return nil
}

func main() {
    var storageEngine EntityStorage
    fmt.Println(reflect.TypeOf(storageEngine), reflect.ValueOf(storageEngine))
    fmt.Println(storageEngine == nil) // true
    storageEngine = OpenRedis()
    fmt.Println(reflect.TypeOf(storageEngine), reflect.ValueOf(storageEngine))
    fmt.Println(storageEngine == nil) // false
}

具体问题楼主已经说了,两个解决方法都可以。

另外,从习惯上,有两个点可以帮助避免这些坑:

  1. 多返回值error
  2. storageEngine变量类型是EntityStorage,那么OpenRedis()返回值也应该是EntityStorage,保持一致。
jkingben
jkingben · #17 · 4年之前

今天刚碰到,interface的比较真的是个大坑啊。

添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传