当前位置: 首页 > news >正文

Go语言 string

string是什么

        我们首先来了解一下Go语言中string类型的结构定义,先来看一下官方定义:

// string is the set of all strings of 8-bit bytes, conventionally but not
// necessarily representing UTF-8-encoded text. A string may be empty, but
// not nil. Values of string type are immutable.
type string string

  string是一个8位字节的集合,通常但不一定代表UTF-8编码的文本。string可以为空,但是不能为nil。string的值是不能改变的。        

  • 字符串是所有8bit字节的集合,但不一定是 UTF-8 编码的文本
  • 字符串可以为empty,但不能为 nil ,empty字符串就是一个没有任何字符的空串""
  • 字符串不可以被修改,所以字符串类型的值是不可变的

        字符串的本质是一串字符数组,每个字符在存储时都对应一个整数,也有可能对应多个整数,具体要看字符串的编码方式。可以看个例子:

package mainimport ("fmt""time"
)func main() {ss := "Hello"for _, v := range ss {fmt.Printf("%d\n", v)}
}

运行结果:

s[0]: 72
s[1]: 101
s[2]: 108
s[3]: 108
s[4]: 111

可以看到,字符串中每个字符对应着一个整数,这个整数就是字符的UTF-8编码值。

string数据结构

string类型在底层是一个结构体,这个结构体在src/runtime/string.go文件中定义,如下:

type stringStruct struct {str unsafe.Pointerlen int
}

  stringStruct 包含两个字段,str类型为unsafe.Pointerlen类型为int

  stringStructslice还是很相似的,str指针指向的是某个数组的首地址,len代表的就是数组长度。

        怎么和slice这么相似,底层指向的也是数组,是什么数组呢?我们看看他在实例化时调用的方法:

//go:nosplit
func gostringnocopy(str *byte) string {ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}s := *(*string)(unsafe.Pointer(&ss))return s
}

        入参是一个byte类型的指针,从这我们可以看出string类型底层是一个byte类型的数组,所以我们可以画出这样一个图片:

  string类型本质上就是一个byte类型的数组,在Go语言中string类型被设计为不可变的,不仅是在Go语言,其他语言中string类型也是被设计为不可变的,这样的好处就是:在并发场景下,我们可以在不加锁的控制下,多次使用同一字符串,在保证高效共享的情况下而不用担心安全问题。

        通过观察字符串的结构定义我们可以发现,其定义中并没有一个表示容量(Cap)的字段,所以意味着字符串类型并不能被扩容,字符串上的写操作包括拼接,追加等等都是通过拷贝来实现的。

  string类型虽然是不能更改的,但是可以被替换因为stringStruct中的str指针是可以改变的,只是指针指向的内容是不可以改变的,也就说每一个更改字符串,就需要重新分配一次内存,之前分配的空间会被gc回收。

string与[]byte的互相转换

        先看两种数据类型

  string是只读的,不可以被改变,但是我们在编码过程中,进行重新赋值也是很正常的,既然可以重新赋值,为什么说不能被修改呢,这不是互相矛盾吗?

        这里要弄弄清楚一个概念,字符串修改并不等于重新赋值。我们在开发中所使用的,其实是对字符串的重新赋值,而不是修改。

str := "Hello"
str = "Golang"   // 重新赋值
str[0] = "I"     // 修改,不允许

示例:

package mainimport "fmt"func main() {var ss stringss = "Hello"ss[1] = "A"fmt.Println(ss)
}

运行结果:

./main.go:10:12: cannot assign to ss[1]

        程序会报错,提示 string是不可修改的。

        这样一分析,那么可不可以将字符串转化为字节数组,然后通过下标修改字节数组,再转化回字符串呢,答案是可行的。

        相互转化的语法如下例所示:

package mainimport "fmt"func main() {var ss stringss = "Hello"strByte := []byte(ss)strByte[1] = 65fmt.Println(string(strByte))fmt.Println(ss)
}

运行结果:

HAllo
Hello

  Hello变成了HAllo,好像达到了我们修改的目的,其实不然。这里需要注意,修该的只是ss字符串拷贝后大顶堆新变量strByte,源字符串ss并没有变化,也就是字符串不能被修改。

string与[]byte的转化方式

  string[]byte的转化其实会发生一次内存拷贝,并申请一块新的切片内存空间

  byte切片转化为string,大致过程分为两步:

  1. 新申请切片内存空间,构建内存地址为addr,长度为len
  2. 构建 string对象,指针地址为addrlen字段赋值为lenstring.str = addr;string.len = len;
  3. 将原切片中数据拷贝到新申请的string中指针指向的内存空间

[]byte转换成string一定会拷贝内存吗?

        byte切片转换成string的场景很多,为了性能上的考虑,有时候只是临时需要字符串的场景下,byte切片转换成string时并不会拷贝内存,而是直接返回一个string,这个string的指针(string.str)指向切片的内存。
比如,编译器会识别如下临时场景:

• 使用m[string(b)]来查找map(map是string为key,临时把切片b转成string);
• 字符串拼接,如< + string(b) + >;
• 字符串比较:string(b) == foo

        因为是临时把byte切片转换成string,也就避免了因byte切片同容改成而导致string引用失败的情况,所以此时可以不必拷贝内存新建一个string。

1、标准方式

Golang中string与[]byte的互换,这是我们常用的,也是立马能想到的转换方式,这种方式称为标准方式。

// string 转 []byte
s1 := "xiaoxu"
b := []byte(s1)// []byte 转 string
s2 := string(b)

2、强转换方式

强转换方式是通过unsafe和reflect包来实现的,代码如下:

//[]byte转string
func b2s(b []byte) string {return *(*string)(unsafe.Pointer(&b))
}//string转[]byte
func s2b(s string) (b []byte) {bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))sh := (*reflect.StringHeader)(unsafe.Pointer(&s))bh.Data = sh.Databh.Cap = sh.Lenbh.Len = sh.Lenreturn b
}

可以看出利用reflect.SliceHeader(代表一个运行时的切片) 和 unsafe.Pointer进行指针替换。

🚩为什么可以这么做呢?

前面我们在讲string和[]byte类型的时候就提了,因为两者的底层结构的字段相似!

array和str的len是一致的,而唯一不同的就是cap字段,所以他们的内存布局上是对齐的。

转换方式分析

        我们看下这两种转换方式底层是如何实现的,这些实现代码在标准库中都是有的,下面仅部分主要 的底层实现代码。

标准方式底层实现

string转[]byte底层实现

        先看string转[]byte的实现,(实现源码在 src/runtime/string.go 中)

const tmpStringBufSize = 32//长度32的数组
type tmpBuf [tmpStringBufSize]byte//时间函数
func stringtoslicebyte(buf *tmpBuf, s string) []byte {var b []byte//判断字符串长度是否小于等于32if buf != nil && len(s) <= len(buf) {*buf = tmpBuf{}b = buf[:len(s)]} else {//预定义数组长度不够,重新分配内存b = rawbyteslice(len(s))}copy(b, s)return b
}// rawbyteslice allocates a new byte slice. The byte slice is not zeroed.
//rawbyteslice函数 分配一个新的字节片。字节片未归零
func rawbyteslice(size int) (b []byte) {cap := roundupsize(uintptr(size))p := mallocgc(cap, nil, false)if cap != uintptr(size) {memclrNoHeapPointers(add(p, uintptr(size)), cap-uintptr(size))}*(*slice)(unsafe.Pointer(&b)) = slice{p, size, int(cap)}return
}

上面代码可以看出string转[]byte是,会根据字符串长度来决定是否需要重新分配一块内存

  • 预先定义了一个长度为32的数组
  • 若字符串的长度不超过这个长度32的数组,copy函数实现string到[]byte的拷贝
  • 若字符串的长度超过了这个长度32的数组,重新分配一块内存了,再进行copy
[]byte转string底层实现

        再看[]byte转string的实现,(实现源码在 src/runtime/string.go 中)

