【Go】slice的一些使用技巧

2024-09-02 07:48
文章标签 go 使用 技巧 slice

本文主要是介绍【Go】slice的一些使用技巧,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

原文链接:https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html

sliceGo 语言十分重要的数据类型,它承载着很多使命,从语言层面来看是 Go 语言的内置数据类型,从数据结构来看是动态长度的顺序链表,由于 Go 不能直接操作内存(通过系统调用可以实现,但是语言本身并不支持),往往 slice 也可以用来帮助开发者申请大块内存实现缓冲、缓存等功能。

Go 语言项目中大量的使用 slice, 我总结三年来对 slice 的一些操作技巧,以方便可以高效的使用 slice, 并使用 slice 解决一些棘手的问题。

slice 的基本操作

先熟悉一些 slice 的基本的操作, 对最常规的 : 操作就可玩出很多花样。

  • s=ss[:] 引用一个切片或数组
  • s=s[:0] 清空切片
  • s=s[:10] s=s[10:] s=s[10:20] 截取接片
  • s=ss[0:10:20] 从切片或数组引用指定长度和容量的切片

下标索引操作的一些误区 s[i:l:c] i 是起始偏移的起始位置,l 是起始偏移的长度结束位置, l-i 就是新 slice 的长度, c 是起始偏移的容量结束位置,c-i 就是新 slice 的容量。其中 ilc 并不是当前 slice 的索引,而是引用底层数组相对当前 slice 起始位置的偏移量,所以是可超出当前 slice 的长度的, 但不能超出当前 slice 的容量,如下操作是合法的:

package mainimport ("fmt"
)func main() {s := make([]int, 100)s[20] = 100s1 := s[10:10]s2 := s1[10:20]fmt.Println(s1)fmt.Println(s2)
}

其中 s1[]s2[100 0 0 0 0 0 0 0 0 0], 这里并不会发生下标越界的情况,一个更好的例子在 csv reader 中的一个例子

创建 slice

创建切片的方法有很多,下面罗列一些常规的:

  • var s []int 创建 nil切片
  • s := make([]int, 0, 0)s=[]int{} 创建无容量空切片
  • s:= make([]int, 0, 100) 创建有容量空切片
  • s:=make([]int, 100) 创建零值切片
  • s:=array[:] 引用数组创建切片

内置函数

  • len(s) 获取切片的长度
  • cap(s) 获取切片的容量
  • append(s, ...) 向切片追加内容
  • copy(s, s1) 向切片拷贝内容

一个缓冲的简单示例

遇到过很多拼接字符串的方法,各种各样的都有 fmt builder buffer + 等等,实际上 builderbuffer 都是使用 []byte 的切片作为缓冲来实现的,fmt 往往性能最差,原因是它主要功能不是连接字符串而是格式化数据会用到反射等等操作。+ 操作在大量拼接时性能也是很差, 不过小字符串少量拼接效果很理想,builder 往往性能不如 buffer 特别是在较短字符串拼接上,实际 builderbuffer 实现原理非常类似,builder 在转成字符串时使用了 unsafe 减少了一次内存分配,因为小字符串因为扩容机制不如 buffer 灵活,所以性能有所不如,大字符串降低一次大的内存分配就显得很明显了。

经常遇到一个需求就是拼接 []int 中个各个元素,很多种实现都有人用,都是需要遍历转换 intstring,但是拼接方法千奇百怪,以下提供两种方法对比(源码在GitHub)。

package sliceimport ("strconv""unsafe"
)func SliceInt2String1(s []int) string {if len(s) < 1 {return ""}ss := strconv.Itoa(s[0])for i := 1; i < len(s); i++ {ss += "," + strconv.Itoa(s[i])}return ss
}func SliceInt2String2(s []int) string {if len(s) < 1 {return ""}b := make([]byte, 0, 256)b = append(b, strconv.Itoa(s[0])...)for i := 1; i < len(s); i++ {b = append(b, ',')b = append(b, strconv.Itoa(s[i])...)}return string(b)
}func SliceInt2String3(s []int) string {if len(s) < 1 {return ""}b := make([]byte, 0, 256)b = append(b, strconv.Itoa(s[0])...)for i := 1; i < len(s); i++ {b = append(b, ',')b = append(b, strconv.Itoa(s[i])...)}return *(*string)(unsafe.Pointer(&b))
}

