切片
文章目录
- 切片
- 将切片传递给函数
- make() 创建一个切片
- new() 和 make()的区别
- 多维切片
- bytes包
- for-range
- 切片重组 reslice
- 切片的复制和追加
- 字符串、数组和切片的应用
- 获取字符串的某一部分
- 字符串和切片的内存结构
- 修改字符串中的某个字符
- 字节数组对比函数
- 搜索及排序切片和数组
- append() 函数常见操作
- 切片和垃圾回收
-
切片是对数组中一个连续片段的引用,该数组我们称之为相关数组,通常是匿名
-
切片是一个引用类型
-
这个片段可以是整个数组,或者是由起始和终止索引标识的一些项的子集。需要注意的是,终止索引标识的项不包括在切片内。切片提供了一个相关数组的动态窗口
-
切片是可索引的
-
给定项的切片索引可能比相关数组的相同元素的索引小。和数组不同的是,切片的长度可以在运行时修改,最小为 0, 最大为相关数组的长度:切片是一个 长度可变的数组。
-
切片提供了计算容量的函数
cap()
可以测量切片最长可以达到多少:它等于切片的长度 + 数组除切片之外的长度。如果s
是一个切片,cap(s)
就是从s[0]
到数组末尾的数组长度。切片的长度永远不会超过它的容量,所以对于切片s
来说该不等式永远成立: -
0 < = l e n ( s ) < = c a p ( s ) 0 <= len(s) <= cap(s) 0<=len(s)<=cap(s)
-
多个切片如果表示同一个数组的片段,它们可以共享数据;因此一个切片和相关数组的其他切片是共享存储的,相反,不同的数组总是代表不同的存储。数组实际上是切片的构建块。
-
优点 因为切片是引用,所以它们不需要使用额外的内存并且比使用数组更有效率,所以在 Go 代码中切片比数组更常用。
声明切片的格式是:
var identifier []type //不需要说明长度
一个切片在未初始化之前默认为 nil
,长度为 0。
切片的初始化格式是:
var slice1 []type = arr1[start:end]
这表示 slice1
是由数组 arr1
从 start
索引到 end-1
索引之间的元素构成的子集(切分数组,start:end
被称为切片表达式)。所以 slice1[0]
就等于 arr1[start]
。这可以在 arr1
被填充前就定义好。
如果某个人写:
var slice1 []type = arr1[:]
那么 slice1
就等于完整的 arr1
数组(所以这种表示方式是 arr1[0:len(arr1)]
的一种缩写)。另外一种表述方式是:
slice1 = &arr1
arr1[2:]
和 arr1[2:len(arr1)]
相同,都包含了数组从第三个到最后的所有元素。
arr1[:3]
和 arr1[0:3]
相同,包含了从第一个到第三个元素(不包括第四个)。
如果你想去掉 slice1
的最后一个元素,只要 :
slice1 = slice1[:len(slice1)-1]
一个由数字 1、2、3 组成的切片可以这么生成
s := [3]int{1,2,3}[:]
(注:应先用 s := [3]int{1, 2, 3}
生成数组, 再使用 s[:]
转成切片)甚至更简单的 s := []int{1,2,3}
。
s2 := s[:]
是用切片组成的切片,拥有相同的元素,但是仍然指向相同的相关数组。
一个切片 s
可以这样扩展到它的大小上限:s = s[:cap(s)]
,如果再扩大的话就会导致运行时错误
w
对于每一个切片(包括 string
),以下状态总是成立的:
s == s[:i] + s[i:] // i是一个整数且: 0 <= i <= len(s)
len(s) <= cap(s)
切片也可以用类似数组的方式初始化:var x = []int{2, 3, 5, 7, 11}
。这样就创建了一个长度为 5 的数组并且创建了一个相关切片。
切片在内存中的组织方式实际上是一个有 3 个域的结构体:指向相关数组的指针,切片长度以及切片容量。下图给出了一个长度为 2,容量为 4 的切片 y
。
y[0] = 3
且y[1] = 5
。- 切片
y[0:4]
由 元素3
,5
,7
和11
组成。
如果 s2
是一个切片,你可以将 s2
向后移动一位 s2 = s2[1:]
,但是末尾没有移动。切片只能向后移动,s2 = s2[-1:]
会导致编译错误。切片不能被重新分片以获取数组的前一个元素。
注意 绝对不要用指针指向切片。切片本身已经是一个引用类型,所以它本身就是一个指针!!
将切片传递给函数
如果你有一个函数需要对数组做操作,你可能总是需要把参数声明为切片。当你调用该函数时,把数组分片,创建为一个切片引用并传递给该函数。这里有一个计算数组元素和的方法:
func sum(a []int) int {
s := 0
for i := 0; i < len(a); i++ {
s += a[i]
}
return s
}
func main() {
var arr = [5]int{0, 1, 2, 3, 4}
sum(arr[:])
}
make() 创建一个切片
当使用make
函数创建切片时,Go会分配虚拟内存,而不会立即分配物理内存。这是一种延迟分配的策略;这种方式可以节省物理内存,因为程序不会立即占用大量内存,而是根据实际需要逐渐分配。
当相关数组还没有定义时,我们可以使用 make()
函数来创建一个切片,同时创建好相关数组:
var slice1 []type = make([]type, len)
也可以简写为:
slice1 := make([]type, len) //这里的len是数组的长度也是slice的初始长度
如果你想创建一个 slice1
,它不占用整个数组,而只是占用len
长度,那么只要:
slice1 := make([]type, len, cap)
切片的长度为 len
,并且具有与容量 cap
相同的底层数组的大小。这意味着 slice1
会占用以 len
为个数个元素,但在需要时可以增长到 cap
,这是Go切片的一个特性。这对于在不需要整个数组的情况下,有效地管理内存和数据非常有用
所以下面两种方法可以生成相同的切片:
make([]int, 50, 100)
new([100]int)[0:50]
下图描述了使用 make()
方法生成的切片的内存结构:
new() 和 make()的区别
都在堆上分配内存,但是它们的行为不同,适用于不同的类型。
new(T)
为每个新的类型T
分配一片内存,初始化为0
并且返回类型为*T
的内存地址:这种方法 返回一个指向类型为T
,值为0
的地址的指针,它适用于值类型如数组和结构体体或用户定义的类型;它相当于&T{}
。make(T)
用于分配内存并初始化数据结构的内部字段,返回的是数据结构本身,它只适用于 3 种内建的引用类型:切片、map
和channel
。
换言之,new()
函数分配内存,make()
函数初始化;下图给出了区别:
如何理解 new、make、slice、map、channel 的关系*
1.slice、map 以及 channel 都是 golang 内建的一种引用类型,三者在内存中存在多个组成部分,
需要对内存组成部分初始化后才能使用,而 make 就是对三者进行初始化的一种操作方式
2. new 获取的是存储指定变量内存地址的一个变量,对于变量内部结构并不会执行相应的初始化操作,
所以 slice、map、channel 需要 make 进行初始化并获取对应的内存地址,而非 new 简单的获取内存地址
多维切片
和数组一样,切片通常也是一维的,但是也可以由一维组合成高维。通过分片的分片(或者切片的数组),长度可以任意动态变化,所以 Go 语言的多维切片可以任意切分。而且,内层的切片必须单独分配(通过 make()
函数)。
package main
import "fmt"
func main() {
s := make([]string, 3)
s[0] = "a"
s[1] = "b"
s[2] = "c"
fmt.Println("get:", s[2])
fmt.Println("len:", len(s))
s = append(s, "d")
s = append(s, "e", "f")
fmt.Println(s)
c := make([]string, len(s))
copy(c, s)
fmt.Println(c)
fmt.Println(s[2:5])
fmt.Println(s[:5])
fmt.Println(s[2:])
good := []string{"g", "o", "o", "d"}
fmt.Println(good)
}
输出
bytes包
类型 []byte
的切片十分常见,Go 语言有一个 bytes
包专门用来提供这种类型的操作方法。
- 动态缓冲区:
bytes.Buffer
是一个动态缓冲区,它可以自动调整其大小以容纳不断增长的数据。这使它非常适合在运行时动态构建和拼接二进制数据,而不必担心缓冲区溢出。 - 高效的字符串拼接:
bytes.Buffer
可以用于高效地拼接字符串,而不必反复分配和释放内存。这对于构建大型字符串或将文本逐行追加到缓冲区非常有用。 - 读写接口:
bytes.Buffer
实现了io.Reader
和io.Writer
接口,因此它可以与标准的 I/O 操作和函数一起使用。您可以使用它来从缓冲区读取数据或将数据写入缓冲区,而无需关心底层的内存管理。 - 字节操作:
bytes.Buffer
主要用于操作字节数据,而不是文本。它提供了一系列方法来操作和读取二进制数据,包括读取、写入、截取、查找等操作。
而且它还包含一个十分有用的类型 Buffer
:
import "bytes"
type Buffer struct {
...
}
这是一个长度可变的 bytes
的 buffer,提供 Read()
和 Write()
方法,因为读写长度未知的 bytes
最好使用 buffer
。
Buffer
可以这样定义:
var buffer bytes.Buffer
或者使用 new()
获得一个指针:
var r *bytes.Buffer = new(bytes.Buffer)
或者通过函数:
func NewBuffer(buf []byte) *Buffer
创建一个 Buffer
对象并且用 buf
初始化好;NewBuffer
最好用在从 buf
读取的时候使用。
通过 buffer 串联字符串
类似于 Java 的 StringBuilder 类。
在下面的代码段中,我们创建一个 buffer
,通过 buffer.WriteString(s)
方法将字符串 s
追加到后面,最后再通过 buffer.String()
方法转换为 string
:
var buffer bytes.Buffer
for {
if s, ok := getNextString(); ok { //method getNextString() not shown here
buffer.WriteString(s)
} else {
break
}
}
fmt.Print(buffer.String(), "\n")
这种实现方式比使用 +=
要更节省内存和 CPU,尤其是要串联的字符串数目特别多的时候。
for-range
这种构建方法可以应用于数组和切片:
for ix, value := range slice1 {
...
}
第一个返回值 ix
是数组或者切片的索引,第二个是在该索引位置的值;他们都是仅在 for
循环内部可见的局部变量。value
只是 slice1
某个索引位置的值的一个拷贝,不能用来修改 slice1
该索引位置的值。
多维切片下的 for-range:
通过计算行数和矩阵值可以很方便的写出:
for row := range screen {
for column := range screen[row] {
screen[row][column] = 1
}
}
切片重组 reslice
切片创建的时候通常比相关数组长度小,这么做的好处是切片在达到容量上限之后可以进行扩容,改变切片长度的过程称之为切片重组,用法:
slice1 = slice1[0 : end]
切片可以反复拓展至占据整个相关数组
切片的复制和追加
如果想增加切片的容量,我们必须创建一个新的更大的切片并把原分片的内容都拷贝过来。下面的代码描述了从拷贝切片的 copy
函数和向切片追加新元素的 append()
函数:
func copy(dst, src []T) int
方法将类型为 T
的切片从源地址 src
拷贝到目标地址 dst
,覆盖 dst
的相关元素,并且返回拷贝的元素个数。源地址和目标地址可能会有重叠。拷贝个数是 src
和 dst
的长度最小值。如果 src
是字符串那么元素类型就是 byte
。如果你还想继续使用 src
,在拷贝结束后执行 src = dst
:
slFrom := []int{1, 2, 3}
slTo := make([]int, 10)
n := copy(slTo, slFrom)
func append(s[]T, x ...T) []T
其中 append()
方法将 0 个或多个具有相同类型 s
的元素追加到切片后面并且返回新的切片;追加的元素必须和原切片的元素是同类型。
如果 s
的容量不足以存储新增元素,append()
会分配新的切片来保证已有切片元素和新增元素的存储。因此,返回的切片可能已经指向一个不同的相关数组了。append()
方法总是返回成功,除非系统内存耗尽了。
sl3 := []int{1, 2, 3}
sl3 = append(sl3, 4, 5, 6)
如果你想将切片 y
追加到切片 x
后面,只要将第二个参数扩展成一个列表即可:x = append(x, y...)
。
注意: append()
在大多数情况下很好用,但是如果你想完全掌控整个追加过程,你可以实现一个这样的 AppendByte()
方法:
func AppendByte(slice []byte, data ...byte) []byte {
m := len(slice)
n := m + len(data)
if n > cap(slice) { // if necessary, reallocate
// allocate double what's needed, for future growth.
newSlice := make([]byte, (n+1)*2)
copy(newSlice, slice)
slice = newSlice
}
slice = slice[0:n]
copy(slice[m:n], data)
return slice
}
字符串、数组和切片的应用
假设 s
是一个字符串(本质上是一个字节数组),那么就可以直接通过 c := []byte(s)
来获取一个字节的切片 c
。另外,您还可以通过 copy()
函数来达到相同的目的:copy(dst []byte, src string)
:
func main() {
s := "\u00ff\u754c"
for i, c := range s {
fmt.Printf("\n%d: %c", i, c)
}
}
输出:
我们知道,Unicode 字符会占用 2 个字节,有些甚至需要 3 个或者 4 个字节来进行表示。如果发现错误的 UTF8 字符,则该字符会被设置为 U+FFFD
(�)来代替它。这是一个特殊字符,表示无效的Unicode字符;并且索引向前移动一个字节。
和字符串转换一样,同样可以使用 c := []int32(s)
语法,这样切片中的每个 int
都会包含对应的 Unicode 代码,因为字符串中的每次字符都会对应一个整数
也可以将字符串转换为元素类型为 rune
的切片:r := []rune(s)
,其中每个rune
表示字符串中的一个Unicode字符。这可以通过将字符串转换为[]rune
来实现。
可以通过代码 len([]int32(s))
来获得字符串中字符的数量,但使用 utf8.RuneCountInString(s)
效率会更高一点
获取字符串的某一部分
使用
substr := str[start:end]
可以从字符串 str 获取到从索引 start
开始到 end-1
位置的子字符串。同样的:
str[start:]
则表示获取从 start
开始到 len(str)-1
位置的子字符串。而 str[:end]
表示获取从 0 开始到 end-1
的子字符串。
字符串和切片的内存结构
在内存中,一个字符串实际上是一个双字结构,即一个指向实际数据的指针和记录字符串长度的整数。因为指针对用户来说是完全不可见,因此我们可以依旧把字符串看做是一个值类型,也就是一个字符数组。
字符串的底层内存是只读的,因此多个字符串可以共享相同的字节数组,这有助于节省内存。
字符串 string s = "hello"
和子字符串 t = s[2:3]
在内存中的结构可以用下图表示:
切片是可变的数据类型,在内存中包含一个指向底层数据的指针、长度和容量
切片的内容是底层数组的一部分,可以通过索引来读取或修改切片的元素。
切片支持动态调整长度和容量,通过 append()
函数可以增加切片的长度,但可能会分配新的底层数组。
总结:
切片和字符串在内存结构上的主要区别在于,切片是一个引用类型,它引用底层的数组,而字符串是一个值类型,它是一个不可变的字节数组。这使得切片更适合用于处理动态数据集合,而字符串更适合用于不可变文本。理解这些区别对于有效地使用这两种数据类型非常重要
修改字符串中的某个字符
Go 语言中的字符串是不可变的,也就是说 str[index]
这样的表达式是不可以被放在等号左侧的。如果尝试运行 str[i] = 'D'
会得到错误:cannot assign to str[i]
。
因此,您必须先将字符串转换成字节数组,然后再通过修改数组中的元素值来达到修改字符串的目的,最后将字节数组转换回字符串格式。
例如,将字符串 "hello"
转换为 "cello"
:
s := "hello"
c := []byte(s)
c[0] = 'c'
s2 := string(c) // s2 == "cello"
注意:在需要频繁修改字符串的情况下,会有一些性能开销。如果只需要构建新的字符串,可以使用 +
运算符或 strings.Join()
等方法来构建新字符串而不修改原始字符串。
字节数组对比函数
下面的 Compare()
函数会返回两个字节数组字典顺序的整数对比结果,即 0 if a == b, -1 if a < b, 1 if a > b
。
func Compare(a, b[]byte) int {
for i:=0; i < len(a) && i < len(b); i++ {
switch {
case a[i] > b[i]:
return 1
case a[i] < b[i]:
return -1
}
}
// 数组的长度可能不同
switch {
case len(a) < len(b):
return -1
case len(a) > len(b):
return 1
}
return 0 // 数组相等
}
搜索及排序切片和数组
标准库提供了 sort
包来实现常见的搜索和排序操作。您可以使用 sort
包中的函数 func Ints(a []int)
来实现对 int
类型的切片排序。例如 sort.Ints(arri)
,其中变量 arri
就是需要被升序排序的数组或切片。为了检查某个数组是否已经被排序,可以通过函数 IntsAreSorted(a []int) bool
来检查,如果返回 true
则表示已经被排序。
类似的,可以使用函数 func Float64s(a []float64)
来排序 float64
的元素,或使用函数 func Strings(a []string)
排序字符串元素。
想要在数组或切片中搜索一个元素,该数组或切片必须先被排序(因为标准库的搜索算法使用的是二分法)。然后,您就可以使用函数 func SearchInts(a []int, n int) int
进行搜索,并返回对应结果的索引值。
当然,还可以搜索 float64
和字符串:
func SearchFloat64s(a []float64, x float64) int
func SearchStrings(a []string, x string) int
您可以通过查看 官方文档 来获取更详细的信息。
这就是如何使用 sort
包的方法,我们会在第 11.7 节 对它的细节进行深入,并实现一个属于我们自己的版本。
append() 函数常见操作
我们在第 7.5 节提到的 append()
非常有用,它能够用于各种方面的操作:
-
将切片
b
的元素追加到切片a
之后:a = append(a, b...)
-
复制切片
a
的元素到新的切片b
上:b = make([]T, len(a)) copy(b, a)
-
删除位于索引
i
的元素:a = append(a[:i], a[i+1:]...)
-
切除切片
a
中从索引i
至j
位置的元素:a = append(a[:i], a[j:]...)
-
为切片
a
扩展j
个元素长度:a = append(a, make([]T, j)...)
-
在索引
i
的位置插入元素x
:a = append(a[:i], append([]T{x}, a[i:]...)...)
-
在索引
i
的位置插入长度为j
的新切片:a = append(a[:i], append(make([]T, j), a[i:]...)...)
-
在索引
i
的位置插入切片b
的所有元素:a = append(a[:i], append(b, a[i:]...)...)
-
取出位于切片
a
最末尾的元素x
:x, a = a[len(a)-1], a[:len(a)-1]
-
将元素
x
追加到切片a
:a = append(a, x)
因此,您可以使用切片和 append()
操作来表示任意可变长度的序列。
从数学的角度来看,切片相当于向量,如果需要的话可以定义一个向量作为切片的别名来进行操作。
如果您需要更加完整的方案,可以学习一下 Eleanor McHugh 编写的几个包:slices
、chain
和 lists
。
切片和垃圾回收
切片的底层指向一个数组,该数组的实际容量可能要大于切片所定义的容量。只有在没有任何切片指向的时候,底层的数组内存才会被释放,这种特性有时会导致程序占用多余的内存。
函数 FindDigits()
将一个文件加载到内存,然后搜索其中所有的数字并返回一个切片:
var digitRegexp = regexp.MustCompile("[0-9]+")
func FindDigits(filename string) []byte {
b, _ := ioutil.ReadFile(filename)
return digitRegexp.Find(b)
}
这段代码可以顺利运行,但返回的 []byte
指向的底层是整个文件的数据。只要该返回的切片不被释放,垃圾回收器就不能释放整个文件所占用的内存。换句话说,一点点有用的数据却占用了整个文件的内存。
想要避免这个问题,可以通过拷贝我们需要的部分到一个新的切片中:
func FindDigits(filename string) []byte {
b, _ := ioutil.ReadFile(filename)
b = digitRegexp.Find(b)
c := make([]byte, len(b))
copy(c, b)
return c
}
事实上,上面这段代码只能找到第一个匹配正则表达式的数字串。要想找到所有的数字,可以尝试下面这段代码:
func FindFileDigits(filename string) []byte {
fileBytes, _ := ioutil.ReadFile(filename)
b := digitRegexp.FindAll(fileBytes, len(fileBytes))
c := make([]byte, 0)
for _, bytes := range b {
c = append(c, bytes...)
}
return c
}
是的,底层的数组内存在没有任何切片指向时才会被释放。在 Go 中,切片是对底层数组的引用,它们包括一个指向数组的指针、长度和容量。底层数组的内存只有在没有任何切片引用时才会被回收。
这是因为 Go 的垃圾回收机制(Garbage Collection)负责管理内存,它会跟踪对象(包括切片和底层数组),并在这些对象不再被引用时将它们标记为垃圾,最终释放它们占用的内存。
当没有任何切片指向底层数组时,底层数组就变成不可达的对象,即没有引用指向它。在下一次垃圾回收周期时,底层数组的内存将被释放,以便重用或返回给操作系统。这有助于Go应用程序有效地管理内存。
这也意味着在某些情况下,即使你不再使用切片,底层数组的内存也可能不会立即被释放,因为垃圾回收的时机是由Go运行时系统控制的。如果需要确保内存立即被释放,可以使用 runtime.GC()
强制进行垃圾回收,但这在一般情况下是不推荐的,因为Go的垃圾回收机制通常会在合适的时机进行内存回收。