10 Go 字符串是原生类型
字符串类型是现代编程语言中最常使用的数据类型之一。在 Go 语言的先祖之一 C 语言当中,字符串类型并没有被显式定义,而是以字符串字面值常量或以’\0’结尾的字符类型(char)数组来呈现的:
#define GOAUTHERS "Robert Griesemer, Rob Pike, and Ken Thompson"
const char * s = "hello world"
char s[] = "hello gopher"
这给 C 程序员在使用字符串时带来一些问题,诸如:
- 类型安全性差;
- 字符串操作要时时刻刻考虑结尾的’\0’;
- 字符串数据可变(主要指以字符数组形式定义的字符串类型);
- 获取字符串长度代价大(O(n)时间复杂度);
- 未内置对非 ASCII 字符(如中文字符)的处理。
Go 内置了 string 类型,统一了对“字符串”的抽象。这样无论是字符串常量、字符串变量或是代码中出现的字符串字面值,它们的类型都被统一设置为 string:
// sources/string_type.go
package main
import "fmt"
const (
s = "string constant"
)
func main() {
var s1 string = "string variable"
fmt.Printf("%T\n", s) // string
fmt.Printf("%T\n", s1) // string
fmt.Printf("%T\n", "temporary string literal") // string
}
Go string 类型设计充分吸取了 C 语言字符串设计的经验教训,并结合了其他主流语言在字符串类型设计上的最佳实践,最终为 Gopher 呈现的 Go string 类型具有如下功能特点:
string 类型的数据是不可变的
一旦声明了一个 string 类型的标识符,无论是变量还是变量,那么该标识符所指代的数据在整个程序的生命周期内便无法被更改。下面我来尝试修改一下 string 数据,看看能得到怎样的结果。先来看第一种方法:
// sources/string_immutable1.go
package main
import (
"fmt"
)
func main() {
// original string
var s string = "hello"
fmt.Println("original string:", s)
// reslice it and try to change the original string
sl := []byte(s)
sl[0] = 't'
fmt.Println("slice:", string(sl))
fmt.Println("after reslice, the original string is:", string(s))
}
该程序的运行结果如下:
$go run string_immutable1.go
original string: hello
slice: tello
after reslice, the original string is: hello
我们看到在例子中我们试图通过将 string 转换为一个 slice 并通过该 slice 对其内容进行修改,但结果事与愿违。对 string 进行 slice 转换后,Go 编译器会为 slice 变量重新分配底层存储而不是共用 string 的底层存储,因此对 slice 的修改并未对原 string 的数据产生任何影响。
我们再来试试通过更为“暴力”一些的手段对 string 的数据发起“攻击”:
// sources/string_immutable2.go
package main
import (
"fmt"
"unsafe"
)
func main() {
// original string
var s string = "hello"
fmt.Println("original string:", s)
// try to change the original string through unsafe pointer
modifyString(&s)
fmt.Println(s)
}
func modifyString(s *string) {
// 取出第一个8字节的值
p := (*uintptr)(unsafe.Pointer(s))
// 获取底层数组的地址
var array *[5]byte = (*[5]byte)(unsafe.Pointer(*p))
var len *int = (*int)(unsafe.Pointer(uintptr(unsafe.Pointer(s)) + unsafe.Sizeof((*uintptr)(nil))))
for i := 0; i < (*len); i++ {
fmt.Printf("%p => %c\n", &((*array)[i]), (*array)[i])
p1 := &((*array)[i])
v := (*p1)
(*p1) = v + 1 //try to change the character
}
}
我们试图通过 unsafe 指针指向 string 在运行时内部表示结构(具体参考本节后面的讲解)中的数据存储块的地址,然后通过指针修改那块内存中存储的数据。我们运行这段程序得到下面的结果:
$go run string_immutable2.go
original string: hello
0x10d1b9d => h
unexpected fault address 0x10d1b9d
fatal error: fault
[signal SIGBUS: bus error code=0x2 addr=0x10d1b9d pc=0x109b079]
我们看到:对 string 的底层的数据存储区仅能做只读操作,一旦试图修改那块区域的数据,便会得到 SIGBUS 的运行时错误。
零值可用
Go string 类型支持零值可用的理念。Go 字符串无需像 C 语言中那样考虑结尾’\0’字符,因此其零值为"",长度为 0。
var s string
fmt.Println(s) // s = ""
fmt.Println(len(s)) // 0
获取长度的时间复杂度是 O(1) 级别
Go string 类型数据是不可变的,因此一旦有了初值后,那块数据就不会改变,其长度也不会改变。Go 将这个长度作为一个字段存储在了运行时的 string 类型结构中了(在本节后面有说明)。这样获取 string 长度的操作,即 len(s) 实际上就是读取存储在运行时中的那个长度值,这是一个代价极低的 O(1)操作。
支持通过+/+=操作符进行字符串连接
通过+/+=操作符进行的字符串连接是对开发者体验最好的字符串连接操作,Go 语言支持这种操作。
s := "Rob Pike, "
s = s + "Robert Griesemer, "
s += " Ken Thompson"
fmt.Println(s) // Rob Pike, Robert Griesemer, Ken Thompson
支持各种比较关系操作符:==、!= 、>=、<=、> 和 < Go 支持各种比较关系操作符:
// sources/string_compare.go
package main
import "fmt"
func main() {
// ==
s1 := "世界和平"
s2 := "世界" + "和平"
fmt.Println(s1 == s2) // true
// !=
s1 = "Go"
s2 = "C"
fmt.Println(s1 != s2) // true
// < and <=
s1 = "12345"
s2 = "23456"
fmt.Println(s1 < s2) // true
fmt.Println(s1 <= s2) // true
// > and >=
s1 = "12345"
s2 = "123"
fmt.Println(s1 > s2) // true
fmt.Println(s1 >= s2) // true
}
鉴于 Go string 是不可变的,因此如果两个字符串的 length 不相同,那么无需比较具体字符串数据,也可以断定两个字符串是不同的;如果 length 相同,则要进一步判断数据指针是否指向同一块底层存储数据。如果相同,则两个字符串是等价的,如果不同,则还需进一步去比对实际的数据内容。
对非 ASCII 字符提供原生支持
Go 源文件的字符编码默认为 UTF-8 编码。UTF-8 编码是目前市面上最流行的字符编码格式,囊括了几乎所有主流非 ASCII 字符(包括汉语字符)。Go string 类型也是以 UTF-8 编码作为内码存储将对应的文本存储在内存当中的。我们来看一个例子:
// sources/string_nonascii.go
package main
import "fmt"
func main() {
// 中文字符 Unicode CodePoint(码点) UTF8编码
// 中 U+4E2D E4B8AD
// 国 U+56FD E59BBD
// 欢 U+6B22 E6ACA2
// 迎 U+8FCE E8BF8E
// 您 U+60A8 E682A8
s := "中国欢迎您"
rs := []rune(s)
sl := []byte(s)
for i, v := range rs {
var utf8Bytes []byte
for j := i * 3; j < (i+1)*3; j++ {
utf8Bytes = append(utf8Bytes, sl[j])
}
fmt.Printf("%s => %X => %X\n", string(v), v, utf8Bytes)
}
}
执行结果如下
$go run string_nonascii.go
中 => 4E2D => E4B8AD
国 => 56FD => E59BBD
欢 => 6B22 => E6ACA2
迎 => 8FCE => E8BF8E
您 => 60A8 => E682A8
我们看到字符串变量 s 中存储的文本是”中国欢迎你“五个汉字字符(非 ASCII 字符范畴),这里我们输出了每个中文字符对应的 Unicode 码点(Code Point,见输出结果的第二列),一个 rune 对应一个码点。UTF-8 编码是 Unicode 码点的一种字符编码形式,也是最常用的一种编码格式,也是 Go 默认的字符编码格式。我们还可以使用其他字符编码格式来映射 Unicode 码点,比如:UTF-16 等。
在 UTF-8 中,大多数中文字符都使用三个字节表示。[]byte(s)的转型让我们获得了 s 底层存储的“复制品”,从而我们得到了每个汉字字符对应的 UTF-8 编码字节(见输出结果的第三列)。
原生支持多行字符串
C 语言中要构造多行字符串,要么使用多个字符串的自然拼接,要么需要结合续行符"",很难控制好格式:
#include <stdio.h>
char *s = "好雨知时节,当春乃发生。\n"
"随风潜入夜,润物细无声。\n"
"野径云俱黑,江船火独明。\n"
"晓看红湿处,花重锦官城。";
char *s1 = "好雨知时节,当春乃发生。\n\
随风潜入夜,润物细无声。\n\
野径云俱黑,江船火独明。\n\
晓看红湿处,花重锦官城。";
int main() {
printf("%s\n", s);
printf("%s\n", s1);
}
Go 语言直接提供了通过反引号(`)构造“所见即所得”的多行字符串的方法:
// sources/string_multilines.go
package main
import "fmt"
const s = `好雨知时节,当春乃发生。
随风潜入夜,润物细无声。
野径云俱黑,江船火独明。
晓看红湿处,花重锦官城。`
func main() {
fmt.Println(s)
}
执行结果如下:
$go run string_multilines.go
好雨知时节,当春乃发生。
随风潜入夜,润物细无声。
野径云俱黑,江船火独明。
晓看红湿处,花重锦官城。
Go string 类型上述特性的实现与 Go 运行时对 string 类型的内部表示是分不开的。Go string 在运行时表示为下面结构:
// $GOROOT/src/runtime/string.go
type stringStruct struct {
str unsafe.Pointer
len int
}
我们看到 string 类型也是一个“描述符”,它本身并不真正存储数据,而仅是由一个指向底层存储的指针和字符串的长度字段组成。我们结合一个 string 的实例化过程来看。下面是 runtime 包中实例化一个字符串对应的函数:
// $GOROOT/src/runtime/string.go
// 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
}
我们用下面示意图来表示 rawstring 调用后的一个 string 实例的状态:
我们看到每个字符串类型变量/常量对应一个 stringStruct 实例,经过 rawstring 实例化后,stringStruct 中的 str 指针指向真正存储字符串数据的底层内存区域,len 字段存储的是字符串的长度(这里是 5);rawstring 同时还创建了一个临时的 slice,该 slice 的 array 指针也同样指向存储字符串数据的底层内存区域。注意 rawstring 调用后,新申请的内存区域还未被写入数据,该 slice 就是用于后续 runtime 层向其写入数据(“hello”)用的,写完数据后,该 slice 就可以被回收掉了,这也是上图中将 slice 结构以虚线框表示的原因。
通过 string 在运行时的表示我们可以得到这样一个结论,那就是我们直接将 string 类型通过函数/方法参数传入也不会有太多的损耗,因为传入的仅仅是一个“描述符”,而不是真正的字符串数据。我们可以通过一个简单的基准测试来验证一下:
// sources/string_as_param_benchmark_test.go
package main
import (
"testing"
)
var s string = `Go, also known as Golang, is a statically typed, compiled programming language designed at Google by Robert Griesemer, Rob Pike, and Ken Thompson. Go is syntactically similar to C, but with memory safety, garbage collection, structural typing, and communicating sequential processes (CSP)-style concurrency.`
func handleString(s string) {
_ = s + "hello"
}
func handlePtrToString(s *string) {
_ = *s + "hello"
}
func BenchmarkHandleString(b *testing.B) {
for n := 0; n < b.N; n++ {
handleString(s)
}
}
func BenchmarkHandlePtrToString(b *testing.B) {
for n := 0; n < b.N; n++ {
handlePtrToString(&s)
}
}
运行该基准测试:
$go test -bench . -benchmem string_as_param_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkHandleString-8 15668872 70.7 ns/op 320 B/op 1 allocs/op
BenchmarkHandlePtrToString-8 15809401 71.8 ns/op 320 B/op 1 allocs/op
PASS
ok command-line-arguments 2.407s
我们看到直接传入 string 与传入 string 指针两者的基准测试结果几乎一模一样。Gopher 们大可放心地直接使用 string 作为函数/方法参数类型。
前面提到过,Go 原生支持通过 +/+= 操作符来连接多个字符串以构造一个更长的字符串,并且通过 +/+= 操作符的字符串连接构造是最自然、开发体验最好的一种。但 Go 还提供了其他一些构造字符串的方法,比如:
- 使用 fmt.Sprintf
- 使用 strings.Join
- 使用 strings.Builder
- 使用 bytes.Buffer
在这些方法中哪种方法更为高效呢?我们使用基准测试的数据来做参考:
// sources/string_concat_benchmark_test.go
package main
import (
"bytes"
"fmt"
"strings"
"testing"
)
var sl []string = []string{
"Rob Pike ",
"Robert Griesemer ",
"Ken Thompson ",
}
func concatStringByOperator(sl []string) string {
var s string
for _, v := range sl {
s += v
}
return s
}
func concatStringBySprintf(sl []string) string {
var s string
for _, v := range sl {
s = fmt.Sprintf("%s%s", s, v)
}
return s
}
func concatStringByJoin(sl []string) string {
return strings.Join(sl, "")
}
func concatStringByStringsBuilder(sl []string) string {
var b strings.Builder
for _, v := range sl {
b.WriteString(v)
}
return b.String()
}
func concatStringByStringsBuilderWithInitSize(sl []string) string {
var b strings.Builder
b.Grow(64)
for _, v := range sl {
b.WriteString(v)
}
return b.String()
}
func concatStringByBytesBuffer(sl []string) string {
var b bytes.Buffer
for _, v := range sl {
b.WriteString(v)
}
return b.String()
}
func concatStringByBytesBufferWithInitSize(sl []string) string {
buf := make([]byte, 0, 64)
b := bytes.NewBuffer(buf)
for _, v := range sl {
b.WriteString(v)
}
return b.String()
}
func BenchmarkConcatStringByOperator(b *testing.B) {
for n := 0; n < b.N; n++ {
concatStringByOperator(sl)
}
}
func BenchmarkConcatStringBySprintf(b *testing.B) {
for n := 0; n < b.N; n++ {
concatStringBySprintf(sl)
}
}
func BenchmarkConcatStringByJoin(b *testing.B) {
for n := 0; n < b.N; n++ {
concatStringByJoin(sl)
}
}
func BenchmarkConcatStringByStringsBuilder(b *testing.B) {
for n := 0; n < b.N; n++ {
concatStringByStringsBuilder(sl)
}
}
func BenchmarkConcatStringByStringsBuilderWithInitSize(b *testing.B) {
for n := 0; n < b.N; n++ {
concatStringByStringsBuilderWithInitSize(sl)
}
}
func BenchmarkConcatStringByBytesBuffer(b *testing.B) {
for n := 0; n < b.N; n++ {
concatStringByBytesBuffer(sl)
}
}
func BenchmarkConcatStringByBytesBufferWithInitSize(b *testing.B) {
for n := 0; n < b.N; n++ {
concatStringByBytesBufferWithInitSize(sl)
}
}
运行该基准测试:
$go test -bench=. -benchmem ./string_concat_benchmark_test.go
goos: darwin
goarch: amd64
BenchmarkConcatStringByOperator-8 11744653 89.1 ns/op 80 B/op 2 allocs/op
BenchmarkConcatStringBySprintf-8 2792876 420 ns/op 176 B/op 8 allocs/op
BenchmarkConcatStringByJoin-8 22923051 49.1 ns/op 48 B/op 1 allocs/op
BenchmarkConcatStringByStringsBuilder-8 11347185 96.6 ns/op 112 B/op 3 allocs/op
BenchmarkConcatStringByStringsBuilderWithInitSize-8 26315769 42.3 ns/op 64 B/op 1 allocs/op
BenchmarkConcatStringByBytesBuffer-8 14265033 82.6 ns/op 112 B/op 2 allocs/op
BenchmarkConcatStringByBytesBufferWithInitSize-8 24777525 48.1 ns/op 48 B/op 1 allocs/op
PASS
ok command-line-arguments 8.816s
从基准测试的输出结果的第三列,即每操作耗时的数值来看:
- 做了预初始化的 strings.Builder 连接构建字符串效率最高;
- 带有预初始化的 bytes.Buffer 和 strings.Join 两种方法不相伯仲,分列二三位;
- 未预初始的 strings.Builder、bytes.Buffer 和操作符连接在第三档次;
- fmt.Sprintf性能最差,排在末尾。
由此可以得出一些结论:
- 在能预估出最终字符串长度的情况下,使用预初始化的 strings.Builder 连接构建字符串效率最高;
- strings.Join 连接构造字符串的平均性能最稳定,如果输入的多个字符串是以[]string 承载,那么 strings.Join 也是不错的选择;
- 使用操作符连接的方式最直观、最自然;如果在编译器可以知晓预连接的字符串个数,那么使用此种方式可以得到编译器的优化处理;
- fmt.Sprintf 虽然效率不高,但也不是一无是处;如果是由多种不同类型变量来构造特定格式的字符串,那么这种方式还是最适合的。
在前面的例子中,我们看到了 string 到[]rune 以及 string 到[]byte 的转换,这两个转换也是可逆的,也就是说 string 和[]rune、[]byte 可以双向相互转换。下面就是从[]rune 或[]byte 反向转换为 string 的例子:
// sources/string_slice_to_string.go
package main
import "fmt"
func main() {
rs := []rune{
0x4E2D,
0x56FD,
0x6B22,
0x8FCE,
0x60A8,
}
s := string(rs)
fmt.Println(s)
sl := []byte{
0xE4, 0xB8, 0xAD,
0xE5, 0x9B, 0xBD,
0xE6, 0xAC, 0xA2,
0xE8, 0xBF, 0x8E,
0xE6, 0x82, 0xA8,
}
s = string(sl)
fmt.Println(s)
}
执行如下
$go run string_slice_to_string.go
中国欢迎您
中国欢迎您
无论是 string 转 slice 还是 slice 转 string,转换都是要付出代价的,这些代价的根源在于 string 是不可变的,运行时要为转换后的类型分配新内存。我们以 byte slice 与 string 相互转换为例,看看转换过程的内存分配情况:
// sources/string_mallocs_in_convert.go
package main
import (
"fmt"
"testing"
)
func byteSliceToString() {
sl := []byte{
0xE4, 0xB8, 0xAD,
0xE5, 0x9B, 0xBD,
0xE6, 0xAC, 0xA2,
0xE8, 0xBF, 0x8E,
0xE6, 0x82, 0xA8,
0xEF, 0xBC, 0x8C,
0xE5, 0x8C, 0x97,
0xE4, 0xBA, 0xAC,
0xE6, 0xAC, 0xA2,
0xE8, 0xBF, 0x8E,
0xE6, 0x82, 0xA8,
}
_ = string(sl)
}
func stringToByteSlice() {
s := "中国欢迎您,北京换欢您"
_ = []byte(s)
}
func main() {
fmt.Println(testing.AllocsPerRun(1, byteSliceToString))
fmt.Println(testing.AllocsPerRun(1, stringToByteSlice))
}
运行这个例子:
$go run string_mallocs_in_convert.go
1
1
我们看到:针对"中国欢迎您,北京换欢您"这个长度的字符串,在 string 与 byte slice 互转的过程中都要有一次的内存分配操作。
在 Go 运行时层面,字符串与 rune slice、byte slice 相互转换对应的函数如下:
// $GOROOT/src/runtime/string.go
slicebytetostring: []byte -> string
slicerunetostring: []rune -> string
stringtoslicebyte: string -> []byte
stringtoslicerune: string -> []rune
我们以 byte slice 为例,看看 slicebytetostring 和 stringtoslicebyte 的实现:
// $GOROOT/src/runtime/string.go
// The constant is known to the compiler.
// There is no fundamental theory behind this number.
const tmpStringBufSize = 32
type tmpBuf [tmpStringBufSize]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))
}
copy(b, s)
return b
}
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 ""
}
// ... ... 此处省略一些代码
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
}
欲要更高效地进行转换,唯一的方法就是减少和避免额外的内存分配操作。我们看到运行时实现转换的函数中已经加入了一些避免每种情况都要分配新内存操作的优化(比如:tmpBuf 的复用)。
slice 类型是不可比较的,而 string 类型是可比较的,因此在日常 Go 编码中,我们会经常遇到将 slice 临时转换为 string 的情况。Go 编译器为这样的场景提供了优化。在运行时中有一个名为 slicebytetostringtmp 的函数就是协助实现这一优化的:
// $GOROOT/src/runtime/string.go
func slicebytetostringtmp(b []byte) string {
if raceenabled && len(b) > 0 {
racereadrangepc(unsafe.Pointer(&b[0]),
uintptr(len(b)),
getcallerpc(),
funcPC(slicebytetostringtmp))
}
if msanenabled && len(b) > 0 {
msanread(unsafe.Pointer(&b[0]), uintptr(len(b)))
}
return *(*string)(unsafe.Pointer(&b))
}
该函数的“秘诀”就在于不为 string 新开辟一块内存,而是直接使用 slice 的底层存储。当然使用这个函数的前提是:在原 slice 被修改后,这个 string 不能再被使用了。因此这样的优化是针对特定场景的,包括:
- string(b)用在 map 类型的 key 中
b := []byte{'k', 'e', 'y'}
m := make(map[string]string)
m[string(b)] = "value"
m[[3]string{string(b), "key1", "key2"}] = "value1"
- string(b)用在字符串连接语句中
b := []byte{'t', 'o', 'n', 'y'}
s := "hello " + string(b) + "!"
- string(b)用于字符串比较中
s := "tom"
b := []byte{'t', 'o', 'n', 'y'}
if s < string(b) {
... ...
}
Go 编译器对用在 for-range 循环中的 string 到[]byte 的转换也有优化处理,Go 编译器不会为[]byte 做额外的内存分配,而是直接使用 string 的底层数据。我们看下面例子:
// sources/string_for_range_covert_optimize.go
package main
import (
"fmt"
"testing"
)
func convert() {
s := "中国欢迎您,北京欢迎您"
sl := []byte(s)
for _, v := range sl {
_ = v
}
}
func convertWithOptimize() {
s := "中国欢迎您,北京欢迎您"
for _, v := range []byte(s) {
_ = v
}
}
func main() {
fmt.Println(testing.AllocsPerRun(1, convert))
fmt.Println(testing.AllocsPerRun(1, convertWithOptimize))
}
运行这个例子程序:
$go run string_for_range_covert_optimize.go
1
0
从结果我们看到,convertWithOptimize 函数将 string 到[]byte 的转换放在 for-range 循环中,Go 编译器对其进行了优化,节省了一次内存分配操作。
在如今的强大的硬件算力面前,少数的几次 string 和 slice 的转换代价可能微不足道。但能充分理解 Go 编译器对 string 和 slice 互转在特定场景下的优化依然是大有裨益的。在性能敏感的领域,这些优化也许能起到大作用。
在本小节中,我们了解到 Go 语言内置了 string 类型,统一了对字符串的抽象,并且为 string 类型提供了强大的内置操作支持,包括:基于 +/+= 的字符串连接操作、基于 =、!=、>、< 等的比较操作、O(1) 复杂度的长度获取操作、对非 ASCII 字符提供原生支持、对 string 类型与 slice 类型的相互转换提供优化等。
与此同时,Go 语言还在标准库中提供了 strings 和 strconv 包,可以辅助 Gopher 们对 string 类型数据进行更多高级操作。鉴于篇幅有限,这里就不赘述了,大家可以自行查阅以上两个包的使用说明文档。