SliceInt2String1 使用原始的 + 操作,因为是较小的字符串拼接,使用 + 主要是因为在小字符串拼接性能优于其它几种方法,SliceInt2String2SliceInt2String3 都使用了一个 256 容量的 []byte 作为缓冲, 唯一的区别是在返回时一个使用 string 转换类型,一个使用 unsafe 转换类型。

写了一个性能测试(源码在GitHub),看一下效果吧:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/example/slice
BenchmarkSliceInt2String1-8   	 3000000	       461 ns/op	     144 B/op	       9 allocs/op
BenchmarkSliceInt2String2-8   	20000000	       117 ns/op	      32 B/op	       1 allocs/op
BenchmarkSliceInt2String3-8   	10000000	       144 ns/op	     256 B/op	       1 allocs/op
PASS
ok  	github.com/thinkeridea/example/slice	5.928s

明显可以看得出 SliceInt2String2 的性能是 SliceInt2String1 7倍左右,提升很明显,SliceInt2String2SliceInt2String3 差异很小,主要是因为使用 unsafe 转换类型导致大内存无法释放,实际这个测试中连接字符串只需要 32 个字节,使用 unsafe 却导致 256 个字节无法被释放,这也正是 builderbuffer 的差别,所以小字符串拼接 buffer 性能往往更好。在这里简单的通过 []byte 减少内存分配次数来实现缓冲。

如果连续拼接一组这样的操作,比如输入 [][]int, 输出 []string (源码在GitHub):

package sliceimport ("strconv""unsafe"
)func SliceInt2String4(s [][]int) []string {res := make([]string, len(s))for i, v := range s {if len(v) < 1 {res[i] = ""continue}res[i] += strconv.Itoa(v[0])for j := 1; j < len(v); j++ {res[i] += "," + strconv.Itoa(v[j])}}return res
}func SliceInt2String5(s [][]int) []string {res := make([]string, len(s))b := make([]byte, 0, 256)for i, v := range s {if len(v) < 1 {res[i] = ""continue}b = b[:0]b = append(b, strconv.Itoa(v[0])...)for j := 1; j < len(v); j++ {b = append(b, ',')b = append(b, strconv.Itoa(v[j])...)}res[i] = string(b)}return res
}

SliceInt2String5 中使用 b = b[:0] 来促使达到反复使用一块缓冲区,写了一个性能测试(源码在GitHub),看一下效果吧:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/example/slice
BenchmarkSliceInt2String4-8   	  300000	      4420 ns/op	    1440 B/op	      82 allocs/op
BenchmarkSliceInt2String5-8   	 1000000	      1102 ns/op	     432 B/op	      10 allocs/op
PASS
ok  	github.com/thinkeridea/example/slice	8.364s

+ 版本提升接近4倍的性能,这是使用 slice 作为缓冲区极好的技巧,使用非常方便,并不用使用 builderbufferslice 操作非常的简单实用。

append 与 copy

如果合并多个 slice 为一个,有三种方式来合并,主要合并差异来源于创建新 slice 的方法,使用 var news []int 或者 news:=make([]int, 0, len(s1)+len(s2)....) 的方式创建的新变量就需要使用 append 来合并,如果使用 news:=make([]int, len(s1)+len(s2)....) 就需要使用 copy 来合并。不同的方法也有差异,appendcopy 在这个例子中主要差异在于 append 适用于零长度的初始化 slicecopy 适用于确定长度的 slice

写了一个测试来看看两者的差异吧(源码在GitHub):

func BenchmarkExperiment3Append1(b *testing.B) {for i := 0; i < b.N; i++ {var s []intfor j := 0; j < 20; j++ {s = append(s, []int{j, j + 1, j + 2, j + 3, j + 4}...)}}
}func BenchmarkExperiment3Append2(b *testing.B) {for i := 0; i < b.N; i++ {s := make([]int, 0, 100)for j := 0; j < 20; j++ {s = append(s, []int{j, j + 1, j + 2, j + 3, j + 4}...)}}
}func BenchmarkExperiment3Copy(b *testing.B) {for i := 0; i < b.N; i++ {s := make([]int, 100)n := 0for j := 0; j < 20; j++ {n += copy(s[n:], []int{j, j + 1, j + 2, j + 3, j + 4})}}
}

测试结果如下:

