3.4 字符串
Golang 的字符串是一个只读的字节数组.
若代码中存在字符串,编译器会将其标记成只读数据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.StringHeader
结构表示:
type StringHeader struct {
Data uintptr
Len int
}
3.4.2 解析过程
解析器会在词法分析阶段解析字符串,词法分析阶段会对源文件中的字符串进行切片和分组,将原有无意义的字符流转换成 Token 序列。
Golang 可以使用两种方式声明字符串:
- 标准字符串,使用双引号,只能用于单行初始化
- 原始字符串,使用反引号,可用于多行
s1 := "abcd"
s2 := `abcd
efg`
标准字符串解析
cmd/compile/internal/syntax.scanner
会将输入的字符串转换成 Token 流,cmd/compile/internal/syntax.scanner.stdString
方法用来解析使用双引号的标准字符串:
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.rawString
会将非反引号的所有字符都划分到当前字符串的范围中,所以我们可以使用它支持复杂的多行字符串:
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.basicLit
方法处理:
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.Unquote
会将字符串进行还原,去除引号。
3.4.3 拼接
Golang 字符串拼接使用 +
运算符,编译器会将OADD
转换成OADDSTR
类型的节点,然后在cmd/compile/internal/gc.walkexpr
中调用 cmd/compile/internal/gc.addstr
函数生成用于拼接字符串的代码:
func walkexpr(n *Node, init *Nodes) *Node {
switch n.Op {
...
case OADDSTR:
n = addstr(n, init)
}
}
其中cmd/compile/internal/gc.addstr
会选择适合的函数进行拼接:
- 字符串数量小于等于 5 个,使用
concatstring2/3/4/5
等系列函数 - 字符串数量大于 5 个,使用
runtime.concatstrings
传入一个数组切片
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.concatstrings
,它会先对遍历传入的切片参数,再过滤空字符串并计算拼接后字符串的长度。
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并且字符串不在栈上,则直接返回该字符串,不做任何操作。
正常情况下将会使用copy
将多个字符串拷贝到目标字符串所在的内存空间,新字符串是一段新内存空间,若需要拼接的字符串非常大,会造成大量的性能损耗。
3.4.4 类型转换
字节数组 -> 字符串
从字节数组到字符串的转换需要使用 runtime.slicebytetostring
函数。
对于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.stringStructOf
会将传入的字符串指针转换成 runtime.stringStruct
结构体指针,然后设置结构体持有的字符串指针 str
和长度 len
,最后通过 runtime.memmove
将原 []byte
中的字节全部复制到新的内存空间中。
字符串 -> 字节数组
将字符串转换成 []byte
类型时,需要使用 runtime.stringtoslicebyte
函数:
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.rawbyteslice
创建新的字节切片并将字符串中的内容拷贝过去
3.4.5 小结
Golang 的字符串是只读的。
字符串拼接和类型转换要注意性能损耗,在需要极致性能的场景要尽量减少类型转换的次数。