const tmpStringBufSize = 32//长度32的数组
type tmpBuf [tmpStringBufSize]byte//实现函数
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) {...if n == 1 {p := unsafe.Pointer(&staticuint64s[*ptr])if goarch.BigEndian {p = add(p, 7)}stringStructOf(&str).str = pstringStructOf(&str).len = 1return}var p unsafe.Pointer//判断字符串长度是否小于等于32if buf != nil && n <= len(buf) {p = unsafe.Pointer(buf)} else {p = mallocgc(uintptr(n), nil, false)}stringStructOf(&str).str = pstringStructOf(&str).len = n//拷贝byte数组至字符串memmove(p, unsafe.Pointer(ptr), uintptr(n))return
}

        跟string转[]byte一样,当数组长度超过32时,同样需要调用mallocgc分配一块新内存

强转换底层实现

从标准的转换方式中,我们知道如果字符串长度超过32的话,会重新分配一块新内存,进行内存拷贝。

//string转[]byte
func s2b(s string) (b []byte) {bh := (*reflect.SliceHeader)(unsafe.Pointer(&b))sh := (*reflect.StringHeader)(unsafe.Pointer(&s))bh.Data = sh.Databh.Cap = sh.Lenbh.Len = sh.Lenreturn b
}

强转换过程中,通过神奇的unsafe.Pointer指针

  • 任何类型的指针 *T 都可以转换为unsafe.Pointer类型的指针,可以存储任何变量的地址
  • unsafe.Pointer 类型的指针也可以转换回普通指针,并且可以和类型*T不相同

强转换过程中,通过 神奇的unsafe.Pointer指针
任何类型的指针 *T 都可以转换为unsafe.Pointer类型的指针,可以存储任何变量的地址
unsafe.Pointer 类型的指针也可以转换回普通指针,并且可以和类型*T不相同

🚩 refletc包的 reflect.SliceHeader 和 reflect.StringHeader分别代表什么意思?

reflect.SliceHeader:slice类型的运行时表示形式
reflect.StringHeader:string类型的运行时表示形式

//slice在运行时的描述符
type SliceHeader struct {      Data uintptrLen  intCap  int
}//string在运行时的描述符
type StringHeader struct {Data uintptrLen  int
}

        (*reflect.SliceHeader)(unsafe.Pointer(&b)) 的目的就是通过unsafe.Pointer 把它们转换为*reflect.SliceHeader 指针。
而运行时表现形式 SliceHeader 和 StringHeader,而这两个结构体都有一个 Data 字段,用于存放指向真实内容的指针。

✏️[]byte 和 string之间的转换,就可以理解为是通过 unsafe.Pointer 把 *SliceHeader 转为 *StringHeader,也就是 *[]byte 和 *string之间的转换。
那么我们就可以理解相对于标准转换方式,强转换方式的优点在哪了!

直接替换指针的指向,避免了申请新内存(零拷贝),因为两者指向的底层字段Data地址相同

总结

        Go语言提供给我们使用的还是标准转换方式,主要是因为在不确定安全隐患的情况下,使用强转化方式可能不必要的问题。

字符串声明

        Go语言中以字面量来声明字符串有两种方式,双引号和反引号:

str1 := "Hello World"
str2 := `Hello
Golang`

        使用双引号声明的字符串和其他语言中的字符串没有太多的区别,但是这种使用双引号的字符串只能用于单行字符串的初始化,当字符串里使用到一些特殊字符,比如双引号,换行符等等需要用\进行转义。但是,反引号声明的字符串没有这些限制,字符内容即为字符串里的原始内容,所以一般用反引号来声明的比较复杂的字符串,比如json串

json := `{"hello": "golang", "name": ["zhangsan"]}`

字符串拼接

        Go语言中字符串是不可改变的,所以我们在对字符串进行拼接的时候会有内存的拷贝,存在性能损耗。常见的你字符串拼接有以下6种方式:

  • +操作符
  • fmt.Sprintf
  • bytes.Buffer
  • strings.Builder
  • append
  • string.Join

字符串拼接的6种方式及原理

原生拼接方式"+"

  Go语言原生支持使用+操作符直接对两个字符串进行拼接,使用例子如下:

var s string
s += "asong"
s += "真帅"

        这种方式使用起来最简单,基本所有语言都有提供这种方式,使用+操作符进行拼接时,会对字符串进行遍历,计算并开辟一个新的空间来存储原来的两个字符串。

