标签 Array 下的文章

当函数设计遇到切片

本文永久链接 – 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应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 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

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go 1.17新特性详解:支持将切片转换为数组指针

本文永久链接 – https://tonybai.com/2021/08/18/go-language-specs-changes-in-go-1-17

Go属于那种极简的语言,从诞生到现在语言自身特性变化很小,不会像其他主流语言那样走“你有的我也要有”的特性融合路线。因此新语言特性对于Gopher来说属于“稀缺品”,属于“供不应求”那类事物^_^。这也直接导致了每次Go新版本发布,我们都要首先看看语言特性是否有变更,每个新加入语言的特性都值得我们去投入更多关注,去深入研究。下面我们就来深入Go 1.17版本中语言规范的一些变化!

1. 支持将切片转换为数组指针

在Go 1.17版本之前,我们可以将数组转换为切片,数组将成为转换后的切片底层存储数组,因此,通过切片可以直接改变数组中的元素,就像下面代码这样:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func array2slice() {
    var a = [5]int{11, 12, 13, 14, 15}
    var b = a[0:len(a)] // or var b = a[:]
    b[1] += 10
    fmt.Printf("%v\n", b) // [11 22 13 14 15]
}

但反过来则不行,Go不支持将切片再转换回数组类型,编译器会报下面错误信息:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func slice2array() {
    var b = []int{11, 12, 13}
    var a = [3]int(b) // cannot convert b (type []int) to type [3]int
    fmt.Printf("%v\n", a)
}

那么在Go中我们就没法将切片转换为数组了么?也不是绝对的。我们可以通过unsafe包以hack的方式实现这样的转换,如下面代码所示:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func slice2arrayWithHack() {
    var b = []int{11, 12, 13}
    var a = *(*[3]int)(unsafe.Pointer(&b[0]))
    a[1] += 10
    fmt.Printf("%v\n", b) // [11 12 13]
}

上面代码中,我们实际上得到是切片底层数组的一份拷贝,修改该拷贝中的元素值,切片中的元素将不会受到影响。如果想通过数组修改切片中元素,我们还得通过获取数组指针的方式,如下面代码所示。

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func slice2arrayptrWithHack() {
    var b = []int{11, 12, 13}
    var p = (*[3]int)(unsafe.Pointer(&b[0]))
    p[1] += 10
    fmt.Printf("%v\n", b) // [11 22 13]
}

但是使用unsafe,一如其名,其安全性没有编译器和runtime层的保证,只能由开发者自己保证,Gopher在通常情况下应该避免使用。

于是在2009年末,也就是Go语言宣布开源后不久(那时Go 1.0版本尚未发布),Roger Peppe便提出一个issue(那时go的开发还没有如今这么规范,没有proposal流程):“spec: use (*[4]int)(x) to convert slice x into array pointer”。最初该issue的提出仅仅是因为语法层面缺失了从切片到数组的转换语法,同时希望这种转换以及转换后的数组使用时的下标边界能得到编译器和runtime的协助检查。这个issue得到了当时Go核心开发组成员的支持,Russ Cox还提出将Roger Peppe提议的语法形式做如下变动:

从
b := a.[0:4]

变为 

b := (*[4]int)(a[0:4])

但不知何故,该issue始终没有被纳入Go主干中,直到Go 1.17版本,该issue又被重新提出来了。Go 1.17直接支持将切片转换为数组指针,我们可以在Go 1.17中编写和运行如下面这样的代码,而无需再借助unsafe的hack:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go
func slice2arrayptr() {
    var b = []int{11, 12, 13}
    var p = (*[3]int)(b)
    p[1] = p[1] + 10
    fmt.Printf("%v\n", b) // [11 22 13]
}

Go通过运行时对这类切片到数组指针的转换代码做检查,如果发现越界行为,就会通过运行时panic予以处理。Go运行时实施检查的一条原则就是“转换后的数组长度不能大于原切片的长度”,注意这里是切片的长度(len),而不是切片的容量(cap),于是下面的转换有些合法,有些非法:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/slice2arrayptr/main.go

var b = []int{11, 12, 13}
var p = (*[4]int)(b) // cannot convert slice with length 3 to pointer to array with length 4
var p = (*[0]int)(b) // ok,*p = []
var p = (*[1]int)(b) // ok,*p = [11]
var p = (*[2]int)(b) // ok,*p = [11, 12]
var p = (*[3]int)(b) // ok,*p = [11, 12, 13]
var p = (*[3]int)(b[:1]) // cannot convert slice with length 1 to pointer to array with length 3

关于这个语言特性的应用场合,目前还待Go社区挖掘,不过已经有人提出提出利用该特性优化go编译器的可行性评估了。

2. unsafe包新增了两个“语法糖”函数

Go 1.17中增加了两个“语法糖”函数:AddSlice。这两个函数原型如下:

// $GOROOT/src/unsafe.go
func Add(ptr Pointer, len IntegerType) Pointe
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

之所以这两个函数能进入unsafe包,和其他已经存在于unsafe包中的函数的目的是一样的,那就是将Go开发人员一些经常使用的“代码片段模式”升级为unsafe包内置的函数,这样不仅可以降低开发人员误用的比例,还可以让Go runtime提供一些检查,增加类型安全性。

unsafe.Add函数

由于go原生不允许指针加减操作,因此我们在特定场景下不得不使用unsafe包来做指针加减,比如下面代码:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/unsafe/add/main.go
const intLen = unsafe.Sizeof(int(8))

func foo() {
    var a = [5]int{11, 12, 13, 14, 15}
    for i := 0; i < 5; i++ {
        p := (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(&a[0])) + uintptr(uintptr(i)*intLen)))
        *p = *p + 10
    }
    fmt.Println(a)// [21 22 23 24 25]
}

上面代码中间变量p声明同时赋值那行是在Go 1.17之前unsafe包最常见的一种用法和代码模式。大家都这么用,但用起来还那么繁琐,于是便有了unsafe.Add。如果用unsafe.Add改造上面代码,便能简略一些,如下面代码所示:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/unsafe/add/main.go
const intLen = unsafe.Sizeof(int(8))

func bar() {
    var a = [5]int{11, 12, 13, 14, 15}
    for i := 0; i < 5; i++ {
        p := (*int)(unsafe.Add(unsafe.Pointer(&a[0]), uintptr(i)*intLen))
        *p = *p + 10
    }
    fmt.Println(a)
}

本质上unsafe.Add(ptr, len) 就等价于unsafe.Pointer(uintptr(ptr) + uintptr(len))。在之前版本中,runtime的stubs.go中也有个类似的实现:

$GOROOT/src/runtime/stubs.go

  // Should be a built-in for unsafe.Pointer?
  //go:nosplit
  func add(p unsafe.Pointer, x uintptr) unsafe.Pointer {
      return unsafe.Pointer(uintptr(p) + x)
  }

Go 1.17有了这个Add函数后,建议大家就多多使用该函数,而尽量不要自己去拼那个“大长串”了。

unsafe.Slice函数

unsafe.Slice函数支持基于一个数组创建一个切片,该数组将作为切片的底层存储,它也可以理解为等价于下面常用“代码片段”语法糖函数:

func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType

<=>

(*[len]ArbitraryType)(unsafe.Pointer(ptr))[:]

下面是unsafe.Slice的一个应用例子:

// github.com/bigwhite/experiments/tree/master/go1.17-examples/lang/unsafe/slice/main.go
func main() {
    var a = [5]int{11, 12, 13, 14, 15}
    s1 := a[:]
    s2 := unsafe.Slice(&a[0], 5)

    fmt.Println(s1) // [11 12 13 14 15]
    fmt.Println(s2) // [11 12 13 14 15]
    fmt.Printf("the type of s2 is %T\n", s2)

    s2[2] += 10
    fmt.Println(a)  // [11 12 23 14 15]
    fmt.Println(s1) // [11 12 23 14 15]
    fmt.Println(s2) // [11 12 23 14 15]
}

我们看到基于unsafe.Slice与基于数组进行切片得到的两个切片一样的,它们的底层数组都是数组a。因此,无论通过修改哪个切片元素,都会反映到另外一个切片中并反映到底层数组上。

3. 小结

在本文中,我们了解到了Go 1.17新增的很少的语言特性,这些个性更多从语言的易用性、安全性等方面考虑才添加的,相较于以往版本,这些新增特性算是不少了。如果要期待语言特性的巨大变更,那还是一起等Go 1.18吧。Go 1.18保证让你爽歪歪。泛型(类型参数)的加入必然让go代码变得比以前更烧脑一些。

本文涉及代码可以在这里下载:https://github.com/bigwhite/experiments/tree/master/go1.17-examples/lang


“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强。在2021年上半年,部落将策划两个专题系列分享,并且是部落独享哦:

  • Go技术书籍的书摘和读书体会系列
  • Go与eBPF系列

欢迎大家加入!

Go技术专栏“改善Go语⾔编程质量的50个有效实践”正在慕课网火热热销中!本专栏主要满足广大gopher关于Go语言进阶的需求,围绕如何写出地道且高质量Go代码给出50条有效实践建议,上线后收到一致好评!欢迎大家订
阅!

img{512x368}

我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖中,欢迎小伙伴们订阅学习!

img{512x368}

我爱发短信:企业级短信平台定制开发专家 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
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言进阶课 AI原生开发工作流实战 从 0 开始构建 Agent Harness Go语言精进之路1 Go语言精进之路2 Go语言第一课 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com
这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats