【1-3 Golang】Go语言快速入门—字符串

tomato01 · · 621 次点击 · · 开始浏览    

&emsp;&emsp;Go语言字符串的用法还是比较简单的,常用也就是字符串相加,字符串与byte切片、rune切片互相转换,字符串输出等等操作。那有什么可学的呢?其实还是有一些细节需要关注,比如字符串"只读"特性,字符串编码等等。 ## 基本操作 &emsp;&emsp;字符串只读?是的,就是你想的那样,只读就是不能修改的意思。那下面程序怎么解释呢? ``` package main import "fmt" func main() { str := "hello" str += " world" fmt.Println(str) //hello world } ``` &emsp;&emsp;看到了吧,我确实改变了字符串str的值。是的,字符串str确实改变了,而字符串确实也是只读的;这里可能存在一些歧义,准备的说,应该是字符串变量str指向了新的字符串。字符串"hello"并没有改变,只是创建了一个新的字符串"hello world",同时让字符串变量str指向这个新的字符串。还有一个方法验证这个说法: ``` go tool compile -S -N -l test.go go.string."hello" SRODATA dupok size=5 0x0000 68 65 6c 6c 6f hello go.string." world" SRODATA dupok size=6 0x0000 20 77 6f 72 6c 64 world ``` &emsp;&emsp;go.string."hello"所属内存区域是SRODATA,RO就是read only只读的意思。再举一个事例:字符串不能按照索引操作,如果将将字符串转换为byte切片,按理说byte切片与字符串底层数据应该共用,那么修改该byte切片,字符串也应该同步改变。 ``` package main import "fmt" func main() { str := "hello world" //字符串转化为byte切片,修改切片元素 b := []byte(str) b[0] = 69 fmt.Println(str) //hello world for _, c := range b { fmt.Printf("%c", c) //Eello world } } ``` &emsp;&emsp;byte切片确实被修改了,而字符串变量str却没有改变,为什么呢?因为字符串是只读的,所以在[]byte(str)强制类型转化时,会执行了数据的拷贝,避免修改byte切片影响原字符串。 &emsp;&emsp;最后在使用字符串时,还需要注意一个问题:len用于获取字符串长度,纯英文字符串一切正常,但是当字符串中包含中文时,情况就有些不同了。 ``` package main import "fmt" func main() { str := "Go语言还是挺不错的" fmt.Println(len(str)) //26 } ``` &emsp;&emsp;str字符串包含2个英文字母,8个中文汉字,输出显示字符串长度是26。这就有Go语言字符串编码有关了,Go语言字符串采取utf-8编码,一个中文汉字占3个字节,所以算下来字符串长度就是26了。那确实想获取字符串的字符数目呢?可通过下面方式: ``` package main import "fmt" func main() { str := "Go语言还是挺不错的" r := []rune(str) //rune其实就是int32,4字节表示一个字符;r相当于字符切片 fmt.Println(len(r)) n := 0 for _, _ = range str { //range遍历字符串,返回字符索引,与当前字符 n ++ } fmt.Println(n) } ``` ## 实现原理 &emsp;&emsp;下面我们将结合底层实现原理,一一解释上面的几种情况:字符串相加,字符串与byte切片转换,字符串与rune切片互相转换。 &emsp;&emsp;字符串结构定义以及基本操作可以在文件runtime/string.go查看,字符串结构定义比切片类似,稍微简单些,因为字符串只读,所以没有必要预分配空间,也就不需要cap字段: ``` type stringStruct struct { str unsafe.Pointer len int } ``` &emsp;&emsp;str指向底层真正存储字符串的数组,只是我们不能获取到该数组引用,所以也就无法直接修改字符串。而字符串在作为输入参数时,传递的也是该结构;len函数获取字符串长度时,字符串变量地址 +8字节就是了,这些都和上一小节切片的基本原理非常类似。 &emsp;&emsp;s字符串相加,编译阶段会替换为函数调用concatstrings,其实现也挺简单的,计算所有字符串长度之和,申请内存,拷贝原始多个字符串到新的内存,构造字符串结构体stringStruct返回。函数concatstrings核心逻辑如下: ``` func concatstrings(a []string) string { l := 0 //计算所有字符串长度之和 for _, x := range a { n := len(x) l += n } var s string var b []byte //申请内存 p := mallocgc(uintptr(l), nil, false) //构造字符串stringStruct结构 (*stringStruct)(unsafe.Pointer(s)).str = p (*stringStruct)(unsafe.Pointer(s)).len = l //借切片拷贝 *(*slice)(unsafe.Pointer(&b)) = slice{p, l, l} for _, x := range a { copy(b, x) b = b[len(x):] } return s } ``` &emsp;&emsp;看到了吧,字符串相加,是申请了新的内存,并执行了数据拷贝,原始字符串没有发生任何改变,往往改变的只是字符串变量指向的内存地址。 &emsp;&emsp;字符串转化为byte切片,修改切片,为什么字符串却没有改变,要回答这个问题,只能看字符串转化切片的实现函数了。通过[]byte("")形式类型强转,编译阶段会替换为函数调用stringtoslicebyte,而该函数其实也是申请新的内存,拷贝数据,构造切片结构返回。函数stringtoslicebyte核心逻辑如下: ``` func stringtoslicebyte(s string) []byte { var b []byte //申请内存 cap := roundupsize(uintptr(len(s))) p := mallocgc(cap, nil, false) //构造切片结构 & 拷贝数据 *(*slice)(unsafe.Pointer(&b)) = slice{p, len(s), int(cap)} copy(b, s) return b } ``` &emsp;&emsp;字符串转化为rune切片的逻辑与stringtoslicebyte非常类似,只是rune类型占4个字节罢了。这里就不再赘述了。 ## 常用库函数 & stringBuilder &emsp;&emsp;包strings定义了一些常用的字符串库函数,如下: ``` //字符串比较 func Compare(a, b string) int //字符串是否以xxx开始 func HasPrefix(s, prefix string) bool //字符串是否以xxx结束 func HasSuffix(s, suffix string) bool //字符串是否包含指定子串 func Contains(s, substr string) bool //返回子串在字符串是的位置,-1字符串不包含子串,还有更高级的字符串查找stringFinder func Index(s, substr string) int //字符串数组转换为字符串,按sep分隔 func Join(elems []string, sep string) string //字符串分隔为字符串数组 func Split(s, sep string) []string //字符串替换,还有更高级的字符串替换Replacer func Replace(s, old, new string, n int) string //字符串大小写转换 func ToLower(s string) string func ToUpper(s string) string …… ``` &emsp;&emsp;这些库函数非常简单,我就不一一介绍了,这里主要提一下字符串构建stringBuilder。上面我们说过Go语言字符串是只读的,不能修改的,字符串相加也是通过申请内存与数据拷贝方式实现,那么如果存在大量的字符串相加呢?每次都申请内存拷贝数据效率会非常差,这也是stringBuilder存在的原因。stringBuilder底层维护了一个[]byte,追加字符串只是追加到该切片,最终一次性转换该切片为字符串,避免了中间N多次的内存申请与数据拷贝。 &emsp;&emsp;我们写一个小事例,测试验证大量字符串相加情况下,stringBuilder带来的性能提升: ``` package main import ( "fmt" "strings" "time" ) func main() { count := 100000 start := time.Now() s := "" for i := 0; i < count; i ++ { s += "abc" } fmt.Println(time.Now().Sub(start).Microseconds()) //1466286微妙 start1 := time.Now() b := strings.Builder{} for i := 0; i < count; i ++ { b.WriteString("abc") } fmt.Println(time.Now().Sub(start1).Microseconds()) //492微妙,效率提升非常明显。 } ``` ## 字符串编码 &emsp;&emsp;上面我们介绍到,Go语言一个汉字占3字节,所有字符串包含汉字时,len返回字符串长度大于字符数。我们都知道计算机存储只识别二进制,所以字符需要编码为二进制,那么Go语言字符串到底采取哪种编码方式呢? &emsp;&emsp;先简单介绍下几个常用编码。最简单的编码就是ASCII码了,只需一个字节,可以表示一些基本的字符、数字与字母,如"? \ ! . 10 A b c"。那么中文怎么办?一个字节肯定是无法满足的。于是诞生了unicode编码,占两个字节,可以容纳所有语言的大部分文字。在unicode编码方式下,所有字符都需要两个字节,不论汉字还是字母(高字节全0,低字节就是ASCII码),显然对于字母有些浪费空间了。所以又诞生了utf-8编码,这时候字符可以占1-4字节(可变的),中文汉字在utf-8编码方式占3个字节,英文字母占1个字节,Go语言采用的就是该编码方式。这下终于明白了如何计算包含汉字的字符串长度了。 &emsp;&emsp;话不多说,再来个小事例测试一下: ``` package main import "fmt" func main() { str := "Go语言还是挺不错的" r := []rune(str) for _, v := range r{ fmt.Printf("%x ", v) } } //47 6f 8bed 8a00 8fd8 662f 633a 4e0d 9519 7684 ``` &emsp;&emsp;好像有些许不对劲,这些汉字貌似只占了2字节,这些是utf-8编码吗?其实上面输出的都是unicode编码,所有字符都占2字节。读者可以找一些工具测试下,将上面字符串转换为unicode,对比看结果是否一致。 &emsp;&emsp;必须要说明的是,rune其实就是int32,该类型本来就占4字节。Go语言字符串在存储时,确实是采用utf-8编码,但是当转化为[]rune操作时,又将所有字符转化为unicode编码。字符串与[]rune转化函数为stringtoslicerune/slicerunetostring。unicode编码与utf-8编码转化函数定义在文件runtime/utf8.go,分别为decoderune/encoderune。 &emsp;&emsp;这里就不详细介绍这几个函数的具体实现了。不过需要注意的是,在使用range遍历字符串时,返回的是字符,也存在utf-8到unicode编码转换。range的实现逻辑在源码中找不到,是编译阶段自动生成的,如下: ``` //参考:cmd/compile/internal/walk/range.go:walkRange // Transform string range statements like "for v1, v2 = range a" into // // ha := a // for hv1 := 0; hv1 < len(ha); { // hv1t := hv1 // hv2 := rune(ha[hv1]) // if hv2 < utf8.RuneSelf { // hv1++ // } else { // hv2, hv1 = decoderune(ha, hv1) // } // v1, v2 = hv1t, hv2 // // original body // } ``` ## 总结 &emsp;&emsp;字符串的基本使用与实现原理就讲解到这里了,要牢记字符串是不可读的,而字符串相加,字符串与[]byte/[]rune互相转换都是通过申请内存以及数据拷贝方式实现的。另外要注意中文汉字编码占3个字节,所以包含中文汉字的字符串,其长度与字符数是不同的。

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

621 次点击  ∙  1 赞  
加入收藏 微博
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传