字符串格式化函数fmt.Sprintf

  Go语言中默认使用函数fmt.Sprintf进行字符串格式化,所以也可使用这种方式进行字符串拼接:

str := "asong"
str = fmt.Sprintf("%s%s", str, str)

  fmt.Sprintf实现原理主要是使用到了反射,看到反射,就会产生性能的损耗,你们懂得!!!

Strings.builder

  Go语言提供了一个专门操作字符串的库strings,使用strings.Builder可以进行字符串拼接,提供了writeString方法拼接字符串,使用方式如下:

var builder strings.Builder
builder.WriteString("asong")
builder.String()

strings.builder的实现原理很简单,结构如下:

type Builder struct {addr *Builder // of receiver, to detect copies by valuebuf  []byte // 1
}

  addr字段主要是做copycheckbuf字段是一个byte类型的切片,这个就是用来存放字符串内容的,提供的writeString()方法就是像切片buf中追加数据:

func (b *Builder) WriteString(s string) (int, error) {b.copyCheck()b.buf = append(b.buf, s...)return len(s), nil
}

        提供的String方法就是将[]byte转换为string类型,这里为了避免内存拷贝的问题,使用了强制转换来避免内存拷贝:

func (b *Builder) String() string {return *(*string)(unsafe.Pointer(&b.buf))
}

bytes.Buffer

        因为string类型底层就是一个byte数组,所以我们就可以Go语言的bytes.Buffer进行字符串拼接。bytes.Buffer是一个缓冲byte类型的缓冲器,这个缓冲器里存放着都是byte。使用方式如下:

buf := new(bytes.Buffer)
buf.WriteString("asong")
buf.String()

  bytes.buffer底层也是一个[]byte切片,结构体如下:

type Buffer struct {buf      []byte // contents are the bytes buf[off : len(buf)]off      int    // read at &buf[off], write at &buf[len(buf)]lastRead readOp // last read operation, so that Unread* can work correctly.
}

        因为bytes.Buffer可以持续向Buffer尾部写入数据,从Buffer头部读取数据,所以off字段用来记录读取位置,再利用切片的cap特性来知道写入位置,这个不是本次的重点,重点看一下WriteString方法是如何拼接字符串的:

func (b *Buffer) WriteString(s string) (n int, err error) {b.lastRead = opInvalidm, ok := b.tryGrowByReslice(len(s))if !ok {m = b.grow(len(s))}return copy(b.buf[m:], s), nil
}

        切片在创建时并不会申请内存块,只有在往里写数据时才会申请,首次申请的大小即为写入数据的大小。如果写入的数据小于64字节,则按64字节申请。采用动态扩展slice的机制,字符串追加采用copy的方式将追加的部分拷贝到尾部,copy是内置的拷贝函数,可以减少内存分配。

        但是在将[]byte转换为string类型依旧使用了标准类型,所以会发生内存分配:

func (b *Buffer) String() string {if b == nil {// Special case, useful in debugging.return "<nil>"}return string(b.buf[b.off:])
}

strings.join

  Strings.join方法可以将一个string类型的切片拼接成一个字符串,可以定义连接操作符,使用如下:

baseSlice := []string{"asong", "真帅"}
strings.Join(baseSlice, "")

strings.join也是基于strings.builder来实现的,代码如下:

func Join(elems []string, sep string) string {switch len(elems) {case 0:return ""case 1:return elems[0]}n := len(sep) * (len(elems) - 1)for i := 0; i < len(elems); i++ {n += len(elems[i])}var b Builderb.Grow(n)b.WriteString(elems[0])for _, s := range elems[1:] {b.WriteString(sep)b.WriteString(s)}return b.String()
}

        唯一不同在于在join方法内调用了b.Grow(n)方法,这个是进行初步的容量分配,而前面计算的n的长度就是我们要拼接的slice的长度,因为我们传入切片长度固定,所以提前进行容量分配可以减少内存分配,很高效。

切片append

        因为string类型底层也是byte类型数组,所以我们可以重新声明一个切片,使用append进行字符串拼接,使用方式如下:

buf := make([]byte, 0)
base = "asong"
buf = append(buf, base...)
string(base)

        如果想减少内存分配,在将[]byte转换为string类型时可以考虑使用强制转换。                

性能测试--Benchmark对比

        使用Go语言中testing包下的Benchmark来分析一下到底哪种字符串拼接方式更高效。

package mainimport ("bytes""fmt""strings""testing"
)var loremIpsum = `
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas non odio eget quam gravida laoreet vitae id est. Cras sit amet porta dui. Pellentesque at pulvinar ante. Pellentesque leo dolor, tristique a diam vel, posuere rhoncus ex. Mauris gravida, orci eu molestie pharetra, mi nibh bibendum arcu, in bibendum augue neque ac nulla. Phasellus consectetur turpis et neque tincidunt molestie. Vestibulum diam quam, sodales quis nulla eget, volutpat euismod mauris.
`var strSLice = make([]string, LIMIT)const LIMIT = 1000func init() {for i := 0; i < LIMIT; i++ {strSLice[i] = loremIpsum}
}func BenchmarkConcatenationOperator(b *testing.B) {for i := 0; i < b.N; i++ {var q stringfor _, v := range strSLice {q = q + v}}b.ReportAllocs()
}func BenchmarkFmtSprint(b *testing.B) {for i := 0; i < b.N; i++ {var q stringfor _, v := range strSLice {q = fmt.Sprint(q, v)}}b.ReportAllocs()
}func BenchmarkBytesBuffer(b *testing.B) {for i := 0; i < b.N; i++ {var q bytes.Bufferq.Grow(len(loremIpsum) * len(strSLice))for _, v := range strSLice {q.WriteString(v)}_ = q.String()}b.ReportAllocs()
}func BenchmarkStringBuilder(b *testing.B) {for i := 0; i < b.N; i++ {var q strings.Builderq.Grow(len(loremIpsum) * len(strSLice))for _, v := range strSLice {q.WriteString(v)}_ = q.String()}b.ReportAllocs()
}func BenchmarkAppend(b *testing.B) {for i := 0; i < b.N; i++ {// var q = make([]byte, 0, len(loremIpsum)*len(strSLice))var q []bytefor _, v := range strSLice {q = append(q, v...)}_ = string(q)}b.ReportAllocs()
}func BenchmarkJoin(b *testing.B) {for i := 0; i < b.N; i++ {var q stringq = strings.Join(strSLice, "")_ = q}b.ReportAllocs()
}

运行结果:

goos: windows
goarch: amd64
pkg: gostudy/test
cpu: AMD Ryzen 7 7745HX with Radeon Graphics
BenchmarkConcatenationOperator
BenchmarkConcatenationOperator-16             54          18706593 ns/op238063081 B/op      1010 allocs/op
BenchmarkFmtSprint
BenchmarkFmtSprint-16                         24          63046967 ns/op488318771 B/op      4275 allocs/op
BenchmarkBytesBuffer
BenchmarkBytesBuffer-16                    15199             74857 ns/op950280 B/op          2 allocs/op
BenchmarkStringBuilder
BenchmarkStringBuilder-16                  30217             37815 ns/op475140 B/op          1 allocs/op
BenchmarkAppend
BenchmarkAppend-16                          5632            307936 ns/op3011173 B/op         24 allocs/op
BenchmarkJoin
BenchmarkJoin-16                           32806             42271 ns/op475140 B/op          1 allocs/op
PASS

        可以看到采用sprintf拼接字符串性能是最差的,性能最好的方式是string.Builderstring.Join

        所以平时在拼接字符串的时候,最好采用后面几种方式,不要直接采用+或者sprintfsprintf一般用于字符串的格式化而不用于拼接。

性能原理分析

方法说明
++ 拼接 2 个字符串时,会生成一个新的字符串,开辟一段新的内存空间,新空间的大小是原来两个字符串的大小之和,所以没拼接一次买就要开辟一段空间,性能很差
SprintfSprintf 会从临时对象池中获取一个 对象,然后格式化操作,最后转化为string,释放对象,实现很复杂,性能也很差
strings.Bulider底层存储使用[] byte,转化为字符串时可复用,每次分配内存的时候,支持预分配内存并且自动扩容,所以总体来说,开辟内存的次数就少,性能最好
bytes.Buffer底层存储使用[] byte,转化为字符串时不可复用,底层实现和strings.Builder差不多,性能比strings.Builder略差一点,区别是bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,而 strings.Builder 直接将底层的 []byte 转换成了字符串类型返回了回来,性能仅次于strings.Builder
append直接使用[]byte扩容机制,可复用,支持预分配内存和自动扩容,性能只比+和Sprintf好,但是如果能提前分配好内存的话,性能将会仅次于strings.Bulider
string.Joinstrings.join的性能约等于strings.builder,在已经字符串slice的时候可以使用,未知时不建议使用,构造切片也是会有性能损耗的

结论

  • 当进行少量字符串拼接时,直接使用+操作符进行拼接字符串,效率还是挺高的,但是当要拼接的字符串数量上来时,+操作符的性能就比较低了;
  • 函数fmt.Sprintf还是不适合进行字符串拼接,无论拼接字符串数量多少,性能损耗都很大,还是老老实实做他的字符串格式化就好了;
  • strings.Builder无论是少量字符串的拼接还是大量的字符串拼接,性能一直都能稳定,这也是为什么Go语言官方推荐使用strings.builder进行字符串拼接的原因,在使用strings.builder时最好使用Grow方法进行初步的容量分配,观察strings.join方法的benchmark就可以发现,因为使用了grow方法,提前分配好内存,在字符串拼接的过程中,不需要进行字符串的拷贝,也不需要分配新的内存,这样使用strings.builder性能最好,且内存消耗最小。
  • bytes.Buffer方法性能是低于strings.builder的,bytes.Buffer 转化为字符串时重新申请了一块空间,存放生成的字符串变量,不像strings.buidler这样直接将底层的 []byte 转换成了字符串类型返回,这就占用了更多的空间。

最终总结:

性能对比:strings.builder ≈ strings.join > bytes.buffer > append > + > fmt.sprintf
  • 如果进行少量的字符串拼接时,直接使用+操作符是最方便也算是性能最高的,就无需使用strings.builder
  • 如果进行大量的字符串拼接时,使用strings.builder是最佳选择。
http://www.dtcms.com/a/316586.html

相关文章:

  • stm32项目(21)——基于STM32和MPU6050的体感机械臂开发
  • 跨尺度目标漏检率↓82.4%!陌讯多尺度融合算法在占道经营识别的实战优化
  • 结构化开发方法详解:软件工程的奠基性范式
  • 机器学习——贝叶斯
  • Android 之 Kotlin中的协程(Dispatchers.IO)
  • Android UI 组件系列(十一):RecyclerView 多类型布局与数据刷新实战
  • ara::log::LogStream::WithTag的概念和使用案例
  • 鸿蒙开发--web组件
  • Java技术栈/面试题合集(5)-SpringBoot篇
  • SpringBoot3.x入门到精通系列:4.1 整合 MongoDB 详解
  • 《四种姿势用Java玩转AI大模型:从原生HTTP到LangChain4j》
  • Ubuntu24.04环境下非DOCKER方式安装Mysql5.7
  • 今日行情明日机会——20250805
  • 呼叫中心系统录音管理功能的应用
  • 初学docker
  • 深度拆解Dify:开源LLM开发平台的架构密码与技术突围
  • QUdpSocket发送组播和接受组播数据
  • 【类与对象(上)】C++封装之美:类与this指针解析
  • Nginx 单一端点上高效部署多个 LLM 模型
  • ES 模块动态导入
  • 海上电磁波传播:两径模型 vs 抛物方程模型传播损耗对比
  • 37.字典树
  • Redis集群模式下确保Key在同一Slot的实现方法
  • 按位运算 - C++
  • Velero 简介和部署
  • Linux进程信号——初步认识信号、信号的产生
  • 《UE教程》第一章第六回——迁移独立项目(资源)
  • IAR软件中变量监控的几种方法
  • 如何在 FastAPI 中优雅处理后台任务异常并实现智能重试?
  • Wireshark安装过程 Npcap Setup: Failed to create the npcap service: 0x8007007e.