字符串处理函数
字符串在开发中使用频率较高,我们经常需要对字符串进行拆分、判断等操作,可以借助Go标准库中的strings包快速达到处理字符串的目的。
字符串分割
func Split(s, sep string) []string
- 功能:把s字符串按照sep分割,返回slice
- 返回值:切片,存储拆分好的子串
fmt.Printf("%q\n", strings.Split("a,b,c", ","))
fmt.Printf("%q\n", strings.Split("a man a plan a canal panama", "a "))
//运行结果:
//["a" "b" "c"]
//["" "man " "plan " "canal panama"]
按空格拆分字符串
func Fields(s string) []string
- 功能:去除s字符串的空格符,并且按照空格分割,返回slice
- 返回值:切片,存储拆分好的子串
fmt.Printf("Fields are: %q", strings.Fields(" foo bar baz "))
//运行结果:Fields are: ["foo" "bar" "baz"]
判断字符串后缀
func HasSuffix(s, suffix string) bool
- 功能:判断s字符串是否有后缀子串suffix
- 返回值:true or false
fmt.Printf("%v\n", strings.HasSuffix("World Cup.png", ".png"))
//运行结果:true
判断字符串前缀
func HasPrefix(s, prefix string) bool
- 功能:判断s字符串是否有前缀子串suffix
- 返回值:true or false
fmt.Printf("%v\n", strings.HasPrefix("World Cup.png", "world"))
//运行结果:false
字符编码
Go 语言中的标识符可以包含任何 Unicode 编码可以表示的字母字符。把整数转换为 string 的时候,被转换整数应该可以代表一个有效 Unicode, 否则转换结果是"�",即:一个仅由高亮的问号组成的字符串值。
string 类型值别转换为 []rune 类型时,其字符串会拆分成一个个 Unicode 字符。Go 语言采用的字符编码方案从属于 Unicode 编码规范,其源码文件必须使用 UTF-8 编码格式进行存储。
ASCII 编码
ASCII 编码方案使用单个字节(byte)的二进制数来编码一个字符。标准的 ASCII 编码用一个字节的最高比特(bit)位作为奇偶校验位,而扩展的 ASCII 编码则将此位也用于表示字符。ASCII 编码支持的可打印字符和控制字符的集合也被叫做 ASCII 编码集。
Unicode 编码是另一个更加通用的、针对书面字符和文本的字符编码标准。它为世界上现存的所有自然语言中的每一个字符,都设定了一个唯一的二进制编码。它定义了不同自然语言的文本数据在国际间交换的统一方式,并为全球化软件创建了一个重要的基础。
Unicode 编码规范以 ASCII 编码集为出发点,并突破了 ASCII 只能对拉丁字母进行编码的限制。Unicode 编码规范通常使用十六进制表示法来表示 Unicode 代码点的整数值,并使用“U+”作为前缀。
Unicode 编码规范提供了三种不同的编码格式,即:UTF-8、UTF-16 和 UTF-32。在这几种编码格式的名称中,“-”右边的整数的含义是,以多少个比特位作为一个编码单元。以 UTF-8 为例,它会以 8 个比特,也就是一个字节,作为一个编码单元。它与标准的 ASCII 编码是完全兼容的。
UTF-8 是一种可变宽的编码方案。它会用一个或多个字节的二进制数来表示某个字符,最多使用四个字节。
string 底层表达
在底层,一个string类型的值是由一系列相对应的 Unicode 代码点的 UTF-8 编码值来表达的。
string类型的值既可以被拆分为一个包含多个字符的序列,也可以被拆分为一个包含多个字节的序列。前者可以由一个以rune为元素类型的切片来表示,而后者则可以由一个以byte为元素类型的切片代表。
rune是 Go 语言特有的一个基本数据类型,它的一个值就代表一个字符,即:一个 Unicode 字符。UTF-8 编码方案会把一个 Unicode 字符编码为一个长度在 [1, 4] 范围内的字节序列。所以,一个rune类型的值也可以由一个或多个字节来代表。
type rune = int32
一个rune类型的值会由四个字节宽度的空间来存储。它的存储空间总是能够存下一个 UTF-8 编码值。
str := "Go 爱好者 "
fmt.Printf("The string: %q\n", str)
fmt.Printf(" => runes(char): %q\n", []rune(str))
fmt.Printf(" => runes(hex): %x\n", []rune(str))
fmt.Printf(" => bytes(hex): [% x]\n", []byte(str))
执行结果
The string: "Go 爱好者 "
=> runes(char): ['G' 'o' ' ' '爱' '好' '者' ' ']
=> runes(hex): [47 6f 20 7231 597d 8005 20]
=> bytes(hex): [47 6f 20 e7 88 b1 e5 a5 bd e8 80 85 20]
一个string类型的值会由若干个 Unicode 字符组成,每个 Unicode 字符都可以由一个rune类型的值来承载,string类型的值在底层就是一个能够表达若干个 UTF-8 编码值的字节序列。
遍历字符串
带有range子句的for语句会先把被遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的每一个 UTF-8 编码值。
str := "Go 爱好者 "
for i, c := range str {
fmt.Printf("%d: %q [% x]\n", i, c, []byte(string(c)))
}
执行结果
0: 'G' [47]
1: 'o' [6f]
2: '爱' [e7 88 b1]
5: '好' [e5 a5 bd]
8: '者' [e8 80 85]
for语句可以逐一地迭代出字符串值里的每个 Unicode 字符。但是,相邻的 Unicode 字符的索引值并不一定是连续的。这取决于前一个 Unicode 字符是否为单字节字符。
strings.Builder
标准库中的strings代码包用到了不少unicode包和unicode/utf8包中的程序实体。
strings.Builder类型的值(以下简称Builder值)的优势有下面的三种:
- 已存在的内容不可变,但可以拼接更多的内容;
- 减少了内存分配和内容拷贝的次数;
- 可将内容重置,可重用值。
string类型的值是不可变的。只能基于原字符串进行裁剪、拼接等操作,从而生成一个新的字符串。裁剪操作可以使用切片表达式,而拼接操作可以用操作符+实现。
在底层,一个string值的内容会被存储到一块连续的内存空间中。同时,这块内存容纳的字节数量也会被记录下来,并用于表示该string值的长度。在一个string值上应用切片表达式,就相当于在对其底层的字节数组做切片。
一个string类型仍然属于值类型,其值会在底层与它的所有副本共用同一个字节数组。由于这里的字节数组永远不会被改变,所以这样做是绝对安全的。
与string值相比,Builder值的优势其实主要体现在字符串拼接方面。Builder值中有一个用于承载内容的容器。它是一个以byte为元素类型的切片。和 string 一样,通过一个unsafe.Pointer类型的字段来持有那个指向了底层字节数组的指针值的。Builder内容只能拼接或重置,不能进行修改。
Builder值拥有的一系列指针方法,包括:Write、WriteByte、WriteRune和WriteString。我们可以把它们统称为拼接方法。通过调用上述方法把新的内容拼接到已存在的内容的尾部。这时,如有必要,Builder值会自动地对自身的内容容器进行扩容。这里的自动扩容策略与切片的扩容策略一致。
Builder 可以通过Grow方法手动扩容,它接受一个int类型的参数n,该参数用于代表将要扩充的字节数量,拷贝字节到新容器中。
var builder1 strings.Builder
fmt.Println("Grow the builder ...")
builder1.Grow(10)
fmt.Printf("The length of contents in the builder is %d.\n", builder1.Len())
当前的内容容器中的未用容量已经够用了,即:未用容量大于或等于n,Grow方法没有效果。
Builder值是可以被重用的。通过调用它的Reset方法,我们可以让Builder值重新回到零值状态,就像它从未被使用过那样,原内容会被垃圾回收。
strings.Builder类型约束
- 在已被真正使用后就不可再被复制;
- 由于其内容不是完全不可变的,所以需要使用方自行解决操作冲突和并发安全问题。
调用了Builder值的拼接方法或扩容方法,就意味着开始真正使用它了,不能再以任何的方式对其所属值进行复制,否则会引发 panic。
var builder1 strings.Builder
builder1.Grow(1)
builder3 := builder1
//builder3.Grow(1) // 这里会引发 panic。
这样限制的目的是避免了多个同源的Builder值在拼接内容时可能产生的冲突问题。
虽然已使用的Builder值不能再被复制,但是它的指针值却可以。
f2 := func(bp *strings.Builder) {
// 这里虽然不会引发 panic,但不是并发安全的。
(*bp).Grow(1)
builder4 := *bp
//builder4.Grow(1) // 这里会引发 panic。
_ = builder4
}
f2(&builder1)
所以Builder值被多方同时操作,容易引发 panic,最好的做法是绝不共享Builder值以及它的指针值。
strings.Reader
strings.Reader类型是为了高效读取字符串而存在的。后者的高效主要体现在它对字符串的读取机制上,它封装了很多用于在string值上读取内容的最佳实践。
strings.Reader类型的值可以让我们很方便地读取一个字符串中的内容。在读取的过程中,Reader值会保存已读取的字节的计数。
已读计数是字符串切片,读取回退和位置设定时的重要依据,可以通过该值的Len方法和Size把它计算出来的
var reader1 strings.Reader
// 省略若干代码。
readingIndex := reader1.Size() - int64(reader1.Len()) // 计算出的已读计数。
Reader值拥有的大部分用于读取的方法都会及时地更新已读计数。
-
ReadByte方法会在读取成功后将这个计数的值加1 -
``ReadRune`方法在读取成功之后,会把被读取的字符所占用的字节数作为计数的增量。
-
ReadAt方法算是一个例外。它既不会依据已读计数进行读取,也不会在读取后更新它。正因为如此,这个方法可以自由地读取其所属的Reader值中的任何内容。 -
Reader值的Seek方法会更新该值的已读计数。实际上,这个Seek方法的主要作用正是设定下一次读取的起始索引位置。- 如果我们把常量
io.SeekCurrent的值作为第二个参数值传给该方法,那么它还会依据当前的已读计数,以及第一个参数offset的值来计算新的计数值。
offset2 := int64(17) expectedIndex := reader1.Size() - int64(reader1.Len()) + offset2 fmt.Printf("Seek with offset %d and whence %d ...\n", offset2, io.SeekCurrent) readingIndex, _ := reader1.Seek(offset2, io.SeekCurrent) fmt.Printf("The reading index in reader: %d (returned by Seek)\n", readingIndex) fmt.Printf("The reading index in reader: %d (computed by me)\n", expectedIndex) // 执行结果 // Seek with offset 17 and whence 1 ... // The reading index in reader: 17 (returned by Seek) // The reading index in reader: 17 (computed by me) - 如果我们把常量
Reader值实现高效读取的关键就在于它内部的已读计数。计数的值就代表着下一次读取的起始索引位置。它可以很容易地被计算出来。Reader值的Seek方法可以直接设定该值中的已读计数值。
bytes包
bytes包面对的则主要是字节和字节切片。bytes包中最有特色的类型Buffer。bytes.Buffer是集读、写功能于一身的数据类型,类型的用途主要是作为字节序列的缓冲区。
bytes.Buffer不但可以拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序地读取其中的子序列。
在内部,bytes.Buffer类型同样是使用字节切片作为内容容器的。并且,与strings.Reader类型类似,bytes.Buffer有一个int类型的字段,用于代表已读字节的计数,可以简称为已读计数。
与strings.Reader类型的Len方法一样,buffer1的Len方法返回的也是内容容器中未被读取部分的长度,而不是其中已存内容的总长度(以下简称内容长度)
var buffer1 bytes.Buffer
contents := "Simple byte buffer for marshaling data."
buffer1.WriteString(contents)
// buffer length: 39
// buffer capacity: 64
p1 := make([]byte, 7)
n, _ := buffer1.Read(p1)
fmt.Printf("%d bytes were read. (call Read)\n", n)
fmt.Printf("The length of buffer: %d\n", buffer1.Len())
fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap())
执行结果
7 bytes were read. (call Read)
The length of buffer: 32
The capacity of buffer: 64
Buffer值的长度是未读内容的长度,而不是已存内容的总长度。它与在当前值之上的读操作和写操作都有关系,并会随着这两种操作的进行而改变,它可能会变得更小,也可能会变得更大。
由于strings.Reader还有一个Size方法可以给出内容长度的值,所以我们用内容长度减去未读部分的长度,就可以很方便地得到它的已读计数。
然而,bytes.Buffer类型却没有这样一个方法,它只有Cap方法。可是Cap方法提供的是内容容器的容量,也不是内容长度,很难估算出Buffer值的已读计数。
已读计数
bytes.Buffer的绝大多数方法都用到了已读计数,而且都是非用不可。
- 在读取完成后,会更新已读计数。这里所说的相应方法包括了所有名称以
Read开头的方法,以及Next方法和WriteTo方法。 - 在写入内容的时候,会先检查当前的内容容器容量,不够则扩容。
- 在扩容的时候,方法会在必要时,依据已读计数找到未读部分,并把其中的内容拷贝到扩容后内容容器的头部位置。把已读计数的值置为
0,以表示下一次读取需要从内容容器的第一个字节开始。用于写入内容的相应方法,包括了所有名称以Write开头的方法,以及ReadFrom方法。 - 用于截断内容的方法
Truncate它会接受一个int类型的参数,代表了:在截断时需要保留头部的多少个字节。头部指的是未读部分的头部。 - 在
bytes.Buffer中,用于读回退的方法有UnreadByte和UnreadRune。 这两个方法分别用于回退一个字节和回退一个 Unicode 字符。调用它们一般都是为了退回在上一次被读取内容末尾的那个分隔符,或者为重新读取前一个字节或字符做准备。- 退回的前提是,在调用它们之前的那一个操作必须是“读取”,并且是成功的读取,否则这些方法就只能忽略后续操作并返回一个非
nil的错误值。
- 退回的前提是,在调用它们之前的那一个操作必须是“读取”,并且是成功的读取,否则这些方法就只能忽略后续操作并返回一个非
UnreadByte把已读计数的值减1就好了。而UnreadRune方法需要从已读计数中减去的,是上一次被读取的 Unicode 字符所占用的字节数。
在已读计数代表的索引之前的那些内容,永远都是已经被读过的,它们几乎没有机会再次被读取。不过,这些已读内容所在的内存空间可能会被存入新的内容。这一般都是由于重置或者扩充内容容器导致的。这时,已读计数一定会被置为0,从而再次指向内容容器中的第一个字节。
扩容策略
Buffer值既可以被手动扩容,也可以进行自动扩容。并且,这两种扩容方式的策略是基本一致的。
在扩容的时候,Buffer值中相应的代码(以下简称扩容代码)会先判断内容容器的剩余容量,是否可以满足调用方的要求,或者是否足够容纳新的内容。如果可以,那么扩容代码会在当前的内容容器之上,进行长度扩充。
b.buf = b.buf[:length+need]
如果内容容器的剩余容量不够了,那么扩容代码可能就会用新的内容容器去替代原有的内容容器,从而实现扩容。
如果当前内容容器的容量的一半仍然大于或等于其现有长度再加上另需的字节数的和。那么,扩容代码就会复用现有的内容容器,并把容器中的未读内容拷贝到它的头部位置。这也意味着其中的已读内容,将会全部被未读内容和之后的新内容覆盖掉。
若当前内容容器的容量小于新长度的二倍,那么扩容代码就只能再创建一个新的内容容器,并把原有容器中的未读内容拷贝进去,最后再用新的容器替换掉原有的容器。这个新容器的容量将会等于原有容量的二倍再加上另需字节数的和。
新容器的容量 =2* 原有容量 + 所需字节数
扩容代码还会把已读计数置为0,并再对内容容器做一下切片操作,以掩盖掉原有的已读内容。
内容泄露
内容泄露是指,使用Buffer值的一方通过某种非标准的(或者说不正式的)方式得到了本不该得到的内容。
在bytes.Buffer中,Bytes方法和Next方法都可能会造成内容的泄露。原因在于,它们都把基于内容容器的切片直接返回给了方法的调用方。
通过切片,我们可以直接访问和操纵它的底层数组。不论这个切片是基于某个数组得来的,还是通过对另一个切片做切片操作获得的,都是如此。Bytes方法和Next方法返回的字节切片,都是通过对内容容器做切片操作得到的。也就是说,它们与内容容器共用了同一个底层数组
contents := "ab"
buffer1 := bytes.NewBufferString(contents)
fmt.Printf("The capacity of new buffer with contents %q: %d\n",
contents, buffer1.Cap()) // 内容容器的容量为:8。
// unreadBytes,包含了在那时其中的所有未读内容。
unreadBytes := buffer1.Bytes()
fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 未读内容为:[97 98]。
// 向该值写入了字符串值"cdefg",此时,其容量仍然是8。
buffer1.WriteString("cdefg")
fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) // 内容容器的容量仍为:8。
unreadBytes = unreadBytes[:cap(unreadBytes)] // buffer1的新内容就被泄露出来
fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 基于前面获取到的结果值可得,未读内容为:[97 98 99 100 101 102 103 0]。
结果值与buffer1的内容容器在此时还共用着同一个底层数组。所以,只需通过简单的再切片操作,就可以利用这个结果值拿到buffer1在此时的所有未读内容。如此一来,buffer1的新内容就被泄露出来了。
如果把unreadBytes的值传到了外界,那么外界就可以通过该值操纵buffer1的内容了。

被折叠的 条评论
为什么被折叠?



