跳到主要内容
  1. Skills/
  2. 后端编程/

golang 中的零值问题与判断为空

··字数 5498·11 分钟
go

零值(zero value),即使你是 java、c 等编程语言的有经验开发者,对这个概念也会比较陌生,只是大概你可以猜到它可能是什么。不管你是新手,还是老鸟,只要你是编写 golang 程序,是无法绕过这个概念的,因为它是 golang 程序中的一种机制,凡写 go 程序必遇到。如果你已经比较熟悉的编程经验,在学习或编写 golang 程序之前,需要抱弃经验主义,仔细了解清楚这个特性,它的确与你熟悉的语言用法不太一样。

本文将涉及 golang 关于如下方面的内容:

  • 指针
  • 基本数据类型的零值与空值判断

在说零值问题之前,先来看看一个貌似无关的概念,指针。

本文可能跳跃得比较快,从指针入手,至变量定义赋值,一下就要 golang 零值,中间缺少的知识联系,请读者自行另外补充。

空指针 #

指针,是内存地址,指针变量用于存放内存地址的变量。通俗地讲,就是指向内存中某个位置,根据用户的数据类型与数据结构定义,可以从这个位置上获取要数值或数据内容。

指针可以节省内存,提高程序运行效率,比如,在函数调用时需要传递参数值,如果这个值是一个比较大的结构体,那么在堆栈上就需要开辟一个比较大的空间来临时存储这个参数变量。如果使用指针类型定义参数,那么传递参数时需要的空间可以理解为一个指针大小,在一种编程语言中,指针值大小是固定的,32位或者64位的整数型。

空指针,指没有确定的内存地址的指针,通常就是声明了指针变量而没有赋值,或者是在程序过程中指针变量被赋予空值。

如果你常经学习并动手编写 c 语言类的程序,大概都跟 空指针 缠斗过,一些变量用着用着,就出现莫名其妙的值,为了追查这个期望以外的值,必须对变量从赋值到异常处逐步调试。

如果你开发过 java 程序,一定非常熟悉下面这个提示。

Exception in thread "main" java.lang.NullPointerException

偷偷告诉你,这个看似低级的错误,会消耗掉程序测试的大部分时间。即使是有经验的程序员,也经常会出现类似于空指针、变量未赋值或初始化的低级错误。这太正常了。在以前,针对这类问题,你很可能只能在程序中频繁的对可能出现这类情况的变量进行空值或者空指针检查。以至于,很多语言的编译器,都发明了帮助开发者发现『 变量未定义、变量未赋值、变量定义未使用』这些人为犯下的低级错误。

而在近十多年内诞生、并被应用得规模比较广的语言中,都几乎必定存在一个功能,就是在开发者编写保存代码文件时,语言检查器就会检查这类错误的存在。

即使有了这种编程助手,但在编写程序时还是要非常小心,助手只是减少了上面所述情况的出现,并不能解决100%的问题。

golang 中的解决方案 #

golang, 起初是 google 设计出来解决 python 不能满足 google 大规模 web 服务的开发语言,当前已经被大量互联网企业所接受,有悟猜测,假以时日,golang 会与 java 平分天下,甚至是在某些领域会替换掉 java。

原因有很多,不过这不是本文要所涉及的。

有悟终于遇到了有针对『没有』、『空值』进行明确定义的语言。从业务角度,『没有』与『空值』在很多场景是等同的,都表示取不结果。但在编程语言中,『没有』与『空值』有语义上的严谨不同,『没有』即是什么都没有,『空值』即是有定义但当前的没有值。

在 golang 中,有一个统一的概念,零值(zero value),简单并容易理解。所有的变量在定义时,就被绑定数据类型,如果没有显式的设置初始值,会使用默认的规则对变量进行隐式初始化,从而减少了在其它语言中由于空指针或未赋值而导致的错误。它可用于表示指针、接口、map、切片(slice)、通道(channels)、函数等类型的未初始化值。

下面是一段来自 Tour of Go 非官方的关于『零值』的描述:

Zero values

Variables declared without an explicit initial value are given their zero value.

The zero value is:

0 for numeric types, false for the boolean type, and "" (the empty string) for strings.

大意:

零值: 变量变量时没有显式初始化的,会赋予零值。零值规则有:

  • 数字类型:使用 0 作为初始化值
  • 布尔类型:使用 false 作为初始化值
  • 字符串:使用 空字符串 “” 作为初始化值

引用类型 #

引用,可以理解为一个指针,在函数调用时或程序中声明变量时,为避免在赋值或参数值传递过程中产生比较大的数据存储空间,这时会使用引用类型的变量。 指针类型就是引用。

在 golang 中,数组、切片(slice)、map、函数、指针等类型,都是引用。所以对于这些类型来说,直接声明使用即可,得到的变量就是一个引用。而无须再声明指向这些类型引用的指针,不但难以理解,也会容易出错。判断引用为空的语法形式就是 引用类型的变量 != nil

golang 中判断为空 #

有了对付空值的手段,并不意味着就可以不对空值进判断。因为程序逻辑往往在『有值』与『空值或无值』时对应有不同的处理逻辑。

那么有了 零值(zero value),判断为空的逻辑也要在思维方式上有所调整。

为了下面例子演示的需要,先创建一个工程:

~/youwu.today/go/examples
➜  mkdir zerovalue && zerovalue

~/youwu.today/go/examples/zerovalue
➜  go mod init zerovalue

~/youwu.today/go/examples/zerovalue
➜  touch zero.go

zero.go 中添加如下示例代码:

package main

import "fmt"

func main() {
    fmt.Println("😀😀 有悟的 go 零值 zero-values 例子 😀😀")
}

数字类型与布尔类型变量的空值判断 #

数字类型、布尔类型是 golang 中的原生数据类型,占用空间小,定义这种类型时的变量时,通常不会通过指针来使用。你再也不必担心变量没有赋值,反而应关注零值 『数字类型变量为0』、『布尔变量初始化为 false』对你的程序逻辑是否有影响,并使用 数字变量>0布尔变量 == false 来区分。

// zero.go
...
func int_bool_zerovalue() {
    fmt.Printf("\n************演示 int/bool 零值*********\n")
    var i int
    if i == 0 {
        fmt.Println("i(int) 的零值: ", i)
    }
    var i2 *int // 注意,这是不良示范
    if i2 == nil {
        fmt.Println("i2(*int) 的零值: ", i2)
    }
    var b bool
    if b == false {
        fmt.Println("b(bool) 的零值: ", b)
    }
    var b2 *bool // 注意,这是不良示范
    if b2 == nil {
        fmt.Println("b2(*bool) 的零值: ", b2)
    }
}
...

func main 中调用 int_bool_zerovalue, 运行得到的结果如下:

~/youwu.today/go/examples/zerovalue 
➜  go build && ./zerovalue
😀😀 有悟 go 零值 zero-values 例子 😀😀

************演示 int/bool 零值*********
i(int),零值判断语法: 变量 == 0, 零值: 0
i2(*int),零值判断语法: 变量 == nil, 零值: <nil>
b(bool),零值判断语法: 变量 == false 或 !变量, 零值: false
b2(*bool),零值判断语法: 变量 == nil, 零值: <nil>

数组或切片的空值判断 #

在 golang 中数组与切片(slice)之间的概念非常容易混淆,你也不用纠结其中,在变量是否为空时,手段都是一样的。

注意,数组与切片的区别,超过本文范围,你可能需要仔细去了解。

当你使用 var a []int 定义一个数组时,其实得到的是一个指向数组的指针。 所以可以使用 变量 != nil 来判断数组是否为空。也可以判断它的长度、或者容量是否为0 len(数组变量或者切片变量) == 0cap(数组变量或者切片变量) == 0