goos: darwin
goarch: amd64
pkg: github.com/thinkeridea/example/slice
BenchmarkExperiment3Append1-8   	 2000000	       782 ns/op	    3024 B/op	       6 allocs/op
BenchmarkExperiment3Append2-8   	10000000	       192 ns/op	       0 B/op	       0 allocs/op
BenchmarkExperiment3Copy-8      	10000000	       217 ns/op	       0 B/op	       0 allocs/op
PASS
ok  	github.com/thinkeridea/example/slice	6.926s

从结果上来看使用没有容量的 append 性能真的很糟糕,实际上不要对没有任何容量的 slice 进行 append 操作是最好的实践,在准备用 append 的时候应该预先给定一个容量,哪怕这个容量并不是确定的,像前面缓存连接字符串时一样,并不能明确使用的空间,先分配256个字节,这样的好处是可以减少系统调用分配内存的次数,即使空间不能用完,也不用太过担心浪费,append 本身扩容机制也会导致空间不是刚刚好用完的,而初始化的容量往往结合业务场景给的一个均值,这是很好的。

appendcopy 在预先确定长度和容量时 append 效果更好一些,主要原因是 copy 需要一个变量来记录位置。 如果使用场景中没有强制限定长度,建议使用 append 因为 append 会根据实际情况再做内存分配,较 copy 也更加灵活一些, 而 copy 往往用在长度固定的地方,可以防止数据长度溢出的问题,例如标准库中 strings.Repeat 函数,它采用指数增长的方式快速填充指定数量的字符,但是如果使用 append 就会发生多余的内存分配,导致长度溢出。

func Repeat(s string, count int) string {b := make([]byte, len(s)*count)bp := copy(b, s)for bp < len(b) {copy(b[bp:], b[:bp])bp *= 2}return string(b)
}

csv reader 中的一个例子

官方标准库 csv 的读取性能极高,其中 reader 里面有使用 slice 极好的例子,以下是简略的代码,如果想要全面了解程序需要去看标准库的源码:

func (r *Reader) readRecord(dst []string) ([]string, error) {line, errRead = r.readLine()if errRead == io.EOF {return nil, errRead}r.recordBuffer = r.recordBuffer[:0]r.fieldIndexes = r.fieldIndexes[:0]
parseField:for {if r.TrimLeadingSpace {line = bytes.TrimLeftFunc(line, unicode.IsSpace)}i := bytes.IndexRune(line, r.Comma)field := lineif i >= 0 {field = field[:i]} else {field = field[:len(field)-lengthNL(field)]}r.recordBuffer = append(r.recordBuffer, field...)r.fieldIndexes = append(r.fieldIndexes, len(r.recordBuffer))if i >= 0 {line = line[i+commaLen:]continue parseField}break parseField}if err == nil {err = errRead}// Create a single string and create slices out of it.// This pins the memory of the fields together, but allocates once.str := string(r.recordBuffer) // Convert to string once to batch allocationsdst = dst[:0]if cap(dst) < len(r.fieldIndexes) {dst = make([]string, len(r.fieldIndexes))}dst = dst[:len(r.fieldIndexes)]var preIdx intfor i, idx := range r.fieldIndexes {dst[i] = str[preIdx:idx]preIdx = idx}return dst, err
}

这里删除了极多的代码,但是能看懂大意,其中 line 是一段 bufio 中的一段引用,所以这块数据不能返回给用户,也不能进行并发读取操作。

r.recordBufferr.fieldIndexescsv 的缓存,他们初始的时候容量是0,是不是会有些奇怪,之前还建议 slice 初始一个长度,来减少内存分配,csv 这个库的设计非常的巧妙,假设 csv 每行字段的个数一样,数据长度也相近,现实业务确实如此,所以只有读取第一行数据的时候才会发生大量的 slice 扩容, 之后其它行扩容的可能性非常的小,整个文件读取完也不会发生太多次,不得不说设计的太妙了。

r.recordBuffer 用来存储行中除了分隔符的所有数据,r.fieldIndexes 用来存储每个字段数据在 r.recordBuffer 中的索引。每次都通过 r.recordBuffer[:0] 这个的数据获取,读取每行数据都反复使用这块内存,极大的减少内存开销。

