切片(slice)是Go语言中的一种重要的也是最常用的同构数据类型。在Go语言编码过程中,我们多数情况下会使用slice替代数组,一来是因为其动态可扩展,二来在多数场合传递slice的开销要比传递数组要小(这里有一些例外)。
切片算是“半个”零值可用的类型,为什么这么说呢?
当我们声明一个切片类型实例但在未显式初始化的情况下,我们不能直接对其做下标操作,比如:
var sl []int
sl[0] = 5 // 错误:引发panic
但是我们可以通过Go内置的append函数对其进行追加操作,即便sl目前的值为nil:
var sl []int
sl = append(sl, 5) // ok
到这里,我要提出本文要讨论的topic了:为什么append函数要通过返回值返回切片结果呢?再泛化一点:当你在函数设计环节遇到要传入传出切片类型时,你会如何设计函数的参数与返回值呢?下面我们就来探讨一下。
我们在$GOROOT/src/builtin/builtin.go中找到了append预置函数的原型:
func append(slice []Type, elems ...Type) []Type
显然参照“append”函数的设计,通过参数传入slice,通过返回值传出更新过的切片肯定是一个正确的方案,比如下面的第一版MyAppend函数:
func myAppend1(sl []int, elems ...int) []int {
return append(sl, elems...)
}
func main() {
var in = []int{1, 2, 3}
fmt.Println("in slice:", in) // 输出:in slice: [1 2 3]
fmt.Println("out slice:", myAppend1(in, 4, 5, 6)) // 输出:out slice: [1 2 3 4 5 6]
}
到这里,有些初学者会提出:切片不是动态数组吗?是不是可以既作为输入参数,又兼作输出参数呢?我理解提出这个问题的小伙伴们希望设计出像下面这样的函数原型:
func myAppend2(sl []int, elems ...int)
这里sl作为输入参数传入myAppend2,然后在myAppend2对其进行update后,myAppend2函数的调用者将得到更新后的sl。但实际情况是这样的吗?我们来看一下:
func myAppend2(sl []int, elems ...int) {
sl = append(sl, elems...)
}
func main() {
var inOut = []int{1, 2, 3}
fmt.Println("in slice:", inOut)
myAppend2(inOut, 4, 5, 6)
fmt.Println("out slice:", inOut)
}
运行这段程序,我们得到如下结果:
in slice: [1 2 3]
out slice: [1 2 3]
我们看到myAppend2并未如我们预期的那样工作,传入的切片并未在myAppend2中得到预期的更新,这是为什么呢?首先这是与切片在运行时的表示有关的。在我的专栏中有对切片在运行时表示的细致讲解,这里简单说说:
切片在运行时由三个字段构成,[reflect包]中有切片在类型系统中表示的对应的定义:
// $GOROOT/src/reflect/value.go
type SliceHeader struct {
Data uintptr // 指向底层数组的指针
Len int // 切片长度
Cap int // 切片容量
}
此外,Go函数采用“值拷贝”的参数传递方式,这意味着myAppend2传递的切片sl实质上仅仅传递的是切片“描述符” - SliceHeader。myAppend2函数体内改变的是形参sl的各个字段的值,但myAppend2的实参并未受到任何影响,即执行完myAppend2后,inOut的len和cap依旧保持不变,而其底层数组是否改变了呢?在这个例子中肯定是“改变”了,但改变的是inOut长度(len)范围之外,cap之内的元素,通过对inOut的常规访问是无法获取到这些元素的。
那么我们该如何让slice作为in/out参数呢?答案是使用指向切片的指针,我们来看下面例子:
func myAppend3(sl *[]int, elems ...int) {
(*sl) = append(*sl, elems...)
}
func main() {
var inOut = []int{1, 2, 3}
fmt.Println("in slice:", inOut) // in slice: [1 2 3]
myAppend3(&inOut, 4, 5, 6)
fmt.Println("out slice:", inOut) // out slice: [1 2 3 4 5 6]
}
我们看到myAppend3函数使用*[]int类型的形参的确解决了切片参数作为输入输出参数的问题:myAppend3对切片的更改操作都反映到inOut变量所代表的这个slice上了,即便在myAppend3内切片进行了动态扩容,inOut也能“捕捉”到这点。
不过我在Go标准库中查找了一下,使用指向切片的指针作为参数的函数“少得可怜”:
$grep "*\[\]" */*go|grep func
grep: cmd/cgo: Is a directory
grep: cmd/go: Is a directory
grep: runtime/cgo: Is a directory
log/log.go:func itoa(buf *[]byte, i int, wid int) {
log/log.go:func (l *Logger) formatHeader(buf *[]byte, t time.Time, file string, line int) {
regexp/onepass.go:func mergeRuneSets(leftRunes, rightRunes *[]rune, leftPC, rightPC uint32) ([]rune, []uint32) {
regexp/onepass.go: extend := func(newLow *int, newArray *[]rune, pc uint32) bool {
runtime/mstats.go:func readGCStats(pauses *[]uint64) {
runtime/mstats.go:func readGCStats_m(pauses *[]uint64) {
runtime/proc.go:func saveAncestors(callergp *g) *[]ancestorInfo {
综上,当我们在函数设计时遇到切片类型数据时,如果要对切片做更新操作,优先还是要参考append函数的设计方案,即通过切片作为输入参数和返回值的方式实现该操作逻辑,必要时也可以使用指向切片的指针的方式传递切片,就像myAppend3那样。
讲师主页:tonybai_cn
讲师博客: Tony Bai
专栏:《改善Go语言编程质量的50个有效实践》
实战课:《Kubernetes实战:高可用集群搭建,配置,运维与应用》
免费课:《Kubernetes基础:开启云原生之门》
共同学习,写下你的评论
评论加载中...
作者其他优质文章