当函数设计遇到切片
本文永久链接 – https://tonybai.com/2022/10/27/when-encountering-slice-during-function-design
切片(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中得到预期的更新,这是为什么呢?首先这是与切片在运行时的表示有关的。在我的专栏和《Go语言精进之路》一书中都有对切片在运行时表示的细致讲解,这里简单说说:
切片在运行时由三个字段构成,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那样。
“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!
我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。
著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。
Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily
我的联系方式:
- 微博:https://weibo.com/bigwhite20xx
- 博客:tonybai.com
- github: https://github.com/bigwhite
商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。
评论