更巧妙的设计是 str := string(r.recordBuffer) 源代码中也有详细的说明,一次性分配足够的内存, 要知道类型转换是会发生内存拷贝的,分配新的内存, 如果每个字段转换一次,会发生很多的内存拷贝和分配,之后通过 dst[i] = str[preIdx:idx] 引用 str 中的数据达到切分字段的效果,因为引用字符串并不会拷贝字符串(字符串不可变,引用字符串的子串是安全的)所以其代价非常的小。

这段源码中还有一个很多人都不知道的 slice 特性的例子,dst = dst[:0]; dst = dst[:len(r.fieldIndexes)] 这两句话放到一起是不是感觉很不可思议,明明 dst 的长度被清空了,dst[:len(r.fieldIndexes)] 不是会发生索引越界吗,很多人认为 s[i:l] 这种写法是当前 slice 的索引,实际并非如此,这里面的 ij 是底层引用数组相对当前 slice 引用位置的索引,并不受当前 slice 的长度的影响。

这里只是简单引用 csv 源码中的一段分析其 slice 的巧妙用法,即把 slice 当做数据缓存,也作为分配内存的一种极佳的方法,这个示例中的关于 slice 的使用值得反复推敲。

内存池

早些时间阅读 GitHub 上的一些源码,发现一个实现内存次的例子,里面对 slice 的应用非常有特点,在这里拿来分析一下(GitHub源码):

