golang数据结构初探之字符串string

go 专栏收录该内容
15 篇文章 0 订阅

字符串string

string 是Go语言中的基础数据类型。

特性速览

声明

声明string变量非常简单,常见的方式有以下两种:

声明一个空字符串后再赋值

var s string 
s = "hello world"

需要注意的是空字符只是长度为0,但不是nil。不存在值为nil的string

使用简短变量声明:

s := "hello world"	//直接初始化字符串

双引号与单引号

字符串不仅可以使用双引号赋值,也可以使用反单引号赋值,它们的区别是在于对特殊字符的处理。

假如我们希望string变量表示下面的字符串,它包括换行符和双引号:

Hi,
this is "Steven".

使用双引号表示时,需要对特殊字符转义,如下所示:

s:= "Hi, \nthis is \"Steven\"."

如果使用反单引号时,不需要对特殊符号转义,如下所示:

s := `Hi,
this is "Steven".`

使用反单引号表示字符串比较直观,可以清晰的看出字符串内容。如果有数据库sql以及描述性说明,可以优先使用反单引号的方式表达。

字符串拼接

字符串可以使用加号进行拼接:

s = s + "a" + "b"

需要注意的是,字符串拼接会触发内存分配以及内存拷贝,单行语句拼接多个字符串只分配一次内存。比如上面的语句中,在拼接时,会先计算最终字符串的长度后再分配内存。

类型转换

项目中,数据经常需要在string和字节[]byte之间转换

[]byte 转 string

func ByteToString(){
	b:=[]byte{'h','e','l','l','o'}
	s:=string(b)
	fmt.Println(s)	//hello
}

string 转 []byte

func StringToByte(){
	s := "hello"
	b := []byte(s)
	fmt.Println(b)	
}

需要注意的是,无论是字符串转成[]byte,还是[]byte转成string,都将发生一次内存拷贝,会有一定的性能开销。

正因为string和[]byte之间的转换非常方便,在某些高频场景中往往会成为性能的瓶颈,比如数据库访问、http请求处理等。

特点

UTF编码

string使用8比特字节的集合来存储字符,而且存储的字符是UTF-8编码。

在使用for-range 遍历字符串时,每次迭代将返回UTF-8编码的首个字节下标以及字节值,这意味着下标可能不连续。

比如下面的函数:

func StringIteration(){
	s :="中国"
	for i,v :=range s {
    fmt.Printf("i : %d , v : %c \n ",i , v)
	}
}

函数输出:

i : 0 , v : 中 
i : 3 , v :

此外,字符串的长度是指字节数,而非字符数。 比如汉字"中"和"国"的UTF-8编码各占3个字节,字符串"中国"的长度是6而不是2。

不可变

字符串可以为空,但值不会是nil。另外字符串不可以修改(和Java语言中的String一样)。字符串变量可以接受新的字符串赋值,但是不能通过下标的方式进行修改字符串的值。

如下所示:

s := "Hello"
&s[0]=byte(104)	//非法
s = "hello"	//合法

字符串不支持取地址操作,也就无法修改字符串的值,上面的语句中会出现编译错误:

cannot take the address of s[0]

标准函数库

标准库strings包提供了大量的字符串操作函数。可以参考 [Go语言中文网] ,如下所示:

image-20210823102448620

实现原理

Go标准库builtin中定义了string类型:

type string string
8byte序列构成的字符串,约定但不必须是utf-8编码的文本。字符串可以为空但不能是nil,其值不可变。
image-20210823102934680

数据结构

源码包中 src/runtime/string.go:stringStruct 定义了string的数据结构:

type stringStruct struct {
	str unsafe.Pointer
	len int
}

string的数据结构很简单,只包含两个成员。

  • str :字符串的首地址
  • len:字符串的长度

string的数据结构跟切片类似,只不过切片slice还有一个表示容量的变量,事实上string和切片slice([]byte)经常转换。

在runtime包中使用gostringnocopy()函数来生成字符串。如下代码所示,声明一个string变量并赋值:

var str string
str = "Hello World"

字符串生成时,会先构建stringStruct对象,再转成string。转换的源码如下:

//go:nosplit
func gostringnocopy(str *byte) string {
	//先构造stringStruct
	ss := stringStruct{str: unsafe.Pointer(str), len: findnull(str)}
	//再将stringStruct转成string
	s := *(*string)(unsafe.Pointer(&ss))
	return s
}

string在runtime包中是stringStruct类型,对外呈现则为string类型。

字符串表示

字符串使用Unicode编码存储字节,对于英文字符来说,每个字符的Unicode编码只用一个字节即可表示,如下图所示:

image-20210823111424961

此时字符串的长度等于字符数。而对于非ASCII字符来说,其Unicode编码可能需要更多的字节来表示,如下图所示:

image-20210823111834631

此时字符串的长度会大于实际字符数,字符串的长度实际上表现的是字节数。

字符串拼接

字符串可以很方便的拼接,像下面所示:

str :="str1" + "str2" + "str3"

即便有非常多的字符串需要拼接,性能上也有比较好的保证,因为新的字符串内存空间是一次性分配完成的,所以性能主要是消耗在内存拷贝上。

在runtime包中,使用concatstrings()函数来拼接字符串。在一个拼接语句中,所有待拼接字符串都被编译器组织到一个切片中并传入concatstrings()函数中,拼接过程需要遍历两次切片,第一次遍历获取总的字符串长度,据此来申请内存空间,第二次遍历会将字符串逐个拷贝进去。

// concatstrings implements a Go string concatenation x+y+z+...
// The operands are passed in the slice a.
// If buf != nil, the compiler has determined that the result does not
// escape the calling function, so the string data can be stored in buf
// if small enough.
func concatstrings(buf *tmpBuf, a []string) string {
	idx := 0
	l := 0
	count := 0
	for i, x := range a {
		n := len(x)
		if n == 0 {
			continue
		}
		if l+n < l {
			throw("string concatenation too long")
		}
		l += n
		count++
		idx = i
	}
	if count == 0 {
		return ""
	}

	// If there is just one string and either it is not on the stack
	// or our result does not escape the calling frame (buf != nil),
	// then we can return that string directly.
	if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
		return a[idx]
	}
  //分配内存,返回一个string和切片,二者共享内存空间
	s, b := rawstringtmp(buf, l)
  //string无法修改,只能通过[]byte来修改
	for _, x := range a {
		copy(b, x)
		b = b[len(x):]
	}
	return s
}

因为string无法直接修改,所以这里使用rawstringtmp()函数初始化一个指定的大小的string,同时返回一个切片,二者共享同一块内存空间,后者向切片中拷贝数据,也就间接的修改了string。

rawstringtmp()函数

func rawstringtmp(buf *tmpBuf, l int) (s string, b []byte) {
	if buf != nil && l <= len(buf) {
		b = buf[:l]
		s = slicebytetostringtmp(&b[0], len(b))
	} else {
		s, b = rawstring(l)
	}
	return
}

rawstring()函数

//生成一个新的string,返回的string和切片共享相同的空间
// rawstring allocates storage for a new string. The returned
// string and byte slice both refer to the same storage.
// The storage is not zeroed. Callers should use
// b to set the string contents and then drop b.
func rawstring(size int) (s string, b []byte) {
	p := mallocgc(uintptr(size), nil, false)

	stringStructOf(&s).str = p
	stringStructOf(&s).len = size

	*(*slice)(unsafe.Pointer(&b)) = slice{p, size, size}

	return
}

类型转换

[]byte转string

byte切片可以很方便地转成string:

func GetStringBySlice(s []byte)string {
	return string(s)
}

需要注意的是:这种转换需要一次内存拷贝。

转换过程如下:

  • 根据切片的长度申请内存空间,假设内存地址为p,长度为len
  • 构建string(string.str = p , string.len = len )
  • 拷贝数据(切片中将数据拷贝到新申请的内存空间)

转换示意图如下所示:

image-20210823113242521

在runtime包中使用slicebytetostring()函数将[]byte转成string。

slicebytetostring() 函数如下:

// slicebytetostring converts a byte slice to a string.
// It is inserted by the compiler into generated code.
// ptr is a pointer to the first element of the slice;
// n is the length of the slice.
// Buf is a fixed-size buffer for the result,
// it is not nil if the result does not escape.
func slicebytetostring(buf *tmpBuf, ptr *byte, n int) (str string) {
	if n == 0 {
		// Turns out to be a relatively common case.
		// Consider that you want to parse out data between parens in "foo()bar",
		// you find the indices and convert the subslice to string.
		return ""
	}
	if raceenabled {
		racereadrangepc(unsafe.Pointer(ptr),
			uintptr(n),
			getcallerpc(),
			funcPC(slicebytetostring))
	}
	if msanenabled {
		msanread(unsafe.Pointer(ptr), uintptr(n))
	}
	if n == 1 {
		p := unsafe.Pointer(&staticuint64s[*ptr])
		if sys.BigEndian {
			p = add(p, 7)
		}
		stringStructOf(&str).str = p
		stringStructOf(&str).len = 1
		return
	}

	var p unsafe.Pointer
	if buf != nil && n <= len(buf) {
  	//如果预留buf够用,则用预留的buf
		p = unsafe.Pointer(buf)
	} else {
    //否则重新申请内存
		p = mallocgc(uintptr(n), nil, false)
	}
  //构建字符串
	stringStructOf(&str).str = p
	stringStructOf(&str).len = n
  //将切片底层数组中数据拷贝到字符串中
	memmove(p, unsafe.Pointer(ptr), uintptr(n))
	return
}

slicebytetostring()函数会优先使用一个固定大小的buf,当buf长度不够时才会申请新的内存,这样子避免了内存空间浪费。

string转[]byte

string也可以很方便的转成byte切片

func GetSliceByString(str string) []byte {
	return []byte(str)
}

string转成byte切片同样也需要一次内存拷贝的动作,其过程如下:

  • 申请切片内存空间
  • 将string拷贝到切片中

转换示意图如下所示:

image-20210823113943427

在runtime包中,使用stringtoslicebyte()函数将string转成[]byte

stringtoslicebyte()函数如下:

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
	var b []byte
	if buf != nil && len(s) <= len(buf) {
		*buf = tmpBuf{}
    //从预留buf中切出新的切片
		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.
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
}

stringtoslicebyte()函数中也使用了预留buf,并只在该buf长度不够时才会申请内存,其中rawbyteslice()函数用于申请新的未初始化的切片。由于字符串内容将完整覆盖切片的存储空间,所以可以不初始化切片从而提升分配效率。

编译优化

byte切片转换成string的场景很多,出于性能上的考虑,有时候只是应用于在临时需要字符串的场景下,byte切片转换成string时并不会拷贝内存,而是直接返回一个string,这个string的指针(string.str)指向切片的内存地址。

比如,编译器会识别如下临时场景:

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

由于只是临时把byte切片转换成string,也就避免了因byte切片内容修改而导致string数据变化的问题,所以此时可以不必拷贝内存。

小结

为何不允许修改字符串

像C++语言中的string,其本身拥有内存空间,修改string是支持的。但在Go语言的实现中,string不包含内存空间,只有一个内存的指针和长度,这样做的好处是string变得非常轻量级,可以很方便地进行传递而不用担心内存拷贝。

因为string通常指向字符串字面量,而字符串字面量存储的位置是只读段,而不是堆或者栈上,所以才有了string不可修改的约定。

string和[]byte如何取舍

string和[]byte都可以表示字符串,但是因为数据结构不同,其衍生出来的方法也不一样,要根据具体的场景选择不同的结构来使用。

string擅长的场景:

  • 需要字符串比较的场景
  • 不需要nil字符串的场景

[]byte擅长的场景:

  • 修改字符串,尤其是修改粒度为1个字节的场景
  • 函数返回值,需要用nil表示含义的场景
  • 需要切片操作的场景

虽然看起来string使用的场景不多,但是因为string直观,在实际应用中还是大量存在的,相对而言,在偏底层的实现中[]byte使用的更多一些。

  • 0
    点赞
  • 0
    评论
  • 0
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2021 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值