6

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

 2 years ago
source link: https://studygolang.com/articles/35865
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

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

tomato01 · 大约7小时之前 · 117 次点击 · 预计阅读时间 7 分钟 · 大约8小时之前 开始浏览    

  Go语言字符串的用法还是比较简单的,常用也就是字符串相加,字符串与byte切片、rune切片互相转换,字符串输出等等操作。那有什么可学的呢?其实还是有一些细节需要关注,比如字符串"只读"特性,字符串编码等等。

  字符串只读?是的,就是你想的那样,只读就是不能修改的意思。那下面程序怎么解释呢?

package main

import "fmt"

func main() {
    str := "hello"
    str += " world"
    fmt.Println(str)   //hello world
}

  看到了吧,我确实改变了字符串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

  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
    }
}

  byte切片确实被修改了,而字符串变量str却没有改变,为什么呢?因为字符串是只读的,所以在[]byte(str)强制类型转化时,会执行了数据的拷贝,避免修改byte切片影响原字符串。

  最后在使用字符串时,还需要注意一个问题:len用于获取字符串长度,纯英文字符串一切正常,但是当字符串中包含中文时,情况就有些不同了。

package main

import "fmt"

func main() {
    str := "Go语言还是挺不错的"
    fmt.Println(len(str))   //26
}

  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)
}

  下面我们将结合底层实现原理,一一解释上面的几种情况:字符串相加,字符串与byte切片转换,字符串与rune切片互相转换。

  字符串结构定义以及基本操作可以在文件runtime/string.go查看,字符串结构定义比切片类似,稍微简单些,因为字符串只读,所以没有必要预分配空间,也就不需要cap字段:

type stringStruct struct {
    str unsafe.Pointer
    len int
}

  str指向底层真正存储字符串的数组,只是我们不能获取到该数组引用,所以也就无法直接修改字符串。而字符串在作为输入参数时,传递的也是该结构;len函数获取字符串长度时,字符串变量地址 +8字节就是了,这些都和上一小节切片的基本原理非常类似。

  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
}

  看到了吧,字符串相加,是申请了新的内存,并执行了数据拷贝,原始字符串没有发生任何改变,往往改变的只是字符串变量指向的内存地址。

  字符串转化为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
}

  字符串转化为rune切片的逻辑与stringtoslicebyte非常类似,只是rune类型占4个字节罢了。这里就不再赘述了。

常用库函数 & stringBuilder

  包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

……

  这些库函数非常简单,我就不一一介绍了,这里主要提一下字符串构建stringBuilder。上面我们说过Go语言字符串是只读的,不能修改的,字符串相加也是通过申请内存与数据拷贝方式实现,那么如果存在大量的字符串相加呢?每次都申请内存拷贝数据效率会非常差,这也是stringBuilder存在的原因。stringBuilder底层维护了一个[]byte,追加字符串只是追加到该切片,最终一次性转换该切片为字符串,避免了中间N多次的内存申请与数据拷贝。

  我们写一个小事例,测试验证大量字符串相加情况下,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微妙,效率提升非常明显。
}

字符串编码

  上面我们介绍到,Go语言一个汉字占3字节,所有字符串包含汉字时,len返回字符串长度大于字符数。我们都知道计算机存储只识别二进制,所以字符需要编码为二进制,那么Go语言字符串到底采取哪种编码方式呢?

  先简单介绍下几个常用编码。最简单的编码就是ASCII码了,只需一个字节,可以表示一些基本的字符、数字与字母,如"? \ ! . 10 A b c"。那么中文怎么办?一个字节肯定是无法满足的。于是诞生了unicode编码,占两个字节,可以容纳所有语言的大部分文字。在unicode编码方式下,所有字符都需要两个字节,不论汉字还是字母(高字节全0,低字节就是ASCII码),显然对于字母有些浪费空间了。所以又诞生了utf-8编码,这时候字符可以占1-4字节(可变的),中文汉字在utf-8编码方式占3个字节,英文字母占1个字节,Go语言采用的就是该编码方式。这下终于明白了如何计算包含汉字的字符串长度了。

  话不多说,再来个小事例测试一下:

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

  好像有些许不对劲,这些汉字貌似只占了2字节,这些是utf-8编码吗?其实上面输出的都是unicode编码,所有字符都占2字节。读者可以找一些工具测试下,将上面字符串转换为unicode,对比看结果是否一致。

  必须要说明的是,rune其实就是int32,该类型本来就占4字节。Go语言字符串在存储时,确实是采用utf-8编码,但是当转化为[]rune操作时,又将所有字符转化为unicode编码。字符串与[]rune转化函数为stringtoslicerune/slicerunetostring。unicode编码与utf-8编码转化函数定义在文件runtime/utf8.go,分别为decoderune/encoderune。

  这里就不详细介绍这几个函数的具体实现了。不过需要注意的是,在使用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
// }

  字符串的基本使用与实现原理就讲解到这里了,要牢记字符串是不可读的,而字符串相加,字符串与[]byte/[]rune互相转换都是通过申请内存以及数据拷贝方式实现的。另外要注意中文汉字编码占3个字节,所以包含中文汉字的字符串,其长度与字符数是不同的。


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

280

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK