脚本之家,脚本语言编程技术及教程分享平台!
分类导航

Python|VBS|Ruby|Lua|perl|VBA|Golang|PowerShell|Erlang|autoit|Dos|bat|

服务器之家 - 脚本之家 - Golang - 深度解密Go语言中字符串的使用

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

2022-11-22 10:44古明地觉 Golang

在编程语言中,字符串发挥着重要的角色。这篇文章就来带大家一起深度解密Go语言中的字符串,文中的示例代码讲解详细,需要的可以参考一下

Go 字符串实现原理

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

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    fmt.Println(
        unsafe.Sizeof("komeiji satori"),
    )  // 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 = "琪露诺"
>>> [c for c in name.encode("utf-8")]
[231, 144, 170, 233, 156, 178, 232, 175, 186]
>>>

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

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

我们验证一下:

package main

import "fmt"

func main() {
    name := "琪露诺"
    // 长度是 9,不是 3
    fmt.Println(len(name))  // 9
    // 查看底层数组存储的值
    // 可以转成切片查看
    fmt.Println(
        []byte(name),
    )  // [231 144 170 233 156 178 232 175 186]
}

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

 

字符串的截取

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

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

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

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

package main

import "fmt"

func main() {
    name := "琪露诺"
    fmt.Println(
        name[0], name[1], name[2],
    )  // 231 144 170
    fmt.Println(name[: 3])  // 琪
}

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

package main

import "fmt"

func main() {
    name := "琪露诺"
    // rune 等价于 int32
    // 此时每个元素统一占 4 字节
    // 并且 []rune(name) 的长度才是字符串的字符个数
    fmt.Println(
        []rune(name),
    ) // [29738 38706 35834]

    // 然后再进行截取
    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数组)
# "憨" 需要 230 134 168 三个整数来表示
# 而 "p" 和 "i" 均只需 1 个字节,分别为112和105
>>> [c for c in s.encode("utf-8")]
[230, 134, 168, 112, 105]

# 采用 unicode 编码(类似于Go的[]rune数组)
# 所有字符都只需要1个整数表示
# 但对于ASCII字符而言,不管什么编码,对应的数值不变
>>> [ord(c) for c in s]
[25000, 112, 105]

我们用 Go 再演示一下:

package main

import "fmt"

func main() {
    s := "憨pi"
    fmt.Println(
        []byte(s),
    ) // [230 134 168 112 105]

    fmt.Println(
        []rune(s),
    )  // [25000 112 105]
}

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

package main

import "fmt"

func main() {
    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
type stringStruct struct {
    str unsafe.Pointer
    len int
}

// runtime/slice.go
type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

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

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

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

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

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

 

什么是万能指针

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

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

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

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

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

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    // 一个 []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)  // [513 1027 0 0]
    
    // int8 类型的 1 和 2 组合成 int16 
    // int8 类型的 3 和 4 组合成 int16 
    fmt.Println(2 << 8 + 1)  // 513
    fmt.Println(4 << 8 + 3)  // 1027
}

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

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

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    str := "abc"
    slice := *(*[]byte)(unsafe.Pointer(&str))
    fmt.Println(slice)  // [97 98 99]
    fmt.Println(cap(slice))  // 10036576
}

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

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

package main

import (
    "fmt"
    "unsafe"
)

func StringToBytes(s string) []byte {
    // 既然字符串转切片,会丢失容量
    // 那么加上去就好了,做法也很简单
    // 新建一个结构体,将容量(等于长度)加进去
    return *(*[]byte)(unsafe.Pointer(
        &struct {
            string
            Cap int
        }{s, len(s)},
    ))
}

func BytesToString(b []byte) string {
    // 切片转字符串就简单了,直接转即可
    // 转的过程中,切片的 Cap 字段会丢弃
    return *(*string)(unsafe.Pointer(&b))
}
func main() {
    fmt.Println(
        StringToBytes("abc"),
    ) // [97 98 99]

    fmt.Println(
        BytesToString([]byte{97, 98, 99}),
    ) // abc
}

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

package main

import (
    "fmt"
    "unsafe"
)

func main() {
    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 语言类似。

package main

import (
    "fmt"
    "strconv"
)

func main() {
    fmt.Println(strconv.Itoa(97))
    fmt.Println(strconv.Itoa(97) == "97")
    /*
       97
       true
    */

    // 同理,将字符串转成整数则是 Atoi
    s := "97"
    if num, err := strconv.Atoi(s); err != nil {
        fmt.Println(err)
    } else {
        fmt.Println(num == 97) // true
    }

    s = "97xx" 
    if num, err := strconv.Atoi(s); err != nil {
        fmt.Println(
            err,
        )  // strconv.Atoi: parsing "97xx": invalid syntax
    } 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,否则返回错误;

package main

import (
    "fmt"
    "strconv"
)

func main() {
    //因为字符串转换时可能发生失败,因此都会带一个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。

package main

import (
    "fmt"
    "strconv"
)

func main() {
    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), 
    ) // 0 strconv.ParseInt: parsing "16": invalid syntax

    //只指定 8 位,显然存不下
    fmt.Println(
        strconv.ParseInt("257", 0, 8),
    ) // 127 strconv.ParseInt: parsing "257": value out of range

    //还可以指定正负号
    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

package main

import (
    "fmt"
    "strconv"
)

func main() {
    fmt.Println(
        strconv.ParseFloat("3.14", 64), 
    )  //3.14 <nil>
}

Format 系列函数

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

FormatBool

package main

import (
    "fmt"
    "strconv"
)

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

FormatInt

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

package main

import (
    "fmt"
    "strconv"
)

‍func main() {
    // 数值是 24,但它是 16 进制的
    // 所以对应成 10 进制是 18
    fmt.Println(
        strconv.FormatInt(24, 16),
    )  // 18
}

FormatUint

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

FormatFloat

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

  • f:浮点数;
  • fmt:表示格式,'f'(-ddd.dddd)、'b'(-ddddpddd,指数为二进制)、'e'(-d.ddddedd,十进制指数)、'E'(-d.ddddEdd,十进制指数)、'g'(指数很大时用'e'格式,否则'f'格式)、'G'(指数很大时用'E'格式,否则'f'格式);
  • prec:prec 控制精度(排除指数部分),当 fmt 为'f'、'e'、'E',它表示小数点后的数字个数;为 'g'、'G',它表示总的数字个数。如果 prec为 -1,则代表使用最少数量的、但又必需的数字来表示 f;
  • bitSize:f 是哪一种精度的 float,32 或者 64;
package main

import (
    "fmt"
    "strconv"
)

func main() {
    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语言 字符串的资料请关注服务器之家其它相关文章!

原文链接:https://mp.weixin.qq.com/s/knqzIiZy2asgih0HKsH9mg

延伸 · 阅读

精彩推荐
  • GolangGo语言Handler详细说明

    Go语言Handler详细说明

    这篇文章主要介绍了Go语言Handler详细说明,Handler用于处理请求并给予响应。更严格地说,用来读取请求体、并将请求对应的响应字段(respones header)写入Respo...

    骏马金龙8832022-09-24
  • GolangGo语言select语句用法示例

    Go语言select语句用法示例

    这篇文章主要为大家介绍了Go语言select语句用法示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪...

    六号积极分子4522022-08-03
  • Golang如何使用 Go 中的函数类型 (Function Types)?

    如何使用 Go 中的函数类型 (Function Types)?

    函数类型(function types)是一种很特殊的类型,它表示着所有拥有同样的入参类型和返回值类型的函数集合。...

    Go编程时光5282021-09-15
  • GolangGo标准库http与fasthttp服务端性能对比场景分析

    Go标准库http与fasthttp服务端性能对比场景分析

    这篇文章主要介绍了Go标准库http与fasthttp服务端性能比较,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考...

    程序员王越7082022-10-19
  • GolangGo语言实现的简单网络端口扫描方法

    Go语言实现的简单网络端口扫描方法

    这篇文章主要介绍了Go语言实现的简单网络端口扫描方法,实例分析了Go语言网络程序的实现技巧,具有一定参考借鉴价值,需要的朋友可以参考下 ...

    脚本之家5892020-04-14
  • Golanggolang map的基本操作及定义方式

    golang map的基本操作及定义方式

    这篇文章主要介绍了golang-map的基本操作,由于map是引用类型,所以在操作的时候,必须先初始化,本文通过多种方式给大家讲解map的定义方式,需要的朋友...

    S前进的中浪S6402022-11-13
  • Golanggo语言开发环境安装及第一个go程序(推荐)

    go语言开发环境安装及第一个go程序(推荐)

    这篇文章主要介绍了go语言开发环境安装及第一个go程序,这篇通过实例代码给大家介绍的非常详细,具有一定的参考借鉴价值,需要的朋友可以参考下 ...

    低调码农哥6192020-06-03
  • Golanggolang 字符串切片去重实例

    golang 字符串切片去重实例

    这篇文章主要介绍了golang 字符串切片去重实例,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...

    天天water7602021-03-15