为了账号安全,请及时绑定邮箱和手机立即绑定

【讲师分享】当函数设计遇到切片

标签:
Python C++ Go

图片描述

切片(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那样。


点击查看更多内容
“小礼物走一走,来慕课关注我”
赞赏支持
Tony Bai 说 去围观
Tony Bai,智能网联汽车独角兽公司先行研发部负责人,Go语言专家,资深架构师,《Go语言精进之路》作者。
评论

作者其他优质文章

正在加载中
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消