// zero.go
...
func array_slice_zerovalue() {
    fmt.Println("\n************演示 array/slice 零值*********")
    var a []int
    if a == nil {
        fmt.Printf("a([]int),零值判断语法: %s, 零值: %v\n", "变量 == nil", a)
    }
    if len(a) == 0 {
        fmt.Printf("a([]int),零值判断语法: %s, 零值: %v\n", "len(变量) == 0", a)
    }
    if cap(a) == 0 {
        fmt.Printf("a([]int),零值判断语法: %s, 零值: %v\n", "cap(变量) == 0", a)
    }
    var b *[]int // 注意,这是不良示范
    if b == nil {
        fmt.Printf("b(*[]int),零值判断语法: %s, 零值: %v\n", "变量 == nil", a)
    }

}
...

func main 中调用 array_slice_zerovalue, 运行得到的结果如下:

~/youwu.today/go/examples/zerovalue 
➜  go build && ./zerovalue
😀😀 有悟 go 零值 zero-values 例子 😀😀

************演示 array/slice 零值*********
a([]int),零值判断语法: 变量 == nil, 零值: []
a([]int),零值判断语法: len(变量) == 0, 零值: []
a([]int),零值判断语法: cap(变量) == 0, 零值: []
a2(*[]int),零值判断语法: 变量 == nil, 零值: <nil>

字符类型变量的空值判断 #

golang 中,字符串变量是底层数据结构中包含一个字节数组,判断一个字符串变量是否为空,就看它的长度是否为0 len(字符串变量) 或者更直接的检查 字符串变量 == ""。你也不必担心像在 java 中要预防出现字符类变量带来的空指针(NullPointerException)异常错误(在 java 中 字符串是一个对象)。

知道为什么上节要先谈 数组或切片变量的空值判断 了吧

// zero.go
...
func string_zerovalue() {
    fmt.Println("\n************演示 string 零值*********")
    var s string
    if s == "" {
        fmt.Printf("s(string),零值判断语法: %s, 零值: %v\n", "变量 == \"\"", s)
    }
    if len(s) == 0 {
        fmt.Printf("s(string),零值判断语法: %s, 零值: %v\n", "len(变量) == 0", s)
    }
    var s2 *string // 注意,这是不良示范
    if s2 == nil {
        fmt.Printf("s(*string),零值判断语法: %s, 零值: %v\n", "变量 == nil", s2)
    }
}
...

func main 中调用 string_zerovalue, 运行得到的结果如下:

~/youwu.today/go/examples/zerovalue 
➜  go build && ./zerovalue
😀😀 有悟 go 零值 zero-values 例子 😀😀

************演示 string 零值*********
s(string),零值判断语法: 变量 == "", 零值: 
s(string),零值判断语法: len(变量) == 0, 零值: 
s(*string),零值判断语法: 变量 == nil, 零值: <nil>

map 类型变量的空值判断 #

map 类型类似于 python 中的 dict 字典,用来表示 键值对 数据。当声明了一个 map 类型变量时,得到了一个指向 map 数据的指针。

// zero.go
...
func map_zerovalue() {
    fmt.Println("\n************演示 map 零值*********")
    var m map[int]string
    if m == nil {
        fmt.Printf("map[int]string,零值判断语法: %s, 零值: %v\n", "变量 == nil", m)
    }
    if len(m) == 0 {
        fmt.Printf("map[int]string,零值判断语法: %s, 零值: %v\n", "len(变量) > 0", m)
    }
    var m2 *(map[int]string) // 注意,这是不良示范
    if m2 == nil {
        fmt.Printf("*map[int]string,零值判断语法: %s, 零值: %v\n", "变量 == nil", m2)
    }
}
...

func main 中调用 map_zerovalue, 运行得到的结果如下:

~/youwu.today/go/examples/zerovalue 
➜  go build && ./zerovalue
😀😀 有悟 go 零值 zero-values 例子 😀😀

************演示 map 零值*********
map[int]string,零值判断语法: 变量 == nil, 零值: map[]
map[int]string,零值判断语法: len(变量) > 0, 零值: map[]
*map[int]string,零值判断语法: 变量 == nil, 零值: <nil>

结构体变量的空值判断 #

结构体的情况比较复杂一些。结构体本身不像 map、slice 这些原生类型一样,它不是引用类型,是否是指针引用看在具体声明变量是否指定为指针。当结构体变量作为函数参数进行传递或者是结构体变量赋值时,会发生值拷贝,当结构体很大时,会影响效率。所以,你在阅读开源代码时,经常会看到使用指针的方法来获取或者传递结构体的引用,以减少不必要的值质。但是,是否使用指针关键还是要看具体需求而定。

由于结构体存在类型变量、指针引用变量两种情况,所以需要区别对待。

  • 当声明一般的结构体变量时,这个变量会被赋予一个结构体值,其各个 field 的值,按照它对应的数据类型零值规则初始化。因为不管结构体如何定义、嵌套多少层,所有非嵌套的 field 都会对应到原生类型。 相当于 var s XXXStruct 等效于 s := XXXStruct{}

  • 当声明一个结构体指针变量时,得到的是一个接收指向该结构体类型的指针。这时并没有创建一个空结构体。不过,与 map 和 数组不同的,这个空指针需要强制类型转换,(*XXXStruct)(nil)

// zero.go
...
func struct_zerovalue() {
    fmt.Println("\n************演示 struct 零值*********")
    type ZeroStruct struct {
        F1 string
    }
    var z ZeroStruct // z = ZeroStruct{}
    if z.F1 == "" {
        fmt.Printf("z(struct),零值判断语法: %s, 零值: %v\n", "变量.field 的零值判断", z)
    }
    var z2 *ZeroStruct // z2.F1 会 panic
    if z2 == nil {
        fmt.Printf("z2(*struct),零值判断语法: %s, 零值: %v\n", "变量 == nil", z2)
    }
    if z2 == (*ZeroStruct)(nil) {
        fmt.Printf("z2(*struct),零值判断语法: %s, 零值: %v\n", "变量 == (*struct)(nil)", z2)
    }
}
...

func main 中调用 struct_zerovalue, 运行得到的结果如下:

~/youwu.today/go/examples/zerovalue 
➜  go build && ./zerovalue
😀😀 有悟 go 零值 zero-values 例子 😀😀

************演示 struct 零值*********
z(struct),零值判断语法: 变量.field 的零值判断, 零值: {}
z2(*struct),零值判断语法: 变量 == nil, 零值: <nil>
z2(*struct),零值判断语法: 变量 == (*struct)(nil), 零值: <nil>

引用类型变量的空值判断 #

本小节是对前面几种类型中涉及到的引用类型做一些总结,没有示例代码。

前面几小节中的示例有些不良的示范,即,对于 map、数组、slice、字符串等类型,使用了指针,其实是画蛇添足。因为这些类型在 go 中是引用类型,声明变量之后得到的就是一个隐性的指针。无须再套一个指针,不然就变成了 指向一个引用的指针。

golang 零值带来的问题 #

虽然有 零值(zero value) ,程序再也不会因为未赋值值问题报错,但是正因为这样,还是需要担心一个问题。就是当程序需要对所声明的变量设置某个初始值而非零值时,因为 go 程序不会因为未赋值报错,反而隐藏了值设置不成功时而异常的隐患。

一个典型的例子:

有悟使用 viper 为程序添加配置读取功能时,因为配置文件路径、配置文件中的结构与反序列化的结构体不对应时,得到了一个以为已经完整的空结构体。因为程序没有报错,所能一直不知道是配置文件书写出现错误,得到的变量都是零值。

结构体空指针的情况除外,因为从一个结构体空指针上获取所指向结构体的 field时,此时会因为空指针,这个结构体并不存在,自然取不到 field 的值,go 程序会 panic,因为 go 在编译时并不能报告这种未赋值错误(有时这种错误是过程中引起的),而导致程序崩溃。

所以,你需要确保使用到的变量所赋的值都应该是在把控范围内,如果不确定,就不能吝啬检查手段。