3.4 字符串

Kesa...大约 5 分钟golang

Golang 的字符串是一个只读的字节数组.

in-memory-string
in-memory-string

若代码中存在字符串,编译器会将其标记成只读数据SRODATA:

func main() {
	s := "abc"
	println(s)
}

$ set GOOS=windows&& set GOARCH=amd64&& go tool compile -S main.go
...
gclocals·g2BeySu+wFnoycgXfElmcg== SRODATA dupok size=8
        0x0000 01 00 00 00 00 00 00 00                          ........

3.4.1 数据结构

字符串在运行时,使用 reflect.StringHeaderopen in new window 结构表示:

type StringHeader struct {
	Data uintptr
	Len  int
}

3.4.2 解析过程

解析器会在词法分析open in new window阶段解析字符串,词法分析阶段会对源文件中的字符串进行切片和分组,将原有无意义的字符流转换成 Token 序列。

Golang 可以使用两种方式声明字符串:

  • 标准字符串,使用双引号,只能用于单行初始化
  • 原始字符串,使用反引号,可用于多行
s1 := "abcd"
s2 := `abcd
efg`

标准字符串解析

cmd/compile/internal/syntax.scanneropen in new window 会将输入的字符串转换成 Token 流,cmd/compile/internal/syntax.scanner.stdStringopen in new window 方法用来解析使用双引号的标准字符串:

func (s *scanner) stdString() {
	s.startLit()
	for {
		r := s.getr()
		if r == '"' {
			break
		}
		if r == '\\' {
			s.escape('"')
			continue
		}
		if r == '\n' {
			s.ungetr()
			s.error("newline in string")
			break
		}
		if r < 0 {
			s.errh(s.line, s.col, "string not terminated")
			break
		}
	}
	s.nlsemi = true
	s.lit = string(s.stopLit())
	s.kind = StringLit
	s.tok = _Literal
}

主要逻辑:

  • 标准字串使用双引号表示开头与结尾
  • 标准字符串使用\来转义双引号
  • 标准字符串不能出现隐式换行\n(备注:隐式换行的是出现了换行符,而不是显式的使用\n)

原始字符串解析

cmd/compile/internal/syntax.scanner.rawStringopen in new window 会将非反引号的所有字符都划分到当前字符串的范围中,所以我们可以使用它支持复杂的多行字符串:

func (s *scanner) rawString() {
	s.startLit()
	for {
		r := s.getr()
		if r == '`' {
			break
		}
		if r < 0 {
			s.errh(s.line, s.col, "string not terminated")
			break
		}
	}
	s.nlsemi = true
	s.lit = string(s.stopLit())
	s.kind = StringLit
	s.tok = _Literal
}

字符串解析后被标记成StringLit并传递到语法分析阶段,字符串相关的表达式都会由 cmd/compile/internal/gc.noder.basicLitopen in new window 方法处理:

func (p *noder) basicLit(lit *syntax.BasicLit) Val {
	switch s := lit.Value; lit.Kind {
	case syntax.StringLit:
		if len(s) > 0 && s[0] == '`' {
			s = strings.Replace(s, "\r", "", -1)
		}
		u, _ := strconv.Unquote(s)
		return Val{U: u}
	}
}

strconv.Unquoteopen in new window会将字符串进行还原,去除引号。

3.4.3 拼接

Golang 字符串拼接使用 +运算符,编译器会将OADD转换成OADDSTR类型的节点,然后在cmd/compile/internal/gc.walkexpropen in new window 中调用 cmd/compile/internal/gc.addstropen in new window 函数生成用于拼接字符串的代码:

func walkexpr(n *Node, init *Nodes) *Node {
	switch n.Op {
	...
	case OADDSTR:
		n = addstr(n, init)
	}
}

其中cmd/compile/internal/gc.addstropen in new window会选择适合的函数进行拼接:

func addstr(n *Node, init *Nodes) *Node {
	c := n.List.Len()

	buf := nodnil()
	args := []*Node{buf}
	for _, n2 := range n.List.Slice() {
		args = append(args, conv(n2, types.Types[TSTRING]))
	}

	var fn string
	if c <= 5 {
		fn = fmt.Sprintf("concatstring%d", c)
	} else {
		fn = "concatstrings"

		t := types.NewSlice(types.Types[TSTRING])
		slice := nod(OCOMPLIT, nil, typenod(t))
		slice.List.Set(args[1:])
		args = []*Node{buf, slice}
	}

	cat := syslook(fn)
	r := nod(OCALL, cat, nil)
	r.List.Set(args)
	...

	return r
}

无论使用 concatstring{2,3,4,5} 中的哪一个,最终都会调用 runtime.concatstringsopen in new window,它会先对遍历传入的切片参数,再过滤空字符串并计算拼接后字符串的长度。

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
		}
		l += n
		count++
		idx = i
	}
	if count == 0 {
		return ""
	}
	if count == 1 && (buf != nil || !stringDataOnStack(a[idx])) {
		return a[idx]
	}
	s, b := rawstringtmp(buf, l)
	for _, x := range a {
		copy(b, x)
		b = b[len(x):]
	}
	return s
}

非空字符串数量为 1并且字符串不在栈上,则直接返回该字符串,不做任何操作。

string-concat-and-copy
string-concat-and-copy

正常情况下将会使用copy将多个字符串拷贝到目标字符串所在的内存空间,新字符串是一段新内存空间,若需要拼接的字符串非常大,会造成大量的性能损耗。

3.4.4 类型转换

字节数组 -> 字符串

从字节数组到字符串的转换需要使用 runtime.slicebytetostringopen in new window 函数。

对于string(bytes),该函数在函数体中会先处理两种比较常见的情况,长度为 0 或者 1 的字节数组。

func slicebytetostring(buf *tmpBuf, b []byte) (str string) {
	l := len(b)
	if l == 0 {
		return ""
	}
	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
}

处理完之后会根据传入的缓冲区大小决定是否需要为新字符串分配一片内存空间,runtime.stringStructOfopen in new window 会将传入的字符串指针转换成 runtime.stringStructopen in new window 结构体指针,然后设置结构体持有的字符串指针 str 和长度 len,最后通过 runtime.memmoveopen in new window 将原 []byte 中的字节全部复制到新的内存空间中。

字符串 -> 字节数组

将字符串转换成 []byte 类型时,需要使用 runtime.stringtoslicebyteopen in new window 函数:

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))
	}
	copy(b, s)
	return b
}

会根据是否传入缓冲区做出不同的处理:

  • 当传入缓冲区时,它会使用传入的缓冲区存储 []byte
  • 当没有传入缓冲区时,运行时会调用 runtime.rawbytesliceopen in new window 创建新的字节切片并将字符串中的内容拷贝过去
string-bytes-conversion
string-bytes-conversion

3.4.5 小结

Golang 的字符串是只读的。

字符串拼接类型转换要注意性能损耗,在需要极致性能的场景要尽量减少类型转换的次数。

Reference

  1. https://draveness.me/golang/docs/part2-foundation/ch03-datastructure/golang-string/open in new window
上次编辑于:
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v2.15.2