热门IT资讯网

Go36-36,37-字符串

发表于:2024-11-30 作者:热门IT资讯网编辑
编辑最后更新 2024年11月30日,unicode与字符编码字符编码的问题,是计算机领域中非常基础的一个问题。Unicode编码Go语言中的标识符可以包含任何Unicode编码可以表示的字母字符。可以直接把一个整数数值转换为一个stri

unicode与字符编码

字符编码的问题,是计算机领域中非常基础的一个问题。

Unicode编码

Go语言中的标识符可以包含任何Unicode编码可以表示的字母字符。可以直接把一个整数数值转换为一个string类型的值。被转换的整数值应该是一个有效的Unicode码点,否则会显示为一个"�"字符:

package mainimport "fmt"func main() {    s1 := '你'  // 这是一个字符类型,不是字符串    fmt.Println(int(s1))  // 字符"你"转为整数是20320    s2 := rune(20320)    fmt.Println(string(s2))    s3 := rune(-1)  // 不用费心找一个不存在的Unicode码点,用-1就好    fmt.Println(string(s3))  // 不存在的码点显示的效果}

当一个string类型的值被转换为[]rune类型值的时候,其中的字符串会被拆分成一个一个的Unicode字符:

func main() {    s := "你好,世界!"    r := []rune(s)    fmt.Println(r)    for _, c := range(r) {        fmt.Printf("%c ", c)    }    fmt.Println()}

Go语言采用的字符编码方案从属于Unicode编码规范。更准确的说,Go语言的代码正是由Unicode字符组成的。所有源代码,都必须按照Unicode编码规范这的UTF-8编码格式进行编码。
Go语言的源码文件必须使用UTF-8编码格式进行存储。如果源码中出现了非UTF-8编码的字符,那么在构建、安装以及运行的时候,Go命令就会报告错误"illegal UTF-8 encoding"。

Unicode编码规范

在计算机系统的内部,抽象的字符会被编码为整数。这些整数的范围被称为代码空间。在代码空间之内,每一个特定的整数都被称为一个码点。一个受支持的抽象字符会被映射并分配给某个特定的码点。反过来,一个码点总是可以看成一个被编码的字符。
Unicode编码规范通常使用16进制表示法来表示Unicode码点的整数数值,并使用"U+"作为前缀。比如,字母a的Unicode码点是U+0061。在Unicode编码规范中,一个字符能且只能与它对应的那个码点表示。

UTF-8

Unicode编码规范提供了3种不同的编码格式:

  • UTF-8
  • UTF-16
  • UTF-32

上面的名称中,右边的整数是有含义的。就是以多少个比特位作为一个编码单元。以UTF-8为例,它会以8个比特位,就是1个字节作为一个编码单元。并且,它与标准的ASCII编码是完全兼容的。在[0x00, 0x7f]的范围内,这两种编码表示的字符是相同的。
UTF-8是一种可变宽的编码方案。它会用一个或多个字节的二进制数来表示某个字符,最多使用4个字节。比如,一个英文字符,仅占用1个字节,而一个中文字符,占用3个字节。不论怎样,一个受支持的字符总是可以用UTF-8进行编码,成为一个字节序列。

Go语言中的运行

在底层,一个string类型的值,是由一系列相对应的Unicode码点的UTF-8编码值来表达的。
在Go语言中,一个string类型的值既可以被拆分为一个包含多个字符的序列([]runc 类型),也可以被拆分为一个包含多个字节的序列([]byte 类型)。
rune是Go语言特有的一个基本数据类型,它的一个值就代码一个字符,即:Unicode字符。UTF-8编码方案会把一个Unicode字符编码为一个长度在[1,4]范围内的字节序列。所以,一个rune类型的值也可以由一个或多个字节来代表。下面是rune类型的声明:

type rune = int32

rune类型实际上是int32类型的一个别名类型。一个rune类型的值会由4个字节宽度的空间来存储。一个rune类型的值在底层就是一个UTF-8编码值。
把一个字符串转换为[]rune类型的话,不论是英文占1个字节还是中文占3个字节,其中每一个字符,都会独立成为一个rune类型的元素值:

str := "你好,世界! This is Golang."fmt.Printf("%q\n", []rune(str))

而每个rune类型的值在底层都是由一个UTF-8编码值来表达的,所以可以换一种方式展示为整数的序列:

fmt.Printf("%x\n", []rune(str))

还可以再进一步的拆分,拆分为字节序列:

fmt.Printf("[% x]\n", []byte(str))

字节切片中,英文字符的值和上面的字符切片里是一样的。都是一个字节来表示。
而中文字符占字节切片中的3个元素,在字符切片中占1个元素。以中文字符"你"为例,UTF-8编码的整数为0x4f60,就是10进制的20320,而在字节切片中是3个数:e4、bd、a0。

UTF-8与Unicode的转换

UTF-8是由1至4个字节表示,是变长的。在编码的时候,第一个字节的高位指明了后面还有多少个字节:

  • 0xxxxxxx, 0开头,表示后面没有别的字节,能表示0-127这些字符,就是ASCII字符。
  • 110xxxxx 10xxxxx,110开头,表示一共2个字节,后面的字节都是是10开头。能表示128-2047的码点。
  • 1110xxxx 10xxxxxx 10xxxxxx,1110开头,表示一共3个字节,能表示2048-65536的码点。
  • 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx,11110开头,表示一个4个字节,能表示65536-0x10ffff的码点。

分析一下"你"这个中文字。UTF-8是0x4f60,就是:
0100 1111 0110 0000
把上面的二进制位替换掉1110xxxx 10xxxxxx 10xxxxxx里的x:
11100100 10111101 10100000 就是 e4 bd a0

遍历字符串

使用range遍历字符串的时候,会先把字符串拆成一个字节序列,然后再试图找出每个字节对应的Unicode字符。用for range迭代的时候可以返回2个变量,第一个是索引值,第二个就是字符,类型是rune:

func main() {    s := "Hi 世界"    for i, c := range(s) {        fmt.Printf("%d: %q\t[% x]\n", i, c, []byte(string(c)))    }}/* 执行结果PS G:\Steed\Documents\Go\src\Go36\article36\example04> go run main.go0: 'H'  [48]1: 'i'  [69]2: ' '  [20]3: '世' [e4 b8 96]6: '界' [e7 95 8c]PS G:\Steed\Documents\Go\src\Go36\article36\example04>*/

这里要注意一下执行后的结果,主要是返回的第一个变量也就是下标,或者叫索引值。索引值不是每次都加1的,英文中文字符占3个字节,所以中文字符后的下一个索引值是加3的。
这样的for range可以逐一迭代出字符串里的每一个Unicode字符。但是相邻的Unicode字符的索引值并不一定是连续的。这取决于前一个Unicode字符的宽度。如果想要得到其中某个Unicode字符对应的UTF-8编码的宽度,可以不用去了解上面的UTF-8与Unicode的转换的编码格式。而是可以把下一个字符的索引值减去当前字符的索引值就算好了。

unicode包介绍

标准库中的unicode包及其子包,提供了很多的函数和数据类型,可以解析各种内容中的Unicode字符。这些程序实体都很好用,也都很简单明了,而且有效的隐藏了Unicode编码规范中的一些复杂的细节。不过这部分只是提了一下,没有展开,也没有进行讲解。
另外去找了几个unicode包使用的示例,放这里充实点内容。
统计字符数:

func main() {    s := "Hi 世界"  // 3个ASCII字符,2个中文字符    fmt.Println(len(s))  // 9    fmt.Println(utf8.RuneCountInString(s))  // 5}

返回字符串第一个字符的编码和宽度:

func main() {    s := "Hi 世界"    for i := 0; i < len(s); {        r, size := utf8.DecodeRuneInString(s[i:])        fmt.Printf("%d %c\n", i, r)        i += size    }}

因为Go的for range本身就可以直接遍历Unicode字符,所以其实要处理字符也不需要借助编码工具,用好for range就也是可以的:

func main() {    s := "Hi 世界"  // 3个ASCII字符,2个中文字符    var n uint    for range s {        n++    }    fmt.Println(n)    for i, r := range s {        fmt.Printf("%d\t%c\t%d\n", i, r, len(string(r)))    }}

这个是真正的字数统计了,用了unicode包里的一个函数,排除非文字的字符,主要是会有标点符号的干扰:

func main() {    s := "Make the plan. Execute the plan. Expect the plan to go off the rails. Throw away the plan."    var n uint    for _, r := range s {        if unicode.IsLetter(r) {            n++        }    }    fmt.Println(n)}

strings包与字符串操作

标准库中的strings代码包,在这个包里用到了不少unicode包和unicode/utf8包中的程序实体。比如,strings.Builder类型的WriteRune方法,strings.Reader类型的ReadRune方法,等等。

string类型

原生的string类型的值出不可变的。如果要获得一个不一样的字符串,就需要生成一个新的字符串。在底层,string值的内容会被存储到一块连续的内存空间。同时,这块内存容纳的字节数量也被记录下来了,并用于表示string值的长度。
在进行字符串拼接的时候,Go语言会把所有被拼接的字符串一次拷贝到一个崭新且足够大的连续内存空间中,并把持有相应指针值的string值作为结果返回。当程序中存在过多的字符串拼接操作的时候,会对内存的分配产生非常大的压力。虽然string值在内部持有一个指针值,但其类型仍然属于值类型。不过,由于string值的不可变,其中的指针值也为内存空间的节省做出了贡献。就是string值会在底层与它所有的副本共用同一个字节数组。不过,由于string值的不可变,所以这样做是绝对安全的。

strings.Builder类型

strings.Builder是1.10加入strings包中的新类型。如果是旧版本就没有了。
Golang貌似不支持升级,所以需要卸载,然后安装新版本。
与string的值相比,strings.Builder类型的值有以下3个优势:

  • 已存在的内容不可变,但可以拼接更多的内容
  • 减少内存分配和内容拷贝的次数
  • 可将内容重置,可重用值

比较string
与string值相比,Builder值的优势主要体现在字符串拼接方面。Builder值中有一个用于承载内容的容器,内容容器。它是一个以byte为元素类型的切片,字节切片
字节切片的底层数据就是一个字节数组,它与string值存储内容的方式是一样的。实际上,它们都是通过一个unsafe.Pointer类型的字段来持有那个指向了底层字节数组的指针值的。因为有这样一样的构造,使得Builder值拥有同样高效利用内存的前提条件。虽然对于字节切片本身来说,它包含的任何元素值都可以被修改,但是Builder值并不允许这样做,其中的内容只能够进行拼接或者完全被重置。

拼接方法
这样,已经存在的Builder值中的内容是不可变的。利用Builder值提供的方法拼接更多的内容时就不用担心这些方法会影响到已存在的内容。这里所说的方法就是Builder值拥有的一系列指针方法,或者统称为拼接方法

  • Write
  • WriteByte
  • WriteRune
  • WriteString

拼接方法的示例代码:

package mainimport (    "fmt"    "strings")func main() {    var b1 strings.Builder    b1.WriteString("Make The Plan.")    fmt.Println(b1)    fmt.Println(b1.Len(), b1.String())    b1.WriteByte(' ')    b1.WriteString("Execute the plan")    b1.Write([]byte{'.', ' '})    s := "Expect the plan to go off the rails."    for _, r := range s {        b1.WriteRune(r)    }    fmt.Println(b1.Len(), b1.String())    b1.WriteByte(' ')    s = "Throw away the plan."    for _, c := range []byte(s) {        b1.WriteByte(c)    }    fmt.Println(b1.Len(), b1.String())}

Builder扩容

利用上面这些方法,就可以把新的内容拼接到已存在的内容的尾部。如果需要,Builder值会自动的对自身的内容容器进行扩容。这里的自动扩容策略与切片的扩容策略一致。
除了Builder值的自动扩容,还可以选择手动扩容,这通过调用Builder值的Grow方法实现。Grow方法也可以称为扩容方法,它接受一个int类型的参数n,参数表示将要扩充的字节数量。Grow方法会把内容容器的容量增加n个字节。就是生成一个字节切片作为新的内容容器,切片的容量会是原容器容量的2倍再加上n。之后。把原容器中的所有字节全部拷贝到新容器中。文字描述不如看一下源码更清楚:

func (b *Builder) grow(n int) {    buf := make([]byte, len(b.buf), 2*cap(b.buf)+n)    copy(buf, b.buf)    b.buf = buf}

即使是手动调用的Grow方法,也可能什么都不做,这个还是从源码里看吧:

func (b *Builder) Grow(n int) {    b.copyCheck()    if n < 0 {        panic("strings.Builder.Grow: negative count")    }    if cap(b.buf)-len(b.buf) < n {        b.grow(n)    }}

就是扩容前会检查当前容量够不够,如果当前有足够的容量就不做扩容了。

调用手动扩容的场景
如果只是拼接一次数据,直接进行拼接就好了,不需要手动进行扩容。如果容量不够,那么自动扩容也是一样的。
在需要多次拼接大量的数据之前,先进行手动扩容就可以达到提高性能的效果。如果自动扩容,多次拼接的过程中,就会有多次的扩容操作。而每次扩容操作相对来说都是代价昂贵的。如果提前就把之后需要的空间准备好,只进行一次扩容,减少了扩容操作的次数,应该是会提高性能的。这里应该还可以做一个性能测试,直观的看到效果。

调用扩容方法
调用扩容方法很简单,本想再观察一下扩容前后的效果的,可是封装的太好,没有方便的手段查看。关于Grow方法的效果,关键变量都是私有的,并且包也没有提供相关的方法,就看不到效果了:

func main() {    var b1 strings.Builder    b1.WriteString("你好")    fmt.Println(b1.Len(), b1.String())    b1.Grow(10)    fmt.Println(b1.Len(), b1.String())}

strings.Builder类型的Len方法,源码中是这样的:

type Builder struct {    addr *Builder // of receiver, to detect copies by value    buf  []byte}func (b *Builder) Len() int { return len(b.buf) }

Len方法返回的就是buf这个切片的长度,Grow方法的扩容就是对buf切片的扩容,检验的方法需要查看buf切片的容量就是cap(b.buf)。字段不可导出,也没有提供相应的方法,就不深究了。

Builder重用

还有一个Reset方法,可以让Builder值重新回到零值状态,就好像从未被使用过那样。Reset之后,Builder值中的内容会被直接丢弃。之后会被Go语言的垃圾回收器标记并回收掉。下面是Reset方法的源码:

func (b *Builder) Reset() {    b.addr = nil    b.buf = nil}

全部字段设为零值,就是创建结构体时的状态。所以如果要使用一个Builder,新创建一个和重用一个,获得的Builder都是一样的。重用的时候会把之前的内容都丢弃掉,释放了内存资源。

复制检查copyCheck

Builder在被真正使用后,就不可再被复制了。
只要调用了Builder值的拼接方法或扩容方法,就意味着真正开始使用它了。一旦调用了它们,就不能再以任何的方式对其所属值进行复制。否则只要在任何副本上调用上述方法,就会引发panic。在源码里,这些都是通过一个copyCheck方法来实现的:

func (b *Builder) copyCheck() {    if b.addr == nil {        b.addr = (*Builder)(noescape(unsafe.Pointer(b)))    } else if b.addr != b {        panic("strings: illegal use of non-zero Builder copied by value")    }}

在执行copuCheck方法后,如果此时Builder还没有分配地址,就会设置一个地址了。此时就是真正被使用了。
如果有地址,就会和addr字段进行比较。addr字段里存的就是结构体本身的指针地址,copyCheck方法是个指针方法,本身也是指针,就是比较两个指针是否一样,不过不一样,就引发panic。
copyCheck方法会在所有的4个拼接方法以及扩容方法里执行。这几个方法都是会改变Builder里的内容的,扩容方法看似不改变内容,但是会对buf字段执行copy,拷贝到新的内存区域,拷贝前后引用的位置是不同的。如果此时调用的方法的对象是一个副本,就会在检查指针的时候引发panic。
不能复制是因为不能使用副本调用以上这些方法,而本质就是Builder的内存地址不能变,会产生这种情况的复制行为包括但不限于下面这些:

  • 函数间传递值
  • 通过通道传递值
  • 把值赋值给变量

这种约束还是有好处的,这样肯定不会出现多个Builder值中的内容容器,就是buf字段的字节切面,共用一个底层数据的情况。这样也就避免了多个同源的Builder值在拼接内容时可能产生的冲突问题。
从本质上看,也不是不能复制。副本是可以产生的,只有在对副本调用扩容方法和拼接方法的时候才会引发panic。
可以把声明后还没用过的Builder值,或者是Reset后的Builder值,将它的副本传到各处。似乎先赋值出去再Reset也是可以的,至少是不会引发panic,不过会比传递空值多复制2个指针。另外副本还是可以调用Len方法和String方法的,包括Reset方法,这些都不会改变原Builder值的内容。不过似乎也没什么用,需要的话,只要复制一份String方法的结果保存就可以了。下面试一下复制后调用String方法:

func main() {    var b1 strings.Builder    b1.WriteString("Test Copy 1")    b1.Grow(100)  // 消除扩容时copy的情况对底层数组的影响    b2 := b1    fmt.Println(b1.Len(), b1.String())    fmt.Println(b2.Len(), b2.String())    b1.WriteString(" 再增加点内容")  // 不会对副本的内容产生影响    fmt.Println(b1.Len(), b1.String())    fmt.Println(b2.Len(), b2.String())  // 副本的内容还是原样}

副本的内容容器里的内容不会跟着原Builder而改变。这是一个切片,不考虑扩容的情况,其实副本和原值还是同一个底层数组,但是副本对底层数组的引用范围没变,而且已经被引用的这些内容是不允许改变的。再考虑到扩容的情况,也不可能让副本感知到原来的内容的变化。

并发冲突

由于其内容不是完全不可变的,所以需要调用方自行解决操作冲突和并发安全问题。
虽然Builder值不能被复制,但它的指针值是可以的。无论什么时候,都可以通过任何方式复制这样的指针值。只要记住,这样的指针值都会是同一个Builder值。这时又会产生一个新问题,Builder值被多方同时操作,就会有操作冲突和并发安全问题。
Builder值自己是无法解决问题的。在传递其指针共享Builder值的时候,一定要确保各方对它的使用时正确、有序的,并且是并发安全的。最好还是不要共享Builder值以及它的指针值。虽然可以通过某些方法实现共享Builder值,但是最好不要这么用。

strings.Reader类型

与strings.Builder类型相反,strings.Reader类型是为了高效读取字符串而存在的。高效主要体现在它对字符串的读取机制上,它封装了很多用于在string值上读取内容的最佳实践。
通过Reader值,可以方便地读取一个字符串中的内容。在读取过程中,Reader值会保存已读取的字节的计数,就是已读计数。已读计数也代表着下一次读取的起始索引位置。Reader值正是依靠这样的一个计数,以及针对字符串的切片表达式,从而实现快速读取。这个已读计数还是读取回退和位置设定是的重要依据。虽然它是Reader值的内部结构,但是还是可以通过Len方法和Size方法把它计算出来的:

func main() {    str := "Make the plan. Execute the plan. Expect the plan to go off the rails. Throw away the plan."    r1 := strings.NewReader(str)    fmt.Printf("Size: %d, Len: %d\n", r1.Size(), r1.Len())    buf := make([]byte, 14)    n, _ := r1.Read(buf)  // 忽略错误    fmt.Println(string(buf))  // 都读到这里来了    fmt.Printf("Read: %d\n", n)    fmt.Printf("Size: %d, Len: %d, Read: %d\n", r1.Size(), r1.Len(), r1.Size()-int64(r1.Len()))}

Size是整体的长度,Len是剩余未读内容的长度。相减就是已读计数了,这里注意两个数值类型不一样,需要转一下。
Reader值拥有的大部分用于读取的方法都会及时地更新已读计数。比如,ReadByte方法会在读取成功后讲这个计数的值加1,ReadRune方法会在读取成功后,把读取到的字符所占的字节数作为计数的增量。
不过ReadAt方法例外,不会依赖已读计数进行读取,也不会在读取后更新已读计数。所以读取的是需要多传一个参数,指定起始位置。
还有一个Seek方法,可以更新已读计数。它的主要作用正式设定下一次读取的起始索引位置。它的第二个参数,可以指定以什么方式和第一个参数的offset计算起始索引位置:

package mainimport (    "fmt"    "strings"    "io")func main() {    str := "Make the plan. Execute the plan. Expect the plan to go off the rails. Throw away the plan."    r1 := strings.NewReader(str)    buf := make([]byte, 17)    offset := int64(15)    n, _ := r1.ReadAt(buf, offset)    fmt.Println(n, string(buf))    r1.Seek(offset + int64(n) + 1, io.SeekStart)    buf = make([]byte, 36)    n, _ = r1.Read(buf)    fmt.Println(n, string(buf))}

Seek方法还有2个返回值,返回新的计数值和err。

总结

这篇讲了strings包中的两个重要的类型:

  • Builder : 用于构建字符串
  • Reader : 用于读取字符串

在字符串拼接方法,Builder值会比原生的string值更有优势。而在字符串的读取时,Reader值更高效。
在strings包中有用的程序实体不止这2个,还提供了大量的函数:

  • Count
  • IndexRune
  • Mao
  • Replace
  • SolitN
  • Trim

关于包内各种函数的用法,在下面这篇里有列举:
https://blog.51cto.com/steed/2299514

0