func NewChanPool(minSize, maxSize, factor, pageSize int) *ChanPool {pool := &ChanPool{make([]chanClass, 0, 10), minSize, maxSize}for chunkSize := minSize; chunkSize <= maxSize && chunkSize <= pageSize; chunkSize *= factor {c := chanClass{size:   chunkSize,page:   make([]byte, pageSize),chunks: make(chan []byte, pageSize/chunkSize),}c.pageBegin = uintptr(unsafe.Pointer(&c.page[0]))for i := 0; i < pageSize/chunkSize; i++ {// lock down the capacity to protect append operationmem := c.page[i*chunkSize : (i+1)*chunkSize : (i+1)*chunkSize]c.chunks <- memif i == len(c.chunks)-1 {c.pageEnd = uintptr(unsafe.Pointer(&mem[0]))}}pool.classes = append(pool.classes, c)}return pool
}

这里采用步进式分页,保证每页上的数据块大小相同,一次性创建整个页 make([]byte, pageSize) ,之后从页切分数据块 mem := c.page[i*chunkSize : (i+1)*chunkSize : (i+1)*chunkSize], 容量和数据块长度一致,创建一块较大的内存,减少系统调用,当然这个例子中还可以创建更大的内存,就是每页容量的总大小,避免创建更多页,所有的块数据都引用一块内存。

这里限制了每个块的容量,默认引用 slice 的容量是引用起始位置到底层数组的结尾,但是可以指定容量,这就保证了获取的数据块不会因为用户不遵守约定超出其大小导致数据写入到其它块中的问题,设定了容量用户使用超出容量后就会拷贝出去并创建新的 slice 实在的很妙的用法。

一次分配更大的内存可以减少内存碎片,更好的复用内存。

func (pool *ChanPool) Alloc(size int) []byte {if size <= pool.maxSize {for i := 0; i < len(pool.classes); i++ {if pool.classes[i].size >= size {mem := pool.classes[i].Pop()if mem != nil {return mem[:size]}break}}}return make([]byte, size)
}

获取内存池中的内存就非常简单,查找比需要大小更大的块并返回即可,这不失为一个较好的内存复用算法。

func (pool *ChanPool) Free(mem []byte) {size := cap(mem)for i := 0; i < len(pool.classes); i++ {if pool.classes[i].size == size {pool.classes[i].Push(mem)break}}
}

当使用完释放内存时实现的并不是很好,应该判断释放的数据是否是当前内存的一部分,如果不是的就不能放回到内存池中,因为用户未按约定大小使用,导致大量扩容而使得内存池中的数据碎片化,当然用户一旦发生扩容就会导致内存池中的缓存块丢失,导致存在大块内存无法释放,却也没法使用的情况。

之所以分析这个例子主要是分析其使用 slice 的方法和技巧,并不推荐使用该方法管理内存。

拓展

更多关于 slice 应用的例子可以参考标准库 bytesbufiobufferbufio 的使用极其相似,两个包都是使用 slice 来减少内存分配及系统调用来达到实现缓冲和缓存的例子。

转载:

本文作者: 戚银(thinkeridea)

本文链接: https://blog.thinkeridea.com/201901/go/slice_de_yi_xie_shi_yong_ji_qiao.html

版权声明: 本博客所有文章除特别声明外,均采用 CC BY 4.0 CN协议 许可协议。转载请注明出处!

这篇关于【Go】slice的一些使用技巧的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/1129404

相关文章

Java学习手册之Filter和Listener使用方法

《Java学习手册之Filter和Listener使用方法》:本文主要介绍Java学习手册之Filter和Listener使用方法的相关资料,Filter是一种拦截器,可以在请求到达Servl... 目录一、Filter(过滤器)1. Filter 的工作原理2. Filter 的配置与使用二、Listen

Pandas使用AdaBoost进行分类的实现

《Pandas使用AdaBoost进行分类的实现》Pandas和AdaBoost分类算法,可以高效地进行数据预处理和分类任务,本文主要介绍了Pandas使用AdaBoost进行分类的实现,具有一定的参... 目录什么是 AdaBoost?使用 AdaBoost 的步骤安装必要的库步骤一:数据准备步骤二:模型

使用Pandas进行均值填充的实现

《使用Pandas进行均值填充的实现》缺失数据(NaN值)是一个常见的问题,我们可以通过多种方法来处理缺失数据,其中一种常用的方法是均值填充,本文主要介绍了使用Pandas进行均值填充的实现,感兴趣的... 目录什么是均值填充?为什么选择均值填充?均值填充的步骤实际代码示例总结在数据分析和处理过程中,缺失数

如何使用 Python 读取 Excel 数据

《如何使用Python读取Excel数据》:本文主要介绍使用Python读取Excel数据的详细教程,通过pandas和openpyxl,你可以轻松读取Excel文件,并进行各种数据处理操... 目录使用 python 读取 Excel 数据的详细教程1. 安装必要的依赖2. 读取 Excel 文件3. 读

Go语言开发实现查询IP信息的MCP服务器

《Go语言开发实现查询IP信息的MCP服务器》随着MCP的快速普及和广泛应用,MCP服务器也层出不穷,本文将详细介绍如何在Go语言中使用go-mcp库来开发一个查询IP信息的MCP... 目录前言mcp-ip-geo 服务器目录结构说明查询 IP 信息功能实现工具实现工具管理查询单个 IP 信息工具的实现服

Python ZIP文件操作技巧详解

《PythonZIP文件操作技巧详解》在数据处理和系统开发中,ZIP文件操作是开发者必须掌握的核心技能,Python标准库提供的zipfile模块以简洁的API和跨平台特性,成为处理ZIP文件的首选... 目录一、ZIP文件操作基础三板斧1.1 创建压缩包1.2 解压操作1.3 文件遍历与信息获取二、进阶技

解决Maven项目idea找不到本地仓库jar包问题以及使用mvn install:install-file

《解决Maven项目idea找不到本地仓库jar包问题以及使用mvninstall:install-file》:本文主要介绍解决Maven项目idea找不到本地仓库jar包问题以及使用mvnin... 目录Maven项目idea找不到本地仓库jar包以及使用mvn install:install-file基

Python使用getopt处理命令行参数示例解析(最佳实践)

《Python使用getopt处理命令行参数示例解析(最佳实践)》getopt模块是Python标准库中一个简单但强大的命令行参数处理工具,它特别适合那些需要快速实现基本命令行参数解析的场景,或者需要... 目录为什么需要处理命令行参数?getopt模块基础实际应用示例与其他参数处理方式的比较常见问http

C 语言中enum枚举的定义和使用小结

《C语言中enum枚举的定义和使用小结》在C语言里,enum(枚举)是一种用户自定义的数据类型,它能够让你创建一组具名的整数常量,下面我会从定义、使用、特性等方面详细介绍enum,感兴趣的朋友一起看... 目录1、引言2、基本定义3、定义枚举变量4、自定义枚举常量的值5、枚举与switch语句结合使用6、枚

使用Python从PPT文档中提取图片和图片信息(如坐标、宽度和高度等)

《使用Python从PPT文档中提取图片和图片信息(如坐标、宽度和高度等)》PPT是一种高效的信息展示工具,广泛应用于教育、商务和设计等多个领域,PPT文档中常常包含丰富的图片内容,这些图片不仅提升了... 目录一、引言二、环境与工具三、python 提取PPT背景图片3.1 提取幻灯片背景图片3.2 提取