go语言中的String

go 的String

Go标准库 builtin 给出了所有内置类型的定义。源代码位于 src/builtin/builtin.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编码的文本(文本字符串通常被解释为采用UTF8编码的Unicode码点(rune)序列)。
另外,还提到了两点,非常重要:

  1. string可以为空(长度为0),但不会是nil;
  2. string对象不可以修改。

相关源码在:src/runtime/string

string的结构体

type stringStruct struct {
	str unsafe.Pointer
	len int
}

stringStruct.str:字符串的首地址;
stringStruct.len:字符串的长度;

所以当我们使用len()函数获取到的都是编码后的字节长度,而非字符长度,这一点在使用非 ASCII 字符时很重要:

func main() {
  s1 := "Hello World!"
  s2 := "你好,中国"

  fmt.Println(len(s1))
  fmt.Println(len(s2))
}

输出:

12
15

Hello World!有 12 个字符很好理解,你好,中国有 5 个中文字符,每个中文字符占 3 个字节,所以输出 15。

对于使用非 ASCII 字符的字符串,我们可以使用标准库的 unicode/utf8 包中的RuneCountInString()方法获取实际字符数(rune是int32等价类型):

func main() {
  s1 := "Hello World!"
  s2 := "你好,中国"

  fmt.Println(utf8.RuneCountInString(s1)) // 12
  fmt.Println(utf8.RuneCountInString(s2)) // 5
}

为了方便地遍历字符串,Go 语言中for-range循环对多字符编码有特殊的支持。每次遍历返回的索引是每个字符开始的字节位置,值为该字符的编码值:

func main() {
  s := "Go 语言"

  for index, c := range s {
    fmt.Printf("%d %c\n", index, c)
  }
}

输出:

0 G
1 o
2 
36

使用索引操作字符串,获取的是对应位置上的字节值,如果该位置是某个多字节编码的中间位置,可能返回的字节值不是一个合法的编码值:

s := "中国"
fmt.Println(s[0])

前面介绍过“中”的 UTF8 编码为E4B8AD,故s[0]取第一个字节值,结果为 228(十六进制 E4 的值)
最后需要警惕不可打印字符,之前有个同事请教我一个问题,两个字符串输出的内容相同,但是它们就是不相等:

func main() {
  b1 := []byte{0xEF, 0xBB, 0xBF, 72, 101, 108, 108, 111}
  b2 := []byte{72, 101, 108, 108, 111}

  s1 := string(b1)
  s2 := string(b2)

  fmt.Println(s1)
  fmt.Println(s2)
  fmt.Println(s1 == s2)
}

输出:

hello
hello
false

我直接把字符串内部字节写出来了,可能一眼就看出来了。但是我们当时遇到这个问题还是稍微费了一番功夫来调试的。因为当时字符串是从文件中读取的,而文件采用的是带 BOM 的 UTF8 编码格式。我们都知道 BOM 格式会自动在文件头部加上 3 个字节0xEFBBBF。而字符串比较是会比较长度和每个字节的。让问题更难调试的是,在文件中 BOM 头也是不显示的。

string的构建

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

string的创建过程可以分为两步:

  1. 创建stringStruct结构体
  2. 将stringStruct转换成string
    string在runtime包中就是stringStruct,对外呈现叫做string

[] byte 转 string

源码为

func slicebytetostring(buf *tmpBuf, b []byte) (str string) {
	l := len(b)
	if l == 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 ""
	}
	//默认是false
	if raceenabled {
		racereadrangepc(unsafe.Pointer(&b[0]),
			uintptr(l),
			getcallerpc(),
			funcPC(slicebytetostring))
	}
	//默认是false
	if msanenabled {
		msanread(unsafe.Pointer(&b[0]), uintptr(l))
	}
	if l == 1 {
		stringStructOf(&str).str = unsafe.Pointer(&staticbytes[b[0]])
		stringStructOf(&str).len = 1
		return
	}
	//分配内存
	var p unsafe.Pointer
	if buf != nil && len(b) <= len(buf) {
		p = unsafe.Pointer(buf)
	} else {
		p = mallocgc(uintptr(len(b)), nil, false)
	}
	//构建结构体
	stringStructOf(&str).str = p
	stringStructOf(&str).len = len(b)
	//拷贝数据
	memmove(p, (*(*slice)(unsafe.Pointer(&b))).array, uintptr(len(b)))
	return
}

需要一次内存拷贝。
转换过程如下:

  1. 跟据切片的长度申请内存空间,假设内存地址为p,切片长度为len(b);
  2. 构建string(string.str = p;string.len = len;)
  3. 拷贝数据(切片中数据拷贝到新申请的内存空间)

string转[]byte

源码为:

func stringtoslicebyte(buf *tmpBuf, s string) []byte {
	var b []byte
	if buf != nil && len(s) <= len(buf) {
		*buf = tmpBuf{}
		b = buf[:len(s)]
	} else {
	//申请内存空间
		b = rawbyteslice(len(s))
	}
	//拷贝(string无法修改,只能通过切片修改)
	copy(b, s)
	return b
}
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切片,也需要一次内存拷贝,其过程如下:

  1. 申请切片内存空间
  2. 将string拷贝到切片

字符串拼接

即便有非常多的字符串需要拼接,性能上也有比较好的保证,因为新字符串的内存空间是一次分配完成的,所以性能消耗主要在拷贝数据上。 一个拼接语句的字符串编译时都会被存放到一个切片中,拼接过程需要遍历两次切片

  1. 第一次遍历获取总的字符串长 度,据此申请内存
  2. 第二次遍历会把字符串逐个拷贝过去

源码如下:

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]
	}
	s, b := rawstringtmp(buf, l)//生成指定大小的字符串,返回一个string和切片,二者共享内存空间
	for _, x := range a {
		copy(b, x)//string无法直接修改,只能通过切片修改
		b = b[len(x):]
	}
	return s
}
func rawstringtmp(buf *tmpBuf, l int) (s string, b []byte) {
	if buf != nil && l <= len(buf) {
		b = buf[:l]
		s = slicebytetostringtmp(b)
	} else {
		s, b = rawstring(l)
	}
	return
}
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
}

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

为什么字符串不允许修改

像C++语言中的string,其本身拥有内存空间,修改string是支持的。但Go的实现中,string不包含内存空间,只 有一个内存的指针,这样做的好处是string变得非常轻量,可以很方便的进行传递而不用担心内存拷贝。 因为string通常指向字符串字面量,而字符串字面量存储位置是只读段,而不是堆或栈上,所以才有了string不可 修改的约定。

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

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

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

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

string和[]byte如何取舍

string和[]byte都可以表示字符串,但因数据结构不同,其衍生出来的方法也不同,要跟据实际应用场景来选择。
string 擅长的场景:

  1. 需要字符串比较的场景;
  2. 不需要nil字符串的场景;

[]byte擅长的场景:

  1. 修改字符串的场景,尤其是修改粒度为1个字节;
  2. 函数返回值,需要用nil表示含义的场景;
  3. 需要切片操作的场景;
    虽然看起来string适用的场景不如[]byte多,但因为string直观,在实际应用中还是大量存在,在偏底层的实现中 []byte使用更多。

Java字符串编码

java String用的utf-16编码。一般是占用2个字节,有的不常见字符也会占用4个字节。所以java String内部用的char[]数组(jdk9以前)来存字符。
一个char占用两个字节,而不常见的特殊字符,可能就需要两个char来存了,这时使用charAt等api就会出现问题。

Go字符串编码

Go string默认用UTF-8编码,我们用for range遍历字符串的时候,每个item是rune类型的。rune存的是unicode的码点,是uint32类型的,占4个字节,转为string时会自动按UTF-8编码。UTF-8是变长编码,1-4个字节都有可能,常见的中文一般是3个字节。
如果用普通for循环遍历,然后用str[i]来获取到的是byte(uint8)类型的,占1个字节,记录的是ascaii码点值。超过ascaii码范围,可以通过str[i:]的方式来还原字符串,会自动用utf-8编码,但是如果i的起点不对,可能会乱码。
所以用Go语言编程,剩去了很多转码的过程,要方便很多。

Java不用UTF-8的原因

Java不用UTF-8,是因为UTF-8是变长编码,如果采用UTF-8进行编码的话,类似于charAt、subString等API就不好实现了。虽然UTF-16也是变长的,但它大部分字符都可以用固定的两个字节进行编码,所以在java里可以认为它是定长编码

优缺点

变长的编码无法直接通过索引来访问第n个字符,但是UTF8编码获得了很多额外的优点。首先UTF8编码比较紧凑,完全兼容ASCII码,并且可以自动同步:它可以通过向前回朔最多3个字节就能确定当前字符编码的开始字节的位置。它也是一个前缀编码,所以当从左向右解码时不会有任何歧义也并不需要向前查看(译注:像GBK之类的编码,如果不知道起点位置则可能会出现歧义)。没有任何字符的编码是其它字符编码的子串,或是其它编码序列的字串,因此搜索一个字符时只要搜索它的字节编码序列即可,不用担心前后的上下文会对搜索结果产生干扰。同时UTF8编码的顺序和Unicode码点的顺序一致,因此可以直接排序UTF8编码序列。同时因为没有嵌入的NUL(0)字节,可以很好地兼容那些使用NUL作为字符串结尾的编程语言。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值