learn-tech/专栏/Go语言核心36讲/37strings包与字符串操作.md
2024-10-16 00:01:16 +08:00

214 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
37 strings包与字符串操作
在上一篇文章中我介绍了Go语言与Unicode编码规范、UTF-8编码格式的渊源及运用。
Go语言不但拥有可以独立代表Unicode字符的类型rune而且还有可以对字符串值进行Unicode字符拆分的for语句。
除此之外标准库中的unicode包及其子包还提供了很多的函数和数据类型可以帮助我们解析各种内容中的Unicode字符。
这些程序实体都很好用也都很简单明了而且有效地隐藏了Unicode编码规范中的一些复杂的细节。我就不在这里对它们进行专门的讲解了。
我们今天主要来说一说标准库中的strings代码包。这个代码包也用到了不少unicode包和unicode/utf8包中的程序实体。
比如strings.Builder类型的WriteRune方法。
又比如strings.Reader类型的ReadRune方法等等。
下面这个问题就是针对strings.Builder类型的。我们今天的问题是与string值相比strings.Builder类型的值有哪些优势
这里的典型回答是这样的。
strings.Builder类型的值以下简称Builder值的优势有下面的三种
已存在的内容不可变,但可以拼接更多的内容;
减少了内存分配和内容拷贝的次数;
可将内容重置,可重用值。
问题解析
先来说说string类型。 我们都知道在Go语言中string类型的值是不可变的。 如果我们想获得一个不一样的字符串,那么就只能基于原字符串进行裁剪、拼接等操作,从而生成一个新的字符串。
裁剪操作可以使用切片表达式;
拼接操作可以用操作符+实现。
在底层一个string值的内容会被存储到一块连续的内存空间中。同时这块内存容纳的字节数量也会被记录下来并用于表示该string值的长度。
你可以把这块内存的内容看成一个字节数组而相应的string值则包含了指向字节数组头部的指针值。如此一来我们在一个string值上应用切片表达式就相当于在对其底层的字节数组做切片。
另外我们在进行字符串拼接的时候Go语言会把所有被拼接的字符串依次拷贝到一个崭新且足够大的连续内存空间中并把持有相应指针值的string值作为结果返回。
显然,当程序中存在过多的字符串拼接操作的时候,会对内存的分配产生非常大的压力。
注意虽然string值在内部持有一个指针值但其类型仍然属于值类型。不过由于string值的不可变其中的指针值也为内存空间的节省做出了贡献。
更具体地说一个string值会在底层与它的所有副本共用同一个字节数组。由于这里的字节数组永远不会被改变所以这样做是绝对安全的。
与string值相比Builder值的优势其实主要体现在字符串拼接方面。
Builder值中有一个用于承载内容的容器以下简称内容容器。它是一个以byte为元素类型的切片以下简称字节切片
由于这样的字节切片的底层数组就是一个字节数组所以我们可以说它与string值存储内容的方式是一样的。
实际上它们都是通过一个unsafe.Pointer类型的字段来持有那个指向了底层字节数组的指针值的。
正是因为这样的内部构造Builder值同样拥有高效利用内存的前提条件。虽然对于字节切片本身来说它包含的任何元素值都可以被修改但是Builder值并不允许这样做其中的内容只能够被拼接或者完全重置。
这就意味着已存在于Builder值中的内容是不可变的。因此我们可以利用Builder值提供的方法拼接更多的内容而丝毫不用担心这些方法会影响到已存在的内容。
这里所说的方法指的是Builder值拥有的一系列指针方法包括Write、WriteByte、WriteRune和WriteString。我们可以把它们统称为拼接方法。
我们可以通过调用上述方法把新的内容拼接到已存在的内容的尾部也就是右边。这时如有必要Builder值会自动地对自身的内容容器进行扩容。这里的自动扩容策略与切片的扩容策略一致。
换句话说我们在向Builder值拼接内容的时候并不一定会引起扩容。只要内容容器的容量够用扩容就不会进行针对于此的内存分配也不会发生。同时只要没有扩容Builder值中已存在的内容就不会再被拷贝。
除了Builder值的自动扩容我们还可以选择手动扩容这通过调用Builder值的Grow方法就可以做到。Grow方法也可以被称为扩容方法它接受一个int类型的参数n该参数用于代表将要扩充的字节数量。
如有必要Grow方法会把其所属值中内容容器的容量增加n个字节。更具体地讲它会生成一个字节切片作为新的内容容器该切片的容量会是原容器容量的二倍再加上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())
当然Grow方法还可能什么都不做。这种情况的前提条件是当前的内容容器中的未用容量已经够用了未用容量大于或等于n。这里的前提条件与前面提到的自动扩容策略中的前提条件是类似的。
fmt.Println("Reset the builder ...")
builder1.Reset()
fmt.Printf("The third output(%d):\n%q\n", builder1.Len(), builder1.String())
最后Builder值是可以被重用的。通过调用它的Reset方法我们可以让Builder值重新回到零值状态就像它从未被使用过那样。
一旦被重用Builder值中原有的内容容器会被直接丢弃。之后它和其中的所有内容将会被Go语言的垃圾回收器标记并回收掉。
知识扩展
问题1strings.Builder类型在使用上有约束吗
答案是:有约束,概括如下:
在已被真正使用后就不可再被复制;
由于其内容不是完全不可变的,所以需要使用方自行解决操作冲突和并发安全问题。
我们只要调用了Builder值的拼接方法或扩容方法就意味着开始真正使用它了。显而易见这些方法都会改变其所属值中的内容容器的状态。
一旦调用了它们我们就不能再以任何的方式对其所属值进行复制了。否则只要在任何副本上调用上述方法就都会引发panic。
这种panic会告诉我们这样的使用方式是并不合法的因为这里的Builder值是副本而不是原值。顺便说一句这里所说的复制方式包括但不限于在函数间传递值、通过通道传递值、把值赋予变量等等。
var builder1 strings.Builder
builder1.Grow(1)
builder3 := builder1
//builder3.Grow(1) // 这里会引发panic。
_ = builder3
虽然这个约束非常严格,但是如果我们仔细思考一下的话,就会发现它还是有好处的。
正是由于已使用的Builder值不能再被复制所以肯定不会出现多个Builder值中的内容容器也就是那个字节切片共用一个底层字节数组的情况。这样也就避免了多个同源的Builder值在拼接内容时可能产生的冲突问题。
不过虽然已使用的Builder值不能再被复制但是它的指针值却可以。无论什么时候我们都可以通过任何方式复制这样的指针值。注意这样的指针值指向的都会是同一个Builder值。
f2 := func(bp *strings.Builder) {
(*bp).Grow(1) // 这里虽然不会引发panic但不是并发安全的。
builder4 := *bp
//builder4.Grow(1) // 这里会引发panic。
_ = builder4
}
f2(&builder1)
正因为如此这里就产生了一个问题如果Builder值被多方同时操作那么其中的内容就很可能会产生混乱。这就是我们所说的操作冲突和并发安全问题。
Builder值自己是无法解决这些问题的。所以我们在通过传递其指针值共享Builder值的时候一定要确保各方对它的使用是正确、有序的并且是并发安全的而最彻底的解决方案是绝不共享Builder值以及它的指针值。
我们可以在各处分别声明一个Builder值来使用也可以先声明一个Builder值然后在真正使用它之前便将它的副本传到各处。另外我们还可以先使用再传递只要在传递之前调用它的Reset方法即可。
builder1.Reset()
builder5 := builder1
builder5.Grow(1) // 这里不会引发panic。
总之关于复制Builder值的约束是有意义的也是很有必要的。虽然我们仍然可以通过某些方式共享Builder值但最好还是不要以身犯险“各自为政”是最好的解决方案。不过对于处在零值状态的Builder值复制不会有任何问题。
问题2为什么说strings.Reader类型的值可以高效地读取字符串
与strings.Builder类型恰恰相反strings.Reader类型是为了高效读取字符串而存在的。后者的高效主要体现在它对字符串的读取机制上它封装了很多用于在string值上读取内容的最佳实践。
strings.Reader类型的值以下简称Reader值可以让我们很方便地读取一个字符串中的内容。在读取的过程中Reader值会保存已读取的字节的计数以下简称已读计数
已读计数也代表着下一次读取的起始索引位置。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的值来计算新的计数值。
由于Seek方法会返回新的计数值所以我们可以很容易地验证这一点。比如像下面这样
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)
综上所述Reader值实现高效读取的关键就在于它内部的已读计数。计数的值就代表着下一次读取的起始索引位置。它可以很容易地被计算出来。Reader值的Seek方法可以直接设定该值中的已读计数值。
总结
今天我们主要讨论了strings代码包中的两个重要类型Builder和Reader。前者用于构建字符串而后者则用于读取字符串。
与string值相比Builder值的优势主要体现在字符串拼接方面。它可以在保证已存在的内容不变的前提下拼接更多的内容并且会在拼接的过程中尽量减少内存分配和内容拷贝的次数。
不过这类值在使用上也是有约束的。它在被真正使用之后就不能再被复制了否则就会引发panic。虽然这个约束很严格但是也可以带来一定的好处。它可以有效地避免一些操作冲突。虽然我们可以通过一些手段比如传递它的指针值绕过这个约束但这是弊大于利的。最好的解决方案就是分别声明、分开使用、互不干涉。
Reader值可以让我们很方便地读取一个字符串中的内容。它的高效主要体现在它对字符串的读取机制上。在读取的过程中Reader值会保存已读取的字节的计数也称已读计数。
这个计数代表着下一次读取的起始索引位置同时也是高效读取的关键所在。我们可以利用这类值的Len方法和Size方法计算出其中的已读计数的值。有了它我们就可以更加灵活地进行字符串读取了。
我只在本文介绍了上述两个数据类型但并不意味着strings包中有用的程序实体只有这两个。实际上strings包还提供了大量的函数。比如
`Count``IndexRune``Map``Replace``SplitN``Trim`,等等。
它们都是非常易用和高效的。你可以去看看它们的源码,也许会因此有所感悟。
思考题
今天的思考题是:*strings.Builder和*strings.Reader都分别实现了哪些接口这样做有什么好处吗
戳此查看Go语言专栏文章配套详细代码。