开发者

深度解密Go语言中字符串的使用

目录
  • Go 字符串实现原理
  • 字符串的截取
  • 字符串和切片的转换
  • 字符串和切片共享底层数组
  • 什么是万能指针
  • 字符串和其它数据结构的转化
    • 整数和字符串相互转换
    • Parse 系列函数
    • Format 系列函数
  • 小结

    Go 字符串实现原理

    Go 的字符串有个特性,不管长度是多少,大小都是固定的 16 字节。

    packagemain
    
    import(
    "fmt"
    "unsafe"
    )
    
    funcmain(){
    fmt.Println(
    unsafe.Sizeof("komeijisatori"),
    )//16
    fmt.Println(
    unsafe.Sizeof("satori"),
    )//16
    }

    显然用鼻子也能猜到原因,Go 的字符串底层并没有实际保存这些字符,而是保存了一个指针,该指针指向的内存区域负责存储具体的字符。由于指针的大小是固定的,所以不管字符串多长,大小都是相等的。

    另外字符串大小是 16 字节,指针是 8 字节,那么剩下的 8 字节是什么呢?不用想,显然是长度。下面来验证一下我们结论:

    深度解密Go语言中字符串的使用

    以上是 Go 字符串的底层结构,位于 runtime/string.go 中。字符串在底层是一个结构体,包含两个字段,其中 str 是一个 8 字节的万能指针,指向一个数组,数组里面存储的就是实际的字符;而 len 则表示长度,也是 8 字节。

    因此结构很清晰了:

    深度解密Go语言中字符串的使用

    str 指向的数组里面存储的就是所有的字符,并且类型是 uint8,因为 Go 的字符串默认采用 utf-8 编码。所以一个汉字在 Go 里面占 3 字节,我们先用 python 举个例子:

    >>>name="琪露诺"
    >>>[cforcinname.encode("utf-8")]
    [231,144,170,233,156,178,232,175,186]
    >>>

    那么对于 Go 而言,底层就是这么存储的:

    深度解密Go语言中字符串的使用

    我们验证一下:

    packagemain
    
    import"fmt"
    
    funcmain(){
    name:="琪露诺"
    //长度是9,不是3
    fmt.Println(len(name))//9
    //查看底层数组存储的值
    //可以转成切片查看
    fmt.Println(
    []byte(name),
    )//[231144170233156178232175186]
    }

    结果和我们想的一样,并且内置函数 len 在统计字符串长度时,计算的是底层数组的长度。

    字符串的截取

    如果要截取字符串的某个子串,要怎么做呢?如果是 Python 的话很简单:

    >>>name="琪露诺"
    >>>name[0]
    '琪'
    >>>name[:2]
    '琪露'
    >>>

    因为 Python 字符串里面的每个字符的大小都是相同的,可能是 1 字节、2字节、4字节。但不管是哪种,一个字符串里面的所有字符都具有相同的大小,因此才能通过索引准确定位。

    但在 Go 里面这种做法行不通,Go 的字符串采用 utf-8 编码,不同字符占用的大小不同,ASCII 字符占 1 字节,汉字占 3 字节,所以无法通过索引准确定位。

    packagemain
    
    import"fmt"
    
    funcmain(){
    name:="琪露诺"
    fmt.Println(
    name[0],name[1],name[2],
    )//231144170
    fmt.Println(name[:3])//琪
    }

    如果一个字符串里面既有英文又有中文,那么想通过索引准确定位是不可能的。因此这个时候我们需要进行转换,让它像 Python 一样,每个字符都具有相同的大小。

    packagemain
    
    import"fmt"
    
    funcmain(){
    name:="琪露诺"
    //rune等价于int32
    //此时每个元素统一占4字节
    //并且[]rune(name)的长度才是字符串的字符个数
    fmt.Println(
    []rune(name),
    )//[297383870635834]
    
    //然后再进行截取
    fmt.Println(
    string([]rune(name)[0]),
    string([]rune(name)[:2]),
    )//琪琪露
    }

    所以对于字符串 "憨pi" 而言,如果是 utf-8 存储,那么只需要 5 个字节。但很明显,基于索引查找指定的字符是不可能的,除非事先知道字符串长什么样子。如果是转成 []rune 的话,那么需要 12 字节存储,内存占用变大了,但可以很方便地查找某个字符或者某个子串。

    字符串和切片的转换

    字符串和切片之间是可以互转的,但切片只能是 uint8 或者 int32 类型,另外 uint8 也可以写成 byte,int32 可以写成 rune。

    由于 byte 是 1 字节,那么当字符串包含汉字,转成 []byte 切片时,一个汉字需要 3 个byte 表示。因此字符串 "憨pi" 转成 []byte 之后,长度为 5。

    而 rune 是 4 字节,可以容纳所有的字符,那么转成 []rune 切片时,不管什么字符,都只需要一个 rune 表示即可。所以字符串 "憨pi" 转成 []rune 之后,长度为 3。

    因此当你想统计字符串的字符个数时,最好转成 []rune 数组之后再统计。如果是字节个数,那么直接使用内置函数 len 即可。

    我们举例说明,先来看一段 Python 代码:

    >>>s="憨pi"
    #采用utf-8编码(等价于Go的[]byte数组)
    #"憨"需要230134168三个整数来表示
    #而"p"和"i"均只需1个字节,分别为112和105
    >>>[cforcins.encode("utf-8")]
    [230,134,168,112,105]
    
    #采用unicode编码(类似于Go的[]rune数组)
    #所有字符都只需要1个整数表示
    #但对于ASCII字符而言,不管什么编码,对应的数值不变
    >>>[ord(c)forcins]
    php[25000,112,105]

    我们用 Go 再演示一下:

    packagemain
    
    import"fmt"
    
    funcmain(){
    s:="憨pi"
    fmt.Println(
    []byte(s),
    )//[230134168112105]
    
    fmt.Println(
    []rune(s),
    )//[25000112105]
    }

    结果是一样的,当然这个过程我们也可以反向进行:

    packagemain
    
    import"fmt"
    
    funcmain(){
    s1:=[]byte{230,134,168,112,105}
    fmt.Println(string(s1))//憨pi
    
    s2:=[]rune{25000,112,105}
    fmt.Println(string(s2))//憨pi
    }

    结果没有任何问题。

    字符串和切片共享底层数组

    我们知道字符串和切片内部都有一个指针,指针指向一个数组,该数组存放具体的元素。

    //runtime/string.go
    typestringStructstruct{
    strunsafe.Pointer
    lenint
    }
    
    //runtime/slice.go
    typeslicestruct{
    arrayunsafe.Pointer
    lenint
    capint
    }

    假设有一个字符串 "abc",然后基于该字符串创建一个切片,那么两者的结构如下:

    深度解密Go语言中字符串的使用

    字符串在转成切片的时候,会将底层数组也拷贝一份。那么问题来了,在基于字符串创建切片的时候,能不能不拷贝数组呢?也就是下面这个样子:

    深度解密Go语言中字符串的使用

    如果字符串比较大,或者说需要和切片之间来回转换的话,这种方式无疑会减少大量开销。Go 提供了万能指针帮我们实现这一点,所以先来了解一下什么是万能指针。

    开发者_Go教程么是万能指针

    我们知道 C 的指针不仅可以相互转换,而且还可以参与运算,但 Go 不行,因为 Go 的指针是类型安全的。Go 编译器对类型的检测非常严格,让你在享受指针带来的便利时,又给指针施加了很多制约来保证安全。因此 Go 的指针不可以相互转换,也不可以参与运算。

    但保证安全是需要以牺牲效率为代价的,如果你能保证写出的程序就是安全的,那么可以使用 Go 中的万能指针,从而绕过类型系统的检测,让程序运行的更快。

    万能指针在 Go 里面叫做 unsafe.Pointer,它位于 unsafe 包下面。当然这个包名看起来有点怪怪的,因为这个包可以让我们绕过 Go 类型系统的检测,直接访问内存,从而提升效率。所以它有点危险,而 Go 官方也不推荐开发者使用,于是起了这个名字。

    但实际上 unsafe 包在底层被大量使用,所以不要被名字误导了,这个包是一定要掌握的。

    回到万能指针上面来,Go 的指针不可以相互转换,但是它们都可以和万能指针转换。举个例子:

    packagemain
    
    import(
    "fmt"
    "unsafe"
    )
    
    funcmain(){
    //一个[]int8类型的切片
    s1:=[]int8{1,2,3,4}
    //如果直接转成[]int16是会报错的
    //因为Go的类型系统不允许这么做
    //但是有万能指针,任何指针都可以和它转换
    //我们可以先将s1的指针转成万能指针
    //然后再将万能指针转成*[]int16,最后再解引用
    s2:=*(*[]int16)(unsafe.Pointer(&s1))
    //那么问题来了,指针虽然转换了
    //但是内存地址没变,内存里的值也没变
    //由于s2是[]int16类型,s1是[]int8类型
    //所以它会把s1[0]和s1[1]整体作为s2[0]
    //会把s1[2]和s1[3]整体作为s2[1]
    fmt.Println(s2)//[513102700]
    
    //int8类型的1和2组合成int16
    //int8类型的3和4组合成int16
    fmt.Println(2<<8+1)//513
    fmt.Println(4<<8+3)//1027
    }

    因此把 Go 的万能指针想象成 C 的空指针 void * 即可。

    那么让字符串和切片共享数组,我们就可以这么做:

    packagemain
    
    import(
    "fmt"
    "unsafe"
    )
    
    funcmain(){
    str:="abc"
    slice:=*(*[]byte)(unsafe.Pointer(&str))
    fmt.Println(slice)//[979899]
    fmt.Println(cap(slice))//10036576
    }
    

    虽然转换成功了,但是还有点问题,容量不太对劲。至于原因也很简单,字符串和切片在底层都是结构体,并且它们的前两个字段相同,所以转换之后打印没有问题。但字符串没有容量的概念,它是定长的,所以转成切片的时候 cap 就丢失了,打印的就是乱七八糟的值。

    所以我们需要再完善一下:

    packagemain
    
    import(
    "fmt"
    "unsafe"
    )
    
    funcStringToBytes(sstring)[]byte {
    //既然字符串转切片,会丢失容量
    //那么加上去就好了,做法也很简单
    //新建一个结构体,将容量(等于长度)加进去
    return*(*[]byte)(unsafe.Pointer(
    &struct{
    string
    Capint
    }{s,len(s)},
    ))
    }
    
    funcBytesToString(b[]byte)string {
    //切片转字符串就简单了,直接转即可
    //转的过程中,切片的Cap字段会丢弃
    return*(*string)(unsafe.Pointer(&b))
    }
    funcmain(){
    fmt.Println(
    StringToBytes("abc"),
    )//[979899]
    
    fmt.Prphpintln(
    BytesToString([]byte{97,98,99}),
    )//abc
    }

    结果没有问题,但我们怎么证明它们是共享数组的呢?很简单:

    packagemain
    
    import(
    "fmt"
    "unsafe"
    )
    
    funcmain(){
    slice:=[]byte{97,98,99}
    str:=*(*string)(unsafe.Pointer(&slice))
    fmt.Println(str)//abc
    slice[0]='A'
    fmt.Println(str)//Abc
    }

    操作切片等于操作底层数组,而 str 前后的打印结果不一致,所以确实是共享同一个数组。但需要注意的是,这里是先创建的切片,因此底层数组是可以修改的,没有问题。

    但如果创建的是字符串,然后基于字符串得到切片,那么切片就不可以修改了。因为字符串是不可修改的,所以底层数组也不可修改,也意味着切片不可以修改。

    字符串和其它数据结构的转化

    以上我们就介绍完了字符串的原理,再来看看工作中一些常见的字符串操作。

    整数和字符串相互转换

    如果想把一个整数转成字符串,那么该怎做呢?比如将 97 转成字符串。有过 Python 经验的,应该下意识会想到 string(97),但这是不行的,它返回的是字符串 "a",因为 97 对应的字符是 'a'。

    如果将整数转成字符串,应该使用 strconv 包下的 Itoa 函数,这个和 C 语言类似。

    packajsgemain
    
    import(
    "fmt"
    "strconv"
    )
    
    funcmain(){
    fmt.Println(strconv.Itoa(97))
    fmt.Println(strconv.Itoa(97)=="97")
    /*
    97
    true
    */
    
    //同理,将字符串转成整数则是Atoi
    s:="97"
    ifnum,err:=strconv.Atoi(s);err!=nil{
    fmt.Println(err)
    }else{
    fmt.Println(num==97)//true
    }
    
    s="97xx"
    ifnum,err:=strconv.Atoi(s);err!=nil{
    fmt.Println(
    err,
    )//strconv.Atoi:parsing"97xx":invalidsyntax
    }else{
    fmt.Println(num)
    }
    }

    Atoi 和 Itoa 专门用于整数和字符串之间的转换,strconv 这个包还提供了 Format 系列和 Parse 系列的函数,用于其它数据结构和字符串之间的转换,当然里面也包括整数。

    Parse 系列函数

    Parse 一类函数用于转换字符串为给定类型的值。

    ParseBool

    将指定字符串转换为对应的bool类型,只接受 1、0、t、f、T、F、true、false、True、False、TRUE、FALSE,否则返回错误;

    packagemain
    
    import(
    "fmt"
    "strconv"
    )
    
    funcmain(){
    //因为字符串转换时可能发生失败,因此都会带一个error
    //而这里解析成功了,所以error是nil
    fmt.Println(strconv.ParseBool("1"))//true<nil>
    fmt.Println(strconv.ParseBool("F"))//false<nil>
    }

    ParseInt

    函数原型:func ParseInt(s string, base int, bitSize int) (i int64, err error)

    • s:转成 int 的字符串;
    • base:编程客栈指定进制(2 到 36),如果 base 为 0,那么会从字符串的前缀来判断,如 0x 表示 16 进制等等,如果前缀也没有那么默认是 10 进制;
    • bistSize:整数类型,0、8、16、32、64 分别代表 int、int8、int16、int32、int64;

    返回的 err 是 *NumErr 类型,如果语法有误,err.Error = ErrSyntax;如果结果超出范围,err.Error = ErrRange。

    packagemain
    
    import(
    "fmt"
    "strconv"
    )
    
    funcmain(){
    fmt.Println(
    strconv.ParseInt("0x16",0,0),
    )//22<nil>
    fmt.Println(
    strconv.ParseInt("16",16,0),
    )//22<nil>
    fmt.Println(
    strconv.ParseInt("16",0,0),
    )//16<nil>
    fmt.Println(
    strconv.ParseInt("016",0,0),
    )//14<nil>
    
    //进制为2,但是字符串出现了6,无法解析
    fmt.Println(
    strconv.ParseInt("16",2,0),
    )//0strconv.ParseInt:parsing"16":invalidsyntax
    
    //只指定8位,显然存不下
    fmt.Println(
    strconv.ParseInt("257",0,8),
    )//127strconv.ParseInt:parsing"257":valueoutofrange
    
    //还可以指定正负号
    fmt.Println(
    strconv.ParseInt("-0x16",0,0),
    )//-22<nil>
    fmt.Println(
    strconv.ParseInt("-016",0,0),
    )//-14<nil>
    }

    ParseUint

    ParseUint 类似 ParseInt,但不接受正负号,用于无符号整型。

    ParseFloat

    函数原型:func ParseFloat(s string, bitSize int) (f float64, err error),其中 bitSize为:32、64,表示对应精度的 float

    packagemain
    
    import(
    "fmt"
    "strconv"
    )
    
    funcmain(){
    fmt.Println(
    strconv.ParseFloat("3.14",64),
    )//3.14<nil>
    }

    Format 系列函数

    Format 系列函数就比较简单了,就是将指定类型的数据格式化成字符串,Parse 则是将字符串解析成指定数据类型,这两个是相反的。另外转成字符串的话,则不需要担心 error 了。

    FormatBool

    packagemain
    
    import(
    "fmt"
    "strconv"
    )
    
    funcmain(){
    //如果是Parse系列的话会返回两个值,因为可能会出错
    //所以多一个error,因此需要两个变量来接收
    //而Format系列则无需担心,因为转成字符串是不会出错的
    //所以只返回一个值,接收的时候只需要一个变量即可
    fmt.Println(
    strconv.FormatBool(true),
    )//true
    fmt.Println(
    strconv.FormatBool(false)=="false",
    )//true
    }

    FormatInt

    传入字符串和指定的进制。

    packagemain
    
    import(
    "fmt"
    "strconv"
    )
    
    ‍funcmain(){
    //数值是24,但它是16进制的
    //所以对应成10进制是18
    fmt.Println(
    strconv.FormatIphpnt(24,16),
    )//18
    }

    FormatUint

    是 FormatInt 的无符号版本,两者差别不大。

    FormatFloat

    函数原型:func FormatFloat(f float64, fmt byte, prec, bitSize int) string,作用是将浮点数转成为字符串并返回。

    • f:浮点数;
    • fmt:表示格式,'f'(-ddd.dddd)、'b'(-ddddp±ddd,指数为二进制)、'e'(-d.dddde±dd,十进制指数)、'E'(-d.ddddE±dd,十进制指数)、'g'(指数很大时用'e'格式,否则'f'格式)、'G'(指数很大时用'E'格式,否则'f'格式);
    • prec:prec 控制精度(排除指数部分),当 fmt 为 'f'、'e'、'E',它表示小数点后的数字个数;为 'g'、'G',它表示总的数字个数。如果 prec 为 -1,则代表使用最少数量的、但又必需的数字来表示 f;
    • bitSize:f 是哪一种精度的 float,32 或者 64;
    packagemain
    
    import(
    "fmt"
    "strconv"
    )
    
    funcmain(){
    fmt.Println(
    strconv.FormatFloat(3.1415,'f',-1,64))
    fmt.Println(
    strconv.FormatFloat(3.1415,'e',-1,64))
    fmt.Println(
    strconv.FormatFloat(3.1415,'E',-1,64))
    fmt.Println(
    strconv.FormatFloat(3.1415,'g',-1,64))
    /*
    3.1415
    3.1415e+00
    3.1415E+00
    3.1415
    */
    }

    小结

    • 字符串底层是一个结构体,内部不存储实际数据,而是只保存一个指针和一个长度;
    • 字符串采用 utf-8 编码,这种编码的特点是省内存,但是无法通过索引准确定位字符和截取子串;
    • 字符串可以和 []byte、[]rune 类型的切片互相转换,特别是 []rune,如果想计算字符长度或者截取子串,需要转成 []rune;
    • 字符串和切片之间可以共享底层数组,其实现的核心就在于万能指针;

    以上就是深度解密Go语言中字符串的使用的详细内容,更多关于Go语言 字符串的资料请关注我们其它相关文章!

    0

    上一篇:

    下一篇:

    精彩评论

    暂无评论...
    验证码 换一张
    取 消

    最新开发

    开发排行榜