购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第11条
尽量定义零值可用的类型

保持零值可用。

——Go谚语 [1]

在Go语言中,零值不仅在变量初始化阶段避免了变量值不确定可能带来的潜在问题,而且定义零值可用的类型也是Go语言积极倡导的最佳实践之一,就像上面那句Go谚语所说的那样。

[1] https://go-proverbs.github.io/

11.1 Go类型的零值

作为一个C程序员出身的人,我总是喜欢将在使用C语言时“受过的苦”与使用Go语言中得到的“甜头”做比较,从而来证明Go语言设计者在当初设计Go语言时是经过了充分考量的。

在C99规范中,有一段是否对栈上局部变量进行自动清零初始化的描述:

未被显式初始化且具有自动存储持续时间的对象,其值是不确定的。

规范的用语总是晦涩难懂的,这句话大致的意思是:如果一个变量是在栈上分配的局部变量,且在声明时未对其进行显式初始化,那么它的值是不确定的。比如:


// chapter3/sources/varinit.c
#include <stdio.h>

static int cnt;

void f() {
int n;
printf("local n = %d\n", n);

if (cnt > 5) {
    return;
}

cnt++;
f();
}

int main() {
f();
return 0;
}

编译上面的程序并执行:


// 环境:CentOS; GCC 4.1.2
// 注意:在你的环境中执行上述代码,输出的结果很大可能与这里有所不同
$ gcc varinit.c
$ ./a.out

local n = 0
local n = 10973
local n = 0
local n = 52
local n = 0
local n = 52
local n = 52

我们看到分配在栈上的未初始化变量的值是不确定的。虽然一些编译器的较新版本提供了一些命令行参数选项用于对栈上变量进行零值初始化,比如GCC就提供如下命令行选项:


-finit-local-zero
-finit-derived
-finit-integer=n
-finit-real=<zero|inf|-inf|nan|snan>
-finit-logical=<true|false>
-finit-character=n

但这并不能改变C语言原生不支持对未显式初始化局部变量进行零值初始化的事实。资深C程序员深知这个陷阱带来的问题有多严重。因此出身于C语言的Go设计者们在Go中对这个问题进行了彻底修复和优化。下面是Go语言规范 [1] 中关于变量默认值的描述:

当通过声明或调用new为变量分配存储空间,或者通过复合文字字面量或调用make创建新值,且不提供显式初始化时,Go会为变量或值提供默认值。

Go语言中的每个原生类型都有其默认值,这个默认值就是这个类型的零值。下面是Go规范定义的内置原生类型的默认值(零值)。

■所有整型类型:0

■浮点类型:0.0

■布尔类型:false

■字符串类型:""

■指针、interface、切片(slice)、channel、map、function:nil

另外,Go的零值初始是递归的,即数组、结构体等类型的零值初始化就是对其组成元素逐一进行零值初始化。

[1] Go语言规范关于变量默认值的描述: https://tip.golang.org/ref/spec#The_zero_value

11.2 零值可用

我们知道了Go类型的零值,接下来了解可用。Go从诞生以来就一直秉承着尽量保持“零值可用”的理念,来看两个例子。

第一个例子是关于切片的:


var zeroSlice []int
zeroSlice = append(zeroSlice, 1)
zeroSlice = append(zeroSlice, 2)
zeroSlice = append(zeroSlice, 3)
fmt.Println(zeroSlice) // 输出:[1 2 3]

我们声明了一个[]int类型的切片zeroSlice,但并没有对其进行显式初始化,这样zeroSlice这个变量就被Go编译器置为零值nil。按传统的思维,对于值为nil的变量,我们要先为其赋上合理的值后才能使用。但由于Go中的切片类型具备零值可用的特性,我们可以直接对其进行append操作,而不会出现引用nil的错误。

第二个例子是通过nil指针调用方法:


// chapter3/sources/call_method_through_nil_pointer.go

func main() {
var p *net.TCPAddr
fmt.Println(p) //输出:<nil>
}

我们声明了一个net.TCPAddr的指针变量,但并未对其显式初始化,指针变量p会被Go编译器赋值为nil。在标准输出上输出该变量,fmt.Println会调用p.String()。我们来看看TCPAddr这个类型的String方法实现:


// $GOROOT/src/net/tcpsock.go
func (a *TCPAddr) String() string {
if a == nil {
    return "<nil>"
}
ip := ipEmptyString(a.IP)
if a.Zone != "" {
    return JoinHostPort(ip+"%"+a.Zone, itoa(a.Port))
}
return JoinHostPort(ip, itoa(a.Port))
}

我们看到Go标准库在定义TCPAddr类型及其方法时充分考虑了“零值可用”的理念,使得通过值为nil的TCPAddr指针变量依然可以调用String方法。

在Go标准库和运行时代码中还有很多践行“零值可用”理念的好例子,最典型的莫过于sync.Mutex和bytes.Buffer了。

我们先来看看sync.Mutex。在C语言中,要使用线程互斥锁,我们需要这么做:


pthread_mutex_t mutex; // 不能直接使用

// 必须先对mutex进行初始化
pthread_mutex_init(&mutex, NULL);

// 然后才能执行lock或unlock
pthread_mutex_lock(&mutex);
pthread_mutex_unlock(&mutex);

但是在Go语言中,我们只需这么做:


var mu sync.Mutex
mu.Lock()
mu.Unlock()

Go标准库的设计者很贴心地将sync.Mutex结构体的零值设计为可用状态,让Mutex的调用者可以省略对Mutex的初始化而直接使用Mutex。

Go标准库中的bytes.Buffer亦是如此:


// chapter3/sources/bytes_buffer_write.go
func main() {
var b bytes.Buffer
b.Write([]byte("Effective Go"))
fmt.Println(b.String()) // 输出:Effective Go
}

可以看到,我们无须对bytes.Buffer类型的变量b进行任何显式初始化,即可直接通过b调用Buffer类型的方法进行写入操作。这是因为bytes.Buffer结构体用于存储数据的字段buf支持零值可用策略的切片类型:


// $GOROOT/src/bytes/buffer.go
type Buffer struct {
buf      []byte
off      int
lastRead readOp
}

小结

Go语言零值可用的理念给内置类型、标准库的使用者带来很多便利。不过Go并非所有类型都是零值可用的,并且零值可用也有一定的限制,比如:在append场景下,零值可用的切片类型不能通过下标形式操作数据:


var s []int
s[0] = 12         // 报错!
s = append(s, 12) // 正确

另外,像map这样的原生类型也没有提供对零值可用的支持:


var m map[string]int
m["go"] = 1 // 报错!

m1 := make(map[string]int)
m1["go"] = 1 // 正确

另外零值可用的类型要注意尽量避免值复制:


var mu sync.Mutex
mu1 := mu // 错误: 避免值复制
foo(mu) // 错误: 避免值复制

我们可以通过指针方式传递类似Mutex这样的类型:


var mu sync.Mutex
foo(&mu) // 正确

保持与Go一致的理念,给自定义的类型一个合理的零值,并尽量保持自定义类型的零值可用,这样我们的Go代码会更加符合Go语言的惯用法。 oZzbKmWZeRJ8jh8QGXx2U7IA88P81TSDh6KxToIEf4ABvzftyj6Dbo7bX5dyWM/1

点击中间区域
呼出菜单
上一章
目录
下一章
×